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/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..f0c91ae 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) { @@ -262,6 +265,7 @@ fun FitTrackApp(viewModel: FitTrackViewModel = viewModel()) { viewModel.selectExercise(exercise.id) navController.navigate(EXERCISE_DETAIL) }, + showHeader = true, ) } composable(Tab.MEALS.route) { @@ -341,6 +345,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/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/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/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/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/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 decba08..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 @@ -1,45 +1,161 @@ 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, + // 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, + onSurface = Color.White, + surfaceVariant = Color(0xFF1E1E1E), + onSurfaceVariant = DarkMuted, + 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, + onSurface = Color(0xFF121212), + surfaceVariant = Color(0xFFFBFBF9), + onSurfaceVariant = LightMuted, + 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, + ) +} + +/** + * 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.
  • +
+
+ + + +