Etapp 2: aktiviteter — bibliotek, manuell loggning, kcal via dailySummary
Some checks failed
release / build-release (push) Failing after 1h2m7s
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:
@@ -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 {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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)) })
|
||||
}
|
||||
|
||||
@@ -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<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") }
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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") },
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user