diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cac4cd8..4381050 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.5.0" + (ciRunNumber?.let { "+build.$it" } ?: "-dev") + versionName = "0.6.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 da0abad..be9526c 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/data/GymApi.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/data/GymApi.kt @@ -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 { + 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(it) } + } + + suspend fun activities(limit: Int = 50): List { + 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(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 = 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 e03d007..cc4f4f0 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/ui/AppRoot.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/AppRoot.kt @@ -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)) }) } diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/activities/ActivitiesScreen.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/activities/ActivitiesScreen.kt new file mode 100644 index 0000000..c00f550 --- /dev/null +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/activities/ActivitiesScreen.kt @@ -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ätt–må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>(emptyList()) + val activities = MutableStateFlow>(emptyList()) + val message = MutableStateFlow(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(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, + 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") } + }, + ) +} 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 fc2967a..53724de 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 @@ -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") }, 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 13d9b12..8d7075e 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 @@ -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) diff --git a/doc/activities-plan.md b/doc/activities-plan.md index 928c2be..46f7958 100644 --- a/doc/activities-plan.md +++ b/doc/activities-plan.md @@ -100,7 +100,7 @@ frontend av `src/**` m.fl., FitnessDroid av varje push till main.) ## Status - [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)* -- [ ] Etapp 2 — Aktiviteter: bibliotek + manuell logg +- [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)* - [ ] Etapp 3 — Live-spårning (GPS) - [ ] Etapp 4 — Mål & mätare - [ ] Etapp 5 — Steg & telefondata