Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
12 changes: 10 additions & 2 deletions app/src/main/java/com/kaz229/fittrack/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,24 @@ 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() {
override fun onCreate(savedInstanceState: Bundle?) {
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)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ class FitTrackRepository(

val profile: Flow<UserProfile> = userPreferences.profile

val theme: Flow<ThemeChoice> = userPreferences.theme

suspend fun setTheme(choice: ThemeChoice) = userPreferences.setTheme(choice)

// --- Séances -------------------------------------------------------------

fun allSessions(): Flow<List<Session>> = sessionDao.observeAll()
Expand Down
18 changes: 18 additions & 0 deletions app/src/main/java/com/kaz229/fittrack/data/UserPreferences.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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<UserProfile> = context.dataStore.data.map { prefs ->
Expand Down Expand Up @@ -99,6 +111,12 @@ class UserPreferences(private val context: Context) {
return fresh
}

val theme: Flow<ThemeChoice> = 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<Long?> = context.dataStore.data.map { it[lastBackupKey] }

Expand Down
8 changes: 7 additions & 1 deletion app/src/main/java/com/kaz229/fittrack/ui/FitTrackApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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() }) {
Expand Down Expand Up @@ -240,6 +242,7 @@ fun FitTrackApp(viewModel: FitTrackViewModel = viewModel()) {
lastWeek = lastWeek,
volumeToday = volumeToday,
setCountToday = setCountToday,
today = viewModel.todayLabel,
)
}
composable(Tab.SESSIONS.route) {
Expand All @@ -262,6 +265,7 @@ fun FitTrackApp(viewModel: FitTrackViewModel = viewModel()) {
viewModel.selectExercise(exercise.id)
navController.navigate(EXERCISE_DETAIL)
},
showHeader = true,
)
}
composable(Tab.MEALS.route) {
Expand Down Expand Up @@ -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 ->
Expand Down
13 changes: 13 additions & 0 deletions app/src/main/java/com/kaz229/fittrack/ui/FitTrackViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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<List<Exercise>>(emptyList())
Expand Down Expand Up @@ -388,6 +396,11 @@ class FitTrackViewModel(application: Application) : AndroidViewModel(application

// --- Séances, repas, bilans ---------------------------------------------

val theme: StateFlow<ThemeChoice> = repository.theme
.stateIn(viewModelScope, SharingStarted.Eagerly, ThemeChoice.SYSTEM)

fun setTheme(choice: ThemeChoice) = viewModelScope.launch { repository.setTheme(choice) }

val profile: StateFlow<UserProfile> = repository.profile
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), UserProfile())

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading