feat(Diary): implement quick create functionality for training days and enhance localization
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 44s

- Added a new button for quick creation of training days in the DiaryView, improving user experience.
- Implemented logic to find the next available training slot across groups and create a training day entry.
- Enhanced localization by adding new keys for quick create messages in multiple languages, ensuring better accessibility for users.
- Updated the DiaryManager to handle quick create operations and clear errors effectively.
This commit is contained in:
Torsten Schulz (local)
2026-05-14 22:35:29 +02:00
parent 95a3e9438a
commit 83294406a4
29 changed files with 1976 additions and 46 deletions

View File

@@ -74,6 +74,10 @@ android {
buildTypes {
getByName("release") {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
compileOptions {

View File

@@ -0,0 +1,29 @@
# Release / R8 (ProGuard) Vorbereitung (Phase 14)
# Kotlin Serialization: @Serializable-Klassen & generierte Serializer
-keepattributes *Annotation*, InnerClasses, EnclosingMethod
-keepattributes RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations
-keepclassmembers class kotlinx.serialization.json.** { *; }
-dontnote kotlinx.serialization.**
-keepclassmembers @kotlinx.serialization.Serializable class ** {
*** Companion;
<fields>;
}
-if class **$$serializer { public static ** INSTANCE; }
-keepclassmembers class <1>$$serializer {
<init>(kotlinx.serialization.encoding.CompositeEncoder,kotlinx.serialization.descriptors.SerialDescriptor,int);
private static final ** INSTANCE;
}
# Ktor Client (OkHttp, Engines)
-keep class io.ktor.client.** { *; }
-keep class io.ktor.http.** { *; }
-keep class io.ktor.util.** { *; }
-keep class io.ktor.serialization.** { *; }
# Koin (Module / Reflection)
-keep class org.koin.** { *; }
# BuildConfig (falls R8 später aktiviert wird)
-keep class de.tsschulz.tt_tagebuch.BuildConfig { *; }

View File

@@ -2,6 +2,7 @@ package de.tsschulz.tt_tagebuch.app.ui
import android.content.Intent
import android.net.Uri
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
@@ -746,7 +747,11 @@ private fun NavRailSectionHeader(
) {
Icon(
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription = null,
contentDescription = if (expanded) {
tr("mobile.collapseSection", "Abschnitt einklappen") + ": $title"
} else {
tr("mobile.expandSection", "Abschnitt ausklappen") + ": $title"
},
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.7f),
)
@@ -771,6 +776,9 @@ private fun NavRailLeafItem(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 2.dp, horizontal = 4.dp)
.semantics(mergeDescendants = true) {
contentDescription = label
}
.clickable(onClick = onClick),
color = if (selected) {
MaterialTheme.colors.primary.copy(alpha = 0.1f)
@@ -1376,8 +1384,59 @@ private fun DiaryListScreen(
) {
val clubState by dependencies.clubManager.state.collectAsState()
val diaryState by dependencies.diaryManager.state.collectAsState()
val languageState by dependencies.languageManager.state.collectAsState()
val androidCtx = LocalContext.current
val clubId = clubState.currentClubId ?: return
var dayMenuExpanded by rememberSaveable { mutableStateOf(false) }
var showNewDateDialog by rememberSaveable { mutableStateOf(false) }
var newDiaryDateStr by rememberSaveable { mutableStateOf("") }
var newDiaryStart by rememberSaveable { mutableStateOf("") }
var newDiaryEnd by rememberSaveable { mutableStateOf("") }
val newDateScope = rememberCoroutineScope()
var newDateScheduleGroups by remember { mutableStateOf<List<TrainingGroupDto>>(emptyList()) }
var newDateScheduleLoading by remember { mutableStateOf(false) }
var newDateScheduleLoadError by remember { mutableStateOf<String?>(null) }
var newDateGroupMenuExpanded by remember { mutableStateOf(false) }
var selectedNewDateGroupId by remember { mutableStateOf<Int?>(null) }
var quickCreateBusy by remember { mutableStateOf(false) }
val diaryDatesNormKey = remember(diaryState.dates) {
diaryState.dates.map { it.date.take(10).trim() }.sorted().joinToString("|")
}
val existingDiaryDatesNorm = remember(diaryDatesNormKey) {
diaryDatesNormKey.split("|").filter { it.isNotBlank() }.toSet()
}
val newDateSlotSuggestion = remember(selectedNewDateGroupId, newDateScheduleGroups, existingDiaryDatesNorm) {
val gid = selectedNewDateGroupId ?: return@remember null
val g = newDateScheduleGroups.find { it.id == gid } ?: return@remember null
nextDiarySlotFromTrainingTimes(g.trainingTimes, existingDiaryDatesNorm)
}
/** Sobald eine Gruppe gewählt ist und ein Vorschlag existiert: Felder wie in der Web-App füllen (nächster freier Wochentag). */
LaunchedEffect(selectedNewDateGroupId, newDateSlotSuggestion, showNewDateDialog) {
if (!showNewDateDialog) return@LaunchedEffect
val sug = newDateSlotSuggestion
if (selectedNewDateGroupId != null && sug != null) {
newDiaryDateStr = sug.date
newDiaryStart = sug.trainingStart
newDiaryEnd = sug.trainingEnd
}
}
LaunchedEffect(showNewDateDialog, clubId) {
if (!showNewDateDialog) return@LaunchedEffect
newDateScheduleLoadError = null
newDateScheduleLoading = true
newDateScheduleGroups = runCatching { dependencies.membersManager.trainingScheduleGroups(clubId) }
.fold(
onSuccess = { it },
onFailure = { t ->
newDateScheduleLoadError = t.message ?: t.javaClass.simpleName
emptyList()
},
)
newDateScheduleLoading = false
}
LaunchedEffect(clubId) {
onSelectedEntryId(null)
@@ -1437,16 +1496,66 @@ private fun DiaryListScreen(
}
Button(
onClick = {
val today = kotlin.runCatching { java.time.LocalDate.now().toString() }.getOrElse { "2026-01-01" }
val defaultStart = diaryState.dates.firstOrNull()?.trainingStart?.takeIf { it.isNotBlank() } ?: "17:30:00"
val defaultEnd = diaryState.dates.firstOrNull()?.trainingEnd?.takeIf { it.isNotBlank() } ?: "19:30:00"
dependencies.applicationScope.launch {
dependencies.diaryManager.createDate(clubId, today, defaultStart, defaultEnd)
dependencies.diaryManager.loadDates(clubId)
}
dependencies.diaryManager.clearError()
selectedNewDateGroupId = null
newDateGroupMenuExpanded = false
val tmpl = diaryState.dates.firstOrNull()
newDiaryDateStr = kotlin.runCatching { java.time.LocalDate.now().toString() }.getOrElse { "" }
newDiaryStart = diaryTimeForFormField(tmpl?.trainingStart).ifBlank { "17:30" }
newDiaryEnd = diaryTimeForFormField(tmpl?.trainingEnd).ifBlank { "19:30" }
showNewDateDialog = true
},
modifier = Modifier.heightIn(min = TouchMinHeight),
) { Text(tr("mobile.new", "Neu")) }
OutlinedButton(
onClick = {
if (quickCreateBusy || diaryState.isLoading) return@OutlinedButton
newDateScope.launch {
quickCreateBusy = true
dependencies.diaryManager.clearError()
val lang = languageState.currentLanguageCode
val groups = runCatching { dependencies.membersManager.trainingScheduleGroups(clubId) }.fold(
onSuccess = { it },
onFailure = { t ->
quickCreateBusy = false
Toast.makeText(
androidCtx,
t.message?.takeIf { it.isNotBlank() }
?: MobileStrings.get(lang, "diary.quickCreateFailed", "Der Trainingstag konnte nicht angelegt werden."),
Toast.LENGTH_LONG,
).show()
return@launch
},
)
val slot = findNextQuickDiarySlotAcrossGroups(groups, existingDiaryDatesNorm)
if (slot == null) {
quickCreateBusy = false
Toast.makeText(
androidCtx,
MobileStrings.get(lang, "diary.quickCreateNoSlot", "Kein freier Trainingstermin gefunden."),
Toast.LENGTH_LONG,
).show()
return@launch
}
val id = dependencies.diaryManager.createDate(
clubId,
slot.date,
diaryTimeFieldToApi(slot.trainingStart),
diaryTimeFieldToApi(slot.trainingEnd),
)
quickCreateBusy = false
if (id != null) {
onSelectedEntryId(id)
} else {
val err = dependencies.diaryManager.state.value.error
?: MobileStrings.get(lang, "diary.quickCreateFailed", "Der Trainingstag konnte nicht angelegt werden.")
Toast.makeText(androidCtx, err, Toast.LENGTH_LONG).show()
}
}
},
enabled = !quickCreateBusy && !diaryState.isLoading,
modifier = Modifier.heightIn(min = TouchMinHeight),
) { Text(tr("diary.quickCreate", "Schnellanlegen")) }
}
if (diaryState.isLoading) LoadingInline()
ErrorText(diaryState.error)
@@ -1455,6 +1564,175 @@ private fun DiaryListScreen(
}
Spacer(modifier = Modifier.weight(1f))
}
if (showNewDateDialog) {
AlertDialog(
onDismissRequest = {
if (!diaryState.isLoading) {
showNewDateDialog = false
selectedNewDateGroupId = null
newDateGroupMenuExpanded = false
}
},
title = { Text(tr("diary.createNewDate", "Neuen Trainingstag anlegen")) },
text = {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
) {
Text(
tr("diary.selectTrainingGroup", "Trainingsgruppe"),
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.75f),
)
Spacer(modifier = Modifier.height(6.dp))
if (newDateScheduleLoading) {
CircularProgressIndicator(
modifier = Modifier
.size(28.dp)
.padding(vertical = 4.dp),
strokeWidth = 3.dp,
)
}
newDateScheduleLoadError?.let { err ->
Text(
err,
color = MaterialTheme.colors.error,
style = MaterialTheme.typography.caption,
modifier = Modifier.padding(bottom = 8.dp),
)
}
Box(modifier = Modifier.fillMaxWidth()) {
OutlinedButton(
onClick = { newDateGroupMenuExpanded = true },
enabled = !diaryState.isLoading && !newDateScheduleLoading,
modifier = Modifier.fillMaxWidth(),
) {
val label = selectedNewDateGroupId?.let { gid ->
newDateScheduleGroups.find { it.id == gid }?.name?.takeIf { it.isNotBlank() }
} ?: tr("diary.selectTrainingGroupPlaceholder", "Gruppe wählen (optional)")
Text(label, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
DropdownMenu(
expanded = newDateGroupMenuExpanded,
onDismissRequest = { newDateGroupMenuExpanded = false },
) {
DropdownMenuItem(
onClick = {
selectedNewDateGroupId = null
newDateGroupMenuExpanded = false
},
) {
Text(tr("diary.noTrainingGroupManual", "Keine / manuell"))
}
newDateScheduleGroups.forEach { g ->
DropdownMenuItem(
onClick = {
selectedNewDateGroupId = g.id
newDateGroupMenuExpanded = false
},
) {
Text(g.name.ifBlank { "Gruppe ${g.id}" })
}
}
}
}
newDateSlotSuggestion?.let { sug ->
Spacer(modifier = Modifier.height(8.dp))
Text(
tr("diary.suggestion", "Vorschlag"),
style = MaterialTheme.typography.caption,
fontWeight = FontWeight.SemiBold,
)
Text(
"${tr("diary.nextAppointment", "Nächster Termin")}: ${formatDate(sug.date)} ${sug.trainingStart} ${sug.trainingEnd}",
style = MaterialTheme.typography.body2,
modifier = Modifier.padding(top = 4.dp),
)
TextButton(
onClick = {
newDiaryDateStr = sug.date
newDiaryStart = sug.trainingStart
newDiaryEnd = sug.trainingEnd
},
enabled = !diaryState.isLoading,
) { Text(tr("diary.applySuggestion", "Vorschlag übernehmen")) }
}
Divider(modifier = Modifier.padding(vertical = 12.dp))
OutlinedTextField(
value = newDiaryDateStr,
onValueChange = { newDiaryDateStr = it },
label = { Text(tr("diary.date", "Datum (YYYY-MM-DD)")) },
singleLine = true,
enabled = !diaryState.isLoading,
modifier = Modifier.fillMaxWidth(),
)
TextButton(
onClick = {
newDiaryDateStr = kotlin.runCatching { java.time.LocalDate.now().toString() }.getOrElse { newDiaryDateStr }
},
enabled = !diaryState.isLoading,
) { Text(tr("diary.today", "Heute")) }
OutlinedTextField(
value = newDiaryStart,
onValueChange = { newDiaryStart = it },
label = { Text(tr("diary.trainingStart", "Trainingsbeginn (HH:mm)")) },
singleLine = true,
enabled = !diaryState.isLoading,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = newDiaryEnd,
onValueChange = { newDiaryEnd = it },
label = { Text(tr("diary.trainingEnd", "Trainingsende (HH:mm)")) },
singleLine = true,
enabled = !diaryState.isLoading,
modifier = Modifier.fillMaxWidth(),
)
diaryState.error?.let { err ->
Text(
err,
color = MaterialTheme.colors.error,
style = MaterialTheme.typography.caption,
modifier = Modifier.padding(top = 8.dp),
)
}
}
},
confirmButton = {
Button(
onClick = {
newDateScope.launch {
val id = dependencies.diaryManager.createDate(
clubId,
newDiaryDateStr.trim(),
diaryTimeFieldToApi(newDiaryStart),
diaryTimeFieldToApi(newDiaryEnd),
)
if (id != null) {
showNewDateDialog = false
selectedNewDateGroupId = null
newDateGroupMenuExpanded = false
onSelectedEntryId(id)
}
}
},
enabled = newDiaryDateStr.trim().length >= 8 && !diaryState.isLoading,
) { Text(tr("diary.createDate", "Anlegen")) }
},
dismissButton = {
TextButton(
onClick = {
showNewDateDialog = false
selectedNewDateGroupId = null
newDateGroupMenuExpanded = false
},
enabled = !diaryState.isLoading,
) { Text(tr("messages.cancel", "Abbrechen")) }
},
)
}
}
@Composable
@@ -6260,6 +6538,95 @@ private fun formatTimeRange(start: String?, end: String?): String {
return if (parts.isEmpty()) "Keine Zeiten" else parts.joinToString(" - ")
}
/** Anzeige wie HTML time (HH:mm); API erwartet oft HH:mm:ss. */
private fun diaryTimeForFormField(apiTime: String?): String {
val t = apiTime?.trim().orEmpty()
if (t.isEmpty()) return ""
return t.take(5)
}
private fun diaryTimeFieldToApi(value: String): String? {
val t = value.trim()
if (t.isEmpty()) return null
return when {
t.matches(Regex("""\d{2}:\d{2}:\d{2}""")) -> t
t.matches(Regex("""\d{2}:\d{2}""")) -> "$t:00"
else -> t
}
}
/** Nächster freier Trainingstag aus [TrainingTimeDto] (Wochentag 0=So … 6=Sa, wie Web/Kalender). */
private data class NextDiarySlotSuggestion(
val date: String,
val trainingStart: String,
val trainingEnd: String,
)
private fun localDateToJsWeekday(d: java.time.LocalDate): Int =
when (d.dayOfWeek) {
java.time.DayOfWeek.SUNDAY -> 0
java.time.DayOfWeek.MONDAY -> 1
java.time.DayOfWeek.TUESDAY -> 2
java.time.DayOfWeek.WEDNESDAY -> 3
java.time.DayOfWeek.THURSDAY -> 4
java.time.DayOfWeek.FRIDAY -> 5
java.time.DayOfWeek.SATURDAY -> 6
}
/** Nächster freier Kalendertag ab heute: erstes Datum ohne Tagebuch-Eintrag, dann erste passende Gruppe (Sortierung) mit Trainingszeit. */
private fun findNextQuickDiarySlotAcrossGroups(
groups: List<TrainingGroupDto>,
existingDiaryDatesYyyyMmDd: Set<String>,
): NextDiarySlotSuggestion? {
val sortedGroups = groups.sortedWith(compareBy<TrainingGroupDto> { it.sortOrder }.thenBy { it.id })
val today = java.time.LocalDate.now()
for (offset in 0..366) {
val check = today.plusDays(offset.toLong())
val wd = localDateToJsWeekday(check)
val norm = check.toString().take(10)
if (norm in existingDiaryDatesYyyyMmDd) continue
for (g in sortedGroups) {
val timesForDay = g.trainingTimes
.filter { it.weekday == wd && it.startTime.isNotBlank() }
.sortedWith(compareBy<TrainingTimeDto> { it.startTime }.thenBy { it.id })
val time = timesForDay.firstOrNull() ?: continue
return NextDiarySlotSuggestion(
date = norm,
trainingStart = time.startTime.trim().take(5),
trainingEnd = time.endTime.trim().take(5),
)
}
}
return null
}
private fun nextDiarySlotFromTrainingTimes(
trainingTimes: List<TrainingTimeDto>,
existingDiaryDatesYyyyMmDd: Set<String>,
): NextDiarySlotSuggestion? {
if (trainingTimes.isEmpty()) return null
val sorted = trainingTimes.sortedWith(
compareBy<TrainingTimeDto> { it.weekday }.thenBy { it.startTime }.thenBy { it.id },
)
val today = java.time.LocalDate.now()
for (offset in 0..13) {
val check = today.plusDays(offset.toLong())
val wd = localDateToJsWeekday(check)
val timesForDay = sorted.filter { it.weekday == wd }
if (timesForDay.isEmpty()) continue
val time = timesForDay.first()
val norm = check.toString().take(10)
if (norm !in existingDiaryDatesYyyyMmDd) {
return NextDiarySlotSuggestion(
date = norm,
trainingStart = time.startTime.trim().take(5),
trainingEnd = time.endTime.trim().take(5),
)
}
}
return null
}
@Composable
private fun tr(key: String, fallback: String): String = MobileStrings.get(LocalLanguageCode.current, key, fallback)