From 84341a1662af9aff573ad9c218d8d945e96e834c Mon Sep 17 00:00:00 2001 From: Abdel-Hamid Makarimi Date: Mon, 2 Nov 2026 15:40:19 +0100 Subject: [PATCH 1/2] Pose la palette bento et refait l'accueil MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reprend la direction visuelle proposée, en corrigeant ce qui ne tenait pas à l'usage. Un seul accent vif, réservé aux calories ; les macros gardent trois teintes mais sourdes, pour qu'aucune ne vole la vedette. Les accents ne sont pas les mêmes en clair et en sombre : un vert acide conçu pour le noir tombe à 1,4:1 sur blanc. Les gris de texte passent de 4,0:1 à 7,2:1 en sombre et 5,7:1 en clair, au-dessus du seuil AA. Le thème se choisit à la main dans le profil — clair, sombre, ou comme le système. L'accueil devient une grille : le reste à consommer en très gros, les trois macros, la tendance et l'entraînement. Le graphe des sept jours a désormais une échelle, celle de l'objectif, et une journée qui le dépasse passe en rouge : des barres sans repère ne disaient rien. Les onglets portent leur propre en-tête, la barre du haut n'affiche donc plus un titre en doublon. --- .../java/com/kaz229/fittrack/MainActivity.kt | 12 +- .../fittrack/data/FitTrackRepository.kt | 4 + .../kaz229/fittrack/data/UserPreferences.kt | 18 + .../com/kaz229/fittrack/ui/FitTrackApp.kt | 7 +- .../kaz229/fittrack/ui/FitTrackViewModel.kt | 13 + .../kaz229/fittrack/ui/screens/HomeScreen.kt | 338 ++++++++++---- .../fittrack/ui/screens/ProfileScreen.kt | 15 + .../com/kaz229/fittrack/ui/theme/Theme.kt | 132 +++++- design/bento.html | 440 ++++++++++++++++++ 9 files changed, 852 insertions(+), 127 deletions(-) create mode 100644 design/bento.html diff --git a/app/src/main/java/com/kaz229/fittrack/MainActivity.kt b/app/src/main/java/com/kaz229/fittrack/MainActivity.kt index c283dbc..18ed172 100644 --- a/app/src/main/java/com/kaz229/fittrack/MainActivity.kt +++ b/app/src/main/java/com/kaz229/fittrack/MainActivity.kt @@ -4,7 +4,11 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.compose.runtime.getValue import com.kaz229.fittrack.ui.FitTrackApp +import com.kaz229.fittrack.ui.FitTrackViewModel import com.kaz229.fittrack.ui.theme.FitTrackTheme class MainActivity : ComponentActivity() { @@ -12,8 +16,12 @@ class MainActivity : ComponentActivity() { enableEdgeToEdge() super.onCreate(savedInstanceState) setContent { - FitTrackTheme { - FitTrackApp() + // Le thème est un réglage de l'utilisateur : le ViewModel est créé ici pour + // que l'application entière soit recomposée quand il change. + val viewModel: FitTrackViewModel = viewModel() + val theme by viewModel.theme.collectAsStateWithLifecycle() + FitTrackTheme(choice = theme) { + FitTrackApp(viewModel) } } } diff --git a/app/src/main/java/com/kaz229/fittrack/data/FitTrackRepository.kt b/app/src/main/java/com/kaz229/fittrack/data/FitTrackRepository.kt index 348eb5e..c6ad38e 100644 --- a/app/src/main/java/com/kaz229/fittrack/data/FitTrackRepository.kt +++ b/app/src/main/java/com/kaz229/fittrack/data/FitTrackRepository.kt @@ -26,6 +26,10 @@ class FitTrackRepository( val profile: Flow = userPreferences.profile + val theme: Flow = userPreferences.theme + + suspend fun setTheme(choice: ThemeChoice) = userPreferences.setTheme(choice) + // --- Séances ------------------------------------------------------------- fun allSessions(): Flow> = sessionDao.observeAll() diff --git a/app/src/main/java/com/kaz229/fittrack/data/UserPreferences.kt b/app/src/main/java/com/kaz229/fittrack/data/UserPreferences.kt index 7daf541..ba7405c 100644 --- a/app/src/main/java/com/kaz229/fittrack/data/UserPreferences.kt +++ b/app/src/main/java/com/kaz229/fittrack/data/UserPreferences.kt @@ -31,6 +31,17 @@ data class UserProfile( } } +/** Thème de l'application, choisi à la main ou laissé au système. */ +enum class ThemeChoice(val id: String, val label: String) { + SYSTEM("systeme", "Comme le système"), + LIGHT("clair", "Clair"), + DARK("sombre", "Sombre"); + + companion object { + fun byId(id: String?): ThemeChoice = entries.firstOrNull { it.id == id } ?: SYSTEM + } +} + /** Coordonnées du serveur Kazis. Vides tant que l'utilisateur ne les a pas saisies. */ data class KazisSettings(val url: String = "", val token: String = "") { val configured: Boolean get() = url.isNotBlank() && token.isNotBlank() @@ -47,6 +58,7 @@ class UserPreferences(private val context: Context) { private val kazisUrlKey = stringPreferencesKey("kazis_url") private val kazisTokenKey = stringPreferencesKey("kazis_token") private val deviceIdKey = stringPreferencesKey("device_id") + private val themeKey = stringPreferencesKey("theme_choice") private val lastBackupKey = longPreferencesKey("last_kazis_backup_epoch_second") val profile: Flow = context.dataStore.data.map { prefs -> @@ -99,6 +111,12 @@ class UserPreferences(private val context: Context) { return fresh } + val theme: Flow = context.dataStore.data.map { ThemeChoice.byId(it[themeKey]) } + + suspend fun setTheme(choice: ThemeChoice) { + context.dataStore.edit { it[themeKey] = choice.id } + } + /** Horodatage de la dernière sauvegarde réussie vers Kazis, en secondes. */ val lastBackup: Flow = context.dataStore.data.map { it[lastBackupKey] } diff --git a/app/src/main/java/com/kaz229/fittrack/ui/FitTrackApp.kt b/app/src/main/java/com/kaz229/fittrack/ui/FitTrackApp.kt index 5b44b68..8b42f2f 100644 --- a/app/src/main/java/com/kaz229/fittrack/ui/FitTrackApp.kt +++ b/app/src/main/java/com/kaz229/fittrack/ui/FitTrackApp.kt @@ -121,6 +121,7 @@ fun FitTrackApp(viewModel: FitTrackViewModel = viewModel()) { val healthState by viewModel.health.collectAsStateWithLifecycle() val kazisState by viewModel.kazis.collectAsStateWithLifecycle() val foodSearchState by viewModel.foodSearch.collectAsStateWithLifecycle() + val theme by viewModel.theme.collectAsStateWithLifecycle() val catalog by viewModel.catalog.collectAsStateWithLifecycle() val exercisesById by viewModel.exercisesById.collectAsStateWithLifecycle() val openSession by viewModel.openSession.collectAsStateWithLifecycle() @@ -142,7 +143,8 @@ fun FitTrackApp(viewModel: FitTrackViewModel = viewModel()) { Scaffold( topBar = { TopAppBar( - title = { Text(title) }, + // Les onglets affichent leur propre en-tête : un titre ici ferait doublon + title = { if (!isTopLevel) Text(title) }, navigationIcon = { if (!isTopLevel) { IconButton(onClick = { navController.popBackStack() }) { @@ -240,6 +242,7 @@ fun FitTrackApp(viewModel: FitTrackViewModel = viewModel()) { lastWeek = lastWeek, volumeToday = volumeToday, setCountToday = setCountToday, + today = viewModel.todayLabel, ) } composable(Tab.SESSIONS.route) { @@ -341,6 +344,8 @@ fun FitTrackApp(viewModel: FitTrackViewModel = viewModel()) { composable(PROFILE) { ProfileScreen( profile = profile, + theme = theme, + onThemeChange = viewModel::setTheme, onOpenHealthConnect = { navController.navigate(HEALTH) }, onOpenKazis = { navController.navigate(KAZIS) }, onSave = { weight, goal, macroGoals -> diff --git a/app/src/main/java/com/kaz229/fittrack/ui/FitTrackViewModel.kt b/app/src/main/java/com/kaz229/fittrack/ui/FitTrackViewModel.kt index 129f439..e47f3d7 100644 --- a/app/src/main/java/com/kaz229/fittrack/ui/FitTrackViewModel.kt +++ b/app/src/main/java/com/kaz229/fittrack/ui/FitTrackViewModel.kt @@ -23,6 +23,7 @@ import com.kaz229.fittrack.data.MacroTotals import com.kaz229.fittrack.data.Meal import com.kaz229.fittrack.data.MealType import com.kaz229.fittrack.data.Session +import com.kaz229.fittrack.data.ThemeChoice import com.kaz229.fittrack.data.SyncResult import com.kaz229.fittrack.data.UserProfile import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -38,6 +39,8 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.util.Locale /** Ce que l'écran « Exercices » affiche : la liste filtrée et les filtres disponibles. */ data class CatalogState( @@ -112,6 +115,11 @@ class FitTrackViewModel(application: Application) : AndroidViewModel(application private val repository = FitTrackRepository.get(application) private val today: LocalDate = LocalDate.now() + /** « Dimanche 2 novembre », pour l'en-tête de l'accueil. */ + val todayLabel: String = today + .format(DateTimeFormatter.ofPattern("EEEE d MMMM", Locale.FRENCH)) + .replaceFirstChar { it.uppercase() } + // --- Catalogue d'exercices ---------------------------------------------- private val allExercises = MutableStateFlow>(emptyList()) @@ -388,6 +396,11 @@ class FitTrackViewModel(application: Application) : AndroidViewModel(application // --- Séances, repas, bilans --------------------------------------------- + val theme: StateFlow = repository.theme + .stateIn(viewModelScope, SharingStarted.Eagerly, ThemeChoice.SYSTEM) + + fun setTheme(choice: ThemeChoice) = viewModelScope.launch { repository.setTheme(choice) } + val profile: StateFlow = repository.profile .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), UserProfile()) diff --git a/app/src/main/java/com/kaz229/fittrack/ui/screens/HomeScreen.kt b/app/src/main/java/com/kaz229/fittrack/ui/screens/HomeScreen.kt index 9f7be72..d45595b 100644 --- a/app/src/main/java/com/kaz229/fittrack/ui/screens/HomeScreen.kt +++ b/app/src/main/java/com/kaz229/fittrack/ui/screens/HomeScreen.kt @@ -1,28 +1,43 @@ package com.kaz229.fittrack.ui.screens +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.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.kaz229.fittrack.data.DailySummary import com.kaz229.fittrack.data.MacroTotals import com.kaz229.fittrack.data.UserProfile import com.kaz229.fittrack.ui.formatDay import com.kaz229.fittrack.ui.formatVolume +import com.kaz229.fittrack.ui.theme.LocalMacroColors import kotlin.math.abs +import kotlin.math.roundToInt + +/** Coins et espacements communs : la grille doit respirer de la même façon partout. */ +private val CardShape = RoundedCornerShape(28.dp) +private val CardPadding = 20.dp @Composable fun HomeScreen( @@ -32,151 +47,276 @@ fun HomeScreen( lastWeek: List, volumeToday: Double, setCountToday: Int, + today: String, modifier: Modifier = Modifier, ) { LazyColumn( modifier = modifier.fillMaxWidth(), - contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), ) { - item { - TodayCard(summary = summary, goal = profile.dailyCalorieGoal) - } - item { - MacrosCard(macros = macros, profile = profile) - } - item { - TrainingCard(volumeToday = volumeToday, setCountToday = setCountToday) - } - item { + item { Header(today) } + item { CaloriesCard(summary, profile.dailyCalorieGoal) } + item { MacrosCard(macros, profile) } + item { TrendCard(lastWeek, profile.dailyCalorieGoal) } + item { TrainingCard(volumeToday, setCountToday) } + } +} + +@Composable +private fun Header(today: String) { + Column(modifier = Modifier.padding(bottom = 4.dp)) { + Text( + text = today, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text(text = "Aujourd'hui", style = MaterialTheme.typography.headlineMedium) + } +} + +@Composable +private fun BentoCard(modifier: Modifier = Modifier, content: @Composable ColumnScopeAlias.() -> Unit) { + Card( + modifier = modifier.fillMaxWidth(), + shape = CardShape, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(CardPadding), + verticalArrangement = Arrangement.spacedBy(12.dp), + content = content, + ) + } +} + +private typealias ColumnScopeAlias = androidx.compose.foundation.layout.ColumnScope + +/** Le seul chiffre qu'on doit pouvoir lire à bout de bras. */ +@Composable +private fun CaloriesCard(summary: DailySummary, goal: Int) { + val remaining = goal - summary.net + BentoCard { + Text( + text = if (remaining >= 0) "Il te reste" else "Tu as dépassé de", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row(verticalAlignment = Alignment.Bottom) { Text( - text = "7 derniers jours", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, + text = abs(remaining).toString(), + style = MaterialTheme.typography.displaySmall, + fontWeight = FontWeight.ExtraBold, + ) + Spacer(Modifier.width(8.dp)) + Text( + text = "sur $goal kcal", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 4.dp), ) } - items(lastWeek.reversed()) { day -> - WeekRow(day) + ThickBar( + ratio = if (goal > 0) summary.net.toFloat() / goal else 0f, + color = MaterialTheme.colorScheme.primary, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "${summary.caloriesIn} ingérées", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "${summary.caloriesOut} brûlées", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } - if (lastWeek.isEmpty()) { - item { + } +} + +@Composable +private fun MacrosCard(macros: MacroTotals, profile: UserProfile) { + val colors = LocalMacroColors.current + BentoCard { + MacroLine("Protéines", macros.proteinG, profile.macroGoals.proteinG, colors.protein) + MacroLine("Glucides", macros.carbsG, profile.macroGoals.carbsG, colors.carbs) + MacroLine("Lipides", macros.fatG, profile.macroGoals.fatG, colors.fat) + } +} + +@Composable +private fun MacroLine(label: String, value: Double, goal: Int, color: Color) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Bottom, + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row(verticalAlignment = Alignment.Bottom) { Text( - text = "Aucune donnée pour l'instant. Ajoute une séance ou un repas pour démarrer.", - style = MaterialTheme.typography.bodyMedium, + text = "${value.roundToInt()} g", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Text( + text = " / $goal g", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } + ThinBar(ratio = if (goal > 0) (value / goal).toFloat() else 0f, color = color) } } +/** + * Tendance des sept derniers jours, à l'échelle de l'objectif. + * + * Sans repère, des barres ne disent rien : la hauteur se lit par rapport à l'objectif, + * et une journée qui le dépasse prend la couleur d'alerte. + */ @Composable -private fun TodayCard(summary: DailySummary, goal: Int) { - Card(modifier = Modifier.fillMaxWidth()) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(20.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), +private fun TrendCard(lastWeek: List, goal: Int) { + BentoCard { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { - Text("Aujourd'hui", style = MaterialTheme.typography.titleMedium) + Text("Calories nettes", style = MaterialTheme.typography.titleMedium) Text( - text = "${summary.net} kcal nettes", - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold, + text = "objectif $goal", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) - val ratio = if (goal > 0) (summary.net.toFloat() / goal).coerceIn(0f, 1f) else 0f - LinearProgressIndicator( - progress = { ratio }, - modifier = Modifier.fillMaxWidth(), - ) - val remaining = goal - summary.net + } + if (lastWeek.isEmpty()) { Text( - text = if (remaining >= 0) { - "Il te reste $remaining kcal sur ton objectif de $goal kcal." - } else { - "Tu as dépassé ton objectif de ${abs(remaining)} kcal." - }, + text = "Pas encore d'historique.", style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) - HorizontalDivider() - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - StatColumn("Ingéré", "${summary.caloriesIn} kcal") - StatColumn("Brûlé", "${summary.caloriesOut} kcal") - StatColumn("Objectif", "$goal kcal") + return@BentoCard + } + Row( + modifier = Modifier.fillMaxWidth().height(110.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.Bottom, + ) { + lastWeek.forEach { day -> + DayColumn(day = day, goal = goal, modifier = Modifier.weight(1f)) } } } } @Composable -private fun MacrosCard(macros: MacroTotals, profile: UserProfile) { - Card(modifier = Modifier.fillMaxWidth()) { - Column( +private fun DayColumn(day: DailySummary, goal: Int, modifier: Modifier = Modifier) { + val ratio = if (goal > 0) (day.net.toFloat() / goal).coerceIn(0f, 1.2f) else 0f + val over = goal > 0 && day.net > goal + val label = formatDay(day.epochDay).take(3).lowercase() + Column( + modifier = modifier.fillMaxHeight(), + verticalArrangement = Arrangement.Bottom, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( modifier = Modifier .fillMaxWidth() - .padding(20.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Text("Macros du jour", style = MaterialTheme.typography.titleMedium) - MacroGoalsBlock(totals = macros, goals = profile.macroGoals) - } + .weight(ratio.coerceAtLeast(0.02f)) + .clip(RoundedCornerShape(6.dp)) + .background( + when { + over -> MaterialTheme.colorScheme.error + ratio > 0f -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.outlineVariant + }, + ), + ) + Spacer(Modifier.height(6.dp)) + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) } } @Composable private fun TrainingCard(volumeToday: Double, setCountToday: Int) { - Card(modifier = Modifier.fillMaxWidth()) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(20.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text("Entraînement du jour", style = MaterialTheme.typography.titleMedium) - if (setCountToday == 0) { - Text( - text = "Aucune série enregistrée aujourd'hui.", - style = MaterialTheme.typography.bodyMedium, - ) - } else { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - StatColumn("Séries", setCountToday.toString()) - StatColumn("Volume soulevé", formatVolume(volumeToday)) - } + BentoCard { + Text("Entraînement", style = MaterialTheme.typography.titleMedium) + if (setCountToday == 0) { + Text( + text = "Aucune série aujourd'hui.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + Row(horizontalArrangement = Arrangement.spacedBy(28.dp)) { + Stat("Séries", setCountToday.toString()) + Stat("Volume", formatVolume(volumeToday)) } } } } @Composable -private fun StatColumn(label: String, value: String) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text(text = label, style = MaterialTheme.typography.labelMedium) +private fun Stat(label: String, value: String) { + Column { Text( - text = value, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, + text = label, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) + Text(text = value, style = MaterialTheme.typography.titleLarge) } } @Composable -private fun WeekRow(day: DailySummary) { - val label = formatDay(day.epochDay) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, +private fun ThickBar(ratio: Float, color: Color) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(10.dp) + .clip(RoundedCornerShape(99.dp)) + .background(MaterialTheme.colorScheme.outlineVariant), ) { - Text(text = label, style = MaterialTheme.typography.bodyMedium) - Text( - text = "+${day.caloriesIn} / -${day.caloriesOut} = ${day.net} kcal", - style = MaterialTheme.typography.bodyMedium, + Box( + modifier = Modifier + .fillMaxWidth(ratio.coerceIn(0f, 1f)) + .fillMaxHeight() + .clip(RoundedCornerShape(99.dp)) + .background(color), + ) + } +} + +@Composable +private fun ThinBar(ratio: Float, color: Color) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(6.dp) + .clip(RoundedCornerShape(99.dp)) + .background(MaterialTheme.colorScheme.outlineVariant), + ) { + Box( + modifier = Modifier + .fillMaxWidth(ratio.coerceIn(0f, 1f)) + .fillMaxHeight() + .clip(RoundedCornerShape(99.dp)) + .background(color), ) } } diff --git a/app/src/main/java/com/kaz229/fittrack/ui/screens/ProfileScreen.kt b/app/src/main/java/com/kaz229/fittrack/ui/screens/ProfileScreen.kt index d8b2b7a..459dd6c 100644 --- a/app/src/main/java/com/kaz229/fittrack/ui/screens/ProfileScreen.kt +++ b/app/src/main/java/com/kaz229/fittrack/ui/screens/ProfileScreen.kt @@ -22,12 +22,17 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.ui.unit.dp +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.FilterChip import com.kaz229.fittrack.data.MacroGoals +import com.kaz229.fittrack.data.ThemeChoice import com.kaz229.fittrack.data.UserProfile @Composable fun ProfileScreen( profile: UserProfile, + theme: ThemeChoice, + onThemeChange: (ThemeChoice) -> Unit, onSave: (weightKg: Double, goal: Int, macroGoals: MacroGoals) -> Unit, onOpenHealthConnect: () -> Unit, onOpenKazis: () -> Unit, @@ -72,6 +77,16 @@ fun ProfileScreen( style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, ) + Text(text = "Apparence", style = MaterialTheme.typography.labelLarge) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + ThemeChoice.entries.forEach { choice -> + FilterChip( + selected = choice == theme, + onClick = { onThemeChange(choice) }, + label = { Text(choice.label) }, + ) + } + } OutlinedTextField( value = weight, onValueChange = { weight = it }, diff --git a/app/src/main/java/com/kaz229/fittrack/ui/theme/Theme.kt b/app/src/main/java/com/kaz229/fittrack/ui/theme/Theme.kt index decba08..84c3104 100644 --- a/app/src/main/java/com/kaz229/fittrack/ui/theme/Theme.kt +++ b/app/src/main/java/com/kaz229/fittrack/ui/theme/Theme.kt @@ -1,45 +1,127 @@ package com.kaz229.fittrack.ui.theme -import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Typography import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.dynamicDarkColorScheme -import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import com.kaz229.fittrack.data.ThemeChoice -private val Green = Color(0xFF0B6E4F) -private val GreenLight = Color(0xFF57C79A) -private val Sand = Color(0xFFE8A33D) +/** + * Palette « bento » : un seul accent vif, trois teintes sourdes pour les macros. + * + * Les accents ne sont pas les mêmes en clair et en sombre. Un vert acide conçu pour le + * noir tombe à 1,4:1 sur blanc : il est remplacé par sa version assombrie. Chaque + * couleur de texte tient le contraste AA (4,5:1) sur son fond. + */ +object Bento { + // Sombre + private val DarkBg = Color(0xFF0A0A0A) + private val DarkCard = Color(0xFF151515) + private val DarkBorder = Color(0xFF282828) + private val DarkMuted = Color(0xFF9A9A9A) + private val DarkTrack = Color(0xFF2E2E2E) + private val AcidGreen = Color(0xFFCCFF00) -private val LightColors = lightColorScheme( - primary = Green, - secondary = Sand, - tertiary = GreenLight, + // Clair + private val LightBg = Color(0xFFF2F2EE) + private val LightCard = Color(0xFFFFFFFF) + private val LightBorder = Color(0xFFE2E2DC) + private val LightMuted = Color(0xFF666660) + private val LightTrack = Color(0xFFEDEDE7) + private val DeepGreen = Color(0xFF4D6B00) + + val darkScheme = darkColorScheme( + primary = AcidGreen, + onPrimary = DarkBg, + background = DarkBg, + onBackground = Color.White, + surface = DarkCard, + onSurface = Color.White, + surfaceVariant = Color(0xFF1E1E1E), + onSurfaceVariant = DarkMuted, + outline = DarkBorder, + outlineVariant = DarkTrack, + error = Color(0xFFFF6B6B), + ) + + val lightScheme = lightColorScheme( + primary = DeepGreen, + onPrimary = Color.White, + background = LightBg, + onBackground = Color(0xFF121212), + surface = LightCard, + onSurface = Color(0xFF121212), + surfaceVariant = Color(0xFFFBFBF9), + onSurfaceVariant = LightMuted, + outline = LightBorder, + outlineVariant = LightTrack, + error = Color(0xFFB3261E), + ) +} + +/** + * Teintes des macros, hors du schéma Material : trois couleurs distinctes mais + * sourdes, pour qu'aucune ne vole la vedette à l'accent des calories. + */ +data class MacroColors(val protein: Color, val carbs: Color, val fat: Color) + +private val DarkMacros = MacroColors( + protein = Color(0xFF3DDC97), + carbs = Color(0xFFFFB44D), + fat = Color(0xFFB98AFF), ) -private val DarkColors = darkColorScheme( - primary = GreenLight, - secondary = Sand, - tertiary = Green, +private val LightMacros = MacroColors( + protein = Color(0xFF00765A), + carbs = Color(0xFF9A5800), + fat = Color(0xFF6B35C4), ) +val LocalMacroColors = staticCompositionLocalOf { DarkMacros } + +/** Chiffres larges et serrés pour les valeurs qu'on lit de loin. */ +private val BentoTypography = Typography().let { base -> + base.copy( + displayLarge = base.displayLarge.copy( + fontWeight = FontWeight.ExtraBold, + letterSpacing = (-1.5).sp, + ), + headlineLarge = base.headlineLarge.copy( + fontWeight = FontWeight.ExtraBold, + letterSpacing = (-1).sp, + ), + headlineMedium = base.headlineMedium.copy( + fontWeight = FontWeight.Bold, + letterSpacing = (-0.5).sp, + ), + titleLarge = base.titleLarge.copy(fontWeight = FontWeight.Bold), + titleMedium = base.titleMedium.copy(fontWeight = FontWeight.SemiBold), + labelLarge = base.labelLarge.copy(fontWeight = FontWeight.SemiBold), + ) +} + @Composable fun FitTrackTheme( - darkTheme: Boolean = isSystemInDarkTheme(), - dynamicColor: Boolean = true, + choice: ThemeChoice = ThemeChoice.SYSTEM, content: @Composable () -> Unit, ) { - val colorScheme = when { - dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { - val context = LocalContext.current - if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) - } - darkTheme -> DarkColors - else -> LightColors + val dark = when (choice) { + ThemeChoice.DARK -> true + ThemeChoice.LIGHT -> false + ThemeChoice.SYSTEM -> isSystemInDarkTheme() + } + CompositionLocalProvider(LocalMacroColors provides if (dark) DarkMacros else LightMacros) { + MaterialTheme( + colorScheme = if (dark) Bento.darkScheme else Bento.lightScheme, + typography = BentoTypography, + content = content, + ) } - MaterialTheme(colorScheme = colorScheme, content = content) } diff --git a/design/bento.html b/design/bento.html new file mode 100644 index 0000000..7be1098 --- /dev/null +++ b/design/bento.html @@ -0,0 +1,440 @@ + + + + + +FitTrack — Bento + + + + + +
+
+ + +
+
+
+

Dimanche 2 novembre

+

Aujourd'hui

+
+ +
+ +
+
+

Il te reste

+
+ 1 498 + sur 2 200 kcal +
+
+
702 ingérées0 brûlées
+
+ +
+
+
+
+ Protéines + 43 g / 150 g +
+
+
+
+
+ Glucides + 56 g / 250 g +
+
+
+
+
+ Lipides + 33 g / 70 g +
+
+
+
+
+ +
+
+ Calories nettes + 7 derniers jours +
+
2 200 kcalobjectif
+
+
+
lun
+
mar
+
mer
+
jeu
+
ven
+
sam
+
auj
+
+
+ +
+
+ Entraînement + Push day · en cours +
+
+
Séries
14
+
Volume
4,2 t
+
Durée
47 min
+
+
+
+
+ + +
+
+

702 kcal · P 43 · G 56 · L 33

Repas

+ +
+ +
+
+ +
+
+ Petit-déjeuner + 320 kcal +
+

Flocons d'avoine · Whey

+
+
+ +
+ +
+
+ Déjeuner + 382 kcal +
+

Poulet à l'airfryer · Pommes de terre rissolées · Cancoillotte

+
+ P 40 g + G 35 g + L 12 g +
+
+
+ +
+ + +
+
+
+ + +
+
+

Cette semaine · 3 séances

Séances

+ +
+ +
+
+
+ En cours +

Séance salle

+

Démarrée il y a 47 min

+
+ +
+
+
Séries
14
+
Volume
4,2 t
+
Exercices
5
+
+
+ +

Historique

+ +
+
+ Séance salle + Hier +
+

18 séries · 5,1 t · 62 min · 328 kcal

+
+
+
+ Course à pied + ven. 31/10 · Lyfta +
+

35 min · 400 kcal

+
+
+ + +
+
+

Catalogue

Exercices

+ +
+ + +
+ + + + + +
+
+ + + + +
+ +

1 324 exercices

+ +
+
Développé couché à la barre
+
Pectoraux · pectoraux · Barre — record 85 kg
+
+
+
Élévations latérales aux haltères
+
Épaules · deltoïdes · Haltères — record 14 kg
+
+
+
Tirage vertical à la poulie
+
Dos · grand dorsal · Poulie — record 70 kg
+
+
+
Squat à la barre
+
Cuisses · quadriceps · Barre — record 110 kg
+
+
+ +
+ + +
+ +
+

Ce qui change par rapport à ta maquette

+
    +
  • Un seul accent vif. Le vert acide ne sert plus qu'aux calories. Les macros gardent trois teintes, mais sourdes : elles distinguent sans se disputer l'attention.
  • +
  • Contraste tenu. Le gris de texte passe de #777 (4,0:1) à #9A9A9A (7,2:1). En clair, les néons sont remplacés par leurs versions assombries — un #CCFF00 sur blanc est illisible.
  • +
  • Le graphe a une échelle et la ligne d'objectif : une barre qui la dépasse se voit enfin.
  • +
  • Les exercices retrouvent la recherche et les deux filtres (groupe, matériel) avec le vrai compte, au lieu de quatre tuiles décoratives.
  • +
  • Tout est en français, et la séance en cours montre séries, volume et durée — ce que l'app sait déjà calculer.
  • +
+
+ + + + From 43c804895c914162dbf60e955782f7d1c335c650 Mon Sep 17 00:00:00 2001 From: Abdel-Hamid Makarimi Date: Mon, 2 Nov 2026 16:16:47 +0100 Subject: [PATCH 2/2] =?UTF-8?q?Refait=20les=20repas,=20les=20s=C3=A9ances?= =?UTF-8?q?=20et=20le=20catalogue=20dans=20la=20palette?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les repas deviennent une ligne de temps groupée par moment de la journée : un trou se voit tout de suite, là où une liste à plat le noyait. Chaque repas porte son total, et les macros de chaque ligne sont des étiquettes colorées. Les séances mettent en tête celle qui est ouverte, encadrée de l'accent et annoncée « en cours » : c'est la seule sur laquelle on peut agir. L'historique passe en lignes sobres. Le catalogue garde sa recherche et ses deux filtres, et reçoit l'en-tête des autres onglets. Deux corrections de thème trouvées à l'écran : sans les rôles primaryContainer, secondaryContainer et surfaceContainer, la barre de navigation et les boutons flottants restaient violets ; et une carte sans contentColor explicite grisait son titre. --- README.md | 2 +- .../com/kaz229/fittrack/ui/FitTrackApp.kt | 1 + .../fittrack/ui/screens/ExercisesScreen.kt | 16 +- .../kaz229/fittrack/ui/screens/MealsScreen.kt | 239 +++++++++++++----- .../fittrack/ui/screens/SessionsScreen.kt | 165 +++++++++--- .../com/kaz229/fittrack/ui/theme/Theme.kt | 34 +++ 6 files changed, 356 insertions(+), 101 deletions(-) diff --git a/README.md b/README.md index f1e9510..6834159 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ corde à sauter 11,0 · rameur 7,0…). C'est une estimation, pas une mesure mé | | | |---|---| | Langage | Kotlin 2.0 | -| UI | Jetpack Compose + Material 3 (thème dynamique Android 12+) | +| UI | Jetpack Compose + Material 3, palette « bento » (clair / sombre au choix) | | Navigation | Navigation Compose (4 onglets + écrans de détail) | | Base de données | Room (SQLite) | | Préférences | DataStore | diff --git a/app/src/main/java/com/kaz229/fittrack/ui/FitTrackApp.kt b/app/src/main/java/com/kaz229/fittrack/ui/FitTrackApp.kt index 8b42f2f..f0c91ae 100644 --- a/app/src/main/java/com/kaz229/fittrack/ui/FitTrackApp.kt +++ b/app/src/main/java/com/kaz229/fittrack/ui/FitTrackApp.kt @@ -265,6 +265,7 @@ fun FitTrackApp(viewModel: FitTrackViewModel = viewModel()) { viewModel.selectExercise(exercise.id) navController.navigate(EXERCISE_DETAIL) }, + showHeader = true, ) } composable(Tab.MEALS.route) { diff --git a/app/src/main/java/com/kaz229/fittrack/ui/screens/ExercisesScreen.kt b/app/src/main/java/com/kaz229/fittrack/ui/screens/ExercisesScreen.kt index 1579195..1a43233 100644 --- a/app/src/main/java/com/kaz229/fittrack/ui/screens/ExercisesScreen.kt +++ b/app/src/main/java/com/kaz229/fittrack/ui/screens/ExercisesScreen.kt @@ -44,8 +44,21 @@ fun ExercisesScreen( onSelect: (Exercise) -> Unit, modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(bottom = 16.dp), + showHeader: Boolean = false, ) { Column(modifier = modifier.fillMaxSize()) { + // L'en-tête n'apparaît que sur l'onglet : la feuille de sélection d'une séance + // réutilise cet écran et porte déjà son propre titre. + if (showHeader) { + Column(modifier = Modifier.padding(start = 16.dp, top = 16.dp, end = 16.dp)) { + Text( + text = "Catalogue", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text(text = "Exercices", style = MaterialTheme.typography.headlineMedium) + } + } OutlinedTextField( value = state.query, onValueChange = onQueryChange, @@ -73,7 +86,8 @@ fun ExercisesScreen( state.loading -> "Chargement du catalogue…" else -> "${state.exercises.size} exercice(s)" }, - style = MaterialTheme.typography.labelLarge, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), ) if (state.loading) { diff --git a/app/src/main/java/com/kaz229/fittrack/ui/screens/MealsScreen.kt b/app/src/main/java/com/kaz229/fittrack/ui/screens/MealsScreen.kt index e25647e..807ace2 100644 --- a/app/src/main/java/com/kaz229/fittrack/ui/screens/MealsScreen.kt +++ b/app/src/main/java/com/kaz229/fittrack/ui/screens/MealsScreen.kt @@ -1,5 +1,7 @@ package com.kaz229.fittrack.ui.screens +import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -7,12 +9,17 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row 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.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Delete import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -20,6 +27,8 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -28,7 +37,15 @@ import com.kaz229.fittrack.data.MacroTotals import com.kaz229.fittrack.data.Meal import com.kaz229.fittrack.data.MealType import com.kaz229.fittrack.ui.formatGrams +import com.kaz229.fittrack.ui.theme.LocalMacroColors +import kotlin.math.roundToInt +/** + * Journal alimentaire présenté comme une ligne de temps. + * + * Les repas sont groupés par moment de la journée, dans l'ordre où on les prend : + * un trou se voit alors tout de suite, là qu'une liste à plat le noyait. + */ @Composable fun MealsScreen( meals: List, @@ -37,95 +54,201 @@ fun MealsScreen( onDelete: (Meal) -> Unit, modifier: Modifier = Modifier, ) { - if (meals.isEmpty()) { - EmptyState( - message = "Aucun repas aujourd'hui.\n" + - "Scanne un code-barres, ou saisis l'aliment à la main.", - modifier = modifier, - ) - return + val byType = MealType.entries.associateWith { type -> + meals.filter { it.mealType == type.id } } val total = meals.sumOf { it.calories } + LazyColumn( modifier = modifier.fillMaxWidth(), - // Marge basse dégagée : les deux boutons flottants (saisie et scan) recouvrent - // sinon la dernière ligne du journal. - contentPadding = PaddingValues(start = 16.dp, top = 16.dp, end = 16.dp, bottom = 104.dp), + contentPadding = PaddingValues(start = 16.dp, top = 16.dp, end = 16.dp, bottom = 120.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { item { - Card(modifier = Modifier.fillMaxWidth()) { + Column { + Text( + text = if (meals.isEmpty()) { + "Rien d'enregistré" + } else { + "$total kcal · P ${macros.proteinG.roundToInt()}" + + " · G ${macros.carbsG.roundToInt()}" + + " · L ${macros.fatG.roundToInt()}" + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text(text = "Repas", style = MaterialTheme.typography.headlineMedium) + } + } + + MealType.entries.forEach { type -> + item(key = type.id) { + MealBlock( + type = type, + meals = byType[type].orEmpty(), + onDelete = onDelete, + ) + } + } + } +} + +/** Un moment de la journée : son point sur la ligne, et ce qu'on y a mangé. */ +@Composable +private fun MealBlock(type: MealType, meals: List, onDelete: (Meal) -> Unit) { + val filled = meals.isNotEmpty() + Row(modifier = Modifier.fillMaxWidth()) { + TimelineMarker(filled = filled) + Column( + modifier = Modifier.padding(start = 14.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (!filled) { + EmptyMeal(type) + return@Column + } + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(22.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + ) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(20.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth().padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), ) { - Text( - text = "Total du jour : $total kcal", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) - MacroGoalsBlock(totals = macros, goals = macroGoals) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Bottom, + ) { + Text(text = type.label, style = MaterialTheme.typography.titleMedium) + Row(verticalAlignment = Alignment.Bottom) { + Text( + text = meals.sumOf { it.calories }.toString(), + style = MaterialTheme.typography.titleLarge, + ) + Text( + text = " kcal", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + meals.forEach { meal -> MealLine(meal, onDelete = { onDelete(meal) }) } } } } - items(meals, key = { it.id }) { meal -> - MealCard(meal = meal, onDelete = { onDelete(meal) }) - } } } @Composable -private fun MealCard(meal: Meal, onDelete: () -> Unit) { - Card(modifier = Modifier.fillMaxWidth()) { - Row( +private fun TimelineMarker(filled: Boolean) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Box( modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = meal.name, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, + .padding(top = 20.dp) + .size(12.dp) + .clip(CircleShape) + .background( + if (filled) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.surface, ) - Text( - text = buildString { - append(MealType.byId(meal.mealType).label) - meal.grams?.let { append(" · ${formatGrams(it)}") } - append(" · ${meal.calories} kcal") - }, - style = MaterialTheme.typography.bodyMedium, - ) - if (meal.hasMacros) { - Text( - text = "P ${formatGrams(meal.proteinG)} · " + - "G ${formatGrams(meal.carbsG)} · " + - "L ${formatGrams(meal.fatG)}", - style = MaterialTheme.typography.bodySmall, - ) + .border(2.dp, MaterialTheme.colorScheme.outline, CircleShape), + ) + Box( + modifier = Modifier + .padding(top = 6.dp) + .width(2.dp) + .height(56.dp) + .background(MaterialTheme.colorScheme.outline), + ) + } +} + +@Composable +private fun MealLine(meal: Meal, onDelete: () -> Unit) { + val colors = LocalMacroColors.current + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text(text = meal.name, style = MaterialTheme.typography.bodyLarge) + Text( + text = buildString { + meal.grams?.let { append("${formatGrams(it)} · ") } + append("${meal.calories} kcal") + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (meal.hasMacros) { + Row( + modifier = Modifier.padding(top = 6.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + MacroTag("P ${meal.proteinG.roundToInt()} g", colors.protein) + MacroTag("G ${meal.carbsG.roundToInt()} g", colors.carbs) + MacroTag("L ${meal.fatG.roundToInt()} g", colors.fat) } } - IconButton(onClick = onDelete) { - Icon(Icons.Filled.Delete, contentDescription = "Supprimer le repas") - } } + IconButton(onClick = onDelete) { + Icon( + Icons.Filled.Delete, + contentDescription = "Supprimer ${meal.name}", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun MacroTag(text: String, color: Color) { + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + color = color, + fontWeight = FontWeight.SemiBold, + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(color.copy(alpha = 0.12f)) + .padding(horizontal = 8.dp, vertical = 4.dp), + ) +} + +/** Un repas vide reste visible : c'est ce qui rappelle de le remplir. */ +@Composable +private fun EmptyMeal(type: MealType) { + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(22.dp)) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outline, + shape = RoundedCornerShape(22.dp), + ) + .padding(18.dp), + ) { + Text( + text = "Ajouter ${type.label.lowercase()}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } @Composable fun EmptyState(message: String, modifier: Modifier = Modifier) { Box( - modifier = modifier - .fillMaxSize() - .padding(32.dp), + modifier = modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center, ) { Text( text = message, style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, ) } diff --git a/app/src/main/java/com/kaz229/fittrack/ui/screens/SessionsScreen.kt b/app/src/main/java/com/kaz229/fittrack/ui/screens/SessionsScreen.kt index f9858aa..69f35e6 100644 --- a/app/src/main/java/com/kaz229/fittrack/ui/screens/SessionsScreen.kt +++ b/app/src/main/java/com/kaz229/fittrack/ui/screens/SessionsScreen.kt @@ -1,5 +1,7 @@ package com.kaz229.fittrack.ui.screens +import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -9,11 +11,11 @@ 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.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Delete -import androidx.compose.material3.AssistChip -import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -21,6 +23,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.kaz229.fittrack.data.Activities @@ -35,31 +38,106 @@ fun SessionsScreen( onDelete: (Session) -> Unit, modifier: Modifier = Modifier, ) { - if (sessions.isEmpty()) { - EmptyState( - message = "Aucune séance enregistrée.\n" + - "Appuie sur + pour démarrer une séance de salle ou noter du cardio.", - modifier = modifier, - ) - return + // La séance ouverte remonte en tête : c'est la seule sur laquelle on peut agir + val live = sessions.firstOrNull { + SessionKind.byId(it.kind) == SessionKind.GYM && !it.finished } + val past = sessions.filter { it.id != live?.id } + LazyColumn( modifier = modifier.fillMaxWidth(), - contentPadding = PaddingValues(16.dp), + contentPadding = PaddingValues(start = 16.dp, top = 16.dp, end = 16.dp, bottom = 120.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - items(sessions, key = { it.id }) { session -> - SessionCard( - session = session, - onOpen = { onOpen(session) }, - onDelete = { onDelete(session) }, + item { + Column { + Text( + text = "${sessions.count { it.finished }} terminées", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text(text = "Séances", style = MaterialTheme.typography.headlineMedium) + } + } + + live?.let { session -> + item(key = "live") { LiveCard(session, onOpen = { onOpen(session) }) } + } + + if (past.isEmpty() && live == null) { + item { + Text( + text = "Aucune séance enregistrée. Démarre une séance de salle, " + + "ou note du cardio.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + if (past.isNotEmpty()) { + item { + Text( + text = "Historique", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 12.dp), + ) + } + items(past, key = { it.id }) { session -> + SessionRow( + session = session, + onOpen = { onOpen(session) }, + onDelete = { onDelete(session) }, + ) + } + } + } +} + +/** Séance ouverte : encadrée de l'accent, avec ce qu'on y a déjà fait. */ +@Composable +private fun LiveCard(session: Session, onOpen: () -> Unit) { + Card( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, MaterialTheme.colorScheme.primary, RoundedCornerShape(28.dp)) + .clickable(onClick = onOpen), + shape = RoundedCornerShape(28.dp), + // Sans contentColor explicite, Material déduit onSurfaceVariant du conteneur + // et le titre ressort grisé. + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurface, + ), + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(20.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = "EN COURS", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier + .clip(RoundedCornerShape(99.dp)) + .background(MaterialTheme.colorScheme.primary) + .padding(horizontal = 10.dp, vertical = 4.dp), + ) + Text(text = "Séance salle", style = MaterialTheme.typography.headlineSmall) + Text( + text = "Démarrée ${formatDay(session.epochDay).lowercase()} · " + + "appuie pour reprendre", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } @Composable -private fun SessionCard(session: Session, onOpen: () -> Unit, onDelete: () -> Unit) { +private fun SessionRow(session: Session, onOpen: () -> Unit, onDelete: () -> Unit) { val kind = SessionKind.byId(session.kind) val title = when (kind) { SessionKind.GYM -> "Séance salle" @@ -69,45 +147,50 @@ private fun SessionCard(session: Session, onOpen: () -> Unit, onDelete: () -> Un modifier = Modifier .fillMaxWidth() .clickable(enabled = kind == SessionKind.GYM, onClick = onOpen), + shape = RoundedCornerShape(22.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), ) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), + modifier = Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically, ) { Column( modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp), ) { - Text( - text = title, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(text = title, style = MaterialTheme.typography.titleMedium) + Text( + text = formatDay(session.epochDay), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } Text( text = buildString { - append(formatDay(session.epochDay)) - if (session.durationMinutes > 0) append(" · ${session.durationMinutes} min") - if (session.caloriesBurned > 0) append(" · ${session.caloriesBurned} kcal") + if (session.durationMinutes > 0) append("${session.durationMinutes} min") + if (session.caloriesBurned > 0) { + if (isNotEmpty()) append(" · ") + append("${session.caloriesBurned} kcal") + } + if (session.note.isNotBlank()) { + if (isNotEmpty()) append(" · ") + append(session.note) + } }, - style = MaterialTheme.typography.bodyMedium, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) - if (session.note.isNotBlank()) { - Text(text = session.note, style = MaterialTheme.typography.bodySmall) - } - if (kind == SessionKind.GYM && !session.finished) { - AssistChip( - onClick = onOpen, - label = { Text("En cours") }, - colors = AssistChipDefaults.assistChipColors( - labelColor = MaterialTheme.colorScheme.primary, - ), - ) - } } IconButton(onClick = onDelete) { - Icon(Icons.Filled.Delete, contentDescription = "Supprimer la séance") + Icon( + Icons.Filled.Delete, + contentDescription = "Supprimer la séance", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } } diff --git a/app/src/main/java/com/kaz229/fittrack/ui/theme/Theme.kt b/app/src/main/java/com/kaz229/fittrack/ui/theme/Theme.kt index 84c3104..a313e87 100644 --- a/app/src/main/java/com/kaz229/fittrack/ui/theme/Theme.kt +++ b/app/src/main/java/com/kaz229/fittrack/ui/theme/Theme.kt @@ -40,6 +40,16 @@ object Bento { val darkScheme = darkColorScheme( primary = AcidGreen, onPrimary = DarkBg, + // Sans ces rôles, la barre de navigation et les boutons flottants retombent + // sur le violet par défaut de Material. + primaryContainer = AcidGreen, + onPrimaryContainer = DarkBg, + secondaryContainer = Color(0xFF2A3300), + onSecondaryContainer = AcidGreen, + secondary = AcidGreen, + onSecondary = DarkBg, + tertiary = AcidGreen, + onTertiary = DarkBg, background = DarkBg, onBackground = Color.White, surface = DarkCard, @@ -49,11 +59,28 @@ object Bento { outline = DarkBorder, outlineVariant = DarkTrack, error = Color(0xFFFF6B6B), + // La barre de navigation et les surfaces élevées tirent leur fond d'ici ; + // sans ces rôles, Material y applique sa teinte violette. + surfaceContainer = DarkCard, + surfaceContainerLow = DarkCard, + surfaceContainerHigh = Color(0xFF1E1E1E), + surfaceContainerHighest = Color(0xFF242424), + surfaceTint = Color.Transparent, + inverseSurface = Color.White, + inverseOnSurface = DarkBg, ) val lightScheme = lightColorScheme( primary = DeepGreen, onPrimary = Color.White, + primaryContainer = DeepGreen, + onPrimaryContainer = Color.White, + secondaryContainer = Color(0xFFE4EFC4), + onSecondaryContainer = Color(0xFF2E4000), + secondary = DeepGreen, + onSecondary = Color.White, + tertiary = DeepGreen, + onTertiary = Color.White, background = LightBg, onBackground = Color(0xFF121212), surface = LightCard, @@ -63,6 +90,13 @@ object Bento { outline = LightBorder, outlineVariant = LightTrack, error = Color(0xFFB3261E), + surfaceContainer = LightCard, + surfaceContainerLow = LightCard, + surfaceContainerHigh = Color(0xFFF7F7F3), + surfaceContainerHighest = Color(0xFFEFEFE9), + surfaceTint = Color.Transparent, + inverseSurface = Color(0xFF121212), + inverseOnSurface = Color.White, ) }