diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0661d13..8f3dd23 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -29,7 +29,7 @@ android { minSdk = 31 targetSdk = 35 versionCode = ciRunNumber ?: 1 - versionName = "0.8.4" + (ciRunNumber?.let { "+build.$it" } ?: "-dev") + versionName = "0.9.0" + (ciRunNumber?.let { "+build.$it" } ?: "-dev") } signingConfigs { diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/data/GymApi.kt b/app/src/main/java/eu/brassepc/fitnessdroid/data/GymApi.kt index be9526c..e738ed4 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/data/GymApi.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/data/GymApi.kt @@ -47,6 +47,15 @@ data class DailySummary( val steps: Int? = null, ) +@Serializable +data class GoalProgress( + val id: Int, + val metric: String, + val period: String, + val targetValue: Double, + val currentValue: Double, +) + @Serializable data class Profile( val username: String, @@ -196,6 +205,33 @@ class GymApi( return client.json.decodeFromJsonElement(data["dailySummary"]!!) } + /* ---------- Mål ---------- */ + + /** Mål med progress; klienten skickar sina lokala periodstarter som instants. */ + suspend fun goalsWithProgress( + dayStartIso: String, + weekStartIso: String, + monthStartIso: String, + ): List { + val data = client.execute( + "query(\$d:DateTime!,\$w:DateTime!,\$m:DateTime!){goalsWithProgress(dayStart:\$d,weekStart:\$w,monthStart:\$m){id metric period targetValue currentValue}}", + buildJsonObject { + put("d", dayStartIso); put("w", weekStartIso); put("m", monthStartIso) + }, + auth.bearerToken(), + ) + return data["goalsWithProgress"]!!.jsonArray.map { client.json.decodeFromJsonElement(it) } + } + + /** targetValue <= 0 tar bort målet. */ + suspend fun setGoal(metric: String, period: String, targetValue: Double) { + client.execute( + "mutation(\$m:String!,\$p:String!,\$t:Float!){setGoal(metric:\$m,period:\$p,targetValue:\$t)}", + buildJsonObject { put("m", metric); put("p", period); put("t", targetValue) }, + auth.bearerToken(), + ) + } + companion object { private const val ACTIVITY_FIELDS = "id startedAt durationSeconds distanceMeters elevationGainMeters estimatedKcal rpe source notes " + diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/AppRoot.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/AppRoot.kt index 12b5945..8cbaa28 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/ui/AppRoot.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/AppRoot.kt @@ -38,6 +38,7 @@ import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import eu.brassepc.fitnessdroid.ui.about.AboutScreen import eu.brassepc.fitnessdroid.ui.activities.ActivitiesScreen +import eu.brassepc.fitnessdroid.ui.goals.GoalsScreen import eu.brassepc.fitnessdroid.ui.history.HistoryDetailScreen import eu.brassepc.fitnessdroid.ui.history.HistoryScreen import eu.brassepc.fitnessdroid.ui.home.HomeScreen @@ -63,6 +64,7 @@ object Routes { const val WEIGH = "weigh" const val ABOUT = "about" const val ACTIVITIES = "activities" + const val GOALS = "goals" const val TRACK = "track/{typeId}" fun track(typeId: Int) = "track/$typeId" @@ -119,8 +121,12 @@ fun AppRoot(rootViewModel: RootViewModel = viewModel(factory = RootViewModel.Fac onOpenHistory = { nav.navigate(Routes.HISTORY) }, onOpenActivities = { nav.navigate(Routes.ACTIVITIES) }, onOpenTrack = { typeId -> nav.navigate(Routes.track(typeId)) }, + onOpenGoals = { nav.navigate(Routes.GOALS) }, ) } + composable(Routes.GOALS) { + GoalsScreen(onBack = { nav.popBackStack() }) + } composable(Routes.ACTIVITIES) { ActivitiesScreen( onBack = { nav.popBackStack() }, diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/goals/GoalsScreen.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/goals/GoalsScreen.kt new file mode 100644 index 0000000..df30b60 --- /dev/null +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/goals/GoalsScreen.kt @@ -0,0 +1,280 @@ +package eu.brassepc.fitnessdroid.ui.goals + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Flag +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import eu.brassepc.fitnessdroid.data.GoalProgress +import eu.brassepc.fitnessdroid.data.GymApi +import eu.brassepc.fitnessdroid.ui.appContainer +import eu.brassepc.fitnessdroid.ui.common.compact +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.ZoneId + +val GOAL_METRIC_LABELS = linkedMapOf( + "KCAL" to "Aktiva kalorier", + "GYM_SESSIONS" to "Gympass", + "ACTIVITIES" to "Aktiviteter", + "DISTANCE_KM" to "Distans (km)", + "STEPS" to "Steg", +) + +val GOAL_PERIOD_LABELS = linkedMapOf( + "DAY" to "Per dag", + "WEEK" to "Per vecka", + "MONTH" to "Per månad", +) + +fun goalValueText(value: Double, metric: String): String = + if (metric == "DISTANCE_KM") "${value.compact()} km" else "${value.toInt()}" + +class GoalsViewModel(private val gymApi: GymApi) : ViewModel() { + val goals = MutableStateFlow>(emptyList()) + val message = MutableStateFlow(null) + + init { load() } + + fun load() { + viewModelScope.launch { + runCatching { + val zone = ZoneId.systemDefault() + val today = LocalDate.now() + goals.value = gymApi.goalsWithProgress( + today.atStartOfDay(zone).toInstant().toString(), + today.with(DayOfWeek.MONDAY).atStartOfDay(zone).toInstant().toString(), + today.withDayOfMonth(1).atStartOfDay(zone).toInstant().toString(), + ) + }.onFailure { message.value = "Kunde inte hämta målen — offline?" } + } + } + + fun setGoal(metric: String, period: String, target: Double) { + viewModelScope.launch { + try { + gymApi.setGoal(metric, period, target) + message.value = if (target <= 0) "Målet borttaget" else "Målet sparat" + load() + } catch (e: Exception) { + message.value = "Kunde inte spara — offline?" + } + } + } + + companion object { + val Factory: ViewModelProvider.Factory = viewModelFactory { + initializer { GoalsViewModel(appContainer().gymApi) } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun GoalsScreen( + onBack: () -> Unit, + viewModel: GoalsViewModel = viewModel(factory = GoalsViewModel.Factory), +) { + val goals by viewModel.goals.collectAsStateWithLifecycle() + val message by viewModel.message.collectAsStateWithLifecycle() + + var metric by remember { mutableStateOf("KCAL") } + var period by remember { mutableStateOf("DAY") } + var target by remember { mutableStateOf("") } + + Scaffold( + topBar = { + TopAppBar( + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.Flag, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + Text("Mål", modifier = Modifier.padding(start = 8.dp)) + } + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Tillbaka") + } + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + message?.let { + Text( + it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + if (goals.isNotEmpty()) { + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + goals.forEach { g -> + GoalRow(g, onRemove = { viewModel.setGoal(g.metric, g.period, 0.0) }) + } + } + } + } else { + Text( + "Inga mål satta än. Sätt t.ex. 10 000 steg per dag eller 3 gympass per vecka.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text("Sätt mål", style = MaterialTheme.typography.titleSmall) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + GOAL_METRIC_LABELS.forEach { (key, label) -> + if (key in listOf("KCAL", "GYM_SESSIONS")) { + FilterChip( + selected = metric == key, + onClick = { metric = key }, + label = { Text(label) }, + ) + } + } + } + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + GOAL_METRIC_LABELS.forEach { (key, label) -> + if (key in listOf("ACTIVITIES", "DISTANCE_KM", "STEPS")) { + FilterChip( + selected = metric == key, + onClick = { metric = key }, + label = { Text(label) }, + ) + } + } + } + if (metric == "STEPS") { + Text( + "Stegdata kommer i en senare version (Health Connect) — målet " + + "kan sättas redan nu men står på 0 tills dess.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + GOAL_PERIOD_LABELS.forEach { (key, label) -> + FilterChip( + selected = period == key, + onClick = { period = key }, + label = { Text(label) }, + ) + } + } + OutlinedTextField( + value = target, + onValueChange = { target = it }, + label = { Text("Målvärde") }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + modifier = Modifier.fillMaxWidth(), + ) + Button( + onClick = { + target.trim().replace(',', '.').toDoubleOrNull()?.let { + viewModel.setGoal(metric, period, it) + target = "" + } + }, + enabled = target.trim().replace(',', '.').toDoubleOrNull()?.let { it > 0 } == true, + modifier = Modifier.fillMaxWidth(), + ) { Text("Spara målet") } + } + } + } + } +} + +@Composable +fun GoalRow(g: GoalProgress, onRemove: (() -> Unit)? = null) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "${GOAL_METRIC_LABELS[g.metric] ?: g.metric} · ${ + (GOAL_PERIOD_LABELS[g.period] ?: g.period).lowercase() + }", + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.weight(1f), + ) + Text( + "${goalValueText(g.currentValue, g.metric)} / ${goalValueText(g.targetValue, g.metric)}", + style = MaterialTheme.typography.bodySmall, + color = if (g.currentValue >= g.targetValue) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant, + ) + onRemove?.let { + IconButton(onClick = it) { + Icon( + Icons.Default.Delete, + contentDescription = "Ta bort mål", + tint = MaterialTheme.colorScheme.error, + ) + } + } + } + LinearProgressIndicator( + progress = { (g.currentValue / g.targetValue).toFloat().coerceIn(0f, 1f) }, + modifier = Modifier.fillMaxWidth(), + ) + } +} diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/home/HomeScreen.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/home/HomeScreen.kt index ca6cabd..cdfc405 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/ui/home/HomeScreen.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/home/HomeScreen.kt @@ -19,6 +19,7 @@ import androidx.compose.material.icons.filled.CloudOff import androidx.compose.material.icons.filled.CloudUpload import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.FitnessCenter +import androidx.compose.material.icons.filled.Flag import androidx.compose.material.icons.filled.LocalFireDepartment import androidx.compose.material.icons.filled.PhoneAndroid import androidx.compose.material.icons.filled.PlayArrow @@ -45,12 +46,14 @@ fun HomeScreen( onOpenHistory: () -> Unit, onOpenActivities: () -> Unit = {}, onOpenTrack: (Int) -> Unit = {}, + onOpenGoals: () -> Unit = {}, viewModel: HomeViewModel = viewModel(factory = HomeViewModel.Factory), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val updateState by viewModel.updateState.collectAsStateWithLifecycle() val displayName by viewModel.displayName.collectAsStateWithLifecycle() val kcalToday by viewModel.kcalToday.collectAsStateWithLifecycle() + val goals by viewModel.goals.collectAsStateWithLifecycle() // ViewModellen överlever i navigationsstacken — hämta om dagens kcal // varje gång skärmen kommer tillbaka (t.ex. efter loggad aktivitet). @@ -80,6 +83,43 @@ fun HomeScreen( KcalTodayCard(kcalToday) + Card( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onOpenGoals), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.Flag, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + Text( + "Mål", + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.weight(1f).padding(start = 8.dp), + ) + Text( + if (goals.isEmpty()) "Sätt mål →" else "Ändra →", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + goals.forEach { g -> eu.brassepc.fitnessdroid.ui.goals.GoalRow(g) } + if (goals.isEmpty()) { + Text( + "Kalorier, pass, aktiviteter eller km — per dag, vecka eller månad.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + val trackingState by eu.brassepc.fitnessdroid.data.TrackingService.state.collectAsStateWithLifecycle() if (trackingState.isActive) { Card( diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/home/HomeViewModel.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/home/HomeViewModel.kt index 53ebf57..2f267dd 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/ui/home/HomeViewModel.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/home/HomeViewModel.kt @@ -62,12 +62,14 @@ class HomeViewModel( val displayName = kotlinx.coroutines.flow.MutableStateFlow(null) val kcalToday = kotlinx.coroutines.flow.MutableStateFlow(KcalToday()) + val goals = kotlinx.coroutines.flow.MutableStateFlow>(emptyList()) init { viewModelScope.launch { displayName.value = repo.displayName() updateChecker.check()?.let { updateState.value = UpdateState.Available(it) } } + viewModelScope.launch { refreshGoals() } viewModelScope.launch { refreshKcal() // Passivdelen tickar med tiden — räkna om lokalt varje minut @@ -82,6 +84,18 @@ class HomeViewModel( /** Uppdatera mätaren — anropas när hemskärmen visas (igen). */ fun refresh() { viewModelScope.launch { refreshKcal() } + viewModelScope.launch { refreshGoals() } + } + + private suspend fun refreshGoals() { + runCatching { + val zone = java.time.ZoneId.systemDefault() + val today = java.time.LocalDate.now() + val day = today.atStartOfDay(zone).toInstant().toString() + val week = today.with(java.time.DayOfWeek.MONDAY).atStartOfDay(zone).toInstant().toString() + val month = today.withDayOfMonth(1).atStartOfDay(zone).toInstant().toString() + goals.value = gymApi.goalsWithProgress(day, week, month) + } } private fun secondsIntoToday(): Long = java.time.Duration.between( diff --git a/doc/activities-plan.md b/doc/activities-plan.md index d35697c..3521f38 100644 --- a/doc/activities-plan.md +++ b/doc/activities-plan.md @@ -101,8 +101,8 @@ frontend av `src/**` m.fl., FitnessDroid av varje push till main.) - [x] Etapp 1 — Profil & kalorigrund *(kod klar 2026-08-23: backend a1cd4d3, webb 560894c, app — väntar på användartest + omdeploy av gym-api/frontend-containrarna)* - [x] Etapp 2 — Aktiviteter: bibliotek + manuell logg *(kod klar 2026-08-23: backend 9a81cf6, webb — aktivitetsflik, app 0.6.0; väntar på användartest)* -- [x] Etapp 3 — Live-spårning (GPS) *(kod klar; efter fälttest 2026-08-24/25: GPS-väntfas med starta ändå, offline-kö för aktiviteter, teknisk logg med delning, manifest-fix för platsbehörighet på Android 12+)* -- [ ] Etapp 4 — Mål & mätare +- [x] Etapp 3 — Live-spårning (GPS) *(klar & fälttestad 2026-08-26: GPS-väntfas, providerlyssning gps/fused/network, fartgrind (Doppler), rörelsevakt (accelerometer), offline-kö, teknisk logg — GPS-filter justerbara i inställningarna)* +- [x] Etapp 4 — Mål & mätare *(kod klar 2026-08-26: Goal-modell + goalsWithProgress i API:t, målsektion i webbens aktivitetsflik, målkort med progress-bars på appens hemskärm + målskärm; väntar på användartest)* - [ ] Etapp 5 — Steg & telefondata - [ ] Etapp 6 — Statistik & polish