chore: update .gitignore and enhance backend and mobile app functionality
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 44s
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 44s
- Added mobile app build directories and configuration files to .gitignore for cleaner repository management. - Improved error handling in diaryMemberController by requiring diaryDateId and memberId query parameters. - Refactored DiaryMemberService to log tag IDs instead of raw values for better debugging. - Enhanced TournamentParticipantsTab and TournamentTab components with improved touch-action properties for better user experience. - Updated mobile app's gradle.properties and build.gradle.kts for compatibility with AGP 9.x and Kotlin 2.1.21, including new dependencies for Coil and UCrop. - Refactored MainApplication to simplify initialization and improved MainActivity to handle dependencies more robustly. - Updated various UI components in the mobile app to enhance layout and functionality, including MemberDetailScreen and MemberEditScreen.
This commit is contained in:
@@ -1,3 +1,9 @@
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
val backendBaseUrl = providers.gradleProperty("backendBaseUrl")
|
||||
.orElse("https://tt-tagebuch.de")
|
||||
.get()
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.kotlinMultiplatform)
|
||||
alias(libs.plugins.androidApplication)
|
||||
@@ -8,8 +14,8 @@ plugins {
|
||||
kotlin {
|
||||
androidTarget {
|
||||
compilations.all {
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
compilerOptions.configure {
|
||||
jvmTarget.set(JvmTarget.JVM_17)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +26,7 @@ kotlin {
|
||||
implementation(compose.runtime)
|
||||
implementation(compose.foundation)
|
||||
implementation(compose.material)
|
||||
implementation(compose.materialIconsExtended)
|
||||
implementation(compose.ui)
|
||||
implementation(compose.components.resources)
|
||||
implementation(libs.voyager.navigator)
|
||||
@@ -32,6 +39,8 @@ kotlin {
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.koin.android)
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.yalantis.ucrop)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,6 +55,10 @@ android {
|
||||
targetSdk = libs.versions.android.targetSdk.get().toInt()
|
||||
versionCode = 1
|
||||
versionName = "1.0.0"
|
||||
buildConfigField("String", "BACKEND_BASE_URL", "\"$backendBaseUrl\"")
|
||||
}
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
}
|
||||
packaging {
|
||||
resources {
|
||||
@@ -58,8 +71,8 @@ android {
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,18 +8,34 @@
|
||||
android:allowBackup="true"
|
||||
android:icon="@android:mipmap/sym_def_app_icon"
|
||||
android:label="Trainingstagebuch"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:roundIcon="@android:mipmap/sym_def_app_icon"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
||||
android:theme="@android:style/Theme.Material.Light.NoActionBar"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name="com.yalantis.ucrop.UCropActivity"
|
||||
android:exported="false"
|
||||
android:screenOrientation="portrait"
|
||||
android:theme="@style/Theme.AppCompat.Light.NoActionBar" />
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package de.tt_tagebuch.app
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import de.tt_tagebuch.shared.api.AccidentApi
|
||||
import de.tt_tagebuch.shared.api.ApiConfig
|
||||
import de.tt_tagebuch.shared.api.AuthApi
|
||||
import de.tt_tagebuch.shared.api.PublicAuthApi
|
||||
import de.tt_tagebuch.shared.api.ClubsApi
|
||||
import de.tt_tagebuch.shared.api.DiaryApi
|
||||
import de.tt_tagebuch.shared.api.DiaryMemberActivitiesApi
|
||||
import de.tt_tagebuch.shared.api.DiaryMemberApi
|
||||
import de.tt_tagebuch.shared.api.GroupApi
|
||||
import de.tt_tagebuch.shared.api.ParticipantsApi
|
||||
import de.tt_tagebuch.shared.api.PredefinedActivitiesApi
|
||||
import de.tt_tagebuch.shared.api.MemberActivitiesApi
|
||||
import de.tt_tagebuch.shared.api.MemberGroupPhotosApi
|
||||
import de.tt_tagebuch.shared.api.MembersApi
|
||||
import de.tt_tagebuch.shared.api.PermissionsApi
|
||||
import de.tt_tagebuch.shared.api.SessionApi
|
||||
import de.tt_tagebuch.shared.api.TrainingGroupsApi
|
||||
import de.tt_tagebuch.shared.api.TrainingStatsApi
|
||||
import de.tt_tagebuch.shared.api.TrainingTimesApi
|
||||
import de.tt_tagebuch.shared.api.http.AndroidHttpClientEngineFactory
|
||||
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
|
||||
import de.tt_tagebuch.shared.api.http.PublicHttpClient
|
||||
import de.tt_tagebuch.shared.state.AndroidClubStorage
|
||||
import de.tt_tagebuch.shared.state.AndroidLanguageStorage
|
||||
import de.tt_tagebuch.shared.state.AndroidTokenStorage
|
||||
import de.tt_tagebuch.shared.state.AuthManager
|
||||
import de.tt_tagebuch.shared.state.ClubManager
|
||||
import de.tt_tagebuch.shared.state.DiaryManager
|
||||
import de.tt_tagebuch.shared.state.LanguageManager
|
||||
import de.tt_tagebuch.shared.state.MembersManager
|
||||
import de.tt_tagebuch.shared.state.MutableTokenProvider
|
||||
import de.tt_tagebuch.shared.state.TrainingStatsManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
class AppDependencies(context: Context) {
|
||||
private val appContext = context.applicationContext
|
||||
private val applicationJob = SupervisorJob()
|
||||
|
||||
/**
|
||||
* Für Suspend-Aufrufe aus Button-Callbacks: überlebt das Verlassen der Composition
|
||||
* (z. B. Club wählen → anderer Screen), im Gegensatz zu [rememberCoroutineScope].
|
||||
*/
|
||||
val applicationScope = CoroutineScope(applicationJob + Dispatchers.Main.immediate)
|
||||
val apiConfig = ApiConfig(baseUrl = BuildConfig.BACKEND_BASE_URL)
|
||||
val unauthorizedEvents = MutableStateFlow(0)
|
||||
private val tokenProvider = MutableTokenProvider()
|
||||
private val client = AuthedHttpClient(
|
||||
apiConfig = apiConfig,
|
||||
tokenProvider = tokenProvider,
|
||||
httpClientEngineFactory = AndroidHttpClientEngineFactory(),
|
||||
onUnauthorized = { unauthorizedEvents.value += 1 },
|
||||
)
|
||||
|
||||
private val publicHttpClient = PublicHttpClient(
|
||||
apiConfig = apiConfig,
|
||||
httpClientEngineFactory = AndroidHttpClientEngineFactory(),
|
||||
)
|
||||
|
||||
val publicAuthApi = PublicAuthApi(publicHttpClient)
|
||||
|
||||
val authManager = AuthManager(
|
||||
tokenProvider = tokenProvider,
|
||||
tokenStorage = AndroidTokenStorage(context.applicationContext),
|
||||
authApi = AuthApi(client),
|
||||
sessionApi = SessionApi(client),
|
||||
)
|
||||
|
||||
val clubManager = ClubManager(
|
||||
clubStorage = AndroidClubStorage(context.applicationContext),
|
||||
clubsApi = ClubsApi(client),
|
||||
permissionsApi = PermissionsApi(client),
|
||||
)
|
||||
|
||||
val diaryManager = DiaryManager(
|
||||
DiaryApi(client),
|
||||
ParticipantsApi(client),
|
||||
GroupApi(client),
|
||||
DiaryMemberActivitiesApi(client),
|
||||
DiaryMemberApi(client),
|
||||
PredefinedActivitiesApi(client),
|
||||
AccidentApi(client),
|
||||
MemberGroupPhotosApi(client),
|
||||
)
|
||||
val membersManager = MembersManager(
|
||||
MembersApi(client),
|
||||
TrainingGroupsApi(client),
|
||||
MemberActivitiesApi(client),
|
||||
TrainingTimesApi(client),
|
||||
)
|
||||
val trainingStatsManager = TrainingStatsManager(TrainingStatsApi(client))
|
||||
val languageManager = LanguageManager(AndroidLanguageStorage(context.applicationContext))
|
||||
val sessionApi = SessionApi(client)
|
||||
|
||||
/** Header wie [AuthedHttpClient] (für Coil-Bilder, Downloads). */
|
||||
fun diaryAuthHeaders(): Map<String, String> = buildMap {
|
||||
tokenProvider.token?.let { put("authcode", it) }
|
||||
tokenProvider.username?.let { put("userid", it) }
|
||||
}
|
||||
|
||||
/** Öffnet einen Pfad auf dem konfigurierten Backend im Browser (z. B. Impressum, Datenschutz). */
|
||||
fun openBackendPath(path: String) {
|
||||
val base = apiConfig.baseUrl.trimEnd('/')
|
||||
val suffix = path.trim().let { p -> if (p.startsWith("/")) p else "/$p" }
|
||||
val uri = Uri.parse("$base$suffix")
|
||||
val intent = Intent(Intent.ACTION_VIEW, uri).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
runCatching { appContext.startActivity(intent) }
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,36 @@ package de.tt_tagebuch.app
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import de.tt_tagebuch.app.ui.AppRoot
|
||||
import de.tt_tagebuch.app.ui.TtTagebuchTheme
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
App()
|
||||
val context = LocalContext.current
|
||||
val dependencies = remember {
|
||||
try {
|
||||
AppDependencies(context.applicationContext)
|
||||
} catch (t: Throwable) {
|
||||
android.util.Log.e("MainActivity", "Failed to initialize AppDependencies", t)
|
||||
throw t
|
||||
}
|
||||
}
|
||||
TtTagebuchTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colors.background,
|
||||
) {
|
||||
AppRoot(dependencies)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,6 @@
|
||||
package de.tt_tagebuch.app
|
||||
|
||||
import android.app.Application
|
||||
import de.tt_tagebuch.app.di.appModule
|
||||
import de.tt_tagebuch.shared.di.initKoin
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
|
||||
class MainApplication : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
initKoin(
|
||||
baseUrl = "https://tt-tagebuch.de",
|
||||
additionalModules = listOf(appModule)
|
||||
) {
|
||||
androidContext(this@MainApplication)
|
||||
}
|
||||
}
|
||||
}
|
||||
class MainApplication : Application()
|
||||
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
package de.tt_tagebuch.app.pdf
|
||||
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Typeface
|
||||
import android.graphics.pdf.PdfDocument
|
||||
import android.text.Layout
|
||||
import android.text.StaticLayout
|
||||
import android.text.TextPaint
|
||||
import de.tt_tagebuch.shared.api.models.DiaryDateActivityItem
|
||||
import de.tt_tagebuch.shared.api.models.DiaryFreeformActivity
|
||||
import de.tt_tagebuch.shared.api.models.DiaryTrainingParticipant
|
||||
import de.tt_tagebuch.shared.api.models.Member
|
||||
import de.tt_tagebuch.shared.api.models.displayTitle
|
||||
import de.tt_tagebuch.shared.api.models.isPresentParticipant
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.Locale
|
||||
|
||||
private const val PAGE_W = 595
|
||||
private const val PAGE_H = 842
|
||||
private const val MARGIN = 40f
|
||||
private const val MAX_TEXT_W = (PAGE_W - MARGIN * 2).toInt()
|
||||
|
||||
private fun newTitlePaint(): TextPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.BLACK
|
||||
textSize = 16f
|
||||
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||
}
|
||||
|
||||
private fun newBodyPaint(): TextPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.BLACK
|
||||
textSize = 11f
|
||||
typeface = Typeface.create(Typeface.DEFAULT, Typeface.NORMAL)
|
||||
textLocale = Locale.GERMANY
|
||||
}
|
||||
|
||||
private fun Canvas.drawStatic(text: String, paint: TextPaint, x: Float, y: Float): Float {
|
||||
if (text.isEmpty()) return y
|
||||
val layout = StaticLayout.Builder.obtain(text, 0, text.length, paint, MAX_TEXT_W)
|
||||
.setAlignment(Layout.Alignment.ALIGN_NORMAL)
|
||||
.setLineSpacing(0f, 1.05f)
|
||||
.setIncludePad(false)
|
||||
.build()
|
||||
save()
|
||||
translate(x, y)
|
||||
layout.draw(this)
|
||||
restore()
|
||||
return y + layout.height + 6f
|
||||
}
|
||||
|
||||
fun writeTrainingPlanPdf(
|
||||
outFile: File,
|
||||
clubName: String,
|
||||
dateLabel: String,
|
||||
timeLabel: String,
|
||||
planItems: List<DiaryDateActivityItem>,
|
||||
timeblockFallback: String,
|
||||
) {
|
||||
val doc = PdfDocument()
|
||||
var pageSeq = 0
|
||||
fun openPage(): PdfDocument.Page {
|
||||
pageSeq++
|
||||
return doc.startPage(PdfDocument.PageInfo.Builder(PAGE_W, PAGE_H, pageSeq).create())
|
||||
}
|
||||
var page = openPage()
|
||||
var canvas = page.canvas
|
||||
var y = MARGIN
|
||||
val titlePaint = newTitlePaint()
|
||||
val bodyPaint = newBodyPaint()
|
||||
fun newPageIfNeeded(extra: Float) {
|
||||
if (y + extra > PAGE_H - MARGIN) {
|
||||
doc.finishPage(page)
|
||||
page = openPage()
|
||||
canvas = page.canvas
|
||||
y = MARGIN
|
||||
}
|
||||
}
|
||||
y = canvas.drawStatic("Trainingsplan – $clubName", titlePaint, MARGIN, y)
|
||||
y = canvas.drawStatic("Datum: $dateLabel", bodyPaint, MARGIN, y)
|
||||
y = canvas.drawStatic("Zeiten: $timeLabel", bodyPaint, MARGIN, y)
|
||||
y = canvas.drawStatic("", bodyPaint, MARGIN, y)
|
||||
val sorted = planItems.sortedWith(compareBy({ it.groupId ?: 0 }, { it.orderId }, { it.id }))
|
||||
sorted.forEach { item ->
|
||||
val line = buildString {
|
||||
append(item.displayTitle(timeblockFallback).ifBlank { "Eintrag ${item.id}" })
|
||||
item.durationText?.takeIf { it.isNotBlank() }?.let { append(" — $it") }
|
||||
?: item.duration?.let { append(" — ${it} min") }
|
||||
item.planGroup?.name?.takeIf { it.isNotBlank() }?.let { append(" — Gruppe: $it") }
|
||||
}
|
||||
newPageIfNeeded(40f)
|
||||
y = canvas.drawStatic("• $line", bodyPaint, MARGIN, y)
|
||||
}
|
||||
doc.finishPage(page)
|
||||
FileOutputStream(outFile).use { doc.writeTo(it) }
|
||||
doc.close()
|
||||
}
|
||||
|
||||
fun writeTrainingDaySummaryPdf(
|
||||
outFile: File,
|
||||
clubName: String,
|
||||
dateLabel: String,
|
||||
timeLabel: String,
|
||||
activeMembers: List<Member>,
|
||||
participants: List<DiaryTrainingParticipant>,
|
||||
freeform: List<DiaryFreeformActivity>,
|
||||
planItems: List<DiaryDateActivityItem>,
|
||||
timeblockFallback: String,
|
||||
) {
|
||||
val doc = PdfDocument()
|
||||
var pageSeq = 0
|
||||
fun openPage(): PdfDocument.Page {
|
||||
pageSeq++
|
||||
return doc.startPage(PdfDocument.PageInfo.Builder(PAGE_W, PAGE_H, pageSeq).create())
|
||||
}
|
||||
var page = openPage()
|
||||
var canvas = page.canvas
|
||||
var y = MARGIN
|
||||
val titlePaint = newTitlePaint()
|
||||
val bodyPaint = newBodyPaint()
|
||||
fun newPageIfNeeded(extra: Float) {
|
||||
if (y + extra > PAGE_H - MARGIN) {
|
||||
doc.finishPage(page)
|
||||
page = openPage()
|
||||
canvas = page.canvas
|
||||
y = MARGIN
|
||||
}
|
||||
}
|
||||
y = canvas.drawStatic("Trainingstag – $clubName", titlePaint, MARGIN, y)
|
||||
y = canvas.drawStatic("Datum: $dateLabel", bodyPaint, MARGIN, y)
|
||||
y = canvas.drawStatic("Zeiten: $timeLabel", bodyPaint, MARGIN, y)
|
||||
y = canvas.drawStatic("", bodyPaint, MARGIN, y)
|
||||
|
||||
y = canvas.drawStatic("Teilnehmer (laut App-Stand)", titlePaint, MARGIN, y)
|
||||
val partLines = activeMembers.map { m ->
|
||||
val row = participants.find { it.memberId == m.id }
|
||||
val base = "${m.lastName}, ${m.firstName}"
|
||||
if (row == null) {
|
||||
"$base — (keine Teilnahme-Zeile)"
|
||||
} else if (row.isPresentParticipant()) {
|
||||
"$base — anwesend"
|
||||
} else {
|
||||
when (row.attendanceStatus?.lowercase()) {
|
||||
"excused" -> "$base — entschuldigt"
|
||||
"cancelled" -> "$base — abgesagt"
|
||||
else -> "$base — (${row.attendanceStatus ?: "?"})"
|
||||
}
|
||||
}
|
||||
}
|
||||
if (partLines.isEmpty()) {
|
||||
y = canvas.drawStatic("—", bodyPaint, MARGIN, y)
|
||||
} else {
|
||||
partLines.forEach { n ->
|
||||
newPageIfNeeded(30f)
|
||||
y = canvas.drawStatic("• $n", bodyPaint, MARGIN, y)
|
||||
}
|
||||
}
|
||||
|
||||
y = canvas.drawStatic("", bodyPaint, MARGIN, y)
|
||||
y = canvas.drawStatic("Weitere Tages-Aktivitäten", titlePaint, MARGIN, y)
|
||||
if (freeform.isEmpty()) {
|
||||
y = canvas.drawStatic("—", bodyPaint, MARGIN, y)
|
||||
} else {
|
||||
freeform.forEach { f ->
|
||||
newPageIfNeeded(30f)
|
||||
y = canvas.drawStatic("• ${f.description}", bodyPaint, MARGIN, y)
|
||||
}
|
||||
}
|
||||
|
||||
y = canvas.drawStatic("", bodyPaint, MARGIN, y)
|
||||
y = canvas.drawStatic("Trainingsplan (Aktivitäten)", titlePaint, MARGIN, y)
|
||||
val sorted = planItems.sortedWith(compareBy({ it.groupId ?: 0 }, { it.orderId }, { it.id }))
|
||||
if (sorted.isEmpty()) {
|
||||
y = canvas.drawStatic("—", bodyPaint, MARGIN, y)
|
||||
} else {
|
||||
sorted.forEach { item ->
|
||||
newPageIfNeeded(40f)
|
||||
val line = buildString {
|
||||
append(item.displayTitle(timeblockFallback).ifBlank { "Eintrag ${item.id}" })
|
||||
item.durationText?.takeIf { it.isNotBlank() }?.let { append(" — $it") }
|
||||
?: item.duration?.let { append(" — ${it} min") }
|
||||
}
|
||||
y = canvas.drawStatic("• $line", bodyPaint, MARGIN, y)
|
||||
}
|
||||
}
|
||||
|
||||
doc.finishPage(page)
|
||||
FileOutputStream(outFile).use { doc.writeTo(it) }
|
||||
doc.close()
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package de.tt_tagebuch.app.pdf
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.content.FileProvider
|
||||
import de.tt_tagebuch.app.BuildConfig
|
||||
import java.io.File
|
||||
|
||||
fun sharePdfFile(context: Context, file: File, chooserTitle: String) {
|
||||
val uri = FileProvider.getUriForFile(
|
||||
context,
|
||||
"${BuildConfig.APPLICATION_ID}.fileprovider",
|
||||
file,
|
||||
)
|
||||
val send = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "application/pdf"
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
val chooser = Intent.createChooser(send, chooserTitle).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
context.startActivity(chooser)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
package de.tt_tagebuch.app.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import okhttp3.Headers
|
||||
|
||||
@Composable
|
||||
fun AuthenticatedAsyncImage(
|
||||
imageUrl: String,
|
||||
authHeaders: Map<String, String>,
|
||||
modifier: Modifier = Modifier,
|
||||
contentDescription: String? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val request = remember(imageUrl, authHeaders) {
|
||||
val hb = Headers.Builder()
|
||||
authHeaders.forEach { (k, v) -> hb.add(k, v) }
|
||||
ImageRequest.Builder(context)
|
||||
.data(imageUrl)
|
||||
.headers(hb.build())
|
||||
.crossfade(true)
|
||||
.build()
|
||||
}
|
||||
AsyncImage(
|
||||
model = request,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package de.tt_tagebuch.app.ui
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.content.FileProvider
|
||||
import com.yalantis.ucrop.UCrop
|
||||
import java.io.File
|
||||
|
||||
private fun cropCacheDir(context: Context): File =
|
||||
File(context.cacheDir, "member_crop").also { it.mkdirs() }
|
||||
|
||||
/**
|
||||
* Kopiert die gewählte [Uri] in eine Cache-Datei (FileProvider), damit UCrop zuverlässig lesen kann.
|
||||
*/
|
||||
fun copyPickedImageToCacheForCrop(context: Context, source: Uri, memberId: Int): Uri {
|
||||
val dir = cropCacheDir(context)
|
||||
val inFile = File(dir, "pick-$memberId-${System.currentTimeMillis()}.jpg")
|
||||
context.contentResolver.openInputStream(source)?.use { input ->
|
||||
inFile.outputStream().use { output -> input.copyTo(output) }
|
||||
}
|
||||
return FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.fileprovider",
|
||||
inFile,
|
||||
)
|
||||
}
|
||||
|
||||
fun createCropOutputUri(context: Context, memberId: Int): Pair<File, Uri> {
|
||||
val dir = cropCacheDir(context)
|
||||
val outFile = File(dir, "crop-out-$memberId-${System.currentTimeMillis()}.jpg")
|
||||
if (outFile.exists()) outFile.delete()
|
||||
outFile.createNewFile()
|
||||
val uri = FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.fileprovider",
|
||||
outFile,
|
||||
)
|
||||
return outFile to uri
|
||||
}
|
||||
|
||||
fun buildMemberPortraitCropIntent(context: Context, sourceUri: Uri, destinationUri: Uri): Intent {
|
||||
val options = UCrop.Options().apply {
|
||||
setCompressionFormat(Bitmap.CompressFormat.JPEG)
|
||||
setCompressionQuality(90)
|
||||
setHideBottomControls(false)
|
||||
setFreeStyleCropEnabled(true)
|
||||
}
|
||||
return UCrop.of(sourceUri, destinationUri)
|
||||
.withAspectRatio(1f, 1f)
|
||||
.withMaxResultSize(2048, 2048)
|
||||
.withOptions(options)
|
||||
.getIntent(context)
|
||||
.apply {
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberMemberPortraitCropLauncher(
|
||||
onCropped: (ByteArray) -> Unit,
|
||||
onCancelledOrError: (() -> Unit)? = null,
|
||||
): ActivityResultLauncher<Intent> {
|
||||
val context = LocalContext.current
|
||||
val appContext = remember(context) { context.applicationContext }
|
||||
return rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode != Activity.RESULT_OK || result.data == null) {
|
||||
onCancelledOrError?.invoke()
|
||||
return@rememberLauncherForActivityResult
|
||||
}
|
||||
val data = result.data!!
|
||||
if (UCrop.getError(data) != null) {
|
||||
onCancelledOrError?.invoke()
|
||||
return@rememberLauncherForActivityResult
|
||||
}
|
||||
val outUri = UCrop.getOutput(data) ?: run {
|
||||
onCancelledOrError?.invoke()
|
||||
return@rememberLauncherForActivityResult
|
||||
}
|
||||
runCatching {
|
||||
appContext.contentResolver.openInputStream(outUri)?.use { it.readBytes() }
|
||||
}.getOrNull()?.let { onCropped(it) } ?: onCancelledOrError?.invoke()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package de.tt_tagebuch.app.ui
|
||||
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.lightColors
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
/** Orientierung an `frontend/src/assets/css/main.scss` (:root). */
|
||||
object TtAppColors {
|
||||
val NavRailBackground = Color(0xFFEDF4F0)
|
||||
}
|
||||
|
||||
private val TtLightColors = lightColors(
|
||||
primary = Color(0xFF2F7A5F),
|
||||
primaryVariant = Color(0xFF184636),
|
||||
secondary = Color(0xFFB56E41),
|
||||
background = Color(0xFFF4F6F3),
|
||||
surface = Color(0xFFFFFFFF),
|
||||
onPrimary = Color.White,
|
||||
onSecondary = Color.White,
|
||||
onBackground = Color(0xFF333333),
|
||||
onSurface = Color(0xFF333333),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun TtTagebuchTheme(content: @Composable () -> Unit) {
|
||||
MaterialTheme(colors = TtLightColors, content = content)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<cache-path name="pdf_cache" path="." />
|
||||
</paths>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Lokales HTTP (Cleartext) für Entwicklung: ab API 28 sonst "CLEARTEXT ... not permitted". -->
|
||||
<network-security-config>
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="false">10.0.2.2</domain>
|
||||
<domain includeSubdomains="false">localhost</domain>
|
||||
<domain includeSubdomains="false">127.0.0.1</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
@@ -10,6 +10,7 @@ import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import cafe.adriel.voyager.navigator.LocalNavigator
|
||||
import cafe.adriel.voyager.core.screen.Screen
|
||||
import cafe.adriel.voyager.koin.getScreenModel
|
||||
import de.tt_tagebuch.app.viewmodel.DiaryScreenModel
|
||||
@@ -18,6 +19,7 @@ import de.tt_tagebuch.app.viewmodel.DiaryState
|
||||
class DiaryScreen(private val clubId: Int) : Screen {
|
||||
@Composable
|
||||
override fun Content() {
|
||||
val navigator = LocalNavigator.current
|
||||
val viewModel = getScreenModel<DiaryScreenModel>()
|
||||
val state by viewModel.state.collectAsState()
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package de.tt_tagebuch.app.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.DateRange
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import cafe.adriel.voyager.core.screen.Screen
|
||||
import cafe.adriel.voyager.navigator.LocalNavigator
|
||||
import cafe.adriel.voyager.navigator.Navigator
|
||||
@@ -30,7 +33,7 @@ class HomeScreen(private val clubId: Int) : Screen {
|
||||
}
|
||||
}
|
||||
) { padding ->
|
||||
Box(modifier = androidx.compose.foundation.layout.padding(padding)) {
|
||||
Box(modifier = Modifier.padding(padding)) {
|
||||
when (selectedItem) {
|
||||
0 -> DiaryScreen(clubId).Content()
|
||||
1 -> MemberScreen(clubId).Content()
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Email
|
||||
import androidx.compose.material.icons.filled.Phone
|
||||
import androidx.compose.runtime.*
|
||||
@@ -35,7 +36,7 @@ class MemberDetailScreen(private val member: Member) : Screen {
|
||||
IconButton(onClick = {
|
||||
navigator?.push(MemberEditScreen(member.clubId, member))
|
||||
}) {
|
||||
Icon(androidx.compose.material.icons.filled.Edit, contentDescription = "Edit")
|
||||
Icon(Icons.Default.Edit, contentDescription = "Edit")
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -57,7 +58,7 @@ class MemberDetailScreen(private val member: Member) : Screen {
|
||||
}
|
||||
if (member.testMembership) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
StatusBadge("Test", Color.Orange)
|
||||
StatusBadge("Test", Color(0xFFFFA500))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ class MemberEditScreen(private val clubId: Int, private val member: Member? = nu
|
||||
var gender by remember { mutableStateOf(member?.gender ?: "unknown") }
|
||||
var active by remember { mutableStateOf(member?.active ?: true) }
|
||||
var testMembership by remember { mutableStateOf(member?.testMembership ?: true) }
|
||||
var contacts by remember { mutableStateOf(member?.contacts?.toMutableList() ?: mutableListOf()) }
|
||||
var contacts by remember { mutableStateOf<List<MemberContact>>(member?.contacts?.toMutableList() ?: mutableListOf<MemberContact>()) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -67,6 +68,7 @@ class MemberScreen(private val clubId: Int) : Screen {
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
fun MemberItem(member: de.tt_tagebuch.shared.models.Member, onClick: () -> Unit) {
|
||||
ListItem(
|
||||
|
||||
Reference in New Issue
Block a user