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:
@@ -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}"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user