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