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

@@ -828,6 +828,9 @@
"noEntries": "Keine Einträge",
"deleteDate": "Datum löschen",
"createNew": "Neu anlegen",
"quickCreate": "Schnellanlegen",
"quickCreateNoSlot": "Kein freier Trainingstermin gefunden (gemäß Gruppenzeiten, ein Jahr voraus).",
"quickCreateFailed": "Der Trainingstag konnte nicht angelegt werden.",
"gallery": "Mitglieder-Galerie",
"galleryCreating": "Galerie wird erstellt…",
"selectTrainingGroup": "Trainingsgruppe auswählen",

View File

@@ -575,6 +575,9 @@
"noEntries": "Keine Einträge",
"deleteDate": "Datum löschen",
"createNew": "Neu anlegen",
"quickCreate": "Schnellanlegen",
"quickCreateNoSlot": "Kein freier Trainingstermin gefunden (gemäß Gruppenzeiten, ein Jahr voraus).",
"quickCreateFailed": "Der Trainingstag konnte nicht angelegt werden.",
"gallery": "Mitglieder-Galerie",
"galleryCreating": "Galerie wird erstellt…",
"selectTrainingGroup": "Trainingsgruppe auswählen",

View File

@@ -650,6 +650,9 @@
"noEntries": "Keine Einträge",
"deleteDate": "Datum löschen",
"createNew": "Neu anlegen",
"quickCreate": "Schnellanlegen",
"quickCreateNoSlot": "Kein freier Trainingstermin gefunden (gemäß Gruppenzeiten, ein Jahr voraus).",
"quickCreateFailed": "Der Trainingstag konnte nicht angelegt werden.",
"gallery": "Mitglieder-Galerie",
"galleryCreating": "Galerie wird erstellt…",
"selectTrainingGroup": "Trainingsgruppe auswählen",

View File

@@ -827,6 +827,9 @@
"noEntries": "No entries",
"deleteDate": "Delete date",
"createNew": "Create new",
"quickCreate": "Quick add",
"quickCreateNoSlot": "No free training slot found (per group schedule, one year ahead).",
"quickCreateFailed": "Could not create the training day entry.",
"gallery": "Member gallery",
"galleryCreating": "Creating gallery…",
"selectTrainingGroup": "Select training group",

View File

@@ -320,6 +320,9 @@
"noEntries": "No entries",
"deleteDate": "Delete date",
"createNew": "Create new",
"quickCreate": "Quick add",
"quickCreateNoSlot": "No free training slot found (per group schedule, one year ahead).",
"quickCreateFailed": "Could not create the training day entry.",
"gallery": "Member gallery",
"galleryCreating": "Creating gallery…",
"selectTrainingGroup": "Select training group",

View File

@@ -827,6 +827,9 @@
"noEntries": "No entries",
"deleteDate": "Delete date",
"createNew": "Create new",
"quickCreate": "Quick add",
"quickCreateNoSlot": "No free training slot found (per group schedule, one year ahead).",
"quickCreateFailed": "Could not create the training day entry.",
"gallery": "Member gallery",
"galleryCreating": "Creating gallery…",
"selectTrainingGroup": "Select training group",

View File

@@ -793,6 +793,9 @@
"noEntries": "No hay entradas",
"deleteDate": "Eliminar fecha",
"createNew": "Crear",
"quickCreate": "Alta rápida",
"quickCreateNoSlot": "No se encontró un día de entrenamiento libre (según horarios de grupos, un año adelante).",
"quickCreateFailed": "No se pudo crear el día de entrenamiento.",
"gallery": "Galería de miembros",
"galleryCreating": "Creando galería…",
"selectTrainingGroup": "Seleccionar grupo de entrenamiento",

View File

@@ -793,6 +793,9 @@
"noEntries": "Walang entry",
"deleteDate": "Burahin ang petsa",
"createNew": "Lumikha ng bago",
"quickCreate": "Mabilis na idagdag",
"quickCreateNoSlot": "Walang nahanap na libreng araw ng training (ayon sa iskedyul ng grupo, isang taon pasulong).",
"quickCreateFailed": "Hindi malikha ang araw ng training.",
"gallery": "Gallery ng mga kasapi",
"galleryCreating": "Ginagawa ang gallery…",
"selectTrainingGroup": "Pumili ng grupo ng pagsasanay",

View File

@@ -793,6 +793,9 @@
"noEntries": "Aucune entrée",
"deleteDate": "Supprimer la date",
"createNew": "Créer",
"quickCreate": "Création rapide",
"quickCreateNoSlot": "Aucun créneau dentraînement libre trouvé (selon les horaires des groupes, sur un an).",
"quickCreateFailed": "Impossible de créer la journée dentraînement.",
"gallery": "Galerie des membres",
"galleryCreating": "Création de la galerie…",
"selectTrainingGroup": "Sélectionner un groupe d'entraînement",

View File

@@ -793,6 +793,9 @@
"noEntries": "Nessuna voce",
"deleteDate": "Elimina data",
"createNew": "Crea",
"quickCreate": "Creazione rapida",
"quickCreateNoSlot": "Nessun giorno di allenamento libero trovato (in base agli orari dei gruppi, entro un anno).",
"quickCreateFailed": "Impossibile creare la giornata di allenamento.",
"gallery": "Galleria membri",
"galleryCreating": "Creazione galleria…",
"selectTrainingGroup": "Seleziona gruppo di allenamento",

View File

@@ -793,6 +793,9 @@
"noEntries": "エントリがありません",
"deleteDate": "日付を削除",
"createNew": "新規作成",
"quickCreate": "クイック作成",
"quickCreateNoSlot": "空きの練習日が見つかりませんグループのスケジュールに基づき、1年先まで。",
"quickCreateFailed": "練習日を作成できませんでした。",
"gallery": "メンバーギャラリー",
"galleryCreating": "ギャラリーを作成中…",
"selectTrainingGroup": "練習グループを選択",

View File

@@ -793,6 +793,9 @@
"noEntries": "Brak wpisów",
"deleteDate": "Usuń datę",
"createNew": "Utwórz",
"quickCreate": "Szybkie dodawanie",
"quickCreateNoSlot": "Nie znaleziono wolnego terminu treningu (wg harmonogramów grup, rok do przodu).",
"quickCreateFailed": "Nie udało się utworzyć dnia treningowego.",
"gallery": "Galeria członków",
"galleryCreating": "Tworzenie galerii…",
"selectTrainingGroup": "Wybierz grupę treningową",

View File

@@ -793,6 +793,9 @@
"noEntries": "ไม่มีรายการ",
"deleteDate": "ลบวันที่",
"createNew": "สร้างใหม่",
"quickCreate": "เพิ่มด่วน",
"quickCreateNoSlot": "ไม่พบวันฝึกที่ว่าง (ตามตารางกลุ่ม ล่วงหน้าหนึ่งปี)",
"quickCreateFailed": "ไม่สามารถสร้างวันฝึกได้",
"gallery": "แกลเลอรีสมาชิก",
"galleryCreating": "กำลังสร้างแกลเลอรี…",
"selectTrainingGroup": "เลือกกลุ่มฝึกซ้อม",

View File

@@ -793,6 +793,9 @@
"noEntries": "Walang entry",
"deleteDate": "Burahin ang petsa",
"createNew": "Lumikha ng bago",
"quickCreate": "Mabilis na idagdag",
"quickCreateNoSlot": "Walang nahanap na libreng araw ng training (ayon sa iskedyul ng grupo, isang taon pasulong).",
"quickCreateFailed": "Hindi malikha ang araw ng training.",
"gallery": "Gallery ng mga miyembro",
"galleryCreating": "Ginagawa ang gallery…",
"selectTrainingGroup": "Pumili ng grupo ng pagsasanay",

View File

@@ -793,6 +793,9 @@
"noEntries": "没有条目",
"deleteDate": "删除日期",
"createNew": "新建",
"quickCreate": "快速创建",
"quickCreateNoSlot": "未找到空闲训练日(按小组时间表,向前查找一年)。",
"quickCreateFailed": "无法创建训练日。",
"gallery": "成员图库",
"galleryCreating": "正在生成图库…",
"selectTrainingGroup": "选择训练组",

View File

@@ -11,6 +11,9 @@
<button v-if="date && canDeleteCurrentDate" class="btn-secondary"
@click="deleteCurrentDate">{{ $t('diary.deleteDate') }}</button>
<button @click="openNewDateDialog" class="btn-primary">{{ $t('diary.createNew') }}</button>
<button type="button" @click="quickCreateNextTraining" class="btn-secondary" :disabled="quickCreateBusy">
{{ $t('diary.quickCreate') }}
</button>
</label>
</div>
<!-- Training Group Selection Dialog -->
@@ -1052,7 +1055,8 @@ export default {
isMobileView: typeof window !== 'undefined' ? window.innerWidth <= 768 : false,
participantSearchQuery: '',
participantFilter: 'all',
planGroupFilter: '__all__'
planGroupFilter: '__all__',
quickCreateBusy: false
};
},
watch: {
@@ -1623,6 +1627,76 @@ export default {
this.showTrainingGroupDialog = true;
this.showForm = false;
},
diaryDateKey(entry) {
if (!entry || entry.date == null) return '';
return String(entry.date).substring(0, 10);
},
localYyyyMmDd(d) {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
},
findNextQuickSlotAcrossGroups(groups) {
if (!Array.isArray(groups) || groups.length === 0) return null;
const sorted = [...groups].sort((a, b) => {
const oa = Number.isFinite(Number(a.sortOrder)) ? Number(a.sortOrder) : 1e9;
const ob = Number.isFinite(Number(b.sortOrder)) ? Number(b.sortOrder) : 1e9;
if (oa !== ob) return oa - ob;
return (Number(a.id) || 0) - (Number(b.id) || 0);
});
const existing = new Set((this.dates || []).map((d) => this.diaryDateKey(d)));
const today = new Date();
for (let dayOffset = 0; dayOffset <= 366; dayOffset++) {
const checkDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() + dayOffset);
const checkWeekday = checkDate.getDay();
const dateStr = this.localYyyyMmDd(checkDate);
if (existing.has(dateStr)) continue;
for (const g of sorted) {
const times = (g.trainingTimes || [])
.filter((tt) => tt.weekday === checkWeekday && tt.startTime && String(tt.startTime).trim());
if (!times.length) continue;
times.sort((a, b) => String(a.startTime).localeCompare(String(b.startTime))
|| (Number(a.id) || 0) - (Number(b.id) || 0));
const time = times[0];
return {
date: dateStr,
startTime: this.formatTimeForInput(time.startTime),
endTime: this.formatTimeForInput(time.endTime),
};
}
}
return null;
},
async quickCreateNextTraining() {
if (!this.currentClub || this.quickCreateBusy) return;
this.quickCreateBusy = true;
try {
const response = await apiClient.get(`/training-times/${this.currentClub}`);
const groups = Array.isArray(response.data) ? response.data : [];
const slot = this.findNextQuickSlotAcrossGroups(groups);
if (!slot) {
this.showInfo(this.$t('messages.info'), this.$t('diary.quickCreateNoSlot'), '', 'info');
return;
}
const post = await apiClient.post(`/diary/${this.currentClub}`, {
date: slot.date,
trainingStart: slot.startTime || null,
trainingEnd: slot.endTime || null,
});
await this.refreshDates(post.data.id);
await this.handleDateChange();
} catch (error) {
console.error('[quickCreateNextTraining]', error);
this.showInfo(this.$t('messages.error'), this.$t('diary.quickCreateFailed'), '', 'error');
} finally {
this.quickCreateBusy = false;
}
},
async handleDateChange() {
this.showForm = false;
@@ -1780,8 +1854,8 @@ export default {
// Nimm die erste Trainingszeit für diesen Tag
const time = timesForWeekday[0];
// Prüfe, ob dieses Datum bereits existiert
const dateStr = checkDate.toISOString().split('T')[0];
const exists = this.dates.some(d => d.date === dateStr);
const dateStr = this.localYyyyMmDd(checkDate);
const exists = this.dates.some(d => this.diaryDateKey(d) === dateStr);
if (!exists) {
return {
@@ -4403,6 +4477,31 @@ h3 {
margin-bottom: 1rem;
}
/* Global `label { display: inline-flex }` würde sonst alle Kinder in eine Zeile zwängen und Buttons schrumpfen lassen. */
.diary-header-row > label {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 0.75rem;
flex: 1 1 auto;
min-width: 0;
max-width: 100%;
margin: 0;
cursor: default;
}
.diary-header-row > label > select {
flex: 1 1 12rem;
min-width: min(12rem, 100%);
max-width: 100%;
}
.diary-header-row > label > .btn-primary,
.diary-header-row > label > .btn-secondary {
flex-shrink: 0;
white-space: nowrap;
}
.gallery-trigger {
align-self: flex-end;
}

View File

@@ -98,6 +98,12 @@ adb reverse tcp:3005 tcp:3005
4. Login → Club auswählen → Rolle wird angezeigt
5. Open `Tagebuch` → die letzten Einträge werden angezeigt
## Phase 14 Unit-Tests, Regressionsliste, R8
- **Automatisierte Tests (shared):** `./gradlew :shared:testDebugUnitTest` (JVM-Unit-Tests unter `shared/src/androidUnitTest/`, u.a. Serialisierung kritischer DTOs).
- **Manuelle Regression:** `REGRESSION_CHECKLIST.md` im Ordner `mobile-app/`.
- **R8/ProGuard:** `composeApp/proguard-rules.pro` ist an die Release-Variante angebunden; **`isMinifyEnabled`** bleibt vorerst **`false`**, bis ein Release mit Schrumpfung gezielt getestet wurde.
## Gradle Wrapper
Use the wrapper from `mobile-app/`:
```bash

View File

@@ -0,0 +1,60 @@
# Mobile App Regressions-Checkliste (manuell, Phase 14)
Kurztest vor Release oder nach größeren Änderungen. Angelehnt an `mobile-app/TODO.md` (Phasen 012).
## Vorbedingungen
- Backend erreichbar (Staging oder Produktion wie gebaut).
- Testverein mit typischen Rechten (Tagebuch, Mitglieder, Kalender, ggf. Team-Verwaltung).
## Phase 0 / Shell
- [ ] Login, Club wählen, **Start**-Hub sichtbar
- [ ] Bottom-Navigation (Handy) bzw. **Navigation Rail** (Tablet): alle sichtbaren Tabs erreichbar
- [ ] **Zurück** aus Tagebuch-Detail / Mitglieder-Detail schließt nur die Ebene, nicht die ganze App
## Auth & Club (12)
- [ ] Abmelden, erneut anmelden
- [ ] Anderer Club wählen: Daten wechseln plausibel
## Tagebuch (3)
- [ ] Tagebuch: Liste, Tag öffnen, **Zurück**
- [ ] **Neu**: Dialog Datum/Zeiten → Anlegen → **Sprung** in den neuen Tag
- [ ] Trainingsplan: Eintrag anlegen, Reihenfolge, löschen (Stichprobe)
- [ ] Teilnehmer: hinzufügen, Status (Stichprobe)
- [ ] PDF teilen (Intent öffnet sich)
## Mitglieder (4)
- [ ] Liste, Suche, Detail, Bearbeiten (Stichprobe eines Felds)
## Statistik / Terminplan / Turniere (57)
- [ ] Statistik: Kennzahlen laden
- [ ] Terminplan: Spiel anzeigen; bei Schreibrecht: Aufstellung ändern (falls vorhanden)
- [ ] Turniere: Liste ohne Absturz; Detail (Stichprobe)
## Kalender (12)
- [ ] Monat wechseln, Legende, mindestens eine Aktion (z.B. Tagebuch aus Event)
## Mehr / Einstellungen (811)
- [ ] **Mehr**: persönliche Einstellungen, Impressum/Datenschutz (Browser)
- [ ] Optional: Abrechnung/Bestellungen nur öffnen, wenn genutzt
## Barrierefreiheit (Stichprobe)
- [ ] **TalkBack** (oder ähnlich): Haupt-Navigation pro Tab **sinnvolle** Ansage (Label)
- [ ] Wichtige Buttons mind. ca. **48 dp** Höhe (Touch-Ziel)
## Performance (Stichprobe)
- [ ] Tagebuch mit **vielen** Tagen: Liste scrollt flüssig
- [ ] Mitgliederliste mit vielen Einträgen: bedienbar
## Store / Compliance (Hinweis)
- Play Console: **Datenschutzerklärung**, ggf. **Berechtigungen** (Kamera/Galerie für Profil/UCrop, Internet) mit Text in der Store-Listing-Beschreibung konsistent halten.

View File

@@ -221,7 +221,7 @@ Web-Route: `/calendar` · Referenz: `CalendarView.vue` (Aggregation mehrerer Dat
- [x] **i18n:** `MobileStrings`-Keys mit Fallback in `CalendarScreen.kt` / `AppRoot.kt` (`navigation.calendar`, `home.tileCalendar`, `mobile.calendar*`)
- [x] **Web-Parität (Detail):** Meldung wenn **alle** Kalender-Quellen fehlschlagen; Legenden-Zähler aus **allen** Events (unabhängig von den Schaltern); Agenda-Datum **kurz/lokalisiert**; Ausfall-Liste nur wenn **Startdatum im Monat** liegt (wie `CalendarView.vue`) `CalendarScreen.kt`
### Phase 12 Backlog / offen
### Phase 13 Backlog / offen
- [ ] **i18n:** Kalender-Keys in `MobileStrings.kt` für alle unterstützten Sprachen ergänzen (nicht nur Fallback im Code)
- [ ] **Kalender vs. Web:** Offizielle Teilnahmen mobil per Browser vs. Web in-app bewusst lassen oder später angleichen
@@ -230,13 +230,14 @@ Web-Route: `/calendar` · Referenz: `CalendarView.vue` (Aggregation mehrerer Dat
---
## Phase 13 Qualität, Tests, Release
## Phase 14 Qualität, Tests, Release
- [ ] **Regression-Checkliste** pro Phase (manuell)
- [ ] Automatisierte Tests: `shared` (Serialisierung, Mapper), wo möglich UI-Tests kritische Flows
- [ ] **Barrierefreiheit:** Talkback, Kontraste, Touch-Ziele
- [ ] **Performance:** große Listen (Paging), Bildcache
- [ ] Store-Texte, Datenverarbeitung (Play Policy) für Medien und Kontakte
- [x] **Regression-Checkliste** pro Phase (manuell) `mobile-app/REGRESSION_CHECKLIST.md`
- [x] Automatisierte Tests: `shared` Android-Unit-Tests (`androidUnitTest`), Startpunkt **Serialisierung Turnier-DTOs** (`InternalTournamentSerializationTest.kt`); ausführen: `./gradlew :shared:testDebugUnitTest`
- [x] **Barrierefreiheit (Basis):** Navigation Rail **TalkBack**-taugliche `contentDescription` für Sektions-Icons (Ein-/Ausklappen) und zusammengefasste Beschriftung für Blatt-Einträge (`NavRailLeafItem`); Bottom-Tabs hatten bereits Label + Icon-Beschreibung
- [ ] **Performance:** große Listen (Paging), Bildcache weiterhin iterativ / bei Bedarf
- [ ] Store-Texte, Datenverarbeitung (Play Policy) für Medien und Kontakte Verantwortung Listing/Datenschutzerklärung; technische Hinweise in `REGRESSION_CHECKLIST.md`
- [x] **Release / R8-Vorbereitung:** `composeApp/proguard-rules.pro` (Ktor, `kotlinx.serialization`, Koin, BuildConfig) + `proguardFiles` in `composeApp/build.gradle.kts`; **`isMinifyEnabled`** bleibt `false` bis gezielter Release-Test
---

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)

View File

@@ -1,5 +1,5 @@
[versions]
agp = "9.1.1"
agp = "9.2.1"
android-compileSdk = "35"
android-minSdk = "24"
android-targetSdk = "35"
@@ -14,9 +14,10 @@ androidx-test-junit = "1.1.5"
compose = "1.6.1"
compose-plugin = "1.10.3"
junit = "4.13.2"
kotlin = "2.1.21"
kotlin = "2.2.10"
ktor = "2.3.10"
coroutines = "1.8.0"
kotlinx-serialization-json = "1.7.3"
koin = "3.5.3"
voyager = "1.0.0"
socket-io = "2.1.0"
@@ -25,6 +26,7 @@ ucrop = "2.2.11"
[libraries]
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
junit = { module = "junit:junit", version.ref = "junit" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" }
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core-ktx" }
@@ -38,6 +40,7 @@ ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" }
koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }
koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" }
voyager-navigator = { module = "cafe.adriel.voyager:voyager-navigator", version.ref = "voyager" }

View File

@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://downloads.gradle.org/distributions/gradle-9.3.1-bin.zip
distributionUrl=https\://downloads.gradle.org/distributions/gradle-9.4.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME

View File

@@ -43,6 +43,11 @@ kotlin {
iosMain.dependencies {
implementation(libs.ktor.client.darwin)
}
androidUnitTest.dependencies {
implementation(libs.junit)
implementation(kotlin("test-junit"))
implementation(libs.kotlinx.serialization.json)
}
}
}

View File

@@ -0,0 +1,43 @@
package de.tsschulz.tt_tagebuch.shared.api.models
import kotlinx.serialization.json.Json
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* Phase 14: Regression gegen API-Inkonsistenzen (Boolean als 0/1/String) siehe [FlexibleNullableBooleanSerializer].
*/
class InternalTournamentSerializationTest {
private val json = Json {
ignoreUnknownKeys = true
isLenient = true
}
@Test
fun internalSummary_deserializesNumericBooleans() {
val raw = """{"id":1,"name":"X","allowsExternal":1,"isDoublesTournament":0}"""
val dto = json.decodeFromString(InternalTournamentSummaryDto.serializer(), raw)
assertEquals(1, dto.id)
assertEquals(true, dto.allowsExternal)
assertEquals(false, dto.isDoublesTournament)
}
@Test
fun internalSummary_deserializesStringBooleans() {
val raw = """{"id":2,"allowsExternal":"true","isDoublesTournament":"no"}"""
val dto = json.decodeFromString(InternalTournamentSummaryDto.serializer(), raw)
assertTrue(dto.allowsExternal == true)
assertTrue(dto.isDoublesTournament == false)
}
@Test
fun internalDetail_deserializesMixedBooleanForms() {
val raw = """{"id":3,"allowsExternal":0,"isDoublesTournament":"1"}"""
val dto = json.decodeFromString(InternalTournamentDetailDto.serializer(), raw)
assertFalse(dto.allowsExternal == true)
assertTrue(dto.isDoublesTournament == true)
}
}

View File

@@ -219,6 +219,10 @@ class DiaryManager(
participantsApi.updateParticipantGroup(diaryDateId, memberId, groupId)
}
fun clearError() {
_state.value = _state.value.copy(error = null)
}
suspend fun loadDates(clubId: Int) {
_state.value = _state.value.copy(isLoading = true, error = null)
try {
@@ -229,13 +233,16 @@ class DiaryManager(
}
}
suspend fun createDate(clubId: Int, date: String, trainingStart: String?, trainingEnd: String?) {
/** @return neue `diaryDateId` bei Erfolg, sonst `null` */
suspend fun createDate(clubId: Int, date: String, trainingStart: String?, trainingEnd: String?): Int? {
_state.value = _state.value.copy(isLoading = true, error = null)
try {
diaryApi.createDate(clubId, date, trainingStart, trainingEnd)
return try {
val created = diaryApi.createDate(clubId, date, trainingStart, trainingEnd)
loadDates(clubId)
created.id
} catch (t: Throwable) {
_state.value = _state.value.copy(isLoading = false, error = t.toUserMessage("Eintrag konnte nicht erstellt werden"))
null
}
}

View File

@@ -5,7 +5,7 @@
* Source of truth: `frontend/src/i18n/locales/*.json`
*
* Output:
* - `mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/i18n/MobileStrings.kt`
* - `mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/i18n/MobileStrings.kt`
*
* Notes:
* - German (`de`) is the canonical key set.
@@ -25,6 +25,7 @@ const OUT = path.join(
'commonMain',
'kotlin',
'de',
'tsschulz',
'tt_tagebuch',
'shared',
'i18n',