diff --git a/android-app/app/src/main/AndroidManifest.xml b/android-app/app/src/main/AndroidManifest.xml index 6ab283b..d176523 100644 --- a/android-app/app/src/main/AndroidManifest.xml +++ b/android-app/app/src/main/AndroidManifest.xml @@ -6,6 +6,7 @@ @@ -306,7 +320,6 @@ private fun CmsUserListPage(navController: NavController, showBackNavigation: Bo Text(user.email ?: "-", color = Accent700) Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 6.dp)) { Button(onClick = { viewModel.setUserActive(user.id, true) }, enabled = !state.saving) { Text("Freischalten") } - Button(onClick = { viewModel.resendInvite(user.id) }, enabled = !state.saving) { Text("Invite erneut") } } } } diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsViewModels.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsViewModels.kt index fb6b61e..bdcd443 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsViewModels.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsViewModels.kt @@ -79,7 +79,7 @@ class CmsViewModel @Inject constructor( val fetchedUsers = usersResult.getOrNull()?.users.orEmpty() val pendingUsers = fetchedUsers.filter { it.active == false } val activeUsers = fetchedUsers.filter { it.active == true } - .sortedBy { (it.name ?: "").lowercase() } + .sortedBy { it.name.lowercase() } val orderedUsers = pendingUsers + activeUsers _state.value = CmsUiState( diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaDetailScreens.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaDetailScreens.kt index 378c410..1351dd4 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaDetailScreens.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaDetailScreens.kt @@ -20,6 +20,9 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight @@ -45,13 +48,68 @@ fun MembersScreen( ) { val state by viewModel.state.collectAsState() val query = state.query.trim() - val members = state.members - .filter { member -> - query.isBlank() || - member.name.contains(query, ignoreCase = true) || - member.email.orEmpty().contains(query, ignoreCase = true) + var sortAsc by remember { mutableStateOf(true) } + var sortField by remember { mutableStateOf("Nachname") } + + val filtered = state.members.filter { member -> + query.isBlank() || + member.name.contains(query, ignoreCase = true) || + member.email.orEmpty().contains(query, ignoreCase = true) + } + + // helpers + fun dayMonthKey(m: MemberDto): Int { + val src = m.geburtsdatum ?: m.birthday ?: return Int.MAX_VALUE + val s = src.trim() + try { + if (s.matches(Regex("^\\d{4}[-./].*"))) { + val ld = java.time.LocalDate.parse(s) + return ld.monthValue * 100 + ld.dayOfMonth + } + } catch (_: Exception) { } + // fallback: handle ISO without year (MM-DD or M-D), or German (DD.MM(.YYYY)) + val isoNoYear = Regex("^(\\d{1,2})[-/](\\d{1,2})$") + val german = Regex("^(\\d{1,2})\\.(\\d{1,2})(?:\\.(\\d{2,4}))?$") + isoNoYear.find(s)?.let { + val (mo, d) = it.destructured + return try { mo.toInt() * 100 + d.toInt() } catch (_: Exception) { Int.MAX_VALUE } } - .sortedWith(compareBy { it.lastName.ifBlank { it.name } }.thenBy { it.firstName }) + german.find(s)?.let { + val (d, mo, _) = it.destructured + return try { mo.toInt() * 100 + d.toInt() } catch (_: Exception) { Int.MAX_VALUE } + } + val r = Regex("(\\d{1,2})\\D+(\\d{1,2})") + val match = r.find(s) ?: return Int.MAX_VALUE + val (a, b) = match.destructured + return try { + if (a.toInt() > 12) b.toInt() * 100 + a.toInt() else a.toInt() * 100 + b.toInt() + } catch (_: Exception) { Int.MAX_VALUE } + } + + fun ageKey(m: MemberDto): Int { + val src = m.geburtsdatum ?: m.birthday ?: return Int.MAX_VALUE + val s = src.trim() + try { + if (s.matches(Regex("^\\d{4}[-./].*"))) { + val ld = java.time.LocalDate.parse(s) + val current = java.time.LocalDate.now().year + return current - ld.year + } + } catch (_: Exception) { } + val germanYear = Regex("^(\\d{1,2})\\.(\\d{1,2})\\.(\\d{2,4})$") + germanYear.find(s)?.let { + val yearStr = it.groupValues[3] + return try { java.time.LocalDate.now().year - yearStr.toInt() } catch (_: Exception) { Int.MAX_VALUE } + } + return Int.MAX_VALUE + } + + val members = when (sortField) { + "Vorname" -> filtered.sortedWith(compareBy({ it.firstName.ifBlank { it.name } }, { it.lastName })) + "Geburtstag" -> filtered.sortedWith(compareBy({ dayMonthKey(it) }, { it.lastName })) + "Alter" -> filtered.sortedWith(compareBy({ ageKey(it) }, { it.lastName })) + else -> filtered.sortedWith(compareBy { it.lastName.ifBlank { it.name } }.thenBy { it.firstName }) + }.let { if (sortAsc) it else it.asReversed() } MemberAreaPage(navController, showBackNavigation, "Mitgliederliste", "Kontaktdaten der Vereinsmitglieder") { item { @@ -63,6 +121,19 @@ fun MembersScreen( modifier = Modifier.fillMaxWidth(), ) } + item { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Sortieren nach", color = Accent700) + var expanded by remember { mutableStateOf(false) } + TextButton(onClick = { expanded = true }) { Text(sortField) } + androidx.compose.material3.DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + listOf("Nachname", "Vorname", "Geburtstag", "Alter").forEach { opt -> + androidx.compose.material3.DropdownMenuItem(text = { Text(opt) }, onClick = { sortField = opt; expanded = false }) + } + } + TextButton(onClick = { sortAsc = !sortAsc }) { Text(if (sortAsc) "A→Z" else "Z→A") } + } + } when { state.loading -> item { CircularProgressIndicator(color = Primary600) } state.error != null -> item { ErrorCard(state.error.orEmpty(), viewModel::load) } @@ -141,7 +212,7 @@ private fun MemberAreaPage( Text("< Intern", color = Primary600, fontWeight = FontWeight.SemiBold) } } - Text(title, style = MaterialTheme.typography.displayLarge, color = Accent900) + Text(title, style = MaterialTheme.typography.headlineMedium, color = Accent900) Text(subtitle, color = Accent500, modifier = Modifier.padding(top = 8.dp)) } content() @@ -155,7 +226,10 @@ private fun MemberCard(member: MemberDto) { Text(member.name.ifBlank { "${member.firstName} ${member.lastName}".trim() }, style = MaterialTheme.typography.titleLarge, color = Accent900) if (!member.email.isNullOrBlank()) Text(member.email, color = Primary600) if (!member.phone.isNullOrBlank()) Text(member.phone, color = Accent700) - if (!member.birthday.isNullOrBlank()) Text("Geburtstag: ${member.birthday}", color = Accent500) + if (!member.birthday.isNullOrBlank()) { + val display = formatDayMonth(member.birthday) ?: member.birthday + Text("Geburtstag: $display", color = Accent500) + } Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Badge(if (member.hasLogin) "Login" else member.source.ifBlank { "Mitglied" }) if (member.isMannschaftsspieler) Badge("Mannschaft") @@ -201,3 +275,48 @@ private fun ErrorCard(message: String, onRetry: () -> Unit) { } } } + +private fun normalizeToIso(srcRaw: String?): String? { + val src = srcRaw?.trim() ?: return null + try { + if (src.matches(Regex("^\\d{4}[-./].*"))) { + val ld = java.time.LocalDate.parse(src) + return String.format("%04d-%02d-%02d", ld.year, ld.monthValue, ld.dayOfMonth) + } + } catch (_: Exception) { } + val isoNoYear = Regex("^(\\d{1,2})[-/](\\d{1,2})$") + isoNoYear.find(src)?.let { + val (mo, d) = it.destructured + return String.format("%02d-%02d", mo.toInt(), d.toInt()) + } + val german = Regex("^(\\d{1,2})\\.(\\d{1,2})(?:\\.(\\d{2,4}))?$") + german.find(src)?.let { + val (d, mo, y) = it.destructured + return if (y.isNullOrBlank()) String.format("%02d-%02d", mo.toInt(), d.toInt()) else String.format("%04d-%02d-%02d", y.toInt(), mo.toInt(), d.toInt()) + } + return null +} + +private fun formatDayMonth(srcRaw: String?): String? { + val src = srcRaw?.trim() ?: return null + try { + if (src.matches(Regex("^\\d{4}[-./].*"))) { + val ld = java.time.LocalDate.parse(src) + return String.format("%02d.%02d.", ld.dayOfMonth, ld.monthValue) + } + } catch (_: Exception) { } + val isoNoYear = Regex("^(\\d{1,2})[-/](\\d{1,2})$") + isoNoYear.find(src)?.let { + val (mo, d) = it.destructured + return try { String.format("%02d.%02d.", d.toInt(), mo.toInt()) } catch (_: Exception) { null } + } + val german = Regex("^(\\d{1,2})\\.(\\d{1,2})(?:\\.(\\d{2,4}))?$") + german.find(src)?.let { + val (d, mo, _) = it.destructured + return try { String.format("%02d.%02d.", d.toInt(), mo.toInt()) } catch (_: Exception) { null } + } + val r = Regex("(\\d{1,2})\\D+(\\d{1,2})") + val match = r.find(src) ?: return null + val (a, b) = match.destructured + return try { String.format("%02d.%02d.", a.toInt(), b.toInt()) } catch (_: Exception) { null } +} diff --git a/android-app/app/src/main/res/drawable/ic_launcher_foreground.png b/android-app/app/src/main/res/drawable/ic_launcher_foreground.png new file mode 100644 index 0000000..f00260f Binary files /dev/null and b/android-app/app/src/main/res/drawable/ic_launcher_foreground.png differ diff --git a/android-app/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android-app/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..a8a8fa5 --- /dev/null +++ b/android-app/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android-app/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android-app/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..f00260f Binary files /dev/null and b/android-app/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android-app/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android-app/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..f00260f Binary files /dev/null and b/android-app/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android-app/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android-app/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..f00260f Binary files /dev/null and b/android-app/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android-app/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android-app/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..f00260f Binary files /dev/null and b/android-app/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android-app/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android-app/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..f00260f Binary files /dev/null and b/android-app/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android-app/app/src/main/res/values/colors.xml b/android-app/app/src/main/res/values/colors.xml index ab1782c..d5d1956 100644 --- a/android-app/app/src/main/res/values/colors.xml +++ b/android-app/app/src/main/res/values/colors.xml @@ -3,4 +3,5 @@ #ef4444 #dc2626 #71717a + #FFFFFF \ No newline at end of file