feat(TrainingStats): enhance training statistics view with collapsible panels and localization
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 45s
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 45s
- Refactored the TrainingStatsView to implement collapsible sections for better organization of training statistics. - Added new localization keys for training statistics panels in both German and English. - Updated the mobile app's TrainingStatsScreen to utilize the new collapsible panel structure, improving user experience. - Enhanced the MembersManager to merge training statistics into member data, providing a comprehensive view of member participation. - Introduced new API methods for quick updates and transfers of member data, streamlining member management processes.
This commit is contained in:
@@ -1353,7 +1353,14 @@
|
||||
"participationsTotal": "Teilnahmen (Gesamt)",
|
||||
"lastTraining": "Letztes Training",
|
||||
"actions": "Aktionen",
|
||||
"showDetails": "Details anzeigen"
|
||||
"showDetails": "Details anzeigen",
|
||||
"panelSummary": "Kennzahlen (Filter)",
|
||||
"panelMonthlyTrend": "Monatlicher Verlauf",
|
||||
"panelWeekdayStats": "Trainingstage nach Wochentag",
|
||||
"panelMemberStructure": "Mitgliederstruktur",
|
||||
"panelBestDay": "Stärkster Trainingstag",
|
||||
"panelGroupPerformance": "Entwicklung pro Gruppe",
|
||||
"panelAgeGroups": "Anwesenheit nach Altersklasse"
|
||||
},
|
||||
"tournament": {
|
||||
"apply": "Übernehmen"
|
||||
|
||||
@@ -975,7 +975,14 @@
|
||||
"participationsTotal": "Participations (total)",
|
||||
"lastTraining": "Last training",
|
||||
"actions": "Actions",
|
||||
"showDetails": "Show details"
|
||||
"showDetails": "Show details",
|
||||
"panelSummary": "Key figures (filtered)",
|
||||
"panelMonthlyTrend": "Monthly trend",
|
||||
"panelWeekdayStats": "Training days by weekday",
|
||||
"panelMemberStructure": "Member structure",
|
||||
"panelBestDay": "Busiest training day",
|
||||
"panelGroupPerformance": "Progress by group",
|
||||
"panelAgeGroups": "Attendance by age class"
|
||||
},
|
||||
"courtDrawingTool": {
|
||||
"title": "Table tennis exercise drawing",
|
||||
|
||||
@@ -26,153 +26,194 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="stats-overview">
|
||||
<div class="stats-summary">
|
||||
<div class="stat-card">
|
||||
<h3>Aktive Mitglieder</h3>
|
||||
<div class="stat-number">{{ filteredMembers.length }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Trainingstage 12 Monate</h3>
|
||||
<div class="stat-number">{{ filteredTrainingDays.length }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Ø Teilnehmer je Training</h3>
|
||||
<div class="stat-number">{{ filteredOverview.averageParticipants.toFixed(1) }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Teilnahmen gesamt</h3>
|
||||
<div class="stat-number">{{ filteredOverview.totalParticipants }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Anwesenheitsquote 12 Monate</h3>
|
||||
<div class="stat-number">{{ filteredOverview.attendanceRate.toFixed(1) }}%</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Nicht im Training</h3>
|
||||
<div class="stat-number">{{ filteredMembers.filter((member) => member.notInTraining).length }}</div>
|
||||
<div class="collapsible-section">
|
||||
<div class="section-header" @click="togglePanel('overview')">
|
||||
<h3>{{ $t('trainingStats.panelSummary') }}</h3>
|
||||
<span class="section-header-right">
|
||||
<span class="toggle-icon">{{ panels.overview ? '▼' : '▶' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div v-show="panels.overview" class="section-content">
|
||||
<div class="stats-overview panel-body-padding">
|
||||
<div class="stats-summary">
|
||||
<div class="stat-card">
|
||||
<h3>Aktive Mitglieder</h3>
|
||||
<div class="stat-number">{{ filteredMembers.length }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Trainingstage 12 Monate</h3>
|
||||
<div class="stat-number">{{ filteredTrainingDays.length }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Ø Teilnehmer je Training</h3>
|
||||
<div class="stat-number">{{ filteredOverview.averageParticipants.toFixed(1) }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Teilnahmen gesamt</h3>
|
||||
<div class="stat-number">{{ filteredOverview.totalParticipants }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Anwesenheitsquote 12 Monate</h3>
|
||||
<div class="stat-number">{{ filteredOverview.attendanceRate.toFixed(1) }}%</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Nicht im Training</h3>
|
||||
<div class="stat-number">{{ filteredMembers.filter((member) => member.notInTraining).length }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-panels-grid">
|
||||
<section class="stats-panel">
|
||||
<div class="panel-header">
|
||||
<h3>Monatlicher Verlauf</h3>
|
||||
<span>{{ filteredMonthlyTrend.length }} Monate</span>
|
||||
<div class="collapsible-section stats-panel-collapsible">
|
||||
<div class="section-header" @click="togglePanel('monthlyTrend')">
|
||||
<h3>{{ $t('trainingStats.panelMonthlyTrend') }}</h3>
|
||||
<span class="section-header-right">
|
||||
<span class="section-meta">{{ filteredMonthlyTrend.length }} Monate</span>
|
||||
<span class="toggle-icon">{{ panels.monthlyTrend ? '▼' : '▶' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="trend-list">
|
||||
<div v-for="month in filteredMonthlyTrend" :key="month.key" class="trend-row">
|
||||
<div class="trend-meta">
|
||||
<strong>{{ month.label }}</strong>
|
||||
<span>{{ month.trainingCount }} Trainingstage</span>
|
||||
</div>
|
||||
<div class="trend-bar-track">
|
||||
<div class="trend-bar-fill" :style="{ width: `${getMonthBarWidth(month.averageParticipants)}%` }"></div>
|
||||
</div>
|
||||
<div class="trend-value">{{ month.averageParticipants.toFixed(1) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="stats-panel">
|
||||
<div class="panel-header">
|
||||
<h3>Trainingstage nach Wochentag</h3>
|
||||
<span>{{ filteredWeekdayStats.length }} Wochentage</span>
|
||||
</div>
|
||||
<div class="weekday-grid">
|
||||
<div v-for="day in filteredWeekdayStats" :key="day.weekday" class="weekday-card">
|
||||
<strong>{{ day.weekday }}</strong>
|
||||
<span>{{ day.trainingCount }} Termine</span>
|
||||
<span>Ø {{ day.averageParticipants.toFixed(1) }} Teilnehmer</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="stats-panel">
|
||||
<div class="panel-header">
|
||||
<h3>Mitgliederstruktur</h3>
|
||||
</div>
|
||||
<div class="distribution-grid">
|
||||
<div class="distribution-card">
|
||||
<strong>Sehr aktiv</strong>
|
||||
<span>{{ filteredMemberDistribution.highlyActive }}</span>
|
||||
<small>mind. 75 % der Trainingstage</small>
|
||||
</div>
|
||||
<div class="distribution-card">
|
||||
<strong>Regelmäßig</strong>
|
||||
<span>{{ filteredMemberDistribution.regular }}</span>
|
||||
<small>40 bis unter 75 %</small>
|
||||
</div>
|
||||
<div class="distribution-card">
|
||||
<strong>Gelegentlich</strong>
|
||||
<span>{{ filteredMemberDistribution.occasional }}</span>
|
||||
<small>unter 40 %</small>
|
||||
</div>
|
||||
<div class="distribution-card">
|
||||
<strong>Ohne Teilnahme</strong>
|
||||
<span>{{ filteredMemberDistribution.inactive }}</span>
|
||||
<small>0 Teilnahmen in 12 Monaten</small>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="stats-panel stats-panel-highlight">
|
||||
<div class="panel-header">
|
||||
<h3>Stärkster Trainingstag</h3>
|
||||
</div>
|
||||
<div v-if="filteredOverview.bestTrainingDay" class="highlight-block">
|
||||
<strong>{{ formatDate(filteredOverview.bestTrainingDay.date) }}</strong>
|
||||
<span>{{ getWeekday(filteredOverview.bestTrainingDay.date) }}</span>
|
||||
<div class="highlight-number">{{ filteredOverview.bestTrainingDay.participantCount }}</div>
|
||||
<small>Teilnehmer beim bestbesuchten Training der letzten 12 Monate</small>
|
||||
</div>
|
||||
<div v-else class="highlight-block">
|
||||
<strong>Keine Daten</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="stats-panel">
|
||||
<div class="panel-header">
|
||||
<h3>Entwicklung pro Gruppe</h3>
|
||||
<span>{{ groupPerformance.length }} Gruppen</span>
|
||||
</div>
|
||||
<div class="group-performance-list">
|
||||
<div v-for="group in groupPerformance" :key="group.name" class="group-performance-row">
|
||||
<div class="group-performance-meta">
|
||||
<strong>{{ group.name }}</strong>
|
||||
<span>{{ group.memberCount }} Mitglieder</span>
|
||||
</div>
|
||||
<div class="group-performance-stats">
|
||||
<span>{{ group.averageParticipations12Months.toFixed(1) }} Ø Teilnahmen / 12 Monate</span>
|
||||
<span>{{ group.participationRate.toFixed(1) }}% Anwesenheit</span>
|
||||
<div v-show="panels.monthlyTrend" class="section-content">
|
||||
<div class="trend-list panel-body-padding">
|
||||
<div v-for="month in filteredMonthlyTrend" :key="month.key" class="trend-row">
|
||||
<div class="trend-meta">
|
||||
<strong>{{ month.label }}</strong>
|
||||
<span>{{ month.trainingCount }} Trainingstage</span>
|
||||
</div>
|
||||
<div class="trend-bar-track">
|
||||
<div class="trend-bar-fill" :style="{ width: `${getMonthBarWidth(month.averageParticipants)}%` }"></div>
|
||||
</div>
|
||||
<div class="trend-value">{{ month.averageParticipants.toFixed(1) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="stats-panel">
|
||||
<div class="panel-header">
|
||||
<h3>Anwesenheit nach Altersklasse</h3>
|
||||
<span>{{ ageGroupStats.length }} Klassen</span>
|
||||
<div class="collapsible-section stats-panel-collapsible">
|
||||
<div class="section-header" @click="togglePanel('weekdayStats')">
|
||||
<h3>{{ $t('trainingStats.panelWeekdayStats') }}</h3>
|
||||
<span class="section-header-right">
|
||||
<span class="section-meta">{{ filteredWeekdayStats.length }} Wochentage</span>
|
||||
<span class="toggle-icon">{{ panels.weekdayStats ? '▼' : '▶' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="distribution-grid">
|
||||
<div v-for="entry in ageGroupStats" :key="entry.label" class="distribution-card">
|
||||
<strong>{{ entry.label }}</strong>
|
||||
<span>{{ entry.memberCount }}</span>
|
||||
<small>{{ entry.averageParticipations12Months.toFixed(1) }} Ø Teilnahmen / 12 Monate</small>
|
||||
<div v-show="panels.weekdayStats" class="section-content">
|
||||
<div class="weekday-grid panel-body-padding">
|
||||
<div v-for="day in filteredWeekdayStats" :key="day.weekday" class="weekday-card">
|
||||
<strong>{{ day.weekday }}</strong>
|
||||
<span>{{ day.trainingCount }} Termine</span>
|
||||
<span>Ø {{ day.averageParticipants.toFixed(1) }} Teilnehmer</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="collapsible-section stats-panel-collapsible">
|
||||
<div class="section-header" @click="togglePanel('memberStructure')">
|
||||
<h3>{{ $t('trainingStats.panelMemberStructure') }}</h3>
|
||||
<span class="section-header-right">
|
||||
<span class="toggle-icon">{{ panels.memberStructure ? '▼' : '▶' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div v-show="panels.memberStructure" class="section-content">
|
||||
<div class="distribution-grid panel-body-padding">
|
||||
<div class="distribution-card">
|
||||
<strong>Sehr aktiv</strong>
|
||||
<span>{{ filteredMemberDistribution.highlyActive }}</span>
|
||||
<small>mind. 75 % der Trainingstage</small>
|
||||
</div>
|
||||
<div class="distribution-card">
|
||||
<strong>Regelmäßig</strong>
|
||||
<span>{{ filteredMemberDistribution.regular }}</span>
|
||||
<small>40 bis unter 75 %</small>
|
||||
</div>
|
||||
<div class="distribution-card">
|
||||
<strong>Gelegentlich</strong>
|
||||
<span>{{ filteredMemberDistribution.occasional }}</span>
|
||||
<small>unter 40 %</small>
|
||||
</div>
|
||||
<div class="distribution-card">
|
||||
<strong>Ohne Teilnahme</strong>
|
||||
<span>{{ filteredMemberDistribution.inactive }}</span>
|
||||
<small>0 Teilnahmen in 12 Monaten</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="collapsible-section stats-panel-collapsible stats-panel-highlight">
|
||||
<div class="section-header" @click="togglePanel('bestDay')">
|
||||
<h3>{{ $t('trainingStats.panelBestDay') }}</h3>
|
||||
<span class="section-header-right">
|
||||
<span class="toggle-icon">{{ panels.bestDay ? '▼' : '▶' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div v-show="panels.bestDay" class="section-content">
|
||||
<div class="panel-body-padding">
|
||||
<div v-if="filteredOverview.bestTrainingDay" class="highlight-block">
|
||||
<strong>{{ formatDate(filteredOverview.bestTrainingDay.date) }}</strong>
|
||||
<span>{{ getWeekday(filteredOverview.bestTrainingDay.date) }}</span>
|
||||
<div class="highlight-number">{{ filteredOverview.bestTrainingDay.participantCount }}</div>
|
||||
<small>Teilnehmer beim bestbesuchten Training der letzten 12 Monate</small>
|
||||
</div>
|
||||
<div v-else class="highlight-block">
|
||||
<strong>Keine Daten</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="collapsible-section stats-panel-collapsible">
|
||||
<div class="section-header" @click="togglePanel('groupPerformance')">
|
||||
<h3>{{ $t('trainingStats.panelGroupPerformance') }}</h3>
|
||||
<span class="section-header-right">
|
||||
<span class="section-meta">{{ groupPerformance.length }} Gruppen</span>
|
||||
<span class="toggle-icon">{{ panels.groupPerformance ? '▼' : '▶' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div v-show="panels.groupPerformance" class="section-content">
|
||||
<div class="group-performance-list panel-body-padding">
|
||||
<div v-for="group in groupPerformance" :key="group.name" class="group-performance-row">
|
||||
<div class="group-performance-meta">
|
||||
<strong>{{ group.name }}</strong>
|
||||
<span>{{ group.memberCount }} Mitglieder</span>
|
||||
</div>
|
||||
<div class="group-performance-stats">
|
||||
<span>{{ group.averageParticipations12Months.toFixed(1) }} Ø Teilnahmen / 12 Monate</span>
|
||||
<span>{{ group.participationRate.toFixed(1) }}% Anwesenheit</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="collapsible-section stats-panel-collapsible">
|
||||
<div class="section-header" @click="togglePanel('ageGroup')">
|
||||
<h3>{{ $t('trainingStats.panelAgeGroups') }}</h3>
|
||||
<span class="section-header-right">
|
||||
<span class="section-meta">{{ ageGroupStats.length }} Klassen</span>
|
||||
<span class="toggle-icon">{{ panels.ageGroup ? '▼' : '▶' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div v-show="panels.ageGroup" class="section-content">
|
||||
<div class="distribution-grid panel-body-padding">
|
||||
<div v-for="entry in ageGroupStats" :key="entry.label" class="distribution-card">
|
||||
<strong>{{ entry.label }}</strong>
|
||||
<span>{{ entry.memberCount }}</span>
|
||||
<small>{{ entry.averageParticipations12Months.toFixed(1) }} Ø Teilnahmen / 12 Monate</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Trainingstage-Tabelle (standardmäßig aufgeklappt) -->
|
||||
<div class="collapsible-section">
|
||||
<div class="section-header" @click="toggleTrainingDays">
|
||||
<div class="section-header" @click="togglePanel('trainingDays')">
|
||||
<h3>{{ $t('trainingStats.trainingDays') }}</h3>
|
||||
<span class="toggle-icon">{{ showTrainingDays ? '▼' : '▶' }}</span>
|
||||
<span class="toggle-icon">{{ panels.trainingDays ? '▼' : '▶' }}</span>
|
||||
</div>
|
||||
<div v-if="showTrainingDays" class="section-content">
|
||||
<div v-show="panels.trainingDays" class="section-content">
|
||||
<div class="training-days-container">
|
||||
<table class="training-days-table">
|
||||
<thead>
|
||||
@@ -207,13 +248,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mitglieder-Tabelle (standardmäßig eingeklappt) -->
|
||||
<!-- Mitglieder-Tabelle -->
|
||||
<div class="collapsible-section">
|
||||
<div class="section-header" @click="toggleMembers">
|
||||
<div class="section-header" @click="togglePanel('members')">
|
||||
<h3>{{ $t('trainingStats.memberParticipations') }}</h3>
|
||||
<span class="toggle-icon">{{ showMembers ? '▼' : '▶' }}</span>
|
||||
<span class="toggle-icon">{{ panels.members ? '▼' : '▶' }}</span>
|
||||
</div>
|
||||
<div v-if="showMembers" class="section-content">
|
||||
<div v-show="panels.members" class="section-content">
|
||||
<div class="members-table-container">
|
||||
<table class="members-table">
|
||||
<thead>
|
||||
@@ -576,8 +617,17 @@ export default {
|
||||
loading: false,
|
||||
sortField: 'name',
|
||||
sortDirection: 'asc',
|
||||
showTrainingDays: true,
|
||||
showMembers: false
|
||||
panels: {
|
||||
overview: false,
|
||||
monthlyTrend: false,
|
||||
weekdayStats: false,
|
||||
memberStructure: false,
|
||||
bestDay: false,
|
||||
groupPerformance: false,
|
||||
ageGroup: false,
|
||||
trainingDays: false,
|
||||
members: false
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
@@ -626,15 +676,12 @@ export default {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
toggleTrainingDays() {
|
||||
this.showTrainingDays = !this.showTrainingDays;
|
||||
|
||||
togglePanel(key) {
|
||||
if (!Object.prototype.hasOwnProperty.call(this.panels, key)) return;
|
||||
this.panels = { ...this.panels, [key]: !this.panels[key] };
|
||||
},
|
||||
|
||||
toggleMembers() {
|
||||
this.showMembers = !this.showMembers;
|
||||
},
|
||||
|
||||
|
||||
getWeekday(dateString) {
|
||||
const date = new Date(dateString);
|
||||
const weekdays = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'];
|
||||
@@ -779,6 +826,42 @@ export default {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.stats-panels-grid > .collapsible-section {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.stats-panel-collapsible .section-header {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.section-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.section-meta {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.section-header:hover .section-meta {
|
||||
color: inherit;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.panel-body-padding {
|
||||
padding: 0 1.15rem 1.15rem;
|
||||
}
|
||||
|
||||
.stats-overview.panel-body-padding {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.collapsible-section.stats-panel-highlight {
|
||||
background: linear-gradient(135deg, rgba(47, 122, 95, 0.08), rgba(255, 255, 255, 0.98));
|
||||
}
|
||||
|
||||
.stats-panel {
|
||||
background: white;
|
||||
border: 1px solid var(--border-color);
|
||||
|
||||
@@ -212,7 +212,6 @@ Web-Route: `/calendar` · Referenz: `CalendarView.vue` (Aggregation mehrerer Dat
|
||||
|
||||
### Phase 12 – Backlog / offen
|
||||
|
||||
- [ ] **iOS:** Kalender-UI + Tab (derzeit nur Android `composeApp` / `MainTab.Calendar`)
|
||||
- [ ] **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
|
||||
- [ ] **Release:** Bei `isMinifyEnabled = true` ProGuard/R8 für Ktor, `kotlinx.serialization`, ggf. Koin (`composeApp/proguard-rules.pro`)
|
||||
|
||||
@@ -123,13 +123,15 @@ class AppDependencies(context: Context) {
|
||||
AccidentApi(client),
|
||||
MemberGroupPhotosApi(client),
|
||||
)
|
||||
private val trainingStatsApi = TrainingStatsApi(client)
|
||||
val membersManager = MembersManager(
|
||||
MembersApi(client),
|
||||
TrainingGroupsApi(client),
|
||||
MemberActivitiesApi(client),
|
||||
TrainingTimesApi(client),
|
||||
trainingStatsApi,
|
||||
)
|
||||
val trainingStatsManager = TrainingStatsManager(TrainingStatsApi(client))
|
||||
val trainingStatsManager = TrainingStatsManager(trainingStatsApi)
|
||||
val scheduleManager = ScheduleManager(
|
||||
ClubTeamsApi(client),
|
||||
matchesApi,
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
package de.tt_tagebuch.app.pdf
|
||||
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Typeface
|
||||
import android.graphics.pdf.PdfDocument
|
||||
import android.text.Layout
|
||||
import android.text.StaticLayout
|
||||
import android.text.TextPaint
|
||||
import de.tt_tagebuch.shared.api.models.Member
|
||||
import de.tt_tagebuch.shared.api.models.MemberContactDto
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.time.LocalDate
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
|
||||
private const val PAGE_W = 595
|
||||
private const val PAGE_H = 842
|
||||
private const val MARGIN = 40f
|
||||
private const val MAX_TEXT_W = (PAGE_W - MARGIN * 2).toInt()
|
||||
|
||||
private fun newTitlePaint(): TextPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.BLACK
|
||||
textSize = 14f
|
||||
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||
}
|
||||
|
||||
private fun newBodyPaint(): TextPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.BLACK
|
||||
textSize = 10f
|
||||
typeface = Typeface.create(Typeface.DEFAULT, Typeface.NORMAL)
|
||||
textLocale = Locale.GERMANY
|
||||
}
|
||||
|
||||
private fun newBoldPaint(): TextPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.BLACK
|
||||
textSize = 10f
|
||||
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||
textLocale = Locale.GERMANY
|
||||
}
|
||||
|
||||
private fun Canvas.drawStatic(text: String, paint: TextPaint, x: Float, y: Float): Float {
|
||||
if (text.isEmpty()) return y
|
||||
val layout = StaticLayout.Builder.obtain(text, 0, text.length, paint, MAX_TEXT_W)
|
||||
.setAlignment(Layout.Alignment.ALIGN_NORMAL)
|
||||
.setLineSpacing(0f, 1.05f)
|
||||
.setIncludePad(false)
|
||||
.build()
|
||||
save()
|
||||
translate(x, y)
|
||||
layout.draw(this)
|
||||
restore()
|
||||
return y + layout.height + 4f
|
||||
}
|
||||
|
||||
private fun formatBirthDe(birthDate: String?): String {
|
||||
if (birthDate.isNullOrBlank()) return ""
|
||||
val day = birthDate.trim().take(10)
|
||||
val d = runCatching { LocalDate.parse(day) }.getOrNull() ?: return ""
|
||||
return d.format(DateTimeFormatter.ofPattern("dd.MM.yyyy", Locale.GERMANY))
|
||||
}
|
||||
|
||||
private data class PhoneLine(val value: String, val label: String?)
|
||||
|
||||
private fun memberPhoneLines(member: Member): List<PhoneLine> {
|
||||
val phones = member.contacts
|
||||
.filter { it.type == "phone" && it.value.isNotBlank() }
|
||||
.sortedWith(compareBy<MemberContactDto> { !it.isPrimary }.thenBy { it.id ?: 0 })
|
||||
val out = phones.map { c ->
|
||||
val v = c.value.trim()
|
||||
val suffix = if (c.isParent) {
|
||||
val p = c.parentName?.trim().orEmpty()
|
||||
if (p.isNotEmpty()) " ($p)" else " (Eltern)"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
PhoneLine(v + suffix, null)
|
||||
}.toMutableList()
|
||||
val legacy = member.phone?.trim().orEmpty()
|
||||
if (out.isEmpty() && legacy.isNotEmpty()) {
|
||||
out.add(PhoneLine(legacy, null))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Telefonliste wie Web-[PDFGenerator.addPhoneList]: Name, Geburtsdatum, Telefonnummern.
|
||||
*/
|
||||
fun writeMembersPhoneListPdf(
|
||||
outFile: File,
|
||||
titleLine: String,
|
||||
members: List<Member>,
|
||||
) {
|
||||
val sorted = members.sortedWith(
|
||||
compareBy<Member> { it.lastName.lowercase(Locale.GERMANY) }.thenBy { it.firstName.lowercase(Locale.GERMANY) },
|
||||
)
|
||||
val doc = PdfDocument()
|
||||
var pageSeq = 0
|
||||
fun openPage(): PdfDocument.Page {
|
||||
pageSeq++
|
||||
return doc.startPage(PdfDocument.PageInfo.Builder(PAGE_W, PAGE_H, pageSeq).create())
|
||||
}
|
||||
var page = openPage()
|
||||
var canvas = page.canvas
|
||||
var y = MARGIN
|
||||
val titlePaint = newTitlePaint()
|
||||
val bodyPaint = newBodyPaint()
|
||||
val boldPaint = newBoldPaint()
|
||||
|
||||
fun newPageIfNeeded(extra: Float) {
|
||||
if (y + extra > PAGE_H - MARGIN) {
|
||||
doc.finishPage(page)
|
||||
page = openPage()
|
||||
canvas = page.canvas
|
||||
y = MARGIN
|
||||
}
|
||||
}
|
||||
|
||||
y = canvas.drawStatic(titleLine, titlePaint, MARGIN, y)
|
||||
y = canvas.drawStatic("", bodyPaint, MARGIN, y)
|
||||
y = canvas.drawStatic("Name, Vorname", boldPaint, MARGIN, y)
|
||||
y = canvas.drawStatic("Geburtsdatum / Telefon", bodyPaint, MARGIN, y)
|
||||
y = canvas.drawStatic("—".repeat(42), bodyPaint, MARGIN, y)
|
||||
|
||||
for (member in sorted) {
|
||||
val phones = memberPhoneLines(member)
|
||||
val nameLine = "${member.lastName}, ${member.firstName}".trim()
|
||||
val birth = formatBirthDe(member.birthDate)
|
||||
val blockHeight = 18f + phones.size.coerceAtLeast(1) * 14f
|
||||
newPageIfNeeded(blockHeight)
|
||||
y = canvas.drawStatic(nameLine, boldPaint, MARGIN, y)
|
||||
val sub = buildString {
|
||||
if (birth.isNotEmpty()) append("Geboren: $birth")
|
||||
if (phones.isEmpty()) {
|
||||
if (isNotEmpty()) append(" — ")
|
||||
append("—")
|
||||
}
|
||||
}
|
||||
y = canvas.drawStatic(sub, bodyPaint, MARGIN, y)
|
||||
if (phones.isEmpty()) {
|
||||
y += 4f
|
||||
} else {
|
||||
for (p in phones) {
|
||||
newPageIfNeeded(16f)
|
||||
y = canvas.drawStatic("Tel.: ${p.value}", bodyPaint, MARGIN + 12f, y)
|
||||
}
|
||||
}
|
||||
y = canvas.drawStatic("", bodyPaint, MARGIN, y)
|
||||
}
|
||||
|
||||
doc.finishPage(page)
|
||||
FileOutputStream(outFile).use { doc.writeTo(it) }
|
||||
doc.close()
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,540 @@
|
||||
package de.tt_tagebuch.app.ui
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.PickVisualMediaRequest
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.AlertDialog
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.Checkbox
|
||||
import androidx.compose.material.CircularProgressIndicator
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.OutlinedButton
|
||||
import androidx.compose.material.OutlinedTextField
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.PathFillType
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.tt_tagebuch.app.AppDependencies
|
||||
import de.tt_tagebuch.shared.api.models.Member
|
||||
import de.tt_tagebuch.shared.i18n.MobileStrings
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.ByteArrayOutputStream
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private val CropFrameMaxHeight = 420.dp
|
||||
private const val CropOutputSize = 600
|
||||
private const val MinSelectionPx = 24f
|
||||
private const val SmallCropThresholdPx = 80f
|
||||
|
||||
private fun decodeBitmapForCrop(resolver: android.content.ContentResolver, uri: Uri, maxSide: Int = 4096): Bitmap? {
|
||||
return resolver.openInputStream(uri)?.use { input ->
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeStream(input, null, bounds)
|
||||
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return@use null
|
||||
var sample = 1
|
||||
var w = bounds.outWidth
|
||||
var h = bounds.outHeight
|
||||
while (w > maxSide || h > maxSide) {
|
||||
sample *= 2
|
||||
w = bounds.outWidth / sample
|
||||
h = bounds.outHeight / sample
|
||||
}
|
||||
val opts = BitmapFactory.Options().apply { inSampleSize = sample }
|
||||
resolver.openInputStream(uri)?.use { stream ->
|
||||
BitmapFactory.decodeStream(stream, null, opts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun bitmapToJpeg(bitmap: Bitmap, quality: Int = 90): ByteArray {
|
||||
val out = ByteArrayOutputStream()
|
||||
bitmap.compress(Bitmap.CompressFormat.JPEG, quality, out)
|
||||
return out.toByteArray()
|
||||
}
|
||||
|
||||
private fun cropSquareForUpload(
|
||||
source: Bitmap,
|
||||
selX: Float,
|
||||
selY: Float,
|
||||
selSize: Float,
|
||||
dispW: Float,
|
||||
dispH: Float,
|
||||
): Bitmap? {
|
||||
if (selSize < MinSelectionPx || dispW <= 0f || dispH <= 0f) return null
|
||||
val bw = source.width.toFloat()
|
||||
val bh = source.height.toFloat()
|
||||
val scaleX = bw / dispW
|
||||
val scaleY = bh / dispH
|
||||
val sx = (selX * scaleX).roundToInt().coerceIn(0, source.width - 1)
|
||||
val sy = (selY * scaleY).roundToInt().coerceIn(0, source.height - 1)
|
||||
val sideRaw = (selSize * min(scaleX, scaleY)).roundToInt().coerceAtLeast(1)
|
||||
val side = min(sideRaw, min(source.width - sx, source.height - sy))
|
||||
if (side < 8) return null
|
||||
val cropped = Bitmap.createBitmap(source, sx, sy, side, side)
|
||||
return if (cropped.width == CropOutputSize && cropped.height == CropOutputSize) {
|
||||
cropped
|
||||
} else {
|
||||
val scaled = Bitmap.createScaledBitmap(cropped, CropOutputSize, CropOutputSize, true)
|
||||
if (scaled != cropped) cropped.recycle()
|
||||
scaled
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MemberGroupPortraitCropRoute(
|
||||
clubId: Int,
|
||||
members: List<Member>,
|
||||
dependencies: AppDependencies,
|
||||
canWriteMembers: Boolean,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val languageCode = LocalLanguageCode.current
|
||||
fun s(key: String, fb: String) = MobileStrings.get(languageCode, key, fb)
|
||||
val context = LocalContext.current
|
||||
val density = LocalDensity.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var sourceUri by remember { mutableStateOf<Uri?>(null) }
|
||||
var fullBitmap by remember { mutableStateOf<Bitmap?>(null) }
|
||||
var decodeError by remember { mutableStateOf<String?>(null) }
|
||||
var containerSize by remember { mutableStateOf(IntSize.Zero) }
|
||||
|
||||
var selectionStart by remember { mutableStateOf<Offset?>(null) }
|
||||
var selectionRect by remember { mutableStateOf<Rect?>(null) }
|
||||
var isDragging by remember { mutableStateOf(false) }
|
||||
|
||||
var previewBytes by remember { mutableStateOf<ByteArray?>(null) }
|
||||
var previewBitmap by remember { mutableStateOf<Bitmap?>(null) }
|
||||
|
||||
var memberQuery by remember { mutableStateOf("") }
|
||||
var selectedMemberId by remember { mutableStateOf<Int?>(null) }
|
||||
var makePrimary by remember { mutableStateOf(true) }
|
||||
var memberPickerOpen by remember { mutableStateOf(false) }
|
||||
var saving by remember { mutableStateOf(false) }
|
||||
var statusMessage by remember { mutableStateOf<String?>(null) }
|
||||
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
BackHandler(onBack = onBack)
|
||||
|
||||
LaunchedEffect(sourceUri) {
|
||||
val uri = sourceUri ?: return@LaunchedEffect
|
||||
decodeError = null
|
||||
fullBitmap?.recycle()
|
||||
fullBitmap = null
|
||||
selectionRect = null
|
||||
selectionStart = null
|
||||
previewBytes = null
|
||||
previewBitmap?.recycle()
|
||||
previewBitmap = null
|
||||
val bmp = withContext(Dispatchers.IO) {
|
||||
decodeBitmapForCrop(context.contentResolver, uri)
|
||||
}
|
||||
if (bmp == null) {
|
||||
decodeError = s("members.groupCropDecodeError", "Bild konnte nicht geladen werden.")
|
||||
} else {
|
||||
fullBitmap = bmp
|
||||
}
|
||||
}
|
||||
|
||||
val pickImage = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
|
||||
if (uri != null) sourceUri = uri
|
||||
}
|
||||
|
||||
fun resetSelection() {
|
||||
selectionStart = null
|
||||
selectionRect = null
|
||||
isDragging = false
|
||||
previewBytes = null
|
||||
previewBitmap?.recycle()
|
||||
previewBitmap = null
|
||||
}
|
||||
|
||||
fun recomputePreviewFromSelection(rect: Rect?) {
|
||||
val bmp = fullBitmap ?: return
|
||||
if (rect == null || rect.width < MinSelectionPx || rect.height < MinSelectionPx) {
|
||||
previewBytes = null
|
||||
previewBitmap?.recycle()
|
||||
previewBitmap = null
|
||||
return
|
||||
}
|
||||
val frame = computeImageDisplaySize(bmp.width.toFloat(), bmp.height.toFloat(), containerSize)
|
||||
if (frame.dispW <= 0f || frame.dispH <= 0f) return
|
||||
val cropped = cropSquareForUpload(bmp, rect.left, rect.top, rect.width, frame.dispW, frame.dispH)
|
||||
if (cropped == null) {
|
||||
previewBytes = null
|
||||
previewBitmap?.recycle()
|
||||
previewBitmap = null
|
||||
return
|
||||
}
|
||||
previewBitmap?.recycle()
|
||||
previewBitmap = cropped
|
||||
previewBytes = bitmapToJpeg(cropped)
|
||||
}
|
||||
|
||||
fun updateSelectionFromDrag(start: Offset, current: Offset, maxW: Float, maxH: Float) {
|
||||
val dx = current.x - start.x
|
||||
val dy = current.y - start.y
|
||||
var size = min(abs(dx), abs(dy))
|
||||
var x = if (dx < 0) start.x - size else start.x
|
||||
var y = if (dy < 0) start.y - size else start.y
|
||||
x = max(0f, min(x, maxW - size))
|
||||
y = max(0f, min(y, maxH - size))
|
||||
size = min(size, min(maxW - x, maxH - y))
|
||||
selectionRect = Rect(x, y, x + size, y + size)
|
||||
}
|
||||
|
||||
val filteredMembers = remember(members, memberQuery) {
|
||||
val q = memberQuery.trim().lowercase()
|
||||
members
|
||||
.filter { m ->
|
||||
if (q.isEmpty()) return@filter true
|
||||
"${m.firstName} ${m.lastName}".lowercase().contains(q) ||
|
||||
"${m.lastName} ${m.firstName}".lowercase().contains(q)
|
||||
}
|
||||
.sortedWith(compareBy<Member> { it.lastName.lowercase() }.thenBy { it.firstName.lowercase() })
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.imePadding()
|
||||
.navigationBarsPadding()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 20.dp, vertical = 16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
s("members.groupPortraitCropTitle", "Portrait aus Gruppenfoto"),
|
||||
style = MaterialTheme.typography.h6,
|
||||
)
|
||||
TextButton(onClick = onBack) { Text(s("mobile.back", "Zurück")) }
|
||||
}
|
||||
Text(
|
||||
s(
|
||||
"members.groupPortraitCropHint",
|
||||
"Wähle ein Bild, ziehe mit dem Finger einen quadratischen Rahmen um eine Person, ordne das Mitglied zu und speichere.",
|
||||
),
|
||||
style = MaterialTheme.typography.body2,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.75f),
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
)
|
||||
|
||||
if (!canWriteMembers) {
|
||||
Text(s("members.groupCropNoWrite", "Keine Berechtigung zum Hochladen von Mitgliedsfotos."))
|
||||
return@Column
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
pickImage.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 48.dp),
|
||||
) { Text(s("members.groupCropPickImage", "Bild aus Galerie wählen")) }
|
||||
|
||||
decodeError?.let {
|
||||
Text(it, color = MaterialTheme.colors.error, modifier = Modifier.padding(top = 8.dp))
|
||||
}
|
||||
|
||||
val bmp = fullBitmap
|
||||
if (bmp != null) {
|
||||
val frame = computeImageDisplaySize(
|
||||
bmp.width.toFloat(),
|
||||
bmp.height.toFloat(),
|
||||
containerSize,
|
||||
)
|
||||
val dispWdp = with(density) { frame.dispW.toDp() }
|
||||
val dispHdp = with(density) { frame.dispH.toDp() }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(CropFrameMaxHeight)
|
||||
.padding(top = 12.dp)
|
||||
.onSizeChanged { containerSize = it },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (frame.dispW > 0f && frame.dispH > 0f) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(dispWdp, dispHdp)
|
||||
.pointerInput(frame.dispW, frame.dispH, bmp) {
|
||||
detectDragGestures(
|
||||
onDragStart = { start ->
|
||||
isDragging = true
|
||||
selectionStart = start
|
||||
errorMessage = null
|
||||
statusMessage = null
|
||||
previewBytes = null
|
||||
previewBitmap?.recycle()
|
||||
previewBitmap = null
|
||||
selectionRect = Rect(start.x, start.y, start.x, start.y)
|
||||
},
|
||||
onDrag = { change, _ ->
|
||||
val start = selectionStart ?: return@detectDragGestures
|
||||
updateSelectionFromDrag(start, change.position, frame.dispW, frame.dispH)
|
||||
},
|
||||
onDragEnd = {
|
||||
isDragging = false
|
||||
selectionStart = null
|
||||
val r = selectionRect
|
||||
if (r == null || r.width < MinSelectionPx) {
|
||||
resetSelection()
|
||||
} else {
|
||||
recomputePreviewFromSelection(r)
|
||||
}
|
||||
},
|
||||
onDragCancel = {
|
||||
isDragging = false
|
||||
selectionStart = null
|
||||
val r = selectionRect
|
||||
if (r == null || r.width < MinSelectionPx) {
|
||||
resetSelection()
|
||||
} else {
|
||||
recomputePreviewFromSelection(r)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) {
|
||||
Image(
|
||||
bitmap = bmp.asImageBitmap(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
val r = selectionRect
|
||||
if (r != null && r.width > 0f && r.height > 0f) {
|
||||
val path = Path().apply {
|
||||
addRect(Rect(Offset.Zero, size))
|
||||
addRect(r)
|
||||
fillType = PathFillType.EvenOdd
|
||||
}
|
||||
drawPath(path, Color.Black.copy(alpha = 0.48f))
|
||||
drawRect(
|
||||
color = Color.White.copy(alpha = 0.85f),
|
||||
topLeft = Offset(r.left, r.top),
|
||||
size = Size(r.width, r.height),
|
||||
style = Stroke(width = 2.dp.toPx()),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
selectionRect?.let { r ->
|
||||
if (r.width < SmallCropThresholdPx && r.width >= MinSelectionPx && !isDragging) {
|
||||
Text(
|
||||
s("members.groupCropSmallWarning", "Kleiner Ausschnitt — Qualität kann leiden."),
|
||||
style = MaterialTheme.typography.caption,
|
||||
color = MaterialTheme.colors.secondary,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { resetSelection() },
|
||||
enabled = selectionRect != null && !isDragging,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp),
|
||||
) { Text(s("members.groupCropResetSelection", "Auswahl zurücksetzen")) }
|
||||
|
||||
Text(s("members.groupCropPreview", "Vorschau"), fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(top = 16.dp))
|
||||
val prev = previewBitmap
|
||||
if (prev != null) {
|
||||
Image(
|
||||
bitmap = prev.asImageBitmap(),
|
||||
contentDescription = s("members.groupCropPreview", "Vorschau"),
|
||||
modifier = Modifier
|
||||
.size(160.dp)
|
||||
.padding(top = 6.dp),
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
s("members.groupCropNoPreview", "Noch keine Auswahl."),
|
||||
style = MaterialTheme.typography.caption,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { memberPickerOpen = true },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 16.dp)
|
||||
.heightIn(min = 48.dp),
|
||||
) {
|
||||
Text(
|
||||
selectedMemberId?.let { id ->
|
||||
members.firstOrNull { it.id == id }?.let { "${it.lastName}, ${it.firstName}" }
|
||||
} ?: s("members.groupCropPickMember", "Mitglied wählen"),
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
) {
|
||||
Checkbox(checked = makePrimary, onCheckedChange = { makePrimary = it })
|
||||
Text(s("members.groupCropMakePrimary", "Als Hauptfoto verwenden"))
|
||||
}
|
||||
|
||||
if (saving) {
|
||||
CircularProgressIndicator(modifier = Modifier.padding(16.dp))
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
val bytes = previewBytes
|
||||
val mid = selectedMemberId
|
||||
if (bytes == null || mid == null) return@Button
|
||||
saving = true
|
||||
errorMessage = null
|
||||
statusMessage = null
|
||||
scope.launch {
|
||||
runCatching {
|
||||
dependencies.membersManager.uploadMemberPortrait(clubId, mid, bytes, makePrimary = makePrimary)
|
||||
dependencies.membersManager.loadMembers(clubId)
|
||||
}.fold(
|
||||
onSuccess = {
|
||||
val m = members.firstOrNull { it.id == mid }
|
||||
statusMessage = m?.let {
|
||||
s("members.groupCropSavedFor", "Foto gespeichert für ${it.firstName} ${it.lastName}.")
|
||||
} ?: s("members.groupCropSaved", "Foto gespeichert.")
|
||||
resetSelection()
|
||||
},
|
||||
onFailure = {
|
||||
errorMessage = it.message ?: s("members.groupCropSaveError", "Speichern fehlgeschlagen.")
|
||||
},
|
||||
)
|
||||
saving = false
|
||||
}
|
||||
},
|
||||
enabled = !saving && previewBytes != null && selectedMemberId != null,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 12.dp)
|
||||
.heightIn(min = 48.dp),
|
||||
) {
|
||||
Text(s("members.groupCropSave", "Als Mitgliedsfoto speichern"))
|
||||
}
|
||||
|
||||
statusMessage?.let {
|
||||
Text(it, color = MaterialTheme.colors.primary, modifier = Modifier.padding(top = 8.dp))
|
||||
}
|
||||
errorMessage?.let {
|
||||
Text(it, color = MaterialTheme.colors.error, modifier = Modifier.padding(top = 8.dp))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
|
||||
if (memberPickerOpen) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { memberPickerOpen = false },
|
||||
title = { Text(s("members.groupCropPickMember", "Mitglied wählen")) },
|
||||
text = {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
OutlinedTextField(
|
||||
value = memberQuery,
|
||||
onValueChange = { memberQuery = it },
|
||||
label = { Text(s("mobile.search", "Suche")) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
LazyColumn(modifier = Modifier.height(320.dp).padding(top = 8.dp)) {
|
||||
items(filteredMembers, key = { it.id }) { m ->
|
||||
TextButton(
|
||||
onClick = {
|
||||
selectedMemberId = m.id
|
||||
memberPickerOpen = false
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("${m.lastName}, ${m.firstName}", modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { memberPickerOpen = false }) {
|
||||
Text(s("common.close", "Schließen"))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private data class ImageDisplayFrame(val dispW: Float, val dispH: Float)
|
||||
|
||||
private fun computeImageDisplaySize(bitmapW: Float, bitmapH: Float, container: IntSize): ImageDisplayFrame {
|
||||
if (container.width <= 0 || container.height <= 0 || bitmapW <= 0f || bitmapH <= 0f) {
|
||||
return ImageDisplayFrame(0f, 0f)
|
||||
}
|
||||
val cw = container.width.toFloat()
|
||||
val ch = container.height.toFloat()
|
||||
val scale = min(cw / bitmapW, ch / bitmapH)
|
||||
return ImageDisplayFrame(bitmapW * scale, bitmapH * scale)
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
package de.tt_tagebuch.app.ui
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.PickVisualMediaRequest
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.Card
|
||||
import androidx.compose.material.CircularProgressIndicator
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.OutlinedButton
|
||||
import androidx.compose.material.OutlinedTextField
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.tt_tagebuch.app.AppDependencies
|
||||
import de.tt_tagebuch.shared.api.models.MemberGroupPhotoDto
|
||||
import de.tt_tagebuch.shared.api.models.MemberTransferConfigEnvelope
|
||||
import de.tt_tagebuch.shared.api.models.MemberTransferRunBody
|
||||
import de.tt_tagebuch.shared.api.toAbsoluteUrl
|
||||
import de.tt_tagebuch.shared.i18n.MobileStrings
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
|
||||
private val MembersExtraHorizontalPadding = 20.dp
|
||||
private val MembersExtraTouchMin = 48.dp
|
||||
|
||||
@Composable
|
||||
fun MemberGroupPhotoManageRoute(
|
||||
clubId: Int,
|
||||
dependencies: AppDependencies,
|
||||
canReadMembers: Boolean,
|
||||
canWriteMembers: Boolean,
|
||||
onBack: () -> Unit,
|
||||
onOpenPortraitCrop: () -> Unit = {},
|
||||
) {
|
||||
val languageCode = LocalLanguageCode.current
|
||||
fun s(key: String, fb: String) = MobileStrings.get(languageCode, key, fb)
|
||||
BackHandler(onBack = onBack)
|
||||
val androidContext = LocalContext.current
|
||||
var groupPhotos by remember { mutableStateOf<List<MemberGroupPhotoDto>>(emptyList()) }
|
||||
var groupPhotoBusy by remember { mutableStateOf(false) }
|
||||
var newGroupPhotoTitle by rememberSaveable { mutableStateOf("") }
|
||||
var newGroupPhotoDescription by rememberSaveable { mutableStateOf("") }
|
||||
var loadError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
LaunchedEffect(clubId) {
|
||||
groupPhotoBusy = true
|
||||
loadError = null
|
||||
groupPhotos = runCatching { dependencies.diaryManager.listMemberGroupPhotos(clubId) }.getOrElse {
|
||||
loadError = it.message
|
||||
emptyList()
|
||||
}
|
||||
groupPhotoBusy = false
|
||||
}
|
||||
|
||||
val pickGroupPhoto = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
|
||||
if (uri == null || !canWriteMembers) return@rememberLauncherForActivityResult
|
||||
val title = newGroupPhotoTitle.trim().ifBlank { "Gruppenfoto" }
|
||||
dependencies.applicationScope.launch {
|
||||
groupPhotoBusy = true
|
||||
runCatching {
|
||||
val bytes = androidContext.contentResolver.openInputStream(uri)?.use { it.readBytes() } ?: return@runCatching
|
||||
dependencies.diaryManager.uploadMemberGroupPhoto(
|
||||
clubId,
|
||||
bytes,
|
||||
title = title,
|
||||
description = newGroupPhotoDescription.trim(),
|
||||
)
|
||||
newGroupPhotoTitle = ""
|
||||
newGroupPhotoDescription = ""
|
||||
groupPhotos = dependencies.diaryManager.listMemberGroupPhotos(clubId)
|
||||
}
|
||||
groupPhotoBusy = false
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.imePadding()
|
||||
.navigationBarsPadding()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = MembersExtraHorizontalPadding, vertical = 16.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(s("members.groupPhotoTitle", "Gruppenfoto"), style = MaterialTheme.typography.h6)
|
||||
TextButton(onClick = onBack) { Text(s("mobile.back", "Zurück")) }
|
||||
}
|
||||
Text(
|
||||
s(
|
||||
"members.groupPhotoMobileHint",
|
||||
"Gruppenfotos werden im Verein gespeichert (wie im Tagebuch). Einzelportraits legst du pro Mitglied unter „Profilbild“ fest.",
|
||||
),
|
||||
style = MaterialTheme.typography.body2,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.75f),
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
)
|
||||
if (canWriteMembers) {
|
||||
OutlinedButton(
|
||||
onClick = onOpenPortraitCrop,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 12.dp)
|
||||
.heightIn(min = MembersExtraTouchMin),
|
||||
) {
|
||||
Text(s("members.openPortraitCrop", "Portrait aus Bild zuschneiden"))
|
||||
}
|
||||
}
|
||||
loadError?.let { Text(it, color = MaterialTheme.colors.error, modifier = Modifier.padding(bottom = 8.dp)) }
|
||||
if (!canReadMembers) {
|
||||
Text(s("members.noReadPermission", "Keine Berechtigung."))
|
||||
return@Column
|
||||
}
|
||||
if (canWriteMembers) {
|
||||
OutlinedTextField(
|
||||
value = newGroupPhotoTitle,
|
||||
onValueChange = { newGroupPhotoTitle = it },
|
||||
label = { Text(s("diary.groupPhotoTitle", "Titel")) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !groupPhotoBusy,
|
||||
singleLine = true,
|
||||
)
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
pickGroupPhoto.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
|
||||
},
|
||||
enabled = !groupPhotoBusy,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp)
|
||||
.heightIn(min = MembersExtraTouchMin),
|
||||
) { Text(s("diary.uploadGroupPhoto", "Foto wählen")) }
|
||||
OutlinedTextField(
|
||||
value = newGroupPhotoDescription,
|
||||
onValueChange = { newGroupPhotoDescription = it },
|
||||
label = { Text(s("diary.groupPhotoDescription", "Beschreibung (optional)")) },
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
enabled = !groupPhotoBusy,
|
||||
singleLine = false,
|
||||
maxLines = 3,
|
||||
)
|
||||
}
|
||||
if (groupPhotoBusy) {
|
||||
CircularProgressIndicator(modifier = Modifier.padding(16.dp).align(Alignment.CenterHorizontally))
|
||||
}
|
||||
if (groupPhotos.isEmpty() && !groupPhotoBusy) {
|
||||
Text(s("diary.noGroupPhotos", "Keine Gruppenfotos."), modifier = Modifier.padding(top = 12.dp))
|
||||
} else {
|
||||
val auth = dependencies.diaryAuthHeaders()
|
||||
groupPhotos.forEach { ph ->
|
||||
val url = ph.imageUrl?.let { dependencies.apiConfig.toAbsoluteUrl(it) }
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 6.dp),
|
||||
elevation = 1.dp,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(8.dp)) {
|
||||
Text(ph.title.orEmpty(), fontWeight = FontWeight.SemiBold)
|
||||
if (!ph.description.isNullOrBlank()) {
|
||||
Text(ph.description.orEmpty(), style = MaterialTheme.typography.caption)
|
||||
}
|
||||
if (url != null) {
|
||||
AuthenticatedAsyncImage(
|
||||
imageUrl = url,
|
||||
authHeaders = auth,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(200.dp)
|
||||
.padding(top = 6.dp),
|
||||
contentDescription = ph.title,
|
||||
)
|
||||
}
|
||||
if (canWriteMembers) {
|
||||
TextButton(
|
||||
enabled = !groupPhotoBusy,
|
||||
onClick = {
|
||||
dependencies.applicationScope.launch {
|
||||
groupPhotoBusy = true
|
||||
runCatching {
|
||||
dependencies.diaryManager.deleteMemberGroupPhoto(clubId, ph.id)
|
||||
groupPhotos = dependencies.diaryManager.listMemberGroupPhotos(clubId)
|
||||
}
|
||||
groupPhotoBusy = false
|
||||
}
|
||||
},
|
||||
) { Text(s("common.delete", "Löschen")) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MemberTransferRunRoute(
|
||||
clubId: Int,
|
||||
dependencies: AppDependencies,
|
||||
canWriteMembers: Boolean,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val languageCode = LocalLanguageCode.current
|
||||
fun s(key: String, fb: String) = MobileStrings.get(languageCode, key, fb)
|
||||
BackHandler(onBack = onBack)
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
var envelope by remember { mutableStateOf<MemberTransferConfigEnvelope?>(null) }
|
||||
var transferBusy by remember { mutableStateOf(false) }
|
||||
var loginUser by rememberSaveable { mutableStateOf("") }
|
||||
var loginPass by rememberSaveable { mutableStateOf("") }
|
||||
var statusText by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
LaunchedEffect(clubId) {
|
||||
loading = true
|
||||
envelope = runCatching { dependencies.memberTransferConfigApi.get(clubId) }.getOrNull()
|
||||
loading = false
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.imePadding()
|
||||
.navigationBarsPadding()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = MembersExtraHorizontalPadding, vertical = 16.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(s("members.transferMembers", "Mitgliedstransfer"), style = MaterialTheme.typography.h6)
|
||||
TextButton(onClick = onBack) { Text(s("mobile.back", "Zurück")) }
|
||||
}
|
||||
if (!canWriteMembers) {
|
||||
Text(s("members.transferNoWrite", "Keine Schreibberechtigung für Mitglieder."))
|
||||
return@Column
|
||||
}
|
||||
if (loading) {
|
||||
CircularProgressIndicator(modifier = Modifier.padding(24.dp).align(Alignment.CenterHorizontally))
|
||||
return@Column
|
||||
}
|
||||
val cfg = envelope?.config
|
||||
val hasConfig = cfg != null &&
|
||||
!cfg.transferEndpoint.isNullOrBlank() &&
|
||||
!cfg.transferTemplate.isNullOrBlank() &&
|
||||
!cfg.server.isNullOrBlank()
|
||||
if (!hasConfig) {
|
||||
Text(
|
||||
s(
|
||||
"members.transferConfigMissing",
|
||||
"Es ist keine Transfer-Konfiguration hinterlegt. Lege sie unter „Mehr“ → Vereinsstammdaten → Mitgliedstransfer an.",
|
||||
),
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
return@Column
|
||||
}
|
||||
val c = cfg!!
|
||||
Text(
|
||||
s("members.transferActiveOnlyHint", "Es werden nur aktive Mitglieder übertragen (serverseitig)."),
|
||||
style = MaterialTheme.typography.caption,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.75f),
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
Text("${s("memberTransferDialog.server", "Server")}: ${c.server}", style = MaterialTheme.typography.body2)
|
||||
Text("${s("memberTransferDialog.endpoint", "Endpoint")}: ${c.transferEndpoint}", style = MaterialTheme.typography.body2)
|
||||
Text("${s("memberTransferDialog.method", "Methode")}: ${c.transferMethod ?: "POST"}", style = MaterialTheme.typography.body2)
|
||||
Text(
|
||||
"${s("memberTransferDialog.mode", "Modus")}: ${if (c.useBulkMode == true) s("memberTransferDialog.bulkImport", "Bulk") else s("memberTransferDialog.single", "Einzeln")}",
|
||||
style = MaterialTheme.typography.body2,
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
)
|
||||
if (!c.loginEndpoint.isNullOrBlank()) {
|
||||
Text(s("memberTransferDialog.loginDataOverride", "Login (optional)"), fontWeight = FontWeight.SemiBold)
|
||||
OutlinedTextField(
|
||||
value = loginUser,
|
||||
onValueChange = { loginUser = it },
|
||||
label = { Text(s("memberTransferDialog.usernameEmail", "Benutzername / E-Mail")) },
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
|
||||
enabled = !transferBusy,
|
||||
singleLine = true,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = loginPass,
|
||||
onValueChange = { loginPass = it },
|
||||
label = { Text(s("memberTransferDialog.passwordPlaceholder", "Passwort")) },
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
|
||||
enabled = !transferBusy,
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
)
|
||||
}
|
||||
statusText?.let {
|
||||
Text(it, modifier = Modifier.padding(top = 12.dp), color = MaterialTheme.colors.primary)
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
dependencies.applicationScope.launch {
|
||||
transferBusy = true
|
||||
statusText = null
|
||||
runCatching {
|
||||
val creds = buildJsonObject {
|
||||
val u = loginUser.trim()
|
||||
val p = loginPass.trim()
|
||||
if (u.isNotEmpty()) put("username", JsonPrimitive(u))
|
||||
if (p.isNotEmpty()) put("password", JsonPrimitive(p))
|
||||
}
|
||||
val body = MemberTransferRunBody(
|
||||
transferEndpoint = c.transferEndpoint.orEmpty(),
|
||||
transferMethod = c.transferMethod ?: "POST",
|
||||
transferFormat = c.transferFormat ?: "json",
|
||||
transferTemplate = c.transferTemplate.orEmpty(),
|
||||
useBulkMode = c.useBulkMode == true,
|
||||
bulkWrapperTemplate = c.bulkWrapperTemplate?.takeIf { it.isNotBlank() },
|
||||
loginEndpoint = c.loginEndpoint?.takeIf { it.isNotBlank() },
|
||||
loginFormat = c.loginFormat?.takeIf { it.isNotBlank() },
|
||||
loginCredentials = creds.takeIf { it.isNotEmpty() },
|
||||
)
|
||||
val jo = dependencies.membersManager.transferMembers(clubId, body)
|
||||
val ok = jo["success"]?.jsonPrimitive?.booleanOrNull == true
|
||||
if (ok) {
|
||||
val msg = jo["message"]?.jsonPrimitive?.contentOrNull
|
||||
?: s("memberTransferDialog.transferSuccessShort", "Transfer abgeschlossen.")
|
||||
val transferred = jo["transferred"]?.jsonPrimitive?.let { p ->
|
||||
p.intOrNull?.toString() ?: p.contentOrNull
|
||||
}
|
||||
val total = jo["total"]?.jsonPrimitive?.let { p ->
|
||||
p.intOrNull?.toString() ?: p.contentOrNull
|
||||
}
|
||||
statusText = if (transferred != null && total != null) "$msg ($transferred / $total)" else msg
|
||||
} else {
|
||||
val err = jo["error"]?.jsonPrimitive?.contentOrNull
|
||||
?: jo["message"]?.jsonPrimitive?.contentOrNull
|
||||
?: s("memberTransferDialog.transferFailed", "Transfer fehlgeschlagen.")
|
||||
statusText = err
|
||||
}
|
||||
}.onFailure {
|
||||
statusText = it.message ?: s("memberTransferDialog.transferError", "Netzwerkfehler.")
|
||||
}
|
||||
transferBusy = false
|
||||
}
|
||||
},
|
||||
enabled = !transferBusy,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 16.dp)
|
||||
.heightIn(min = MembersExtraTouchMin),
|
||||
) {
|
||||
Text(if (transferBusy) s("memberTransferDialog.transferring", "Übertrage…") else s("memberTransferDialog.transfer", "Transfer starten"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package de.tt_tagebuch.app.ui
|
||||
|
||||
import de.tt_tagebuch.shared.api.models.Member
|
||||
import java.time.LocalDate
|
||||
import java.time.Period
|
||||
import java.time.ZoneId
|
||||
|
||||
/** Erstes Kalenderjahr der laufenden Spielzeit (Aug–Jul). */
|
||||
fun getSeasonStartYearFromDateToday(zone: ZoneId = ZoneId.systemDefault()): Int {
|
||||
val d = LocalDate.now(zone)
|
||||
return if (d.monthValue >= 8) d.year else d.year - 1
|
||||
}
|
||||
|
||||
fun formatSeasonSlash(seasonStartYear: Int): String =
|
||||
"${seasonStartYear}/${(seasonStartYear + 1).toString().takeLast(2)}"
|
||||
|
||||
fun getStichtagDate(seasonStartYear: Int, classNum: Int): LocalDate =
|
||||
LocalDate.of(seasonStartYear - (classNum - 1), 1, 1)
|
||||
|
||||
private fun parseBirth(member: Member): LocalDate? {
|
||||
val raw = member.birthDate?.trim()?.take(10) ?: return null
|
||||
return runCatching { LocalDate.parse(raw) }.getOrNull()
|
||||
}
|
||||
|
||||
fun memberBirthLocalDate(member: Member): LocalDate? = parseBirth(member)
|
||||
|
||||
fun memberAgeYears(member: Member, today: LocalDate = LocalDate.now()): Int? {
|
||||
val b = parseBirth(member) ?: return null
|
||||
return Period.between(b, today).years
|
||||
}
|
||||
|
||||
fun isFemaleGenderMember(member: Member): Boolean =
|
||||
member.gender?.trim()?.lowercase() == "female"
|
||||
|
||||
/**
|
||||
* Jugendklasse exklusiv (J9…J19) oder adult; `null` = kein gültiges Geburtsdatum.
|
||||
*/
|
||||
fun getExclusiveJugendClass(member: Member, seasonStartYear: Int): String? {
|
||||
val t = parseBirth(member)?.toEpochDay() ?: return null
|
||||
fun c(k: Int) = getStichtagDate(seasonStartYear, k).toEpochDay()
|
||||
val c9 = c(9)
|
||||
val c11 = c(11)
|
||||
val c13 = c(13)
|
||||
val c15 = c(15)
|
||||
val c19 = c(19)
|
||||
if (t < c19) return "adult"
|
||||
if (t >= c9) return "J9"
|
||||
if (t >= c11 && t < c9) return "J11"
|
||||
if (t >= c13 && t < c11) return "J13"
|
||||
if (t >= c15 && t < c13) return "J15"
|
||||
if (t >= c19 && t < c15) return "J19"
|
||||
return "adult"
|
||||
}
|
||||
|
||||
fun memberMatchesTtAgeClass(member: Member, filterKey: String, seasonStartYear: Int): Boolean {
|
||||
if (filterKey.isEmpty() || filterKey == "range") return true
|
||||
val jClass = getExclusiveJugendClass(member, seasonStartYear) ?: return false
|
||||
return when {
|
||||
filterKey == "adult" -> jClass == "adult"
|
||||
filterKey.startsWith("J") -> jClass == filterKey
|
||||
filterKey.startsWith("M") -> isFemaleGenderMember(member) && jClass == "J${filterKey.substring(1)}"
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,22 @@ private val weekdayFilterLabels = listOf(
|
||||
"Samstag" to "6",
|
||||
)
|
||||
|
||||
/** Bits für einklappbare Statistik-Bereiche (Start: alle zu). */
|
||||
private const val EXP_OVERVIEW = 0
|
||||
private const val EXP_MONTHLY = 1
|
||||
private const val EXP_WEEKDAY = 2
|
||||
private const val EXP_STRUCTURE = 3
|
||||
private const val EXP_BEST_DAY = 4
|
||||
private const val EXP_GROUPS = 5
|
||||
private const val EXP_AGE = 6
|
||||
private const val EXP_RAW = 7
|
||||
private const val EXP_TRAINING_DAYS = 8
|
||||
private const val EXP_MEMBERS = 9
|
||||
|
||||
private fun statsPanelExpanded(mask: Int, bit: Int): Boolean = (mask shr bit) and 1 == 1
|
||||
|
||||
private fun statsPanelToggle(mask: Int, bit: Int): Int = mask xor (1 shl bit)
|
||||
|
||||
@Composable
|
||||
internal fun TrainingStatsScreen(dependencies: AppDependencies) {
|
||||
val clubState by dependencies.clubManager.state.collectAsState()
|
||||
@@ -76,8 +92,7 @@ internal fun TrainingStatsScreen(dependencies: AppDependencies) {
|
||||
var selectedWeekday by rememberSaveable { mutableStateOf("all") }
|
||||
var selectedTrainingDay by rememberSaveable { mutableStateOf("all") }
|
||||
var selectedTrainingGroup by rememberSaveable { mutableStateOf("all") }
|
||||
var showTrainingDays by rememberSaveable { mutableStateOf(true) }
|
||||
var showMembers by rememberSaveable { mutableStateOf(false) }
|
||||
var expandedPanelsMask by rememberSaveable { mutableStateOf(0) }
|
||||
var sortField by rememberSaveable { mutableStateOf("name") }
|
||||
var sortAsc by rememberSaveable { mutableStateOf(true) }
|
||||
|
||||
@@ -287,193 +302,241 @@ internal fun TrainingStatsScreen(dependencies: AppDependencies) {
|
||||
}
|
||||
}
|
||||
|
||||
StatsSectionTitle(statsTr("trainingStats.summary", "Übersicht"))
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
StatMiniCard(
|
||||
title = statsTr("members.activeMembers", "Aktive Mitglieder"),
|
||||
value = filteredMembers.size.toString(),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
StatMiniCard(
|
||||
title = statsTr("trainingStats.trainingDays12m", "Trainingstage (Filter)"),
|
||||
value = filteredTrainingDays.size.toString(),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
StatMiniCard(
|
||||
title = statsTr("trainingStats.averageParticipants", "Ø Teilnehmer je Training"),
|
||||
value = "%.1f".format(filteredOverview.averageParticipants),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
StatMiniCard(
|
||||
title = statsTr("trainingStats.totalParticipations", "Teilnahmen gesamt"),
|
||||
value = filteredOverview.totalParticipants.toString(),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
StatMiniCard(
|
||||
title = statsTr("trainingStats.attendanceRate12m", "Anwesenheitsquote %"),
|
||||
value = "%.1f %%".format(filteredOverview.attendanceRate),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
StatMiniCard(
|
||||
title = statsTr("trainingStats.notInTraining", "Nicht im Training"),
|
||||
value = filteredMembers.count { it.notInTraining }.toString(),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
|
||||
StatsSectionTitle(statsTr("trainingStats.monthlyTrend", "Monatlicher Verlauf"))
|
||||
Text(
|
||||
"${filteredMonthlyTrend.size} Monate",
|
||||
style = MaterialTheme.typography.caption,
|
||||
modifier = Modifier.padding(bottom = 4.dp),
|
||||
CollapsibleHeader(
|
||||
title = statsTr("trainingStats.panelSummary", "Kennzahlen (Filter)"),
|
||||
expanded = statsPanelExpanded(expandedPanelsMask, EXP_OVERVIEW),
|
||||
onToggle = { expandedPanelsMask = statsPanelToggle(expandedPanelsMask, EXP_OVERVIEW) },
|
||||
)
|
||||
filteredMonthlyTrend.forEach { month ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(month.label, fontWeight = FontWeight.SemiBold)
|
||||
Text(
|
||||
"${month.trainingCount} ${statsTr("trainingStats.trainingDaysShort", "Trainingstage")}",
|
||||
style = MaterialTheme.typography.caption,
|
||||
)
|
||||
}
|
||||
Column(modifier = Modifier.widthIn(min = 96.dp), horizontalAlignment = Alignment.End) {
|
||||
Text("%.1f".format(month.averageParticipants), fontWeight = FontWeight.Medium)
|
||||
}
|
||||
if (statsPanelExpanded(expandedPanelsMask, EXP_OVERVIEW)) {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
StatMiniCard(
|
||||
title = statsTr("members.activeMembers", "Aktive Mitglieder"),
|
||||
value = filteredMembers.size.toString(),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
StatMiniCard(
|
||||
title = statsTr("trainingStats.trainingDays12m", "Trainingstage (Filter)"),
|
||||
value = filteredTrainingDays.size.toString(),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
StatMiniCard(
|
||||
title = statsTr("trainingStats.averageParticipants", "Ø Teilnehmer je Training"),
|
||||
value = "%.1f".format(filteredOverview.averageParticipants),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
StatMiniCard(
|
||||
title = statsTr("trainingStats.totalParticipations", "Teilnahmen gesamt"),
|
||||
value = filteredOverview.totalParticipants.toString(),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
StatMiniCard(
|
||||
title = statsTr("trainingStats.attendanceRate12m", "Anwesenheitsquote %"),
|
||||
value = "%.1f %%".format(filteredOverview.attendanceRate),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
StatMiniCard(
|
||||
title = statsTr("trainingStats.notInTraining", "Nicht im Training"),
|
||||
value = filteredMembers.count { it.notInTraining }.toString(),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
LinearProgressIndicator(
|
||||
progress = (month.averageParticipants / maxMonthAvg).toFloat().coerceIn(0f, 1f),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(6.dp),
|
||||
)
|
||||
}
|
||||
|
||||
StatsSectionTitle(statsTr("trainingStats.weekdayStats", "Trainingstage nach Wochentag"))
|
||||
Text(
|
||||
"${filteredWeekdayStats.size} ${statsTr("trainingStats.weekdays", "Wochentage")}",
|
||||
style = MaterialTheme.typography.caption,
|
||||
modifier = Modifier.padding(bottom = 4.dp),
|
||||
CollapsibleHeader(
|
||||
title = statsTr("trainingStats.panelMonthlyTrend", "Monatlicher Verlauf"),
|
||||
expanded = statsPanelExpanded(expandedPanelsMask, EXP_MONTHLY),
|
||||
onToggle = { expandedPanelsMask = statsPanelToggle(expandedPanelsMask, EXP_MONTHLY) },
|
||||
)
|
||||
filteredWeekdayStats.chunked(2).forEach { row ->
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
row.forEach { w ->
|
||||
StatMiniCard(
|
||||
title = w.weekday,
|
||||
value = "${w.trainingCount} Term.\nØ %.1f".format(w.averageParticipants),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (statsPanelExpanded(expandedPanelsMask, EXP_MONTHLY)) {
|
||||
Text(
|
||||
"${filteredMonthlyTrend.size} Monate",
|
||||
style = MaterialTheme.typography.caption,
|
||||
modifier = Modifier.padding(bottom = 4.dp),
|
||||
)
|
||||
filteredMonthlyTrend.forEach { month ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(month.label, fontWeight = FontWeight.SemiBold)
|
||||
Text(
|
||||
"${month.trainingCount} ${statsTr("trainingStats.trainingDaysShort", "Trainingstage")}",
|
||||
style = MaterialTheme.typography.caption,
|
||||
)
|
||||
}
|
||||
Column(modifier = Modifier.widthIn(min = 96.dp), horizontalAlignment = Alignment.End) {
|
||||
Text("%.1f".format(month.averageParticipants), fontWeight = FontWeight.Medium)
|
||||
}
|
||||
}
|
||||
if (row.size == 1) Spacer(modifier = Modifier.weight(1f))
|
||||
LinearProgressIndicator(
|
||||
progress = (month.averageParticipants / maxMonthAvg).toFloat().coerceIn(0f, 1f),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
StatsSectionTitle(statsTr("trainingStats.memberStructure", "Mitgliederstruktur"))
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
StatMiniCard(
|
||||
title = statsTr("trainingStats.distHighlyActive", "Sehr aktiv"),
|
||||
value = filteredMemberDistribution.highlyActive.toString(),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
StatMiniCard(
|
||||
title = statsTr("trainingStats.distRegular", "Regelmäßig"),
|
||||
value = filteredMemberDistribution.regular.toString(),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
StatMiniCard(
|
||||
title = statsTr("trainingStats.distOccasional", "Gelegentlich"),
|
||||
value = filteredMemberDistribution.occasional.toString(),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
StatMiniCard(
|
||||
title = statsTr("trainingStats.distInactive", "Ohne Teilnahme"),
|
||||
value = filteredMemberDistribution.inactive.toString(),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
|
||||
StatsSectionTitle(statsTr("trainingStats.bestDay", "Stärkster Trainingstag"))
|
||||
val best = filteredOverview.bestTrainingDay
|
||||
Card(modifier = Modifier.fillMaxWidth(), elevation = 2.dp) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
if (best != null) {
|
||||
Text(TrainingStatsDerived.formatDateGerman(best.date), fontWeight = FontWeight.Bold)
|
||||
Text(TrainingStatsDerived.weekdayGerman(best.date), style = MaterialTheme.typography.body2)
|
||||
Text(
|
||||
"${best.participantCount} ${statsTr("trainingStats.participants", "Teilnehmer")}",
|
||||
style = MaterialTheme.typography.h6,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
Text(
|
||||
statsTr("trainingStats.bestDayHint", "Beim bestbesuchten Training im Filterzeitraum."),
|
||||
style = MaterialTheme.typography.caption,
|
||||
)
|
||||
} else {
|
||||
Text(statsTr("trainingStats.noData", "Keine Daten"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StatsSectionTitle(statsTr("trainingStats.groupPerformance", "Entwicklung pro Gruppe"))
|
||||
Text(
|
||||
"${groupPerformance.size} ${statsTr("trainingStats.groups", "Gruppen")}",
|
||||
style = MaterialTheme.typography.caption,
|
||||
CollapsibleHeader(
|
||||
title = statsTr("trainingStats.panelWeekdayStats", "Trainingstage nach Wochentag"),
|
||||
expanded = statsPanelExpanded(expandedPanelsMask, EXP_WEEKDAY),
|
||||
onToggle = { expandedPanelsMask = statsPanelToggle(expandedPanelsMask, EXP_WEEKDAY) },
|
||||
)
|
||||
groupPerformance.forEach { g ->
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
elevation = 1.dp,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(10.dp)) {
|
||||
Text(g.name, fontWeight = FontWeight.SemiBold)
|
||||
Text("${g.memberCount} ${statsTr("members.members", "Mitglieder")}", style = MaterialTheme.typography.caption)
|
||||
Text(
|
||||
"%.1f Ø / 12M · %.1f %% ${statsTr("trainingStats.presence", "Anwesenheit")}"
|
||||
.format(g.averageParticipations12Months, g.participationRate),
|
||||
style = MaterialTheme.typography.body2,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
if (statsPanelExpanded(expandedPanelsMask, EXP_WEEKDAY)) {
|
||||
Text(
|
||||
"${filteredWeekdayStats.size} ${statsTr("trainingStats.weekdays", "Wochentage")}",
|
||||
style = MaterialTheme.typography.caption,
|
||||
modifier = Modifier.padding(bottom = 4.dp),
|
||||
)
|
||||
filteredWeekdayStats.chunked(2).forEach { row ->
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
row.forEach { w ->
|
||||
StatMiniCard(
|
||||
title = w.weekday,
|
||||
value = "${w.trainingCount} Term.\nØ %.1f".format(w.averageParticipants),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
if (row.size == 1) Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StatsSectionTitle(statsTr("trainingStats.ageGroups", "Anwesenheit nach Altersklasse"))
|
||||
ageGroupStats.chunked(2).forEach { row ->
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
row.forEach { a ->
|
||||
StatMiniCard(
|
||||
title = a.label,
|
||||
value = "${a.memberCount}\nØ %.1f / 12M".format(a.averageParticipations12Months),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
if (row.size == 1) Spacer(modifier = Modifier.weight(1f))
|
||||
CollapsibleHeader(
|
||||
title = statsTr("trainingStats.panelMemberStructure", "Mitgliederstruktur"),
|
||||
expanded = statsPanelExpanded(expandedPanelsMask, EXP_STRUCTURE),
|
||||
onToggle = { expandedPanelsMask = statsPanelToggle(expandedPanelsMask, EXP_STRUCTURE) },
|
||||
)
|
||||
if (statsPanelExpanded(expandedPanelsMask, EXP_STRUCTURE)) {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
StatMiniCard(
|
||||
title = statsTr("trainingStats.distHighlyActive", "Sehr aktiv"),
|
||||
value = filteredMemberDistribution.highlyActive.toString(),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
StatMiniCard(
|
||||
title = statsTr("trainingStats.distRegular", "Regelmäßig"),
|
||||
value = filteredMemberDistribution.regular.toString(),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
StatMiniCard(
|
||||
title = statsTr("trainingStats.distOccasional", "Gelegentlich"),
|
||||
value = filteredMemberDistribution.occasional.toString(),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
StatMiniCard(
|
||||
title = statsTr("trainingStats.distInactive", "Ohne Teilnahme"),
|
||||
value = filteredMemberDistribution.inactive.toString(),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
StatsSectionTitle(statsTr("trainingStats.rawCounts", "Rohzahlen (Verein)"))
|
||||
StatsDetailLine(statsTr("trainingStats.trainings12Months", "Trainings 12 Monate"), s.trainingsCount12Months.toString())
|
||||
StatsDetailLine(statsTr("trainingStats.trainings3Months", "Trainings 3 Monate"), s.trainingsCount3Months.toString())
|
||||
StatsDetailLine(statsTr("members.activeMembers", "Aktive Mitglieder (Backend)"), s.overview.activeMembersCount.toString())
|
||||
CollapsibleHeader(
|
||||
title = statsTr("trainingStats.panelBestDay", "Stärkster Trainingstag"),
|
||||
expanded = statsPanelExpanded(expandedPanelsMask, EXP_BEST_DAY),
|
||||
onToggle = { expandedPanelsMask = statsPanelToggle(expandedPanelsMask, EXP_BEST_DAY) },
|
||||
)
|
||||
if (statsPanelExpanded(expandedPanelsMask, EXP_BEST_DAY)) {
|
||||
val best = filteredOverview.bestTrainingDay
|
||||
Card(modifier = Modifier.fillMaxWidth(), elevation = 2.dp) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
if (best != null) {
|
||||
Text(TrainingStatsDerived.formatDateGerman(best.date), fontWeight = FontWeight.Bold)
|
||||
Text(TrainingStatsDerived.weekdayGerman(best.date), style = MaterialTheme.typography.body2)
|
||||
Text(
|
||||
"${best.participantCount} ${statsTr("trainingStats.participants", "Teilnehmer")}",
|
||||
style = MaterialTheme.typography.h6,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
Text(
|
||||
statsTr("trainingStats.bestDayHint", "Beim bestbesuchten Training im Filterzeitraum."),
|
||||
style = MaterialTheme.typography.caption,
|
||||
)
|
||||
} else {
|
||||
Text(statsTr("trainingStats.noData", "Keine Daten"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CollapsibleHeader(
|
||||
title = statsTr("trainingStats.panelGroupPerformance", "Entwicklung pro Gruppe"),
|
||||
expanded = statsPanelExpanded(expandedPanelsMask, EXP_GROUPS),
|
||||
onToggle = { expandedPanelsMask = statsPanelToggle(expandedPanelsMask, EXP_GROUPS) },
|
||||
)
|
||||
if (statsPanelExpanded(expandedPanelsMask, EXP_GROUPS)) {
|
||||
Text(
|
||||
"${groupPerformance.size} ${statsTr("trainingStats.groups", "Gruppen")}",
|
||||
style = MaterialTheme.typography.caption,
|
||||
)
|
||||
groupPerformance.forEach { g ->
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
elevation = 1.dp,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(10.dp)) {
|
||||
Text(g.name, fontWeight = FontWeight.SemiBold)
|
||||
Text("${g.memberCount} ${statsTr("members.members", "Mitglieder")}", style = MaterialTheme.typography.caption)
|
||||
Text(
|
||||
"%.1f Ø / 12M · %.1f %% ${statsTr("trainingStats.presence", "Anwesenheit")}"
|
||||
.format(g.averageParticipations12Months, g.participationRate),
|
||||
style = MaterialTheme.typography.body2,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CollapsibleHeader(
|
||||
title = statsTr("trainingStats.panelAgeGroups", "Anwesenheit nach Altersklasse"),
|
||||
expanded = statsPanelExpanded(expandedPanelsMask, EXP_AGE),
|
||||
onToggle = { expandedPanelsMask = statsPanelToggle(expandedPanelsMask, EXP_AGE) },
|
||||
)
|
||||
if (statsPanelExpanded(expandedPanelsMask, EXP_AGE)) {
|
||||
ageGroupStats.chunked(2).forEach { row ->
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
row.forEach { a ->
|
||||
StatMiniCard(
|
||||
title = a.label,
|
||||
value = "${a.memberCount}\nØ %.1f / 12M".format(a.averageParticipations12Months),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
if (row.size == 1) Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CollapsibleHeader(
|
||||
title = statsTr("trainingStats.rawCounts", "Rohzahlen (Verein)"),
|
||||
expanded = statsPanelExpanded(expandedPanelsMask, EXP_RAW),
|
||||
onToggle = { expandedPanelsMask = statsPanelToggle(expandedPanelsMask, EXP_RAW) },
|
||||
)
|
||||
if (statsPanelExpanded(expandedPanelsMask, EXP_RAW)) {
|
||||
StatsDetailLine(statsTr("trainingStats.trainings12Months", "Trainings 12 Monate"), s.trainingsCount12Months.toString())
|
||||
StatsDetailLine(statsTr("trainingStats.trainings3Months", "Trainings 3 Monate"), s.trainingsCount3Months.toString())
|
||||
StatsDetailLine(statsTr("members.activeMembers", "Aktive Mitglieder (Backend)"), s.overview.activeMembersCount.toString())
|
||||
}
|
||||
|
||||
CollapsibleHeader(
|
||||
title = statsTr("trainingStats.trainingDays", "Trainingstage"),
|
||||
expanded = showTrainingDays,
|
||||
onToggle = { showTrainingDays = !showTrainingDays },
|
||||
expanded = statsPanelExpanded(expandedPanelsMask, EXP_TRAINING_DAYS),
|
||||
onToggle = { expandedPanelsMask = statsPanelToggle(expandedPanelsMask, EXP_TRAINING_DAYS) },
|
||||
)
|
||||
if (showTrainingDays) {
|
||||
if (statsPanelExpanded(expandedPanelsMask, EXP_TRAINING_DAYS)) {
|
||||
filteredTrainingDays.forEach { day ->
|
||||
Card(
|
||||
modifier = Modifier
|
||||
@@ -510,10 +573,10 @@ internal fun TrainingStatsScreen(dependencies: AppDependencies) {
|
||||
|
||||
CollapsibleHeader(
|
||||
title = statsTr("trainingStats.memberParticipations", "Mitglieder-Teilnahmen"),
|
||||
expanded = showMembers,
|
||||
onToggle = { showMembers = !showMembers },
|
||||
expanded = statsPanelExpanded(expandedPanelsMask, EXP_MEMBERS),
|
||||
onToggle = { expandedPanelsMask = statsPanelToggle(expandedPanelsMask, EXP_MEMBERS) },
|
||||
)
|
||||
if (showMembers) {
|
||||
if (statsPanelExpanded(expandedPanelsMask, EXP_MEMBERS)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
|
||||
@@ -2,7 +2,9 @@ package de.tt_tagebuch.shared.api
|
||||
|
||||
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
|
||||
import de.tt_tagebuch.shared.api.models.Member
|
||||
import de.tt_tagebuch.shared.api.models.MemberQuickMutationResponse
|
||||
import de.tt_tagebuch.shared.api.models.MemberSetBody
|
||||
import de.tt_tagebuch.shared.api.models.MemberTransferRunBody
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.forms.formData
|
||||
import io.ktor.client.request.get
|
||||
@@ -14,6 +16,7 @@ import io.ktor.http.Headers
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.http.contentType
|
||||
import io.ktor.client.request.forms.MultiPartFormDataContent
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
||||
class MembersApi(
|
||||
private val client: AuthedHttpClient,
|
||||
@@ -28,6 +31,24 @@ class MembersApi(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateRatingsFromMyTischtennis(clubId: Int) {
|
||||
client.http.post("/api/clubmembers/update-ratings/$clubId")
|
||||
}
|
||||
|
||||
suspend fun quickUpdateTestMembership(clubId: Int, memberId: Int): MemberQuickMutationResponse {
|
||||
return client.http.post("/api/clubmembers/quick-update-test-membership/$clubId/$memberId").body()
|
||||
}
|
||||
|
||||
suspend fun quickUpdateMemberFormHandedOver(clubId: Int, memberId: Int): MemberQuickMutationResponse {
|
||||
return client.http.post("/api/clubmembers/quick-update-member-form/$clubId/$memberId").body()
|
||||
}
|
||||
|
||||
suspend fun transferMembers(clubId: Int, body: MemberTransferRunBody): JsonObject {
|
||||
return client.http.post("/api/clubmembers/transfer/$clubId") {
|
||||
setBody(body)
|
||||
}.body()
|
||||
}
|
||||
|
||||
suspend fun uploadMemberImage(clubId: Int, memberId: Int, imageBytes: ByteArray, makePrimary: Boolean = true) {
|
||||
client.http.post("/api/clubmembers/image/$clubId/$memberId") {
|
||||
if (makePrimary) {
|
||||
|
||||
@@ -107,3 +107,8 @@ fun UserClubPermissions.canReadStatistics(): Boolean {
|
||||
if (isOwner) return true
|
||||
return permissions.boolAt("statistics", "read")
|
||||
}
|
||||
|
||||
fun UserClubPermissions.canWriteMyTischtennis(): Boolean {
|
||||
if (isOwner) return true
|
||||
return permissions.boolAt("mytischtennis", "write")
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ data class Member(
|
||||
val trainingParticipations: Int? = null,
|
||||
val notInTraining: Boolean? = null,
|
||||
val missedTrainingWeeks: Int? = null,
|
||||
/** Aus Training-Stats gemergt (aktive Mitglieder); leer wenn keine Zuordnung. */
|
||||
val trainingGroups: List<TrainingStatsTrainingGroup> = emptyList(),
|
||||
val contacts: List<MemberContactDto> = emptyList(),
|
||||
val images: List<MemberImageDto> = emptyList(),
|
||||
val primaryImageId: Int? = null,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package de.tt_tagebuch.shared.api.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class MemberQuickMutationResponse(
|
||||
val success: Boolean? = null,
|
||||
val message: String? = null,
|
||||
val error: String? = null,
|
||||
)
|
||||
@@ -45,3 +45,16 @@ data class MemberTransferConfigSaveBody(
|
||||
val useBulkMode: Boolean = false,
|
||||
val bulkWrapperTemplate: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MemberTransferRunBody(
|
||||
val transferEndpoint: String,
|
||||
val transferMethod: String = "POST",
|
||||
val transferFormat: String = "json",
|
||||
val transferTemplate: String,
|
||||
val useBulkMode: Boolean = false,
|
||||
val bulkWrapperTemplate: String? = null,
|
||||
val loginEndpoint: String? = null,
|
||||
val loginFormat: String? = null,
|
||||
val loginCredentials: JsonObject? = null,
|
||||
)
|
||||
|
||||
@@ -3,24 +3,30 @@ package de.tt_tagebuch.shared.state
|
||||
import de.tt_tagebuch.shared.api.MemberActivitiesApi
|
||||
import de.tt_tagebuch.shared.api.MembersApi
|
||||
import de.tt_tagebuch.shared.api.TrainingGroupsApi
|
||||
import de.tt_tagebuch.shared.api.TrainingStatsApi
|
||||
import de.tt_tagebuch.shared.api.TrainingTimesApi
|
||||
import de.tt_tagebuch.shared.api.models.CreateTrainingTimeBody
|
||||
import de.tt_tagebuch.shared.api.models.Member
|
||||
import de.tt_tagebuch.shared.api.models.MemberActivityStatDto
|
||||
import de.tt_tagebuch.shared.api.models.MemberQuickMutationResponse
|
||||
import de.tt_tagebuch.shared.api.models.MemberLastParticipationDto
|
||||
import de.tt_tagebuch.shared.api.models.MemberSetBody
|
||||
import de.tt_tagebuch.shared.api.models.MemberTransferRunBody
|
||||
import de.tt_tagebuch.shared.api.models.TrainingGroupDto
|
||||
import de.tt_tagebuch.shared.api.models.TrainingStatsMember
|
||||
import de.tt_tagebuch.shared.api.models.TrainingTimeDto
|
||||
import de.tt_tagebuch.shared.api.models.UpdateTrainingTimeBody
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
||||
class MembersManager(
|
||||
private val membersApi: MembersApi,
|
||||
private val trainingGroupsApi: TrainingGroupsApi,
|
||||
private val memberActivitiesApi: MemberActivitiesApi,
|
||||
private val trainingTimesApi: TrainingTimesApi,
|
||||
private val trainingStatsApi: TrainingStatsApi,
|
||||
) {
|
||||
private val _state = MutableStateFlow(MembersState())
|
||||
val state: StateFlow<MembersState> = _state.asStateFlow()
|
||||
@@ -29,19 +35,37 @@ class MembersManager(
|
||||
_state.value = _state.value.copy(isLoading = true, error = null)
|
||||
try {
|
||||
val members = membersApi.listMembers(clubId)
|
||||
val merged = runCatching { trainingStatsApi.getStats(clubId) }
|
||||
.fold(
|
||||
onSuccess = { mergeTrainingStatsIntoMembers(members, it.members) },
|
||||
onFailure = { members },
|
||||
)
|
||||
.sortedWith(compareBy<Member> { it.lastName.lowercase() }.thenBy { it.firstName.lowercase() })
|
||||
_state.value = _state.value.copy(members = members, isLoading = false)
|
||||
_state.value = _state.value.copy(members = merged, isLoading = false)
|
||||
} catch (t: Throwable) {
|
||||
_state.value = _state.value.copy(isLoading = false, error = t.toUserMessage("Mitglieder konnten nicht geladen werden"))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateRatingsFromMyTischtennis(clubId: Int) {
|
||||
membersApi.updateRatingsFromMyTischtennis(clubId)
|
||||
}
|
||||
|
||||
suspend fun quickUpdateTestMembership(clubId: Int, memberId: Int): MemberQuickMutationResponse =
|
||||
membersApi.quickUpdateTestMembership(clubId, memberId)
|
||||
|
||||
suspend fun quickUpdateMemberFormHandedOver(clubId: Int, memberId: Int): MemberQuickMutationResponse =
|
||||
membersApi.quickUpdateMemberFormHandedOver(clubId, memberId)
|
||||
|
||||
suspend fun transferMembers(clubId: Int, body: MemberTransferRunBody): JsonObject =
|
||||
membersApi.transferMembers(clubId, body)
|
||||
|
||||
suspend fun saveMember(clubId: Int, body: MemberSetBody) {
|
||||
membersApi.setMember(clubId, body)
|
||||
}
|
||||
|
||||
suspend fun uploadMemberPortrait(clubId: Int, memberId: Int, imageBytes: ByteArray) {
|
||||
membersApi.uploadMemberImage(clubId, memberId, imageBytes, makePrimary = true)
|
||||
suspend fun uploadMemberPortrait(clubId: Int, memberId: Int, imageBytes: ByteArray, makePrimary: Boolean = true) {
|
||||
membersApi.uploadMemberImage(clubId, memberId, imageBytes, makePrimary = makePrimary)
|
||||
}
|
||||
|
||||
suspend fun listTrainingGroups(clubId: Int): List<TrainingGroupDto> = trainingGroupsApi.listGroups(clubId)
|
||||
@@ -94,3 +118,27 @@ class MembersManager(
|
||||
_state.value = MembersState()
|
||||
}
|
||||
}
|
||||
|
||||
private fun mergeTrainingStatsIntoMembers(members: List<Member>, stats: List<TrainingStatsMember>): List<Member> {
|
||||
val byId = stats.associateBy { it.id }
|
||||
return members.map { m ->
|
||||
val s = byId[m.id]
|
||||
if (s == null) {
|
||||
m.copy(
|
||||
trainingParticipations = 0,
|
||||
missedTrainingWeeks = 0,
|
||||
notInTraining = false,
|
||||
lastTraining = null,
|
||||
trainingGroups = emptyList(),
|
||||
)
|
||||
} else {
|
||||
m.copy(
|
||||
trainingParticipations = s.participationTotal,
|
||||
lastTraining = s.lastTraining,
|
||||
notInTraining = s.notInTraining,
|
||||
missedTrainingWeeks = s.missedTrainingWeeks,
|
||||
trainingGroups = s.trainingGroups,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user