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
|
minSdk = 31
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = ciRunNumber ?: 1
|
versionCode = ciRunNumber ?: 1
|
||||||
versionName = "0.8.4" + (ciRunNumber?.let { "+build.$it" } ?: "-dev")
|
versionName = "0.9.0" + (ciRunNumber?.let { "+build.$it" } ?: "-dev")
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
|
|||||||
@@ -47,6 +47,15 @@ data class DailySummary(
|
|||||||
val steps: Int? = null,
|
val steps: Int? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class GoalProgress(
|
||||||
|
val id: Int,
|
||||||
|
val metric: String,
|
||||||
|
val period: String,
|
||||||
|
val targetValue: Double,
|
||||||
|
val currentValue: Double,
|
||||||
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Profile(
|
data class Profile(
|
||||||
val username: String,
|
val username: String,
|
||||||
@@ -196,6 +205,33 @@ class GymApi(
|
|||||||
return client.json.decodeFromJsonElement(data["dailySummary"]!!)
|
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 {
|
companion object {
|
||||||
private const val ACTIVITY_FIELDS =
|
private const val ACTIVITY_FIELDS =
|
||||||
"id startedAt durationSeconds distanceMeters elevationGainMeters estimatedKcal rpe source notes " +
|
"id startedAt durationSeconds distanceMeters elevationGainMeters estimatedKcal rpe source notes " +
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import androidx.navigation.compose.rememberNavController
|
|||||||
import androidx.navigation.navArgument
|
import androidx.navigation.navArgument
|
||||||
import eu.brassepc.fitnessdroid.ui.about.AboutScreen
|
import eu.brassepc.fitnessdroid.ui.about.AboutScreen
|
||||||
import eu.brassepc.fitnessdroid.ui.activities.ActivitiesScreen
|
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.HistoryDetailScreen
|
||||||
import eu.brassepc.fitnessdroid.ui.history.HistoryScreen
|
import eu.brassepc.fitnessdroid.ui.history.HistoryScreen
|
||||||
import eu.brassepc.fitnessdroid.ui.home.HomeScreen
|
import eu.brassepc.fitnessdroid.ui.home.HomeScreen
|
||||||
@@ -63,6 +64,7 @@ object Routes {
|
|||||||
const val WEIGH = "weigh"
|
const val WEIGH = "weigh"
|
||||||
const val ABOUT = "about"
|
const val ABOUT = "about"
|
||||||
const val ACTIVITIES = "activities"
|
const val ACTIVITIES = "activities"
|
||||||
|
const val GOALS = "goals"
|
||||||
const val TRACK = "track/{typeId}"
|
const val TRACK = "track/{typeId}"
|
||||||
|
|
||||||
fun track(typeId: Int) = "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) },
|
onOpenHistory = { nav.navigate(Routes.HISTORY) },
|
||||||
onOpenActivities = { nav.navigate(Routes.ACTIVITIES) },
|
onOpenActivities = { nav.navigate(Routes.ACTIVITIES) },
|
||||||
onOpenTrack = { typeId -> nav.navigate(Routes.track(typeId)) },
|
onOpenTrack = { typeId -> nav.navigate(Routes.track(typeId)) },
|
||||||
|
onOpenGoals = { nav.navigate(Routes.GOALS) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
composable(Routes.GOALS) {
|
||||||
|
GoalsScreen(onBack = { nav.popBackStack() })
|
||||||
|
}
|
||||||
composable(Routes.ACTIVITIES) {
|
composable(Routes.ACTIVITIES) {
|
||||||
ActivitiesScreen(
|
ActivitiesScreen(
|
||||||
onBack = { nav.popBackStack() },
|
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.CloudUpload
|
||||||
import androidx.compose.material.icons.filled.Favorite
|
import androidx.compose.material.icons.filled.Favorite
|
||||||
import androidx.compose.material.icons.filled.FitnessCenter
|
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.LocalFireDepartment
|
||||||
import androidx.compose.material.icons.filled.PhoneAndroid
|
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
@@ -45,12 +46,14 @@ fun HomeScreen(
|
|||||||
onOpenHistory: () -> Unit,
|
onOpenHistory: () -> Unit,
|
||||||
onOpenActivities: () -> Unit = {},
|
onOpenActivities: () -> Unit = {},
|
||||||
onOpenTrack: (Int) -> Unit = {},
|
onOpenTrack: (Int) -> Unit = {},
|
||||||
|
onOpenGoals: () -> Unit = {},
|
||||||
viewModel: HomeViewModel = viewModel(factory = HomeViewModel.Factory),
|
viewModel: HomeViewModel = viewModel(factory = HomeViewModel.Factory),
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val updateState by viewModel.updateState.collectAsStateWithLifecycle()
|
val updateState by viewModel.updateState.collectAsStateWithLifecycle()
|
||||||
val displayName by viewModel.displayName.collectAsStateWithLifecycle()
|
val displayName by viewModel.displayName.collectAsStateWithLifecycle()
|
||||||
val kcalToday by viewModel.kcalToday.collectAsStateWithLifecycle()
|
val kcalToday by viewModel.kcalToday.collectAsStateWithLifecycle()
|
||||||
|
val goals by viewModel.goals.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
// ViewModellen överlever i navigationsstacken — hämta om dagens kcal
|
// ViewModellen överlever i navigationsstacken — hämta om dagens kcal
|
||||||
// varje gång skärmen kommer tillbaka (t.ex. efter loggad aktivitet).
|
// varje gång skärmen kommer tillbaka (t.ex. efter loggad aktivitet).
|
||||||
@@ -80,6 +83,43 @@ fun HomeScreen(
|
|||||||
|
|
||||||
KcalTodayCard(kcalToday)
|
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()
|
val trackingState by eu.brassepc.fitnessdroid.data.TrackingService.state.collectAsStateWithLifecycle()
|
||||||
if (trackingState.isActive) {
|
if (trackingState.isActive) {
|
||||||
Card(
|
Card(
|
||||||
|
|||||||
@@ -62,12 +62,14 @@ class HomeViewModel(
|
|||||||
val displayName = kotlinx.coroutines.flow.MutableStateFlow<String?>(null)
|
val displayName = kotlinx.coroutines.flow.MutableStateFlow<String?>(null)
|
||||||
|
|
||||||
val kcalToday = kotlinx.coroutines.flow.MutableStateFlow(KcalToday())
|
val kcalToday = kotlinx.coroutines.flow.MutableStateFlow(KcalToday())
|
||||||
|
val goals = kotlinx.coroutines.flow.MutableStateFlow<List<eu.brassepc.fitnessdroid.data.GoalProgress>>(emptyList())
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
displayName.value = repo.displayName()
|
displayName.value = repo.displayName()
|
||||||
updateChecker.check()?.let { updateState.value = UpdateState.Available(it) }
|
updateChecker.check()?.let { updateState.value = UpdateState.Available(it) }
|
||||||
}
|
}
|
||||||
|
viewModelScope.launch { refreshGoals() }
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
refreshKcal()
|
refreshKcal()
|
||||||
// Passivdelen tickar med tiden — räkna om lokalt varje minut
|
// 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). */
|
/** Uppdatera mätaren — anropas när hemskärmen visas (igen). */
|
||||||
fun refresh() {
|
fun refresh() {
|
||||||
viewModelScope.launch { refreshKcal() }
|
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(
|
private fun secondsIntoToday(): Long = java.time.Duration.between(
|
||||||
|
|||||||
@@ -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 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 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+)*
|
- [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)*
|
||||||
- [ ] Etapp 4 — Mål & mätare
|
- [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 5 — Steg & telefondata
|
||||||
- [ ] Etapp 6 — Statistik & polish
|
- [ ] Etapp 6 — Statistik & polish
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user