feat(CalendarView): enhance training slot merging and event normalization
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 43s

- Added a new method to normalize training time keys, improving consistency in event handling.
- Updated the logic for merging recurring training slots to include only relevant events, enhancing calendar management.
- Improved subtitle handling for merged training slots to reflect recurring status and additional details, providing clearer event information.
This commit is contained in:
Torsten Schulz (local)
2026-05-13 00:11:03 +02:00
parent 61b1f27e5e
commit ea46a6d4f9
4 changed files with 976 additions and 19 deletions

View File

@@ -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];

View File

@@ -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`
---

View File

@@ -3972,6 +3972,15 @@ private fun RowSwitch(label: String, checked: Boolean, onChecked: (Boolean) -> U
private fun SettingsScreen(dependencies: AppDependencies) {
var clubAdminSection by remember { mutableStateOf<ClubAdminSettingsSection?>(null) }
var stammdatenSection by remember { mutableStateOf<ClubStammdatenSection?>(null) }
var personalHub by remember { mutableStateOf<PersonalHubDestination?>(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."),

View File

@@ -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<MyTischtennisAccountDto?>(null) }
var status by remember { mutableStateOf<MyTischtennisStatusDto?>(null) }
var err by remember { mutableStateOf<String?>(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<String?>(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<String?>(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<ClickTtAccountDto?>(null) }
var status by remember { mutableStateOf<ClickTtAccountStatusDto?>(null) }
var err by remember { mutableStateOf<String?>(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<String?>(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<String?>(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<String?>(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),
)
}
}
}