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
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.clickable
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
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.fillMaxSize
@@ -30,37 +27,46 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
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.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.Alignment
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.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.FilterDropdown
import ch.parano.myicek.ui.components.LoadingIndicator
import ch.parano.myicek.ui.components.FilterAssistChip
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.ScheduleViewModel
import kotlinx.datetime.Instant
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
fun ScheduleScreen(
scheduleViewModel: ScheduleViewModel,
@@ -69,7 +75,6 @@ fun ScheduleScreen(
modifier: Modifier = Modifier,
) {
val scheduleState by scheduleViewModel.state.collectAsState()
val authState by authViewModel.state.collectAsState()
LaunchedEffect(scheduleState.selectedAccount) {
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(
topBar = {
TopAppBar(
title = { Text("MyIceK") },
title = { Text("Calendrier") },
actions = {
val email = (authState as? ch.parano.myicek.viewmodel.AuthState.Authenticated)?.email
if (email != null) {
Text(
text = email,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = 8.dp),
IconButton(onClick = { scheduleViewModel.refreshSchedule() }) {
Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = "Rafraîchir",
)
}
TextButton(onClick = { scheduleViewModel.refreshSchedule() }) {
Text("Rafraichir")
}
TextButton(onClick = {
IconButton(onClick = {
scheduleViewModel.clearCache()
authViewModel.logout()
}) {
Text("Deconnexion")
Icon(
imageVector = Icons.AutoMirrored.Filled.Logout,
contentDescription = "Déconnexion",
)
}
},
)
@@ -107,156 +114,143 @@ fun ScheduleScreen(
Column(
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(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = Dimens.lg, vertical = Dimens.sm),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { filtersExpanded = !filtersExpanded }
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
Column(
modifier = Modifier.padding(Dimens.lg),
verticalArrangement = Arrangement.spacedBy(Dimens.sm),
) {
Text(
text = "Filtres",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium,
TypeFilterSegmented(
selected = scheduleState.typeFilter,
onSelect = { scheduleViewModel.setTypeFilter(it) },
)
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),
Row(
horizontalArrangement = Arrangement.spacedBy(Dimens.sm),
modifier = Modifier.fillMaxWidth(),
) {
FilterAssistChip(
label = "Compte",
selectedValue = scheduleState.accounts.find { it.name == scheduleState.selectedAccount }?.label,
options = scheduleState.accounts.map { it.label },
onSelect = { label ->
val account = scheduleState.accounts.find { it.label == label }
if (account != null) scheduleViewModel.setAccount(account.name)
},
modifier = Modifier.weight(1f),
)
FilterAssistChip(
label = "Âge",
selectedValue = scheduleState.selectedAgegroup,
options = scheduleState.agegroups,
onSelect = { scheduleViewModel.setAgegroup(it) },
modifier = Modifier.weight(1f),
)
}
Spacer(Modifier.weight(1f))
Text(
text = if (filtersExpanded) "" else "",
style = MaterialTheme.typography.bodySmall,
)
}
AnimatedVisibility(
visible = filtersExpanded,
enter = expandVertically(),
exit = shrinkVertically(),
) {
Column(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
Row(
horizontalArrangement = Arrangement.spacedBy(Dimens.sm),
modifier = Modifier.fillMaxWidth(),
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
FilterDropdown(
label = "Compte",
selectedValue = scheduleState.selectedAccount,
options = scheduleState.accounts.map { it.name },
onSelect = { it?.let { acc -> scheduleViewModel.setAccount(acc) } },
modifier = Modifier.weight(1f),
)
FilterDropdown(
label = "Type",
selectedValue = typeFilterLabel(scheduleState.typeFilter),
options = listOf("Tous", "Matchs", "Entrainements"),
onSelect = { label ->
val filter = when (label) {
"Tous" -> TypeFilter.ALL
"Matchs" -> TypeFilter.GAMES
"Entrainements" -> TypeFilter.PRACTICES
else -> TypeFilter.DEFAULT
}
scheduleViewModel.setTypeFilter(filter)
FilterAssistChip(
label = "Sous-groupe",
selectedValue = scheduleState.selectedSubgroup,
options = scheduleState.subgroups,
onSelect = { scheduleViewModel.setSubgroup(it) },
modifier = Modifier.weight(1f),
)
if (hasActiveFilters) {
AssistChipClear(
onClear = {
scheduleViewModel.setAgegroup(null)
scheduleViewModel.setSubgroup(null)
scheduleViewModel.setTypeFilter(TypeFilter.DEFAULT)
},
modifier = Modifier.weight(1f),
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
FilterDropdown(
label = "Age",
selectedValue = scheduleState.selectedAgegroup,
options = scheduleState.agegroups,
onSelect = { scheduleViewModel.setAgegroup(it) },
modifier = Modifier.weight(1f),
)
FilterDropdown(
label = "Sous-groupe",
selectedValue = scheduleState.selectedSubgroup,
options = scheduleState.subgroups,
onSelect = { scheduleViewModel.setSubgroup(it) },
modifier = Modifier.weight(1f),
)
}
}
}
}
PullToRefreshBox(
isRefreshing = scheduleState.isRefreshing,
onRefresh = { scheduleViewModel.refreshSchedule() },
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
when {
scheduleState.isLoading && scheduleState.events.isEmpty() -> {
LoadingIndicator()
LazyColumn {
items(4) { SkeletonCard() }
}
}
scheduleState.error != null && scheduleState.events.isEmpty() -> {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = scheduleState.error!!,
color = MaterialTheme.colorScheme.error,
)
Spacer(modifier = Modifier.height(8.dp))
TextButton(onClick = { scheduleViewModel.refreshSchedule() }) {
Text("Reessayer")
}
}
ErrorState(
icon = Icons.Filled.CloudOff,
title = "Erreur de chargement",
message = scheduleState.error!!,
onRetry = { scheduleViewModel.refreshSchedule() },
)
}
scheduleState.filteredEvents.isEmpty() -> {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text("Aucun evenement disponible")
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,
)
}
}
EmptyState(
icon = Icons.Filled.CalendarMonth,
title = "Aucun événement disponible",
message = "Tirez vers le bas pour rafraîchir le calendrier",
)
}
else -> {
val grouped = groupEventsByDate(scheduleState.filteredEvents)
LazyColumn(
modifier = Modifier.fillMaxSize(),
) {
items(scheduleState.filteredEvents) { event ->
EventCard(
event = event,
onClick = {
onEventClick(
event.idEvent,
scheduleState.selectedAccount!!,
event.title,
event.start,
event.end,
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(
event = event,
onClick = {
onEventClick(
event.idEvent,
scheduleState.selectedAccount!!,
event.title,
event.start,
event.end,
)
},
)
}
}
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) {
TypeFilter.ALL -> "Tous"
TypeFilter.GAMES -> "Matchs"
TypeFilter.PRACTICES -> "Entrainements"
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AssistChipClear(
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}"
}