Etapp 4: mål & progress-mätare på hemskärmen
All checks were successful
release / build-release (push) Successful in 6m52s

- Målkort på Hem: alla mål med progress-bars (aktuellt/mål), tryck →
  målskärmen med sätt/uppdatera/ta bort (metrik × period × värde)
- Metriker: aktiva kalorier, gympass, aktiviteter, distans km, steg
  (stegdata kommer i etapp 5); perioder: dag/vecka/månad
- GymApi: goalsWithProgress (lokala periodstarter som instants) + setGoal
- Version 0.9.0

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C2GA94E1f3cmrvdQLQhYdg
This commit is contained in:
2026-08-26 11:56:05 +02:00
parent 53e4f14b67
commit 49da9190b6
7 changed files with 379 additions and 3 deletions

View File

@@ -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 {

View File

@@ -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<GoalProgress> {
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<GoalProgress>(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 " +

View File

@@ -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() },

View File

@@ -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<List<GoalProgress>>(emptyList())
val message = MutableStateFlow<String?>(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(),
)
}
}

View File

@@ -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(

View File

@@ -62,12 +62,14 @@ class HomeViewModel(
val displayName = kotlinx.coroutines.flow.MutableStateFlow<String?>(null)
val kcalToday = kotlinx.coroutines.flow.MutableStateFlow(KcalToday())
val goals = kotlinx.coroutines.flow.MutableStateFlow<List<eu.brassepc.fitnessdroid.data.GoalProgress>>(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(