diff --git a/frontend/src/views/CalendarView.vue b/frontend/src/views/CalendarView.vue index 4b44c6d9..de5168b2 100644 --- a/frontend/src/views/CalendarView.vue +++ b/frontend/src/views/CalendarView.vue @@ -284,21 +284,33 @@ export default { this.loading = false; }, + normalizeTrainingTimeKey(time) { + return String(time || '') + .trim() + .replace(/[\u2013\u2012\u2212]/g, '-') + .replace(/\s*-\s*/g, '-') + .replace(/\s+/g, ''); + }, /** - * Mehrere regelmäßige Trainingszeiten mit identischem Wochentag und gleichem Uhrzeit-Fenster - * (z. B. parallel genutzte Gruppen / Dubletten) zu einem Kalendereintrag zusammenführen. + * Alle Trainings-Einträge (Tagebuch + regelmäßige Zeiten) mit gleichem Kalendertag und gleichem + * Zeitfenster-Text zu einem Eintrag zusammenführen (z. B. Dublette „Training“ + Gruppen). */ mergeRecurringTrainingSlots(events) { const genericTitle = (t) => !t || /^training$/i.test(String(t).trim()); const slotMap = new Map(); const passthrough = []; for (const e of events) { - if (e.type !== 'training' || !e.isRecurringTraining || !e.time || !e.date) { + if (e.type !== 'training' || !e.date) { + passthrough.push(e); + continue; + } + const timeKey = this.normalizeTrainingTimeKey(e.time); + if (!timeKey) { passthrough.push(e); continue; } const dk = this.toDateKey(e.date); - const slotKey = `${dk}|${e.time}`; + const slotKey = `${dk}|${timeKey}`; if (!slotMap.has(slotKey)) { slotMap.set(slotKey, []); } @@ -316,12 +328,25 @@ export default { const specific = rawTitles.filter((t) => !genericTitle(t)); const titleJoined = specific.length ? specific.join(' · ') : (rawTitles.join(' · ') || base.title); const safeIdKey = slotKey.replace(/\|/g, '-'); + const hasRecurring = sorted.some((x) => x.isRecurringTraining); + const otherSubtitles = [...new Set( + sorted.map((x) => String(x.subtitle || '').trim()).filter((s) => s && s !== 'Regelmäßige Trainingszeit') + )]; + let subtitleJoined = ''; + if (hasRecurring) { + subtitleJoined = otherSubtitles.length + ? `Regelmäßige Trainingszeit · ${otherSubtitles.join(' · ')}` + : 'Regelmäßige Trainingszeit'; + } else { + subtitleJoined = otherSubtitles.join(' · ') || String(base.subtitle || '').trim() || ''; + } mergedSlots.push({ ...base, id: `training-merged-${safeIdKey}`, title: titleJoined, - subtitle: 'Regelmäßige Trainingszeit', - startsAt: Math.min(...sorted.map((x) => x.startsAt)) + subtitle: subtitleJoined, + startsAt: Math.min(...sorted.map((x) => x.startsAt)), + isRecurringTraining: hasRecurring }); } return [...passthrough, ...mergedSlots]; diff --git a/mobile-app/TODO.md b/mobile-app/TODO.md index 9daa7ada..3505da65 100644 --- a/mobile-app/TODO.md +++ b/mobile-app/TODO.md @@ -180,10 +180,10 @@ Web: `DiaryView.vue` (sehr groß). API-Cluster (Auszug – in Web nach `apiClien ## Phase 10 – Persönliche Konten & Integrationen -- [ ] **Persönliche Einstellungen** vollständig (`PersonalSettings.vue` vs. aktuelles „Mehr“) -- [ ] **MyTischtennis-Konto** (`MyTischtennisAccount.vue`) -- [ ] **ClickTT-Konto** (`ClickTtAccount.vue`) -- [ ] **ClickTT-Ansicht** (`ClickTtView.vue`) +- [x] **Persönliche Einstellungen** (`PersonalSettings.vue`) – Mehr → Hub: Sprache, MyTT, ClickTT; `PersonalHubScreens.kt` + `AppRoot.kt` `SettingsScreen` +- [x] **MyTischtennis-Konto** (`MyTischtennisAccount.vue`) – `MyTischtennisApi`, Dialog/Verify/Unlink in `PersonalHubScreens.kt` +- [x] **ClickTT-Konto** (`ClickTtAccount.vue`) – `ClickTtAccountApi`, analog in `PersonalHubScreens.kt` +- [x] **ClickTT-Ansicht** (`ClickTtView.vue`) – Proxy-URL + `WebView` in `ClickTtBrowserScreen` --- diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/AppRoot.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/AppRoot.kt index 1d722f83..e6def419 100644 --- a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/AppRoot.kt +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/AppRoot.kt @@ -3972,6 +3972,15 @@ private fun RowSwitch(label: String, checked: Boolean, onChecked: (Boolean) -> U private fun SettingsScreen(dependencies: AppDependencies) { var clubAdminSection by remember { mutableStateOf(null) } var stammdatenSection by remember { mutableStateOf(null) } + var personalHub by remember { mutableStateOf(null) } + if (personalHub != null) { + PersonalHubFlowScreen( + destination = personalHub!!, + dependencies = dependencies, + onBack = { personalHub = null }, + ) + return + } if (clubAdminSection != null) { ClubAdminFlowScreen( destination = clubAdminSection!!, @@ -4059,15 +4068,23 @@ private fun SettingsScreen(dependencies: AppDependencies) { } } } - SectionTitle(tr("mobile.language", "Sprache")) - MobileStrings.supportedLanguages.forEach { language -> - TextButton( - onClick = { dependencies.applicationScope.launch { dependencies.languageManager.selectLanguage(language.code) } }, - modifier = Modifier.fillMaxWidth(), - ) { - Text(if (language.code == LocalLanguageCode.current) "✓ ${language.label}" else language.label) - } - } + SectionTitle(tr("settings.personalSettings", "Persönliche Einstellungen")) + TextButton( + onClick = { personalHub = PersonalHubDestination.PersonalLanguage }, + modifier = Modifier.fillMaxWidth(), + ) { Text(tr("mobile.language", "Sprache")) } + TextButton( + onClick = { personalHub = PersonalHubDestination.MyTischtennisAccount }, + modifier = Modifier.fillMaxWidth(), + ) { Text(tr("myTischtennisAccount.title", "myTischtennis-Account")) } + TextButton( + onClick = { personalHub = PersonalHubDestination.ClickTtAccount }, + modifier = Modifier.fillMaxWidth(), + ) { Text(tr("navigation.clickTtAccount", "HTTV / click-TT-Account")) } + TextButton( + onClick = { personalHub = PersonalHubDestination.ClickTtBrowser }, + modifier = Modifier.fillMaxWidth(), + ) { Text(tr("navigation.clickTtBrowser", "HTTV / click-TT")) } SectionTitle(tr("mobile.legal", "Rechtliches")) Text( tr("mobile.legalBrowserHint", "Öffnet die Web-Oberfläche des konfigurierten Backends im Browser."), diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/PersonalHubScreens.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/PersonalHubScreens.kt new file mode 100644 index 00000000..aae53c99 --- /dev/null +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/PersonalHubScreens.kt @@ -0,0 +1,915 @@ +package de.tt_tagebuch.app.ui + +import android.annotation.SuppressLint +import android.view.ViewGroup +import android.webkit.WebChromeClient +import android.webkit.WebSettings +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.AlertDialog +import androidx.compose.material.Button +import androidx.compose.material.Card +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedButton +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Switch +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import de.tt_tagebuch.app.AppDependencies +import de.tt_tagebuch.shared.api.http.ApiException +import de.tt_tagebuch.shared.api.models.ClickTtAccountDto +import de.tt_tagebuch.shared.api.models.ClickTtAccountStatusDto +import de.tt_tagebuch.shared.api.models.ClickTtAccountUpsertBody +import de.tt_tagebuch.shared.api.models.MyTischtennisAccountDto +import de.tt_tagebuch.shared.api.models.MyTischtennisAccountUpsertBody +import de.tt_tagebuch.shared.api.models.MyTischtennisStatusDto +import de.tt_tagebuch.shared.i18n.MobileStrings +import kotlinx.coroutines.launch +import java.net.URLEncoder +import java.nio.charset.StandardCharsets +import java.time.OffsetDateTime +import java.time.format.DateTimeFormatter +import java.util.UUID + +private val PersonalHubPad = 20.dp +private val PersonalHubTouchMin = 48.dp + +internal enum class PersonalHubDestination { + PersonalLanguage, + MyTischtennisAccount, + ClickTtAccount, + ClickTtBrowser, +} + +@Composable +internal fun PersonalHubFlowScreen( + destination: PersonalHubDestination, + dependencies: AppDependencies, + onBack: () -> Unit, +) { + BackHandler(onBack = onBack) + when (destination) { + PersonalHubDestination.PersonalLanguage -> PersonalLanguageScreen(dependencies, onBack) + PersonalHubDestination.MyTischtennisAccount -> MyTischtennisAccountScreen(dependencies, onBack) + PersonalHubDestination.ClickTtAccount -> ClickTtAccountScreen(dependencies, onBack) + PersonalHubDestination.ClickTtBrowser -> ClickTtBrowserScreen(dependencies, onBack) + } +} + +@Composable +private fun PersonalHubTopBar(title: String, onBack: () -> Unit) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + Text(title, style = MaterialTheme.typography.h6, fontWeight = FontWeight.SemiBold) + } + Spacer(modifier = Modifier.height(8.dp)) +} + +private fun formatDateTimeUi(iso: String?): String { + if (iso.isNullOrBlank()) return "–" + return try { + val odt = OffsetDateTime.parse(iso) + odt.format(DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm")) + } catch (_: Exception) { + iso.take(19).replace('T', ' ') + } +} + +@Composable +private fun PersonalLanguageScreen(dependencies: AppDependencies, onBack: () -> Unit) { + val languageCode = LocalLanguageCode.current + fun tr(key: String, fb: String) = MobileStrings.get(languageCode, key, fb) + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(PersonalHubPad), + ) { + PersonalHubTopBar(tr("settings.personalSettings", "Persönliche Einstellungen"), onBack) + Card(modifier = Modifier.fillMaxWidth(), elevation = 1.dp) { + Column(Modifier.padding(16.dp)) { + Text(tr("settings.language", "Sprache"), fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.subtitle1) + Text( + tr("settings.languageDescription", "Wähle deine bevorzugte Sprache für die Anwendung"), + style = MaterialTheme.typography.body2, + modifier = Modifier.padding(top = 8.dp), + ) + Text( + tr("settings.selectLanguage", "Sprache auswählen"), + style = MaterialTheme.typography.caption, + modifier = Modifier.padding(top = 12.dp), + ) + MobileStrings.supportedLanguages.forEach { language -> + TextButton( + onClick = { dependencies.applicationScope.launch { dependencies.languageManager.selectLanguage(language.code) } }, + modifier = Modifier.fillMaxWidth().heightIn(min = PersonalHubTouchMin), + ) { + Text(if (language.code == LocalLanguageCode.current) "✓ ${language.label}" else language.label) + } + } + } + } + } +} + +@Composable +private fun MyTischtennisAccountScreen(dependencies: AppDependencies, onBack: () -> Unit) { + val languageCode = LocalLanguageCode.current + fun tr(key: String, fb: String) = MobileStrings.get(languageCode, key, fb) + val scope = rememberCoroutineScope() + var loading by remember { mutableStateOf(true) } + var account by remember { mutableStateOf(null) } + var status by remember { mutableStateOf(null) } + var err by remember { mutableStateOf(null) } + var refresh by remember { mutableIntStateOf(0) } + var showEditor by remember { mutableStateOf(false) } + var loginMode by remember { mutableStateOf(false) } + var verifyBusy by remember { mutableStateOf(false) } + var confirmUnlink by remember { mutableStateOf(false) } + var infoMsg by remember { mutableStateOf(null) } + + LaunchedEffect(refresh) { + loading = true + err = null + runCatching { + val a = dependencies.myTischtennisApi.getAccount() + val s = dependencies.myTischtennisApi.getStatus() + account = a.account + status = s + }.onFailure { err = it.message } + loading = false + } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(PersonalHubPad), + ) { + PersonalHubTopBar(tr("myTischtennisAccount.title", "myTischtennis-Account"), onBack) + infoMsg?.let { + Text(it, color = MaterialTheme.colors.primary, modifier = Modifier.padding(bottom = 8.dp)) + } + err?.let { Text(it, color = MaterialTheme.colors.error, modifier = Modifier.padding(bottom = 8.dp)) } + when { + loading -> CircularProgressIndicator(modifier = Modifier.padding(top = 24.dp)) + account != null -> { + val a = account!! + Card(modifier = Modifier.fillMaxWidth(), elevation = 1.dp) { + Column(Modifier.padding(16.dp)) { + Text(tr("myTischtennisAccount.linkedAccount", "Verknüpfter Account"), fontWeight = FontWeight.SemiBold) + detailRow(tr("myTischtennisAccount.email", "E-Mail"), a.email ?: "–") + detailRow( + tr("myTischtennisAccount.passwordSaved", "Passwort gespeichert"), + if (status?.hasPassword == true) tr("myTischtennisAccount.yes", "Ja") else tr("myTischtennisAccount.no", "Nein"), + ) + if (!a.clubId.isNullOrBlank()) { + val clubLine = buildString { + append(a.clubName ?: "") + append(" (") + append(a.clubId) + if (!a.fedNickname.isNullOrBlank()) append(" - ").append(a.fedNickname) + append(")") + } + detailRow(tr("myTischtennisAccount.club", "Verein"), clubLine) + } + a.lastLoginSuccess?.let { detailRow(tr("myTischtennisAccount.lastSuccessfulLogin", "Letzter erfolgreicher Login"), formatDateTimeUi(it)) } + a.lastLoginAttempt?.let { detailRow(tr("myTischtennisAccount.lastLoginAttempt", "Letzter Login-Versuch"), formatDateTimeUi(it)) } + Spacer(Modifier.height(12.dp)) + Button( + onClick = { loginMode = false; showEditor = true }, + modifier = Modifier.fillMaxWidth().heightIn(min = PersonalHubTouchMin), + ) { Text(tr("myTischtennisAccount.editAccount", "Account bearbeiten")) } + OutlinedButton( + onClick = { + scope.launch { + verifyBusy = true + err = null + runCatching { + dependencies.myTischtennisApi.verifyLogin(null) + refresh++ + infoMsg = tr("myTischtennisAccount.loginSuccessful", "Login erfolgreich") + }.onFailure { e -> + if (e is ApiException && e.statusCode == 400) { + loginMode = true + showEditor = true + } else { + err = e.message + } + } + verifyBusy = false + } + }, + enabled = !verifyBusy, + modifier = Modifier.fillMaxWidth().heightIn(min = PersonalHubTouchMin).padding(top = 8.dp), + ) { + Text(if (verifyBusy) "…" else tr("myTischtennisAccount.loginAgain", "Erneut einloggen")) + } + OutlinedButton( + onClick = { confirmUnlink = true }, + modifier = Modifier.fillMaxWidth().heightIn(min = PersonalHubTouchMin).padding(top = 8.dp), + ) { Text(tr("myTischtennisAccount.unlinkAccount", "Account trennen")) } + } + } + } + else -> { + Text(tr("myTischtennisAccount.noAccountLinked", "Kein myTischtennis-Account verknüpft.")) + Button( + onClick = { loginMode = false; showEditor = true }, + modifier = Modifier.fillMaxWidth().padding(top = 12.dp).heightIn(min = PersonalHubTouchMin), + ) { Text(tr("myTischtennisAccount.linkAccount", "Account verknüpfen")) } + } + } + Spacer(Modifier.height(16.dp)) + Card(modifier = Modifier.fillMaxWidth(), elevation = 1.dp) { + Column(Modifier.padding(16.dp)) { + Text(tr("myTischtennisAccount.aboutMyTischtennis", "Über myTischtennis"), fontWeight = FontWeight.SemiBold) + Text(tr("myTischtennisAccount.aboutDescription", ""), modifier = Modifier.padding(top = 6.dp)) + Text("• " + tr("myTischtennisAccount.aboutFeature1", ""), modifier = Modifier.padding(top = 4.dp)) + Text("• " + tr("myTischtennisAccount.aboutFeature2", ""), modifier = Modifier.padding(top = 2.dp)) + Text("• " + tr("myTischtennisAccount.aboutFeature3", ""), modifier = Modifier.padding(top = 2.dp)) + Text( + tr("messages.info", "Hinweis") + ": " + tr("myTischtennisAccount.aboutHint", ""), + modifier = Modifier.padding(top = 8.dp), + style = MaterialTheme.typography.caption, + ) + } + } + } + + if (showEditor) { + MyTischtennisEditorDialog( + account = account, + loginMode = loginMode, + tr = ::tr, + onDismiss = { showEditor = false; loginMode = false }, + onSaved = { + showEditor = false + loginMode = false + refresh++ + infoMsg = tr("myTischtennisAccount.accountSaved", "Gespeichert") + }, + onLoggedIn = { + showEditor = false + loginMode = false + refresh++ + infoMsg = tr("myTischtennisAccount.loginSuccessful", "Login erfolgreich") + }, + scope = scope, + api = dependencies.myTischtennisApi, + ) + } + + if (confirmUnlink) { + AlertDialog( + onDismissRequest = { confirmUnlink = false }, + title = { Text(tr("myTischtennisAccount.unlinkAccountTitle", "Account trennen")) }, + text = { Text(tr("myTischtennisAccount.unlinkAccountConfirm", "Wirklich trennen?")) }, + confirmButton = { + TextButton( + onClick = { + scope.launch { + runCatching { + dependencies.myTischtennisApi.deleteAccount() + account = null + status = null + confirmUnlink = false + refresh++ + infoMsg = tr("myTischtennisAccount.accountUnlinked", "Getrennt") + }.onFailure { err = it.message; confirmUnlink = false } + } + }, + ) { Text(tr("myTischtennisAccount.unlinkAccount", "Trennen")) } + }, + dismissButton = { + TextButton(onClick = { confirmUnlink = false }) { Text(tr("myTischtennisDialog.cancel", "Abbrechen")) } + }, + ) + } +} + +@Composable +private fun detailRow(label: String, value: String) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(label, fontWeight = FontWeight.Medium, modifier = Modifier.weight(0.45f)) + Text(value, modifier = Modifier.weight(0.55f)) + } +} + +@Composable +private fun MyTischtennisEditorDialog( + account: MyTischtennisAccountDto?, + loginMode: Boolean, + tr: (String, String) -> String, + onDismiss: () -> Unit, + onSaved: () -> Unit, + onLoggedIn: () -> Unit, + scope: kotlinx.coroutines.CoroutineScope, + api: de.tt_tagebuch.shared.api.MyTischtennisApi, +) { + var email by remember(account?.id, loginMode) { mutableStateOf(account?.email.orEmpty()) } + var password by remember(account?.id, loginMode) { mutableStateOf("") } + var savePw by remember(account?.id, loginMode) { mutableStateOf(account?.savePassword == true) } + var autoRat by remember(account?.id, loginMode) { mutableStateOf(account?.autoUpdateRatings == true) } + var userPw by remember(account?.id, loginMode) { mutableStateOf("") } + var err by remember { mutableStateOf(null) } + var busy by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + if (loginMode) tr("myTischtennisDialog.login", "Einloggen") + else if (account != null) tr("myTischtennisDialog.editAccount", "Bearbeiten") + else tr("myTischtennisDialog.linkAccount", "Verknüpfen"), + ) + }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + err?.let { Text(it, color = MaterialTheme.colors.error) } + if (loginMode) { + OutlinedTextField(value = email, onValueChange = {}, readOnly = true, label = { Text(tr("myTischtennisDialog.email", "E-Mail")) }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text(tr("myTischtennisDialog.password", "Passwort")) }, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth(), + ) + } else { + OutlinedTextField(value = email, onValueChange = { email = it }, label = { Text(tr("myTischtennisDialog.email", "E-Mail")) }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text(tr("myTischtennisDialog.password", "Passwort")) }, + placeholder = { Text(tr("myTischtennisDialog.passwordPlaceholderKeep", "")) }, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth(), + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Switch(checked = savePw, onCheckedChange = { savePw = it }) + Text(tr("myTischtennisDialog.savePassword", "Passwort speichern"), modifier = Modifier.padding(start = 8.dp)) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Switch(checked = autoRat, onCheckedChange = { autoRat = it }, enabled = savePw) + Text(tr("myTischtennisDialog.autoUpdateRatings", "Auto-Ratings"), modifier = Modifier.padding(start = 8.dp)) + } + if (password.isNotBlank()) { + OutlinedTextField( + value = userPw, + onValueChange = { userPw = it }, + label = { Text(tr("myTischtennisDialog.appPassword", "App-Passwort")) }, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + }, + confirmButton = { + TextButton( + enabled = !busy && if (loginMode) { + password.isNotBlank() + } else { + email.isNotBlank() && (password.isBlank() || userPw.isNotBlank()) + }, + onClick = { + scope.launch { + busy = true + err = null + runCatching { + if (loginMode) { + api.verifyLogin(password) + onLoggedIn() + } else { + if (password.isNotBlank() && userPw.isBlank()) return@launch + api.upsertAccount( + MyTischtennisAccountUpsertBody( + email = email.trim(), + password = password.ifBlank { null }, + savePassword = savePw, + autoUpdateRatings = if (savePw) autoRat else false, + userPassword = userPw.ifBlank { null }, + ), + ) + onSaved() + } + }.onFailure { e -> err = e.message } + busy = false + } + }, + ) { + Text( + if (loginMode) tr("myTischtennisDialog.login", "Login") + else tr("myTischtennisDialog.save", "Speichern"), + ) + } + }, + dismissButton = { + TextButton(onClick = onDismiss, enabled = !busy) { Text(tr("myTischtennisDialog.cancel", "Abbrechen")) } + }, + ) +} + +@Composable +private fun ClickTtAccountScreen(dependencies: AppDependencies, onBack: () -> Unit) { + val languageCode = LocalLanguageCode.current + fun tr(key: String, fb: String) = MobileStrings.get(languageCode, key, fb) + fun ct(key: String, fb: String) = MobileStrings.get(languageCode, key, fb) // same; use navigation keys + val scope = rememberCoroutineScope() + var loading by remember { mutableStateOf(true) } + var account by remember { mutableStateOf(null) } + var status by remember { mutableStateOf(null) } + var err by remember { mutableStateOf(null) } + var refresh by remember { mutableIntStateOf(0) } + var showEditor by remember { mutableStateOf(false) } + var loginMode by remember { mutableStateOf(false) } + var verifyBusy by remember { mutableStateOf(false) } + var confirmDelete by remember { mutableStateOf(false) } + var infoMsg by remember { mutableStateOf(null) } + + LaunchedEffect(refresh) { + loading = true + err = null + runCatching { + val a = dependencies.clickTtAccountApi.getAccount() + val s = dependencies.clickTtAccountApi.getStatus() + account = a.account + status = s + }.onFailure { err = it.message } + loading = false + } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(PersonalHubPad), + ) { + PersonalHubTopBar(tr("navigation.clickTtAccount", "HTTV / click-TT-Account"), onBack) + infoMsg?.let { Text(it, color = MaterialTheme.colors.primary, modifier = Modifier.padding(bottom = 8.dp)) } + err?.let { Text(it, color = MaterialTheme.colors.error, modifier = Modifier.padding(bottom = 8.dp)) } + when { + loading -> CircularProgressIndicator(modifier = Modifier.padding(top = 24.dp)) + account != null -> { + val a = account!! + Card(modifier = Modifier.fillMaxWidth(), elevation = 1.dp) { + Column(Modifier.padding(16.dp)) { + Text(ct("mobile.clickTt.linked", "Verknüpfter Account"), fontWeight = FontWeight.SemiBold) + detailRow(ct("mobile.clickTt.username", "Benutzername"), a.username ?: "–") + detailRow( + ct("mobile.clickTt.passwordSaved", "Passwort gespeichert"), + if (status?.hasPassword == true) tr("myTischtennisAccount.yes", "Ja") else tr("myTischtennisAccount.no", "Nein"), + ) + a.lastLoginSuccess?.let { detailRow(ct("mobile.clickTt.lastOk", "Letzter erfolgreicher Login"), formatDateTimeUi(it)) } + a.lastLoginAttempt?.let { detailRow(ct("mobile.clickTt.lastTry", "Letzter Login-Versuch"), formatDateTimeUi(it)) } + Spacer(Modifier.height(12.dp)) + Button( + onClick = { loginMode = false; showEditor = true }, + modifier = Modifier.fillMaxWidth().heightIn(min = PersonalHubTouchMin), + ) { Text(ct("mobile.clickTt.edit", "Account bearbeiten")) } + OutlinedButton( + onClick = { + scope.launch { + verifyBusy = true + err = null + runCatching { + dependencies.clickTtAccountApi.verifyLogin(null) + refresh++ + infoMsg = ct("mobile.clickTt.verifyOk", "Login erfolgreich") + }.onFailure { e -> + if (e is ApiException && e.statusCode == 400) { + loginMode = true + showEditor = true + } else { + err = e.message + } + } + verifyBusy = false + } + }, + enabled = !verifyBusy, + modifier = Modifier.fillMaxWidth().heightIn(min = PersonalHubTouchMin).padding(top = 8.dp), + ) { + Text(if (verifyBusy) "…" else ct("mobile.clickTt.verifyAgain", "Login erneut testen")) + } + OutlinedButton( + onClick = { confirmDelete = true }, + modifier = Modifier.fillMaxWidth().heightIn(min = PersonalHubTouchMin).padding(top = 8.dp), + ) { Text(ct("mobile.clickTt.delete", "Account löschen")) } + } + } + } + else -> { + Text(ct("mobile.clickTt.none", "Es ist noch kein HTTV-/click-TT-Account hinterlegt.")) + Button( + onClick = { loginMode = false; showEditor = true }, + modifier = Modifier.fillMaxWidth().padding(top = 12.dp).heightIn(min = PersonalHubTouchMin), + ) { Text(ct("mobile.clickTt.link", "Account verknüpfen")) } + } + } + } + + if (showEditor) { + ClickTtEditorDialog( + account = account, + loginMode = loginMode, + tr = ::ct, + onDismiss = { showEditor = false; loginMode = false }, + onSaved = { + showEditor = false + loginMode = false + refresh++ + infoMsg = ct("mobile.clickTt.saved", "Gespeichert") + }, + onLoggedIn = { + showEditor = false + loginMode = false + refresh++ + infoMsg = ct("mobile.clickTt.verifyOk", "Login erfolgreich") + }, + scope = scope, + api = dependencies.clickTtAccountApi, + ) + } + + if (confirmDelete) { + AlertDialog( + onDismissRequest = { confirmDelete = false }, + title = { Text(ct("mobile.clickTt.deleteTitle", "Account löschen")) }, + text = { Text(ct("mobile.clickTt.deleteConfirm", "Wirklich löschen?")) }, + confirmButton = { + TextButton( + onClick = { + scope.launch { + runCatching { + dependencies.clickTtAccountApi.deleteAccount() + account = null + status = null + confirmDelete = false + refresh++ + infoMsg = ct("mobile.clickTt.deleted", "Gelöscht") + }.onFailure { err = it.message; confirmDelete = false } + } + }, + ) { Text(ct("mobile.clickTt.delete", "Löschen")) } + }, + dismissButton = { + TextButton(onClick = { confirmDelete = false }) { Text(tr("myTischtennisDialog.cancel", "Abbrechen")) } + }, + ) + } +} + +@Composable +private fun ClickTtEditorDialog( + account: ClickTtAccountDto?, + loginMode: Boolean, + tr: (String, String) -> String, + onDismiss: () -> Unit, + onSaved: () -> Unit, + onLoggedIn: () -> Unit, + scope: kotlinx.coroutines.CoroutineScope, + api: de.tt_tagebuch.shared.api.ClickTtAccountApi, +) { + var username by remember(account?.id, loginMode) { mutableStateOf(account?.username.orEmpty()) } + var password by remember(account?.id, loginMode) { mutableStateOf("") } + var savePw by remember(account?.id, loginMode) { mutableStateOf(account?.savePassword == true) } + var userPw by remember(account?.id, loginMode) { mutableStateOf("") } + var err by remember { mutableStateOf(null) } + var busy by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + if (loginMode) tr("mobile.clickTt.loginTitle", "Login") + else if (account != null) tr("mobile.clickTt.editTitle", "Account bearbeiten") + else tr("mobile.clickTt.linkTitle", "Account verknüpfen"), + ) + }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + err?.let { Text(it, color = MaterialTheme.colors.error) } + OutlinedTextField( + value = username, + onValueChange = { if (!loginMode) username = it }, + readOnly = loginMode, + label = { Text(tr("mobile.clickTt.username", "Benutzername")) }, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text(tr("mobile.clickTt.password", "Passwort")) }, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth(), + ) + if (!loginMode) { + Row(verticalAlignment = Alignment.CenterVertically) { + Switch(checked = savePw, onCheckedChange = { savePw = it }) + Text(tr("mobile.clickTt.savePassword", "Passwort speichern"), modifier = Modifier.padding(start = 8.dp)) + } + if (password.isNotBlank()) { + OutlinedTextField( + value = userPw, + onValueChange = { userPw = it }, + label = { Text(tr("mobile.clickTt.appPassword", "App-Passwort")) }, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + }, + confirmButton = { + TextButton( + enabled = !busy && if (loginMode) { + password.isNotBlank() + } else { + username.isNotBlank() && (password.isBlank() || userPw.isNotBlank()) + }, + onClick = { + scope.launch { + busy = true + err = null + runCatching { + if (loginMode) { + api.verifyLogin(password) + onLoggedIn() + } else { + if (password.isNotBlank() && userPw.isBlank()) return@launch + api.upsertAccount( + ClickTtAccountUpsertBody( + username = username.trim(), + password = password.ifBlank { null }, + savePassword = savePw, + userPassword = userPw.ifBlank { null }, + ), + ) + onSaved() + } + }.onFailure { e -> err = e.message } + busy = false + } + }, + ) { + Text(if (loginMode) tr("mobile.clickTt.testLogin", "Login testen") else tr("myTischtennisDialog.save", "Speichern")) + } + }, + dismissButton = { + TextButton(onClick = onDismiss, enabled = !busy) { Text(tr("myTischtennisDialog.cancel", "Abbrechen")) } + }, + ) +} + +private enum class ClickTtPageKind { LeaguePage, ClubInfo, RegionMeetings, DirectUrl, PresetHttvHome, PresetTtdeLogin } + +private val clickTtPresetUrls = mapOf( + ClickTtPageKind.PresetHttvHome to "https://httv.de", + ClickTtPageKind.PresetTtdeLogin to "https://ttde-id.liga.nu/oauth2/authz/ttde?scope=nuLiga&response_type=code&redirect_uri=https%3A%2F%2Fhttv.click-tt.de%2Fcgi-bin%2FWebObjects%2FnuLigaTTDE.woa%2Fwa%2FoAuthLogin&state=nonce%3DVF6WbXUOvTjpsGq9zoZ6oxTH7625JEGH&client_id=XtVpGjXKAhz3BZuu", +) + +private fun encodeQuery(s: String): String = URLEncoder.encode(s, StandardCharsets.UTF_8.name()) + +private fun buildClickTtProxyUrl( + backendBase: String, + sid: String, + kind: ClickTtPageKind, + association: String, + championship: String, + clubId: String, + directUrl: String, +): String { + val base = backendBase.trimEnd('/') + val sb = StringBuilder(base).append("/api/clicktt/proxy?sid=").append(encodeQuery(sid)) + when (kind) { + ClickTtPageKind.DirectUrl -> { + sb.append("&url=").append(encodeQuery(directUrl.trim())) + } + ClickTtPageKind.PresetHttvHome, ClickTtPageKind.PresetTtdeLogin -> { + val u = clickTtPresetUrls[kind]!! + sb.append("&url=").append(encodeQuery(u)) + } + ClickTtPageKind.LeaguePage -> { + sb.append("&type=leaguePage") + sb.append("&association=").append(encodeQuery(association)) + sb.append("&championship=").append(encodeQuery(championship)) + } + ClickTtPageKind.RegionMeetings -> { + sb.append("&type=regionMeetings") + sb.append("&association=").append(encodeQuery(association)) + sb.append("&championship=").append(encodeQuery(championship)) + } + ClickTtPageKind.ClubInfo -> { + sb.append("&type=clubInfo") + sb.append("&association=").append(encodeQuery(association)) + sb.append("&clubId=").append(encodeQuery(clubId.trim())) + } + } + return sb.toString() +} + +@SuppressLint("SetJavaScriptEnabled") +@Composable +private fun ClickTtBrowserScreen(dependencies: AppDependencies, onBack: () -> Unit) { + val languageCode = LocalLanguageCode.current + fun tr(key: String, fb: String) = MobileStrings.get(languageCode, key, fb) + var kind by remember { mutableStateOf(ClickTtPageKind.LeaguePage) } + var assocMenu by remember { mutableStateOf(false) } + var pageMenu by remember { mutableStateOf(false) } + var association by remember { mutableStateOf("HeTTV") } + var championship by remember { mutableStateOf("HTTV 25/26") } + var clubId by remember { mutableStateOf("") } + var directUrl by remember { mutableStateOf("") } + var sid by remember { mutableStateOf(UUID.randomUUID().toString()) } + var loadedUrl by remember { mutableStateOf(null) } + + val assocs = listOf("HeTTV", "RTTV", "WTTV", "TTVNw") + fun canLoad(): Boolean = when (kind) { + ClickTtPageKind.DirectUrl -> directUrl.isNotBlank() + ClickTtPageKind.ClubInfo -> clubId.isNotBlank() + else -> true + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(PersonalHubPad), + ) { + PersonalHubTopBar(tr("navigation.clickTtBrowser", "HTTV / click-TT"), onBack) + Text( + tr("mobile.clickTtBrowser.hint", "Lade click-TT-Seiten über den Server-Proxy. JavaScript ist aktiviert."), + style = MaterialTheme.typography.caption, + modifier = Modifier.padding(bottom = 8.dp), + ) + Text(tr("mobile.clickTtBrowser.pageType", "Seitentyp"), style = MaterialTheme.typography.caption) + Box { + TextButton(onClick = { pageMenu = true }, modifier = Modifier.fillMaxWidth()) { + Text( + when (kind) { + ClickTtPageKind.LeaguePage -> "Ligenübersicht" + ClickTtPageKind.ClubInfo -> "Vereinsinfo" + ClickTtPageKind.RegionMeetings -> "Regionsspielplan" + ClickTtPageKind.DirectUrl -> "Direkte URL" + ClickTtPageKind.PresetHttvHome -> "HTTV Einstieg" + ClickTtPageKind.PresetTtdeLogin -> "TTDE Login" + }, + ) + } + DropdownMenu(expanded = pageMenu, onDismissRequest = { pageMenu = false }) { + DropdownMenuItem(onClick = { kind = ClickTtPageKind.LeaguePage; pageMenu = false }) { Text("Ligenübersicht") } + DropdownMenuItem(onClick = { kind = ClickTtPageKind.ClubInfo; pageMenu = false }) { Text("Vereinsinfo") } + DropdownMenuItem(onClick = { kind = ClickTtPageKind.RegionMeetings; pageMenu = false }) { Text("Regionsspielplan") } + DropdownMenuItem(onClick = { kind = ClickTtPageKind.DirectUrl; pageMenu = false }) { Text("Direkte URL") } + DropdownMenuItem(onClick = { kind = ClickTtPageKind.PresetHttvHome; pageMenu = false }) { Text("HTTV Einstieg") } + DropdownMenuItem(onClick = { kind = ClickTtPageKind.PresetTtdeLogin; pageMenu = false }) { Text("TTDE Login") } + } + } + if (kind == ClickTtPageKind.LeaguePage || kind == ClickTtPageKind.ClubInfo || kind == ClickTtPageKind.RegionMeetings) { + Text(tr("mobile.clickTtBrowser.association", "Verband"), style = MaterialTheme.typography.caption, modifier = Modifier.padding(top = 8.dp)) + Box { + TextButton(onClick = { assocMenu = true }) { Text(association) } + DropdownMenu(expanded = assocMenu, onDismissRequest = { assocMenu = false }) { + assocs.forEach { a -> + DropdownMenuItem(onClick = { association = a; assocMenu = false }) { Text(a) } + } + } + } + } + if (kind == ClickTtPageKind.LeaguePage || kind == ClickTtPageKind.RegionMeetings) { + OutlinedTextField( + value = championship, + onValueChange = { championship = it }, + label = { Text(tr("mobile.clickTtBrowser.championship", "Championship / Saison")) }, + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + singleLine = true, + ) + } + if (kind == ClickTtPageKind.ClubInfo) { + OutlinedTextField( + value = clubId, + onValueChange = { clubId = it }, + label = { Text(tr("mobile.clickTtBrowser.clubId", "Vereins-ID")) }, + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + singleLine = true, + ) + } + if (kind == ClickTtPageKind.DirectUrl) { + OutlinedTextField( + value = directUrl, + onValueChange = { directUrl = it }, + label = { Text("URL") }, + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + singleLine = false, + minLines = 2, + ) + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button( + onClick = { + if (!canLoad()) return@Button + sid = UUID.randomUUID().toString() + loadedUrl = buildClickTtProxyUrl( + dependencies.apiConfig.baseUrl, + sid, + kind, + association, + championship, + clubId, + directUrl, + ) + }, + enabled = canLoad(), + modifier = Modifier.weight(1f).heightIn(min = PersonalHubTouchMin), + ) { Text(tr("mobile.clickTtBrowser.load", "Laden")) } + OutlinedButton( + onClick = { loadedUrl = null }, + modifier = Modifier.heightIn(min = PersonalHubTouchMin), + ) { Text(tr("mobile.clickTtBrowser.clear", "Leeren")) } + } + Spacer(Modifier.height(8.dp)) + val u = loadedUrl + if (u != null) { + AndroidView( + factory = { ctx -> + WebView(ctx).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.cacheMode = WebSettings.LOAD_DEFAULT + webViewClient = WebViewClient() + webChromeClient = WebChromeClient() + loadUrl(u) + } + }, + update = { w -> + if (w.url != u) w.loadUrl(u) + }, + modifier = Modifier + .fillMaxWidth() + .weight(1f), + ) + } else { + Text( + tr("mobile.clickTtBrowser.placeholder", "Seite laden …"), + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(top = 16.dp), + ) + } + } +}