feat(Diary): implement quick create functionality for training days and enhance localization
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 44s
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:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -793,6 +793,9 @@
|
||||
"noEntries": "Aucune entrée",
|
||||
"deleteDate": "Supprimer la date",
|
||||
"createNew": "Créer",
|
||||
"quickCreate": "Création rapide",
|
||||
"quickCreateNoSlot": "Aucun créneau d’entraînement libre trouvé (selon les horaires des groupes, sur un an).",
|
||||
"quickCreateFailed": "Impossible de créer la journée d’entraînement.",
|
||||
"gallery": "Galerie des membres",
|
||||
"galleryCreating": "Création de la galerie…",
|
||||
"selectTrainingGroup": "Sélectionner un groupe d'entraînement",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -793,6 +793,9 @@
|
||||
"noEntries": "エントリがありません",
|
||||
"deleteDate": "日付を削除",
|
||||
"createNew": "新規作成",
|
||||
"quickCreate": "クイック作成",
|
||||
"quickCreateNoSlot": "空きの練習日が見つかりません(グループのスケジュールに基づき、1年先まで)。",
|
||||
"quickCreateFailed": "練習日を作成できませんでした。",
|
||||
"gallery": "メンバーギャラリー",
|
||||
"galleryCreating": "ギャラリーを作成中…",
|
||||
"selectTrainingGroup": "練習グループを選択",
|
||||
|
||||
@@ -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ą",
|
||||
|
||||
@@ -793,6 +793,9 @@
|
||||
"noEntries": "ไม่มีรายการ",
|
||||
"deleteDate": "ลบวันที่",
|
||||
"createNew": "สร้างใหม่",
|
||||
"quickCreate": "เพิ่มด่วน",
|
||||
"quickCreateNoSlot": "ไม่พบวันฝึกที่ว่าง (ตามตารางกลุ่ม ล่วงหน้าหนึ่งปี)",
|
||||
"quickCreateFailed": "ไม่สามารถสร้างวันฝึกได้",
|
||||
"gallery": "แกลเลอรีสมาชิก",
|
||||
"galleryCreating": "กำลังสร้างแกลเลอรี…",
|
||||
"selectTrainingGroup": "เลือกกลุ่มฝึกซ้อม",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -793,6 +793,9 @@
|
||||
"noEntries": "没有条目",
|
||||
"deleteDate": "删除日期",
|
||||
"createNew": "新建",
|
||||
"quickCreate": "快速创建",
|
||||
"quickCreateNoSlot": "未找到空闲训练日(按小组时间表,向前查找一年)。",
|
||||
"quickCreateFailed": "无法创建训练日。",
|
||||
"gallery": "成员图库",
|
||||
"galleryCreating": "正在生成图库…",
|
||||
"selectTrainingGroup": "选择训练组",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
60
mobile-app/REGRESSION_CHECKLIST.md
Normal file
60
mobile-app/REGRESSION_CHECKLIST.md
Normal 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 0–12).
|
||||
|
||||
## 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 (1–2)
|
||||
|
||||
- [ ] 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 (5–7)
|
||||
|
||||
- [ ] 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 (8–11)
|
||||
|
||||
- [ ] **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.
|
||||
@@ -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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -74,6 +74,10 @@ android {
|
||||
buildTypes {
|
||||
getByName("release") {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
|
||||
29
mobile-app/composeApp/proguard-rules.pro
vendored
Normal file
29
mobile-app/composeApp/proguard-rules.pro
vendored
Normal 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 { *; }
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user