Etapp 4: mål & progress-mätare på hemskärmen
All checks were successful
release / build-release (push) Successful in 6m52s
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:
@@ -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 {
|
||||
|
||||
@@ -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 " +
|
||||
|
||||
@@ -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() },
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user