feat: add LoginScreen, ScheduleScreen, and EventDetailScreen
Add 3 Compose Multiplatform screens consuming Task 6 ViewModels and Task 8 components: - LoginScreen: MyIce title, login button, loading/error states - ScheduleScreen: AppBar + filter card (4 dropdowns) + event list with loading/error/empty/refreshing states - EventDetailScreen: AppBar + recap card + players/staff sections with amber empty-state card Use TextButton for Refresh/Logout actions (material-icons-core not available in this Compose Multiplatform setup). Guard setAccount call against nullable onSelect callback.
This commit is contained in:
@@ -0,0 +1,166 @@
|
|||||||
|
package ch.parano.myice.ui.screens
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
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.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
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 ch.parano.myice.ui.components.LoadingIndicator
|
||||||
|
import ch.parano.myice.viewmodel.EventDetailViewModel
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun EventDetailScreen(
|
||||||
|
gameId: String,
|
||||||
|
account: String,
|
||||||
|
eventTitle: String,
|
||||||
|
viewModel: EventDetailViewModel,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
LaunchedEffect(gameId, account) {
|
||||||
|
viewModel.loadEventDetail(gameId, account)
|
||||||
|
}
|
||||||
|
|
||||||
|
val state by viewModel.state.collectAsState()
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(title = { Text(eventTitle) })
|
||||||
|
},
|
||||||
|
modifier = modifier,
|
||||||
|
) { padding ->
|
||||||
|
when {
|
||||||
|
state.isLoading -> {
|
||||||
|
LoadingIndicator(modifier = Modifier.padding(padding))
|
||||||
|
}
|
||||||
|
state.error != null -> {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(padding),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = state.error!!,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
TextButton(onClick = { viewModel.loadEventDetail(gameId, account) }) {
|
||||||
|
Text("Reessayer")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.eventDetail != null -> {
|
||||||
|
val detail = state.eventDetail!!
|
||||||
|
val hasPlayers = detail.convocation.available.isNotEmpty()
|
||||||
|
val hasStaff = detail.convocation.staff.isNotEmpty()
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(padding),
|
||||||
|
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
Text("Type: ${detail.type}")
|
||||||
|
Text("Lieu: ${detail.place}")
|
||||||
|
Text("Heure: ${detail.timeStart} - ${detail.timeEnd}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPlayers && !hasStaff) {
|
||||||
|
item {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = Color(0xFFFFC107),
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Aucun joueur ni personnel convoque",
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasPlayers) {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
text = "Joueurs (${detail.convocation.available.size})",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items(detail.convocation.available) { player ->
|
||||||
|
val position = player.position ?: "N/A"
|
||||||
|
val number = player.number ?: "N/A"
|
||||||
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
) {
|
||||||
|
Text("[$position] #$number - ${player.fname} ${player.lname}")
|
||||||
|
Text(
|
||||||
|
text = "DOB: ${player.dob}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasStaff) {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
text = "Personnel",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items(detail.convocation.staff) { staff ->
|
||||||
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(
|
||||||
|
text = "${staff.role}: ${staff.fname} ${staff.lname}",
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(padding),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
Text("Aucune donnee disponible")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package ch.parano.myice.ui.screens
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import ch.parano.myice.viewmodel.AuthViewModel
|
||||||
|
import ch.parano.myice.viewmodel.AuthState
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LoginScreen(
|
||||||
|
viewModel: AuthViewModel,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsState()
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
||||||
|
LaunchedEffect(state) {
|
||||||
|
if (state is AuthState.Unauthenticated) {
|
||||||
|
snackbarHostState.showSnackbar("Erreur de connexion")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
|
modifier = modifier,
|
||||||
|
) { padding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(padding),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "MyIce",
|
||||||
|
style = MaterialTheme.typography.headlineLarge,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Games",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
if (state is AuthState.Loading) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
} else {
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.login() },
|
||||||
|
) {
|
||||||
|
Text("Se connecter avec Infomaniak")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
package ch.parano.myice.ui.screens
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
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.material3.Card
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
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.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
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.myice.filter.TypeFilter
|
||||||
|
import ch.parano.myice.ui.components.EventCard
|
||||||
|
import ch.parano.myice.ui.components.FilterDropdown
|
||||||
|
import ch.parano.myice.ui.components.LoadingIndicator
|
||||||
|
import ch.parano.myice.viewmodel.AuthViewModel
|
||||||
|
import ch.parano.myice.viewmodel.ScheduleViewModel
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ScheduleScreen(
|
||||||
|
scheduleViewModel: ScheduleViewModel,
|
||||||
|
authViewModel: AuthViewModel,
|
||||||
|
onEventClick: (gameId: String, account: String, eventTitle: String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val scheduleState by scheduleViewModel.state.collectAsState()
|
||||||
|
val authState by authViewModel.state.collectAsState()
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("MyIce") },
|
||||||
|
actions = {
|
||||||
|
val email = (authState as? ch.parano.myice.viewmodel.AuthState.Authenticated)?.email
|
||||||
|
if (email != null) {
|
||||||
|
Text(
|
||||||
|
text = email,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
TextButton(onClick = { scheduleViewModel.refreshSchedule() }) {
|
||||||
|
Text("Refresh")
|
||||||
|
}
|
||||||
|
TextButton(onClick = {
|
||||||
|
scheduleViewModel.clearCache()
|
||||||
|
authViewModel.logout()
|
||||||
|
}) {
|
||||||
|
Text("Logout")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = modifier,
|
||||||
|
) { padding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(padding),
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
FilterDropdown(
|
||||||
|
label = "Compte",
|
||||||
|
selectedValue = scheduleState.selectedAccount,
|
||||||
|
options = scheduleState.accounts.map { it.name },
|
||||||
|
onSelect = { it?.let { acc -> scheduleViewModel.setAccount(acc) } },
|
||||||
|
)
|
||||||
|
FilterDropdown(
|
||||||
|
label = "Age",
|
||||||
|
selectedValue = scheduleState.selectedAgegroup,
|
||||||
|
options = scheduleState.agegroups,
|
||||||
|
onSelect = { scheduleViewModel.setAgegroup(it) },
|
||||||
|
)
|
||||||
|
FilterDropdown(
|
||||||
|
label = "Sous-groupe",
|
||||||
|
selectedValue = scheduleState.selectedSubgroup,
|
||||||
|
options = scheduleState.subgroups,
|
||||||
|
onSelect = { scheduleViewModel.setSubgroup(it) },
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
scheduleState.isLoading && scheduleState.events.isEmpty() -> {
|
||||||
|
LoadingIndicator()
|
||||||
|
}
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
items(scheduleState.filteredEvents) { event ->
|
||||||
|
EventCard(
|
||||||
|
event = event,
|
||||||
|
onClick = {
|
||||||
|
onEventClick(
|
||||||
|
event.idEvent,
|
||||||
|
scheduleState.selectedAccount!!,
|
||||||
|
event.title,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (scheduleState.isRefreshing) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Card {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(32.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Text("Chargement...", fontWeight = FontWeight.Medium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun typeFilterLabel(filter: TypeFilter): String = when (filter) {
|
||||||
|
TypeFilter.ALL -> "Tous"
|
||||||
|
TypeFilter.GAMES -> "Matchs"
|
||||||
|
TypeFilter.PRACTICES -> "Entrainements"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user