diff --git a/composeApp/src/commonMain/kotlin/ch/parano/myicek/ui/screens/ScheduleScreen.kt b/composeApp/src/commonMain/kotlin/ch/parano/myicek/ui/screens/ScheduleScreen.kt index 5969c34..5526883 100644 --- a/composeApp/src/commonMain/kotlin/ch/parano/myicek/ui/screens/ScheduleScreen.kt +++ b/composeApp/src/commonMain/kotlin/ch/parano/myicek/ui/screens/ScheduleScreen.kt @@ -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}" }