Etapp 2: aktiviteter — bibliotek, manuell loggning, kcal via dailySummary
Some checks failed
release / build-release (push) Failing after 1h2m7s

- GymApi: activityTypes/activities/add/update/delete + dailySummary
- Aktivitetsskärm (Hem → Aktiviteter ❤️): logga med typväljare grupperad per
  kategori, datum/tid-pickers, varaktighet, distans+höjdmeter för
  distansbaserade, RPE-slider (talk test) för tidsbaserade; historik med
  tempo och kcal; rätta/ta bort
- Kcal-mätaren använder serverns dailySummary (gym + aktiviteter + BMR) med
  fallback till lokal beräkning mot äldre server
- Version 0.6.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-23 19:29:43 +02:00
parent 06b2f0ec86
commit b8bdeddf4c
7 changed files with 747 additions and 2 deletions

View File

@@ -3,9 +3,50 @@ package eu.brassepc.fitnessdroid.data
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.put
@Serializable
data class ActivityType(
val id: Int,
val key: String,
val nameSv: String,
val nameEn: String = "",
val met: Double = 4.0,
val category: String = "OTHER",
val isDistanceBased: Boolean = false,
val isCardio: Boolean = false,
val iconKey: String? = null,
)
@Serializable
data class Activity(
val id: String,
val startedAt: String,
val durationSeconds: Int,
val distanceMeters: Double? = null,
val elevationGainMeters: Double? = null,
val estimatedKcal: Double? = null,
val rpe: Double? = null,
val source: String = "manual",
val notes: String? = null,
val activityType: ActivityType,
)
@Serializable
data class DailySummary(
val gymKcal: Double = 0.0,
val activityKcal: Double = 0.0,
val activeKcal: Double = 0.0,
val gymSessionCount: Int = 0,
val activityCount: Int = 0,
val distanceMeters: Double = 0.0,
val bmrKcalPerDay: Double? = null,
val phoneKcal: Double? = null,
val steps: Int? = null,
)
@Serializable
data class Profile(
val username: String,
@@ -51,7 +92,114 @@ class GymApi(
return client.json.decodeFromJsonElement(profile)
}
/* ---------- Aktiviteter ---------- */
suspend fun activityTypes(): List<ActivityType> {
val data = client.execute(
"query{activityTypes{id key nameSv nameEn met category isDistanceBased isCardio iconKey}}",
token = auth.bearerToken(),
)
return data["activityTypes"]!!.jsonArray.map { client.json.decodeFromJsonElement<ActivityType>(it) }
}
suspend fun activities(limit: Int = 50): List<Activity> {
val data = client.execute(
"query(\$l:Int){activities(limit:\$l){$ACTIVITY_FIELDS}}",
buildJsonObject { put("l", limit) },
auth.bearerToken(),
)
return data["activities"]!!.jsonArray.map { client.json.decodeFromJsonElement<Activity>(it) }
}
/**
* Logga aktivitet. Servern beräknar kcal (MET/tempo/RPE) om estimatedKcal
* utelämnas. null = utelämna fält.
*/
suspend fun addActivity(
activityTypeId: Int,
startedAtIso: String,
durationSeconds: Int,
distanceMeters: Double? = null,
elevationGainMeters: Double? = null,
rpe: Double? = null,
estimatedKcal: Double? = null,
source: String? = null,
routePolyline: String? = null,
notes: String? = null,
): Activity {
val data = client.execute(
"mutation(\$input: AddActivityInput!){addActivity(input:\$input){$ACTIVITY_FIELDS}}",
buildJsonObject {
put("input", buildJsonObject {
put("activityTypeId", activityTypeId)
put("startedAt", startedAtIso)
put("durationSeconds", durationSeconds)
distanceMeters?.let { put("distanceMeters", it) }
elevationGainMeters?.let { put("elevationGainMeters", it) }
rpe?.let { put("rpe", it) }
estimatedKcal?.let { put("estimatedKcal", it) }
source?.let { put("source", it) }
routePolyline?.let { put("routePolyline", it) }
notes?.let { put("notes", it) }
})
},
auth.bearerToken(),
)
return client.json.decodeFromJsonElement(data["addActivity"]!!)
}
/** null = rör ej, -1/"" = rensa (serverns konvention). */
suspend fun updateActivity(
id: String,
activityTypeId: Int? = null,
startedAtIso: String? = null,
durationSeconds: Int? = null,
distanceMeters: Double? = null,
elevationGainMeters: Double? = null,
rpe: Double? = null,
notes: String? = null,
): Activity {
val data = client.execute(
"mutation(\$input: UpdateActivityInput!){updateActivity(input:\$input){$ACTIVITY_FIELDS}}",
buildJsonObject {
put("input", buildJsonObject {
put("id", id)
activityTypeId?.let { put("activityTypeId", it) }
startedAtIso?.let { put("startedAt", it) }
durationSeconds?.let { put("durationSeconds", it) }
distanceMeters?.let { put("distanceMeters", it) }
elevationGainMeters?.let { put("elevationGainMeters", it) }
rpe?.let { put("rpe", it) }
notes?.let { put("notes", it) }
})
},
auth.bearerToken(),
)
return client.json.decodeFromJsonElement(data["updateActivity"]!!)
}
suspend fun deleteActivity(id: String) {
client.execute(
"mutation(\$id: UUID!){deleteActivity(id:\$id)}",
buildJsonObject { put("id", id) },
auth.bearerToken(),
)
}
/** Dagens sammanställning; [dayStartIso] = lokala midnatt som instant. */
suspend fun dailySummary(dayStartIso: String): DailySummary {
val data = client.execute(
"query(\$d:DateTime){dailySummary(date:\$d){gymKcal activityKcal activeKcal gymSessionCount activityCount distanceMeters bmrKcalPerDay phoneKcal steps}}",
buildJsonObject { put("d", dayStartIso) },
auth.bearerToken(),
)
return client.json.decodeFromJsonElement(data["dailySummary"]!!)
}
companion object {
private const val ACTIVITY_FIELDS =
"id startedAt durationSeconds distanceMeters elevationGainMeters estimatedKcal rpe source notes " +
"activityType{id key nameSv nameEn met category isDistanceBased isCardio iconKey}"
private const val MY_PROFILE =
"query{myProfile{username displayName email bodyWeightKg heightCm birthYear sex}}"
private const val MY_PROFILE_LEGACY =

View File

@@ -37,6 +37,7 @@ import androidx.navigation.compose.currentBackStackEntryAsState
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.history.HistoryDetailScreen
import eu.brassepc.fitnessdroid.ui.history.HistoryScreen
import eu.brassepc.fitnessdroid.ui.home.HomeScreen
@@ -60,6 +61,7 @@ object Routes {
const val SCALE = "scale"
const val WEIGH = "weigh"
const val ABOUT = "about"
const val ACTIVITIES = "activities"
fun historyDetail(id: Int) = "history/$id"
}
@@ -111,8 +113,12 @@ fun AppRoot(rootViewModel: RootViewModel = viewModel(factory = RootViewModel.Fac
HomeScreen(
onOpenSession = { nav.navigate(Routes.SESSION) },
onOpenHistory = { nav.navigate(Routes.HISTORY) },
onOpenActivities = { nav.navigate(Routes.ACTIVITIES) },
)
}
composable(Routes.ACTIVITIES) {
ActivitiesScreen(onBack = { nav.popBackStack() })
}
composable(Routes.HISTORY) {
HistoryScreen(onOpenSession = { id -> nav.navigate(Routes.historyDetail(id)) })
}

View File

@@ -0,0 +1,546 @@
package eu.brassepc.fitnessdroid.ui.activities
import androidx.compose.foundation.clickable
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
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.Add
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.font.FontWeight
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.Activity
import eu.brassepc.fitnessdroid.data.ActivityType
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.LocalDateTime
import java.time.ZoneId
val CATEGORY_LABELS = linkedMapOf(
"WALK" to "Promenad",
"RUN" to "Löpning",
"HIKE" to "Vandring",
"CYCLE" to "Cykling",
"SWIM" to "Simning",
"WATER" to "Vatten",
"CARDIO" to "Kondition",
"FITNESS" to "Rörlighet",
"STRENGTH" to "Styrka",
"MARTIAL_ARTS" to "Kampsport & fäktning",
"SPORT" to "Sport",
"WINTER" to "Vinter",
"OTHER" to "Övrigt",
)
val RPE_LABELS = listOf(
"Ingen ansträngning", "Väldigt lätt", "Lätt", "Lättmåttlig", "Måttlig",
"Normal för aktiviteten", "Något ansträngande", "Ansträngande",
"Mycket ansträngande", "Nästan max", "Kan inte prata mer än ett par ord",
)
class ActivitiesViewModel(private val gymApi: GymApi) : ViewModel() {
val types = MutableStateFlow<List<ActivityType>>(emptyList())
val activities = MutableStateFlow<List<Activity>>(emptyList())
val message = MutableStateFlow<String?>(null)
init { load() }
fun load() {
viewModelScope.launch {
runCatching { types.value = gymApi.activityTypes() }
.onFailure { message.value = "Kunde inte hämta aktivitetsbiblioteket — offline?" }
runCatching { activities.value = gymApi.activities() }
}
}
fun save(
editingId: String?,
type: ActivityType,
startedAt: LocalDateTime,
durationSeconds: Int,
distanceKm: Double?,
elevation: Double?,
rpe: Double?,
notes: String?,
onDone: () -> Unit,
) {
viewModelScope.launch {
try {
val startedIso = startedAt.atZone(ZoneId.systemDefault()).toInstant().toString()
if (editingId == null) {
gymApi.addActivity(
activityTypeId = type.id,
startedAtIso = startedIso,
durationSeconds = durationSeconds,
distanceMeters = distanceKm?.times(1000),
elevationGainMeters = elevation,
rpe = rpe,
notes = notes,
)
message.value = "Aktiviteten loggad"
} else {
// -1/"" = rensa på servern
gymApi.updateActivity(
id = editingId,
activityTypeId = type.id,
startedAtIso = startedIso,
durationSeconds = durationSeconds,
distanceMeters = distanceKm?.times(1000) ?: -1.0,
elevationGainMeters = elevation ?: -1.0,
rpe = rpe ?: -1.0,
notes = notes ?: "",
)
message.value = "Aktiviteten uppdaterad"
}
load()
onDone()
} catch (e: Exception) {
message.value = "Kunde inte spara — offline?"
}
}
}
fun delete(id: String, onDone: () -> Unit) {
viewModelScope.launch {
try {
gymApi.deleteActivity(id)
message.value = "Aktiviteten borttagen"
load()
onDone()
} catch (e: Exception) {
message.value = "Kunde inte ta bort — offline?"
}
}
}
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer { ActivitiesViewModel(appContainer().gymApi) }
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ActivitiesScreen(
onBack: () -> Unit,
viewModel: ActivitiesViewModel = viewModel(factory = ActivitiesViewModel.Factory),
) {
val types by viewModel.types.collectAsStateWithLifecycle()
val activities by viewModel.activities.collectAsStateWithLifecycle()
val message by viewModel.message.collectAsStateWithLifecycle()
var showLog by remember { mutableStateOf(false) }
var editTarget by remember { mutableStateOf<Activity?>(null) }
if (showLog || editTarget != null) {
LogActivityDialog(
types = types,
editing = editTarget,
onSave = { type, start, dur, dist, elev, rpe, notes ->
viewModel.save(editTarget?.id, type, start, dur, dist, elev, rpe, notes) {
showLog = false; editTarget = null
}
},
onDelete = editTarget?.let { a -> { viewModel.delete(a.id) { editTarget = null } } },
onDismiss = { showLog = false; editTarget = null },
)
}
Scaffold(
topBar = {
TopAppBar(
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.Favorite,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
Text("Aktiviteter", modifier = Modifier.padding(start = 8.dp))
}
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Tillbaka")
}
},
)
},
floatingActionButton = {
ExtendedFloatingActionButton(onClick = { showLog = true }) {
Icon(Icons.Default.Add, contentDescription = null)
Text("Logga aktivitet", modifier = Modifier.padding(start = 6.dp))
}
},
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
message?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (activities.isEmpty()) {
Text(
"Inga aktiviteter loggade än. Tryck på Logga aktivitet — " +
"promenader, löprundor, fäktning och annat du gör över tid.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 12.dp),
)
}
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(activities, key = { it.id }) { a ->
ActivityRow(a, onClick = { editTarget = a })
}
}
}
}
}
@Composable
private fun ActivityRow(a: Activity, onClick: () -> Unit) {
Card(modifier = Modifier.fillMaxWidth().clickable(onClick = onClick)) {
Row(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (a.activityType.isCardio) {
Icon(
Icons.Default.Favorite,
contentDescription = "Kondition",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(end = 10.dp),
)
}
Column(modifier = Modifier.weight(1f)) {
Text(a.activityType.nameSv, style = MaterialTheme.typography.titleSmall)
Text(
buildString {
append(a.startedAt.take(10))
append(" · ${formatDuration(a.durationSeconds)}")
a.distanceMeters?.let {
append(" · ${(it / 1000).compact()} km")
val kmh = (it / 1000) / (a.durationSeconds / 3600.0)
append(" · ${kmh.compact()} km/h")
}
a.rpe?.let { append(" · RPE ${it.toInt()}") }
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
a.estimatedKcal?.let {
Text(
"${it.toInt()} kcal",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
)
}
}
}
}
private fun formatDuration(sec: Int): String {
val h = sec / 3600
val m = (sec % 3600) / 60
return if (h > 0) "$h h $m min" else "$m min"
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun LogActivityDialog(
types: List<ActivityType>,
editing: Activity?,
onSave: (ActivityType, LocalDateTime, Int, Double?, Double?, Double?, String?) -> Unit,
onDelete: (() -> Unit)?,
onDismiss: () -> Unit,
) {
var selectedType by remember {
mutableStateOf(
editing?.activityType
?: types.find { it.key == "walking_city" }
?: types.firstOrNull()
)
}
var showTypePicker by remember { mutableStateOf(false) }
var date by remember {
mutableStateOf(
editing?.let { runCatching { java.time.OffsetDateTime.parse(it.startedAt).atZoneSameInstant(ZoneId.systemDefault()).toLocalDate() }.getOrNull() }
?: java.time.LocalDate.now()
)
}
var time by remember {
mutableStateOf(
editing?.let { runCatching { java.time.OffsetDateTime.parse(it.startedAt).atZoneSameInstant(ZoneId.systemDefault()).toLocalTime().withSecond(0).withNano(0) }.getOrNull() }
?: java.time.LocalTime.now().minusHours(1).withSecond(0).withNano(0)
)
}
var hours by remember { mutableStateOf(editing?.let { (it.durationSeconds / 3600).takeIf { h -> h > 0 }?.toString() } ?: "") }
var minutes by remember { mutableStateOf(editing?.let { ((it.durationSeconds % 3600) / 60).toString() } ?: "") }
var distance by remember { mutableStateOf(editing?.distanceMeters?.let { (it / 1000).compact() } ?: "") }
var elevation by remember { mutableStateOf(editing?.elevationGainMeters?.let { it.toInt().toString() } ?: "") }
var rpe by remember { mutableStateOf(editing?.rpe?.toFloat() ?: 5f) }
var notes by remember { mutableStateOf(editing?.notes ?: "") }
var showDatePicker by remember { mutableStateOf(false) }
var showTimePicker by remember { mutableStateOf(false) }
val durationSeconds = ((hours.toIntOrNull() ?: 0) * 3600) + ((minutes.toIntOrNull() ?: 0) * 60)
val canSave = selectedType != null && durationSeconds > 0
if (showTypePicker) {
AlertDialog(
onDismissRequest = { showTypePicker = false },
title = { Text("Välj aktivitet") },
text = {
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
CATEGORY_LABELS.forEach { (cat, label) ->
val inCat = types.filter { it.category == cat }
if (inCat.isNotEmpty()) {
Text(
label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 8.dp),
)
inCat.forEach { t ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
selectedType = t
showTypePicker = false
}
.padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (t.isCardio) {
Icon(
Icons.Default.Favorite,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(end = 8.dp),
)
}
Text(t.nameSv)
}
}
}
}
}
},
confirmButton = {
TextButton(onClick = { showTypePicker = false }) { Text("Stäng") }
},
)
return
}
if (showDatePicker) {
val dateState = androidx.compose.material3.rememberDatePickerState(
initialSelectedDateMillis = date.atStartOfDay(java.time.ZoneOffset.UTC).toInstant().toEpochMilli(),
)
androidx.compose.material3.DatePickerDialog(
onDismissRequest = { showDatePicker = false },
confirmButton = {
TextButton(onClick = {
dateState.selectedDateMillis?.let {
date = java.time.Instant.ofEpochMilli(it).atZone(java.time.ZoneOffset.UTC).toLocalDate()
}
showDatePicker = false
}) { Text("OK") }
},
dismissButton = { TextButton(onClick = { showDatePicker = false }) { Text("Avbryt") } },
) { androidx.compose.material3.DatePicker(state = dateState) }
}
if (showTimePicker) {
val timeState = androidx.compose.material3.rememberTimePickerState(
initialHour = time.hour, initialMinute = time.minute, is24Hour = true,
)
AlertDialog(
onDismissRequest = { showTimePicker = false },
title = { Text("Starttid") },
text = { androidx.compose.material3.TimePicker(state = timeState) },
confirmButton = {
TextButton(onClick = {
time = java.time.LocalTime.of(timeState.hour, timeState.minute)
showTimePicker = false
}) { Text("OK") }
},
dismissButton = { TextButton(onClick = { showTimePicker = false }) { Text("Avbryt") } },
)
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(if (editing == null) "Logga aktivitet" else "Rätta aktivitet") },
text = {
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
androidx.compose.material3.FilledTonalButton(
onClick = { showTypePicker = true },
modifier = Modifier.fillMaxWidth(),
) {
if (selectedType?.isCardio == true) {
Icon(
Icons.Default.Favorite,
contentDescription = null,
modifier = Modifier.padding(end = 6.dp),
)
}
Text(selectedType?.nameSv ?: "Välj aktivitet…")
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
androidx.compose.material3.OutlinedButton(
onClick = { showDatePicker = true },
modifier = Modifier.weight(1f),
) { Text("$date") }
androidx.compose.material3.OutlinedButton(
onClick = { showTimePicker = true },
modifier = Modifier.weight(1f),
) { Text("%02d:%02d".format(time.hour, time.minute)) }
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = hours,
onValueChange = { hours = it },
label = { Text("Timmar") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(1f),
)
OutlinedTextField(
value = minutes,
onValueChange = { minutes = it },
label = { Text("Minuter") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(1f),
)
}
if (selectedType?.isDistanceBased == true) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = distance,
onValueChange = { distance = it },
label = { Text("Distans (km)") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
modifier = Modifier.weight(1f),
)
OutlinedTextField(
value = elevation,
onValueChange = { elevation = it },
label = { Text("Höjdmeter") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(1f),
)
}
} else {
Text(
"Ansträngning: ${RPE_LABELS[rpe.toInt().coerceIn(0, 10)]}",
style = MaterialTheme.typography.labelMedium,
)
Slider(
value = rpe,
onValueChange = { rpe = it },
valueRange = 0f..10f,
steps = 9,
)
}
OutlinedTextField(
value = notes,
onValueChange = { notes = it },
label = { Text("Anteckning (valfritt)") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
onDelete?.let {
TextButton(onClick = it) {
Text("Ta bort aktiviteten", color = MaterialTheme.colorScheme.error)
}
}
}
},
confirmButton = {
Button(
onClick = {
val type = selectedType ?: return@Button
onSave(
type,
LocalDateTime.of(date, time),
durationSeconds,
if (type.isDistanceBased) distance.trim().replace(',', '.').toDoubleOrNull() else null,
if (type.isDistanceBased) elevation.trim().toDoubleOrNull() else null,
if (!type.isDistanceBased) rpe.toInt().toDouble() else null,
notes.trim().ifBlank { null },
)
},
enabled = canSave,
) { Text("Spara") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Avbryt") }
},
)
}

View File

@@ -17,6 +17,7 @@ import androidx.compose.material.icons.filled.Bedtime
import androidx.compose.material.icons.filled.CloudDone
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.LocalFireDepartment
import androidx.compose.material.icons.filled.PhoneAndroid
@@ -42,6 +43,7 @@ import eu.brassepc.fitnessdroid.data.SyncStatus
fun HomeScreen(
onOpenSession: () -> Unit,
onOpenHistory: () -> Unit,
onOpenActivities: () -> Unit = {},
viewModel: HomeViewModel = viewModel(factory = HomeViewModel.Factory),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
@@ -197,6 +199,31 @@ fun HomeScreen(
}
}
Card(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onOpenActivities),
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Default.Favorite,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) {
Text("Aktiviteter", style = MaterialTheme.typography.titleSmall)
Text(
"Promenad, löpning, fäktning — logga och se historik",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
AssistChip(
onClick = onOpenHistory,
label = { Text("Passhistorik") },

View File

@@ -91,6 +91,24 @@ class HomeViewModel(
}
private suspend fun refreshKcal() {
// Serverns dailySummary är sanningen (gym + aktiviteter + BMR).
// Fallback: räkna själva mot äldre server utan aktiviteter.
val summary = runCatching {
val dayStart = java.time.LocalDate.now().atStartOfDay(java.time.ZoneId.systemDefault())
gymApi.dailySummary(dayStart.toInstant().toString())
}.getOrNull()
if (summary != null) {
kcalToday.value = KcalToday(
activeKcal = summary.activeKcal,
passiveKcalSoFar = summary.bmrKcalPerDay?.let { it * secondsIntoToday() / 86_400.0 },
bmrPerDay = summary.bmrKcalPerDay,
phoneKcal = summary.phoneKcal,
hasBodyData = summary.bmrKcalPerDay != null,
)
return
}
val today = java.time.LocalDate.now()
val active = runCatching {
repo.fetchHistory(limit = 15)