feat: redesign ScheduleScreen with icon actions, filter chips, sticky date headers

- TopAppBar: Refresh/Logout icon buttons, 'Calendrier' title
- TypeFilterSegmented + FilterAssistChips replace collapsible filter card
- Account dropdown uses label instead of name
- 'Effacer' chip appears when filters are active
- Sticky date headers group events by day
- SkeletonCard loading state (4 shimmer cards)
- ErrorState/EmptyState shared components
- lastUpdated timestamp displayed at bottom
- French accents corrected throughout
This commit is contained in:
2026-08-06 17:14:15 +02:00
parent 73b8e4480a
commit 3edf991cde
@@ -17,12 +17,9 @@
package ch.parano.myicek.ui.screens package ch.parano.myicek.ui.screens
import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.animation.expandVertically import androidx.compose.foundation.background
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -30,37 +27,46 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Logout
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.CloudOff
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import ch.parano.myicek.filter.TypeFilter import ch.parano.myicek.filter.TypeFilter
import ch.parano.myicek.filter.groupEventsByDate
import ch.parano.myicek.ui.components.EmptyState
import ch.parano.myicek.ui.components.ErrorState
import ch.parano.myicek.ui.components.EventCard import ch.parano.myicek.ui.components.EventCard
import ch.parano.myicek.ui.components.FilterDropdown import ch.parano.myicek.ui.components.FilterAssistChip
import ch.parano.myicek.ui.components.LoadingIndicator import ch.parano.myicek.ui.components.SkeletonCard
import ch.parano.myicek.ui.components.TypeFilterSegmented
import ch.parano.myicek.ui.theme.Dimens
import ch.parano.myicek.util.formatEventDate
import ch.parano.myicek.viewmodel.AuthViewModel import ch.parano.myicek.viewmodel.AuthViewModel
import ch.parano.myicek.viewmodel.ScheduleViewModel import ch.parano.myicek.viewmodel.ScheduleViewModel
import kotlinx.datetime.Instant
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable @Composable
fun ScheduleScreen( fun ScheduleScreen(
scheduleViewModel: ScheduleViewModel, scheduleViewModel: ScheduleViewModel,
@@ -69,7 +75,6 @@ fun ScheduleScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val scheduleState by scheduleViewModel.state.collectAsState() val scheduleState by scheduleViewModel.state.collectAsState()
val authState by authViewModel.state.collectAsState()
LaunchedEffect(scheduleState.selectedAccount) { LaunchedEffect(scheduleState.selectedAccount) {
if (scheduleState.selectedAccount != null && scheduleState.events.isEmpty()) { if (scheduleState.selectedAccount != null && scheduleState.events.isEmpty()) {
@@ -77,27 +82,29 @@ fun ScheduleScreen(
} }
} }
val hasActiveFilters = scheduleState.selectedAgegroup != null ||
scheduleState.selectedSubgroup != null ||
scheduleState.typeFilter != TypeFilter.DEFAULT
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text("MyIceK") }, title = { Text("Calendrier") },
actions = { actions = {
val email = (authState as? ch.parano.myicek.viewmodel.AuthState.Authenticated)?.email IconButton(onClick = { scheduleViewModel.refreshSchedule() }) {
if (email != null) { Icon(
Text( imageVector = Icons.Filled.Refresh,
text = email, contentDescription = "Rafraîchir",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = 8.dp),
) )
} }
TextButton(onClick = { scheduleViewModel.refreshSchedule() }) { IconButton(onClick = {
Text("Rafraichir")
}
TextButton(onClick = {
scheduleViewModel.clearCache() scheduleViewModel.clearCache()
authViewModel.logout() authViewModel.logout()
}) { }) {
Text("Deconnexion") Icon(
imageVector = Icons.AutoMirrored.Filled.Logout,
contentDescription = "Déconnexion",
)
} }
}, },
) )
@@ -107,144 +114,118 @@ fun ScheduleScreen(
Column( Column(
modifier = Modifier.fillMaxSize().padding(padding), modifier = Modifier.fillMaxSize().padding(padding),
) { ) {
var filtersExpanded by remember { mutableStateOf(false) }
val activeFilterCount = listOf(
scheduleState.selectedAccount,
scheduleState.selectedAgegroup,
scheduleState.selectedSubgroup,
).count { it != null } + if (scheduleState.typeFilter != TypeFilter.GAMES) 1 else 0
Card( Card(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
) {
Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { filtersExpanded = !filtersExpanded } .padding(horizontal = Dimens.lg, vertical = Dimens.sm),
.padding(horizontal = 16.dp, vertical = 12.dp), colors = CardDefaults.cardColors(
verticalAlignment = Alignment.CenterVertically, containerColor = MaterialTheme.colorScheme.surfaceVariant,
) { ),
Text(
text = "Filtres",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium,
)
if (activeFilterCount > 0) {
Spacer(Modifier.height(0.dp))
Text(
text = "($activeFilterCount actif${if (activeFilterCount > 1) "s" else ""})",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 8.dp),
)
}
Spacer(Modifier.weight(1f))
Text(
text = if (filtersExpanded) "" else "",
style = MaterialTheme.typography.bodySmall,
)
}
AnimatedVisibility(
visible = filtersExpanded,
enter = expandVertically(),
exit = shrinkVertically(),
) { ) {
Column( Column(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), modifier = Modifier.padding(Dimens.lg),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(Dimens.sm),
) { ) {
Row( TypeFilterSegmented(
horizontalArrangement = Arrangement.spacedBy(8.dp), selected = scheduleState.typeFilter,
) { onSelect = { scheduleViewModel.setTypeFilter(it) },
FilterDropdown(
label = "Compte",
selectedValue = scheduleState.selectedAccount,
options = scheduleState.accounts.map { it.name },
onSelect = { it?.let { acc -> scheduleViewModel.setAccount(acc) } },
modifier = Modifier.weight(1f),
) )
FilterDropdown( Row(
label = "Type", horizontalArrangement = Arrangement.spacedBy(Dimens.sm),
selectedValue = typeFilterLabel(scheduleState.typeFilter), modifier = Modifier.fillMaxWidth(),
options = listOf("Tous", "Matchs", "Entrainements"), ) {
FilterAssistChip(
label = "Compte",
selectedValue = scheduleState.accounts.find { it.name == scheduleState.selectedAccount }?.label,
options = scheduleState.accounts.map { it.label },
onSelect = { label -> onSelect = { label ->
val filter = when (label) { val account = scheduleState.accounts.find { it.label == label }
"Tous" -> TypeFilter.ALL if (account != null) scheduleViewModel.setAccount(account.name)
"Matchs" -> TypeFilter.GAMES
"Entrainements" -> TypeFilter.PRACTICES
else -> TypeFilter.DEFAULT
}
scheduleViewModel.setTypeFilter(filter)
}, },
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
} FilterAssistChip(
Row( label = "Âge",
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
FilterDropdown(
label = "Age",
selectedValue = scheduleState.selectedAgegroup, selectedValue = scheduleState.selectedAgegroup,
options = scheduleState.agegroups, options = scheduleState.agegroups,
onSelect = { scheduleViewModel.setAgegroup(it) }, onSelect = { scheduleViewModel.setAgegroup(it) },
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
FilterDropdown( }
Row(
horizontalArrangement = Arrangement.spacedBy(Dimens.sm),
modifier = Modifier.fillMaxWidth(),
) {
FilterAssistChip(
label = "Sous-groupe", label = "Sous-groupe",
selectedValue = scheduleState.selectedSubgroup, selectedValue = scheduleState.selectedSubgroup,
options = scheduleState.subgroups, options = scheduleState.subgroups,
onSelect = { scheduleViewModel.setSubgroup(it) }, onSelect = { scheduleViewModel.setSubgroup(it) },
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
if (hasActiveFilters) {
AssistChipClear(
onClear = {
scheduleViewModel.setAgegroup(null)
scheduleViewModel.setSubgroup(null)
scheduleViewModel.setTypeFilter(TypeFilter.DEFAULT)
},
modifier = Modifier.weight(1f),
)
} }
} }
} }
} }
PullToRefreshBox( PullToRefreshBox(
isRefreshing = scheduleState.isRefreshing, isRefreshing = scheduleState.isRefreshing,
onRefresh = { scheduleViewModel.refreshSchedule() }, onRefresh = { scheduleViewModel.refreshSchedule() },
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) { ) {
when { when {
scheduleState.isLoading && scheduleState.events.isEmpty() -> { scheduleState.isLoading && scheduleState.events.isEmpty() -> {
LoadingIndicator() LazyColumn {
items(4) { SkeletonCard() }
}
} }
scheduleState.error != null && scheduleState.events.isEmpty() -> { scheduleState.error != null && scheduleState.events.isEmpty() -> {
Column( ErrorState(
horizontalAlignment = Alignment.CenterHorizontally, icon = Icons.Filled.CloudOff,
) { title = "Erreur de chargement",
Text( message = scheduleState.error!!,
text = scheduleState.error!!, onRetry = { scheduleViewModel.refreshSchedule() },
color = MaterialTheme.colorScheme.error,
) )
Spacer(modifier = Modifier.height(8.dp))
TextButton(onClick = { scheduleViewModel.refreshSchedule() }) {
Text("Reessayer")
}
}
} }
scheduleState.filteredEvents.isEmpty() -> { scheduleState.filteredEvents.isEmpty() -> {
Column( EmptyState(
horizontalAlignment = Alignment.CenterHorizontally, icon = Icons.Filled.CalendarMonth,
) { title = "Aucun événement disponible",
Text("Aucun evenement disponible") message = "Tirez vers le bas pour rafraîchir le calendrier",
if (scheduleState.events.isEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Appuyez sur le bouton de rafraichissement pour charger les evenements",
style = MaterialTheme.typography.bodySmall,
color = Color.Gray,
) )
} }
}
}
else -> { else -> {
val grouped = groupEventsByDate(scheduleState.filteredEvents)
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) { ) {
items(scheduleState.filteredEvents) { event -> grouped.forEach { (dateKey, dayEvents) ->
stickyHeader {
Column(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface)
.padding(horizontal = Dimens.lg, vertical = Dimens.sm),
) {
Text(
text = formatEventDate(dateKey + "T00:00:00")
.replaceFirstChar { it.uppercase() },
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
HorizontalDivider()
}
}
items(dayEvents) { event ->
EventCard( EventCard(
event = event, event = event,
onClick = { onClick = {
@@ -259,6 +240,19 @@ fun ScheduleScreen(
) )
} }
} }
if (scheduleState.lastUpdated != null) {
item {
Text(
text = "Dernière mise à jour: ${formatLastUpdated(scheduleState.lastUpdated!!)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.padding(Dimens.lg),
)
}
}
}
} }
} }
} }
@@ -266,8 +260,27 @@ fun ScheduleScreen(
} }
} }
private fun typeFilterLabel(filter: TypeFilter): String = when (filter) { @OptIn(ExperimentalMaterial3Api::class)
TypeFilter.ALL -> "Tous" @Composable
TypeFilter.GAMES -> "Matchs" private fun AssistChipClear(
TypeFilter.PRACTICES -> "Entrainements" onClear: () -> Unit,
modifier: Modifier = Modifier,
) {
AssistChip(
onClick = onClear,
label = { Text("Effacer") },
trailingIcon = {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "Effacer les filtres",
)
},
modifier = modifier,
)
}
private fun formatLastUpdated(timestamp: Long): String {
val instant = Instant.fromEpochMilliseconds(timestamp)
val local = instant.toLocalDateTime(TimeZone.currentSystemDefault())
return "${local.date} ${local.time}"
} }