feat(TrainingCancellation): enhance cancellation functionality and localization support
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 44s

- Updated the training cancellation controller to accept training group IDs, improving the cancellation process.
- Modified the database schema to include a JSON field for training group IDs in the training cancellations table.
- Enhanced the TrainingCancellation model to support the new training group IDs field.
- Updated the training cancellation service to normalize and handle training group IDs effectively.
- Added localization support for training cancellation features across multiple languages, improving user experience.
This commit is contained in:
Torsten Schulz (local)
2026-05-13 10:57:23 +02:00
parent 004801b1a6
commit 7981371136
22 changed files with 2306 additions and 275 deletions

View File

@@ -50,8 +50,12 @@ 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.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.CompositionLocalProvider
@@ -74,10 +78,13 @@ import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlin.math.max
import de.tt_tagebuch.app.AppDependencies
import de.tt_tagebuch.app.pdf.sharePdfFile
import de.tt_tagebuch.app.pdf.writeTrainingDaySummaryPdf
@@ -1455,6 +1462,7 @@ private fun DiaryDetailScreen(
var planGroups by remember { mutableStateOf<List<DiaryPlanGroup>>(emptyList()) }
var planMutating by remember { mutableStateOf(false) }
var planActionError by remember { mutableStateOf<String?>(null) }
var expandedPlanActionsItemId by remember { mutableStateOf<Int?>(null) }
var showAddPlanActivity by rememberSaveable { mutableStateOf(false) }
var showAddPlanGroupActivity by rememberSaveable { mutableStateOf(false) }
var showAddTrainingGroup by rememberSaveable { mutableStateOf(false) }
@@ -1474,6 +1482,8 @@ private fun DiaryDetailScreen(
var editPlanDuration by remember { mutableStateOf("") }
var editPlanDurationText by remember { mutableStateOf("") }
var editPlanGroupId by remember { mutableStateOf<Int?>(null) }
var assigningPlanItem by remember { mutableStateOf<DiaryDateActivityItem?>(null) }
var assignPlanGroupId by remember { mutableStateOf<Int?>(null) }
var participants by remember { mutableStateOf<List<DiaryTrainingParticipant>>(emptyList()) }
var participantsLoading by remember { mutableStateOf(false) }
var participantsError by remember { mutableStateOf<String?>(null) }
@@ -3161,103 +3171,220 @@ private fun DiaryDetailScreen(
val planStartTimes = remember(sortedPlan, entry.trainingStart) {
calculatePlanStartLabels(sortedPlan, entry.trainingStart)
}
if (expandedPlanActionsItemId != null && sortedPlan.none { it.id == expandedPlanActionsItemId }) {
expandedPlanActionsItemId = null
}
val planTableScroll = rememberScrollState()
val planTableWidth = max(LocalConfiguration.current.screenWidthDp, 380)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
.horizontalScroll(planTableScroll)
.clickable { expandedPlanActionsItemId = null },
) {
Text("STARTZEIT", style = MaterialTheme.typography.caption, fontWeight = FontWeight.Bold, modifier = Modifier.width(72.dp))
Text("AKTIVITÄT / ZEITBLOCK", style = MaterialTheme.typography.caption, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f))
Text("GRUPPE", style = MaterialTheme.typography.caption, fontWeight = FontWeight.Bold, modifier = Modifier.width(90.dp))
Text("DAUER", style = MaterialTheme.typography.caption, fontWeight = FontWeight.Bold, modifier = Modifier.width(64.dp))
Spacer(modifier = Modifier.width(140.dp))
}
Divider()
sortedPlan.forEach { item ->
val cfg = dependencies.apiConfig
val mainImg = item.mainActivityImagePath()?.let { cfg.toAbsoluteUrl(it) }
val nestedImg = item.groupActivities.firstNotNullOfOrNull { ga ->
ga.nestedActivityImagePath()?.let { cfg.toAbsoluteUrl(it) }
Column(modifier = Modifier.width(planTableWidth.dp)) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp, bottom = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"STARTZEIT",
style = MaterialTheme.typography.caption,
fontWeight = FontWeight.Bold,
modifier = Modifier.width(DiaryPlanColStart),
)
Text(
"AKTIVITÄT / ZEITBLOCK",
style = MaterialTheme.typography.caption,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f),
)
Text(
"GRUPPE",
style = MaterialTheme.typography.caption,
fontWeight = FontWeight.Bold,
modifier = Modifier.width(DiaryPlanColGroup),
)
Text(
"DAUER",
style = MaterialTheme.typography.caption,
fontWeight = FontWeight.Bold,
modifier = Modifier.width(DiaryPlanColDuration),
)
Spacer(modifier = Modifier.width(26.dp))
}
Divider()
sortedPlan.forEach { item ->
val cfg = dependencies.apiConfig
val mainImg = item.mainActivityImagePath()?.let { cfg.toAbsoluteUrl(it) }
val nestedImg = item.groupActivities.firstNotNullOfOrNull { ga ->
ga.nestedActivityImagePath()?.let { cfg.toAbsoluteUrl(it) }
}
DiaryPlanEditableCard(
item = item,
allPlanItems = planItems,
scheduledStart = planStartTimes[item.id],
planGroups = planGroups,
planMutating = planMutating,
canWriteDiary = canWriteDiary,
mainImageUrl = mainImg,
nestedImageUrl = nestedImg,
canReadImages = canReadDiary,
isExpanded = expandedPlanActionsItemId == item.id,
onToggleExpand = {
expandedPlanActionsItemId = if (expandedPlanActionsItemId == item.id) null else item.id
},
onAssign = {
assignPlanGroupId = item.groupId
assigningPlanItem = item
},
onOpenImage = { url -> planImageViewerUrl = url },
onEdit = { editingPlanItem = item },
onDelete = {
dependencies.applicationScope.launch {
planMutating = true
planActionError = null
try {
dependencies.diaryManager.deletePlanActivity(clubId, item.id)
planItems = dependencies.diaryManager.fetchDateActivities(clubId, entry.id)
planGroups = dependencies.diaryManager.listTrainingGroups(clubId, entry.id)
} catch (t: Throwable) {
planActionError = t.message
} finally {
planMutating = false
}
}
},
onMoveUp = {
val scope = sameTrainingPlanScope(planItems, item)
val idx = scope.indexOfFirst { it.id == item.id }
if (idx > 0) {
val targetOrder = scope[idx - 1].orderId
dependencies.applicationScope.launch {
planMutating = true
planActionError = null
try {
dependencies.diaryManager.updatePlanActivityOrder(clubId, item.id, targetOrder)
planItems = dependencies.diaryManager.fetchDateActivities(clubId, entry.id)
} catch (t: Throwable) {
planActionError = t.message
} finally {
planMutating = false
}
}
}
},
onMoveDown = {
val scope = sameTrainingPlanScope(planItems, item)
val idx = scope.indexOfFirst { it.id == item.id }
if (idx >= 0 && idx < scope.lastIndex) {
val targetOrder = scope[idx + 1].orderId
dependencies.applicationScope.launch {
planMutating = true
planActionError = null
try {
dependencies.diaryManager.updatePlanActivityOrder(clubId, item.id, targetOrder)
planItems = dependencies.diaryManager.fetchDateActivities(clubId, entry.id)
} catch (t: Throwable) {
planActionError = t.message
} finally {
planMutating = false
}
}
}
},
onDeleteNested = { nestedId ->
dependencies.applicationScope.launch {
planMutating = true
planActionError = null
try {
dependencies.diaryManager.deletePlanNestedGroupActivity(clubId, nestedId)
planItems = dependencies.diaryManager.fetchDateActivities(clubId, entry.id)
} catch (t: Throwable) {
planActionError = t.message
} finally {
planMutating = false
}
}
},
)
}
}
DiaryPlanEditableCard(
item = item,
allPlanItems = planItems,
scheduledStart = planStartTimes[item.id],
planGroups = planGroups,
planMutating = planMutating,
canWriteDiary = canWriteDiary,
mainImageUrl = mainImg,
nestedImageUrl = nestedImg,
canReadImages = canReadDiary,
onOpenImage = { url -> planImageViewerUrl = url },
onEdit = { editingPlanItem = item },
onDelete = {
dependencies.applicationScope.launch {
planMutating = true
planActionError = null
try {
dependencies.diaryManager.deletePlanActivity(clubId, item.id)
planItems = dependencies.diaryManager.fetchDateActivities(clubId, entry.id)
planGroups = dependencies.diaryManager.listTrainingGroups(clubId, entry.id)
} catch (t: Throwable) {
planActionError = t.message
} finally {
planMutating = false
}
}
},
onMoveUp = {
val scope = sameTrainingPlanScope(planItems, item)
val idx = scope.indexOfFirst { it.id == item.id }
if (idx > 0) {
val targetOrder = scope[idx - 1].orderId
dependencies.applicationScope.launch {
planMutating = true
planActionError = null
try {
dependencies.diaryManager.updatePlanActivityOrder(clubId, item.id, targetOrder)
planItems = dependencies.diaryManager.fetchDateActivities(clubId, entry.id)
} catch (t: Throwable) {
planActionError = t.message
} finally {
planMutating = false
}
assigningPlanItem?.let { assignItem ->
var assignGroupMenu by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = { if (!planMutating) assigningPlanItem = null },
title = { Text(tr("diary.planAssignGroup", "Zuordnen")) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
assignItem.displayTitle(tr("diary.timeblock", "Zeitblock")),
style = MaterialTheme.typography.body2,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Box(modifier = Modifier.fillMaxWidth()) {
val selectedLabel = assignPlanGroupId?.let { id ->
planGroups.find { it.id == id }?.name ?: "Gruppe $id"
} ?: tr("diary.planGroupGlobal", "Alle / keine Gruppe")
OutlinedButton(
onClick = { assignGroupMenu = true },
enabled = canWriteDiary && !planMutating,
modifier = Modifier.fillMaxWidth(),
) { Text("${tr("diary.planAssignGroup", "Zuordnen")}: $selectedLabel") }
DropdownMenu(expanded = assignGroupMenu, onDismissRequest = { assignGroupMenu = false }) {
DropdownMenuItem(
onClick = {
assignPlanGroupId = null
assignGroupMenu = false
},
) { Text(tr("diary.planGroupGlobal", "Alle / keine Gruppe")) }
planGroups.forEach { g ->
DropdownMenuItem(
onClick = {
assignPlanGroupId = g.id
assignGroupMenu = false
},
) { Text(g.name ?: "Gruppe ${g.id}") }
}
}
}
}
},
onMoveDown = {
val scope = sameTrainingPlanScope(planItems, item)
val idx = scope.indexOfFirst { it.id == item.id }
if (idx >= 0 && idx < scope.lastIndex) {
val targetOrder = scope[idx + 1].orderId
dependencies.applicationScope.launch {
planMutating = true
planActionError = null
try {
dependencies.diaryManager.updatePlanActivityOrder(clubId, item.id, targetOrder)
planItems = dependencies.diaryManager.fetchDateActivities(clubId, entry.id)
} catch (t: Throwable) {
planActionError = t.message
} finally {
planMutating = false
confirmButton = {
TextButton(
enabled = canWriteDiary && !planMutating,
onClick = {
dependencies.applicationScope.launch {
planMutating = true
planActionError = null
try {
dependencies.diaryManager.updatePlanActivity(
clubId,
assignItem.id,
UpdateDiaryPlanActivityRequest(groupId = assignPlanGroupId),
)
assigningPlanItem = null
planItems = dependencies.diaryManager.fetchDateActivities(clubId, entry.id)
planGroups = dependencies.diaryManager.listTrainingGroups(clubId, entry.id)
} catch (t: Throwable) {
planActionError = t.message
} finally {
planMutating = false
}
}
}
}
},
) { Text(tr("common.save", "Speichern")) }
},
onDeleteNested = { nestedId ->
dependencies.applicationScope.launch {
planMutating = true
planActionError = null
try {
dependencies.diaryManager.deletePlanNestedGroupActivity(clubId, nestedId)
planItems = dependencies.diaryManager.fetchDateActivities(clubId, entry.id)
} catch (t: Throwable) {
planActionError = t.message
} finally {
planMutating = false
}
}
dismissButton = {
TextButton(
enabled = canWriteDiary && !planMutating,
onClick = { assigningPlanItem = null },
) { Text(tr("mobile.cancel", "Abbrechen")) }
},
)
}
@@ -4735,6 +4862,48 @@ private fun diaryPlanItemComparator(a: DiaryDateActivityItem, b: DiaryDateActivi
return a.id.compareTo(b.id)
}
private val DiaryPlanColStart = 60.dp
private val DiaryPlanColGroup = 68.dp
private val DiaryPlanColDuration = 48.dp
@Composable
private fun DiaryPlanQuickIconAction(
imageVector: ImageVector,
contentDescription: String,
enabled: Boolean,
onClick: () -> Unit,
) {
Box(
modifier = Modifier
.size(width = 33.dp, height = 30.dp)
.clickable(enabled = enabled, onClick = onClick),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = imageVector,
contentDescription = contentDescription,
modifier = Modifier.size(20.dp),
tint = if (enabled) MaterialTheme.colors.primary
else MaterialTheme.colors.onSurface.copy(alpha = 0.38f),
)
}
}
@Composable
private fun DiaryPlanQuickTextAction(
label: String,
enabled: Boolean,
onClick: () -> Unit,
) {
OutlinedButton(
onClick = onClick,
enabled = enabled,
modifier = Modifier.heightIn(min = 30.dp),
) {
Text(label, style = MaterialTheme.typography.caption, maxLines = 1)
}
}
private fun sameTrainingPlanScope(items: List<DiaryDateActivityItem>, item: DiaryDateActivityItem): List<DiaryDateActivityItem> {
return items.filter { it.groupId == item.groupId }.sortedWith(::diaryPlanItemComparator)
}
@@ -4778,6 +4947,9 @@ private fun DiaryPlanEditableCard(
mainImageUrl: String?,
nestedImageUrl: String?,
canReadImages: Boolean,
isExpanded: Boolean,
onToggleExpand: () -> Unit,
onAssign: () -> Unit,
onOpenImage: (String) -> Unit,
onEdit: () -> Unit,
onDelete: () -> Unit,
@@ -4803,42 +4975,108 @@ private fun DiaryPlanEditableCard(
?: item.groupId?.let { gid -> planGroups.find { it.id == gid }?.name ?: "Gruppe $gid" }
val showImageUrl = mainImageUrl ?: nestedImageUrl
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
val canMutate = canWriteDiary && !planMutating
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 1.dp),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
modifier = Modifier
.fillMaxWidth()
.clickable { onToggleExpand() }
.padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
scheduledStart ?: "",
style = MaterialTheme.typography.body2,
modifier = Modifier.width(72.dp),
style = MaterialTheme.typography.caption,
modifier = Modifier.width(DiaryPlanColStart),
)
Row(modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {
Text(title, fontWeight = FontWeight.SemiBold, maxLines = 1)
Text(
title,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.body2,
modifier = Modifier.weight(1f),
)
if (item.isTimeblock) {
Text(" · $timeblockLabel", style = MaterialTheme.typography.caption, color = MaterialTheme.colors.primary)
Text(
" · $timeblockLabel",
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.primary,
maxLines = 1,
)
}
}
Text(
groupLine ?: "",
style = MaterialTheme.typography.caption,
maxLines = 1,
modifier = Modifier.width(90.dp),
overflow = TextOverflow.Ellipsis,
modifier = Modifier.width(DiaryPlanColGroup),
)
Text(duration ?: "", style = MaterialTheme.typography.body2, modifier = Modifier.width(64.dp))
Row(horizontalArrangement = Arrangement.spacedBy(2.dp), modifier = Modifier.width(140.dp)) {
TextButton(onClick = onMoveUp, enabled = canWriteDiary && !planMutating && canUp) { Text("") }
TextButton(onClick = onMoveDown, enabled = canWriteDiary && !planMutating && canDown) { Text("") }
TextButton(onClick = onEdit, enabled = canWriteDiary && !planMutating) { Text(tr("common.edit", "Bearbeiten")) }
TextButton(onClick = onDelete, enabled = canWriteDiary && !planMutating) { Text(tr("common.delete", "Löschen")) }
Text(
duration ?: "",
style = MaterialTheme.typography.caption,
maxLines = 1,
modifier = Modifier.width(DiaryPlanColDuration),
)
Icon(
imageVector = if (isExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription = tr("mobile.more", "Mehr"),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.75f),
)
}
if (isExpanded) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = DiaryPlanColStart, top = 3.dp, bottom = 3.dp, end = 4.dp)
.background(MaterialTheme.colors.onSurface.copy(alpha = 0.04f))
.padding(horizontal = 6.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
DiaryPlanQuickTextAction(
label = tr("diary.planActionUp", "↑ Hoch"),
enabled = canMutate && canUp,
onClick = onMoveUp,
)
DiaryPlanQuickTextAction(
label = tr("diary.planActionDown", "↓ Runter"),
enabled = canMutate && canDown,
onClick = onMoveDown,
)
DiaryPlanQuickTextAction(
label = tr("diary.planAssignGroup", "Zuordnen"),
enabled = canMutate,
onClick = onAssign,
)
DiaryPlanQuickTextAction(
label = tr("common.edit", "Bearbeiten"),
enabled = canMutate,
onClick = onEdit,
)
DiaryPlanQuickTextAction(
label = tr("common.delete", "Löschen"),
enabled = canMutate,
onClick = onDelete,
)
}
}
if (canReadImages && showImageUrl != null) {
TextButton(
onClick = { onOpenImage(showImageUrl) },
enabled = !planMutating,
modifier = Modifier.padding(start = 72.dp),
) { Text(tr("diary.planShowImage", "Übungsbild anzeigen")) }
Text(
tr("diary.planShowImage", "Übungsbild anzeigen"),
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.primary,
modifier = Modifier
.padding(start = DiaryPlanColStart, top = 0.dp, bottom = 0.dp)
.clickable(enabled = !planMutating) { onOpenImage(showImageUrl) },
)
}
item.groupActivities
.sortedBy { it.orderId ?: it.id ?: 0 }
@@ -4846,15 +5084,24 @@ private fun DiaryPlanEditableCard(
val sub = ga.groupPredefinedActivity.displayLabel()
val line = if (sub.isNotEmpty()) sub else "${tr("mobile.activityFallback", "Aktivität")} ${ga.id}"
Row(
modifier = Modifier.fillMaxWidth().padding(start = 72.dp, top = 2.dp, bottom = 2.dp),
modifier = Modifier.fillMaxWidth().padding(start = DiaryPlanColStart, top = 0.dp, bottom = 0.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text("· $line", style = MaterialTheme.typography.caption, modifier = Modifier.weight(1f))
Text(
"· $line",
style = MaterialTheme.typography.caption,
modifier = Modifier.weight(1f),
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
val nid = ga.id
if (nid != null) {
TextButton(onClick = { onDeleteNested(nid) }, enabled = canWriteDiary && !planMutating) {
Text(tr("common.delete", "Löschen"))
}
DiaryPlanQuickIconAction(
imageVector = Icons.Filled.Delete,
contentDescription = tr("diary.planDeleteNested", "Gruppenübung löschen"),
enabled = canWriteDiary && !planMutating,
onClick = { onDeleteNested(nid) },
)
}
}
}