BT-våg: anslut/väg dig-flöde + kroppsdata i profilen + Om appen

- ScaleManager: brygga app ↔ openScale-lagret (facades, factory, skanner,
  anslutning + händelseflöde)
- Inställningar → Bluetooth-våg: skanna BLE, visa vågar med drivrutinsstöd
  först, spara/ta bort vald våg (runtime-permissions för BLUETOOTH_SCAN/CONNECT)
- Profil: Väg dig-knapp (när våg finns sparad) → väg-skärm med livestatus,
  resultat (vikt + muskel/fett/vatten/ben/bukfett) och spara som kroppsmätning
- Profil: kroppsdata-dialog (längd/födelseår/kön) — skickas till impedansvågar
  som räknar sammansättning ombord (t.ex. Exingtech Y1)
- Om appen under Profil: version, GPL-3.0, attribution openScale + Blessed-Kotlin
- buildConfig på för versionsvisning

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 11:19:52 +02:00
parent 261755794b
commit f4612e05f0
9 changed files with 1065 additions and 4 deletions

View File

@@ -8,6 +8,7 @@ import eu.brassepc.fitnessdroid.data.GraphQlClient
import eu.brassepc.fitnessdroid.data.GymApi
import eu.brassepc.fitnessdroid.data.GymRepository
import eu.brassepc.fitnessdroid.data.RestTimerController
import eu.brassepc.fitnessdroid.data.ScaleManager
import eu.brassepc.fitnessdroid.data.SettingsStore
import eu.brassepc.fitnessdroid.data.SyncEngine
import eu.brassepc.fitnessdroid.data.TokenStore
@@ -31,6 +32,7 @@ class AppContainer(context: Context) {
val gymRepository = GymRepository(database, graphQlClient, authRepository, syncEngine, tokenStore, appScope)
val restTimer = RestTimerController(context, settingsStore, appScope)
val updateChecker = UpdateChecker(context)
val scaleManager = ScaleManager(context, settingsStore, appScope)
}
class FitnessDroidApplication : Application() {

View File

@@ -0,0 +1,110 @@
package eu.brassepc.fitnessdroid.data
import android.content.Context
import com.health.openscale.core.bluetooth.BluetoothEvent
import com.health.openscale.core.bluetooth.ScaleCommunicator
import com.health.openscale.core.bluetooth.ScaleFactory
import com.health.openscale.core.data.GenderType
import com.health.openscale.core.data.User
import com.health.openscale.core.facade.MeasurementFacade
import com.health.openscale.core.facade.SettingsFacade
import com.health.openscale.core.facade.UserFacade
import com.health.openscale.core.service.BluetoothScannerManager
import com.health.openscale.core.service.ScannedDeviceInfo
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import java.util.Calendar
/**
* Brygga mellan appen och de vendrade openScale-drivrutinerna.
* Äger facade-shims, [ScaleFactory] och skannern, och exponerar ett enkelt
* flöde för UI:t: skanna → spara våg → väg dig (events + mätresultat).
*/
class ScaleManager(
context: Context,
private val settingsStore: SettingsStore,
private val scope: CoroutineScope,
) {
private val appContext = context.applicationContext
private val settingsFacade = SettingsFacade(appContext)
private val userFacade = UserFacade()
private val measurementFacade = MeasurementFacade()
val factory = ScaleFactory(appContext, settingsFacade, measurementFacade, userFacade)
val scanner by lazy { BluetoothScannerManager(appContext, scope, factory) }
private var communicator: ScaleCommunicator? = null
private var eventJob: Job? = null
/** Senaste händelsen från vågen — UI:t visar status utifrån denna. */
private val _lastEvent = MutableStateFlow<BluetoothEvent?>(null)
val lastEvent: StateFlow<BluetoothEvent?> = _lastEvent.asStateFlow()
/**
* Uppdatera drivrutinernas användarprofil från appens inställningar.
* Impedansvågar (som Exingtech Y1) får kön/ålder/längd skrivna till sig
* och räknar ut kroppssammansättningen ombord.
*/
private suspend fun refreshScaleUser() {
val s = settingsStore.settings.first()
val birthMillis = s.birthYear?.let { year ->
Calendar.getInstance().apply { set(year, Calendar.JULY, 1, 12, 0, 0) }.timeInMillis
}
userFacade.setSelectedUser(
User(
id = 1,
name = "FitnessDroid",
birthDate = birthMillis,
heightCm = s.heightCm?.toFloat() ?: -1f,
gender = if (s.isFemale == true) GenderType.FEMALE else GenderType.MALE,
)
)
}
/** Har användaren fyllt i kroppsdatan som vågen behöver? */
suspend fun hasBodyData(): Boolean {
val s = settingsStore.settings.first()
return s.heightCm != null && s.birthYear != null && s.isFemale != null
}
/**
* Anslut till den sparade vågen och börja lyssna på händelser.
* Resultatet (inklusive [BluetoothEvent.MeasurementReceived]) kommer i [lastEvent].
* @return false om ingen våg är sparad eller ingen drivrutin matchar.
*/
suspend fun connectSavedScale(): Boolean {
val s = settingsStore.settings.first()
val address = s.scaleAddress ?: return false
val name = s.scaleName ?: ""
refreshScaleUser()
disconnect()
val info = ScannedDeviceInfo(name, address, 0, emptyList(), null)
val comm = factory.createCommunicator(info) ?: return false
communicator = comm
_lastEvent.value = null
eventJob = scope.launch {
comm.getEventsFlow().collect { _lastEvent.value = it }
}
comm.connect(address, null)
return true
}
fun disconnect() {
eventJob?.cancel()
eventJob = null
communicator?.let { comm ->
runCatching { comm.disconnect() }
runCatching { (comm as? AutoCloseable)?.close() }
}
communicator = null
}
}

View File

@@ -23,6 +23,15 @@ data class AppSettings(
val autoRelogin: Boolean = true,
/** Egna stångvikter i skivkalkylatorn (utöver snabbvalen 20/0 kg) */
val customBarWeights: List<Double> = listOf(15.0),
/** Sparad Bluetooth-våg (MAC-adress + BLE-namn + drivrutinens visningsnamn) */
val scaleAddress: String? = null,
val scaleName: String? = null,
val scaleDriver: String? = null,
/** Kroppsdata som impedansvågar behöver (skickas till vågen vid vägning) */
val heightCm: Double? = null,
val birthYear: Int? = null,
/** null = inte satt, false = man, true = kvinna */
val isFemale: Boolean? = null,
)
class SettingsStore(private val context: Context) {
@@ -43,9 +52,35 @@ class SettingsStore(private val context: Context) {
// migrera från gamla enkel-värdet
?: prefs[KEY_CUSTOM_BAR]?.toDoubleOrNull()?.let { listOf(it) }
?: listOf(15.0),
scaleAddress = prefs[KEY_SCALE_ADDRESS],
scaleName = prefs[KEY_SCALE_NAME],
scaleDriver = prefs[KEY_SCALE_DRIVER],
heightCm = prefs[KEY_HEIGHT_CM]?.toDoubleOrNull(),
birthYear = prefs[KEY_BIRTH_YEAR],
isFemale = prefs[KEY_IS_FEMALE]?.toBooleanStrictOrNull(),
)
}
suspend fun setScale(address: String?, name: String?, driver: String?) {
context.settingsDataStore.edit { prefs ->
if (address == null) {
prefs.remove(KEY_SCALE_ADDRESS); prefs.remove(KEY_SCALE_NAME); prefs.remove(KEY_SCALE_DRIVER)
} else {
prefs[KEY_SCALE_ADDRESS] = address
prefs[KEY_SCALE_NAME] = name ?: ""
prefs[KEY_SCALE_DRIVER] = driver ?: ""
}
}
}
suspend fun setBodyData(heightCm: Double?, birthYear: Int?, isFemale: Boolean?) {
context.settingsDataStore.edit { prefs ->
heightCm?.let { prefs[KEY_HEIGHT_CM] = it.toString() } ?: prefs.remove(KEY_HEIGHT_CM)
birthYear?.let { prefs[KEY_BIRTH_YEAR] = it } ?: prefs.remove(KEY_BIRTH_YEAR)
isFemale?.let { prefs[KEY_IS_FEMALE] = it.toString() } ?: prefs.remove(KEY_IS_FEMALE)
}
}
suspend fun addCustomBarWeight(value: Double) {
val v = value.coerceIn(0.1, 100.0)
context.settingsDataStore.edit { prefs ->
@@ -91,5 +126,11 @@ class SettingsStore(private val context: Context) {
private val KEY_AUTO_RELOGIN = stringPreferencesKey("auto_relogin")
private val KEY_CUSTOM_BAR = stringPreferencesKey("custom_bar_weight")
private val KEY_CUSTOM_BARS = stringPreferencesKey("custom_bar_weights")
private val KEY_SCALE_ADDRESS = stringPreferencesKey("scale_address")
private val KEY_SCALE_NAME = stringPreferencesKey("scale_name")
private val KEY_SCALE_DRIVER = stringPreferencesKey("scale_driver")
private val KEY_HEIGHT_CM = stringPreferencesKey("height_cm")
private val KEY_BIRTH_YEAR = intPreferencesKey("birth_year")
private val KEY_IS_FEMALE = stringPreferencesKey("is_female")
}
}

View File

@@ -36,11 +36,14 @@ import androidx.navigation.compose.composable
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.history.HistoryDetailScreen
import eu.brassepc.fitnessdroid.ui.history.HistoryScreen
import eu.brassepc.fitnessdroid.ui.home.HomeScreen
import eu.brassepc.fitnessdroid.ui.picker.ExercisePickerScreen
import eu.brassepc.fitnessdroid.ui.profile.ProfileScreen
import eu.brassepc.fitnessdroid.ui.scale.ScalePairScreen
import eu.brassepc.fitnessdroid.ui.scale.WeighScreen
import eu.brassepc.fitnessdroid.ui.session.SessionScreen
import eu.brassepc.fitnessdroid.ui.settings.SettingsScreen
import eu.brassepc.fitnessdroid.ui.stats.StatsScreen
@@ -54,6 +57,9 @@ object Routes {
const val SETTINGS = "settings"
const val SESSION = "session"
const val PICKER = "picker"
const val SCALE = "scale"
const val WEIGH = "weigh"
const val ABOUT = "about"
fun historyDetail(id: Int) = "history/$id"
}
@@ -121,9 +127,21 @@ fun AppRoot(rootViewModel: RootViewModel = viewModel(factory = RootViewModel.Fac
}
composable(Routes.STATS) { StatsScreen() }
composable(Routes.PROFILE) {
ProfileScreen(onOpenSettings = { nav.navigate(Routes.SETTINGS) })
ProfileScreen(
onOpenSettings = { nav.navigate(Routes.SETTINGS) },
onOpenWeigh = { nav.navigate(Routes.WEIGH) },
onOpenAbout = { nav.navigate(Routes.ABOUT) },
)
}
composable(Routes.SETTINGS) { SettingsScreen(onBack = { nav.popBackStack() }) }
composable(Routes.SETTINGS) {
SettingsScreen(
onBack = { nav.popBackStack() },
onOpenScale = { nav.navigate(Routes.SCALE) },
)
}
composable(Routes.SCALE) { ScalePairScreen(onBack = { nav.popBackStack() }) }
composable(Routes.WEIGH) { WeighScreen(onBack = { nav.popBackStack() }) }
composable(Routes.ABOUT) { AboutScreen(onBack = { nav.popBackStack() }) }
composable(Routes.SESSION) {
SessionScreen(
onBack = { nav.popBackStack() },

View File

@@ -0,0 +1,136 @@
package eu.brassepc.fitnessdroid.ui.about
import android.content.Intent
import android.net.Uri
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.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import eu.brassepc.fitnessdroid.BuildConfig
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AboutScreen(onBack: () -> Unit) {
val context = LocalContext.current
fun open(url: String) {
runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) }
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Om appen") },
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),
) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text("FitnessDroid", style = MaterialTheme.typography.titleMedium)
Text(
"Version ${BuildConfig.VERSION_NAME}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
"Fri programvara under GNU GPL v3. Du får använda, ändra och " +
"sprida appen vidare under samma licens.",
style = MaterialTheme.typography.bodyMedium,
)
LinkRow("Källkod (Gitea)", "https://gitea.brasse-pc.eu/brasse/FitnessDroid", ::open)
LinkRow("GNU GPL v3", "https://www.gnu.org/licenses/gpl-3.0.html", ::open)
}
}
Text("Öppen källkod som används", style = MaterialTheme.typography.titleSmall)
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text("openScale", style = MaterialTheme.typography.titleMedium)
Text(
"Bluetooth-drivrutinerna för kroppsvågar kommer från openScale " +
"av olie.xdev m.fl. (GPL-3.0). Stort tack!",
style = MaterialTheme.typography.bodyMedium,
)
LinkRow("github.com/oliexdev/openScale", "https://github.com/oliexdev/openScale", ::open)
}
}
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text("Blessed-Kotlin", style = MaterialTheme.typography.titleMedium)
Text(
"BLE-bibliotek av Martijn van Welie (MIT-licens).",
style = MaterialTheme.typography.bodyMedium,
)
LinkRow("github.com/weliem/blessed-kotlin", "https://github.com/weliem/blessed-kotlin", ::open)
}
}
}
}
}
@Composable
private fun LinkRow(label: String, url: String, open: (String) -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { open(url) }
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
label,
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
Icon(
Icons.AutoMirrored.Filled.OpenInNew,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
}

View File

@@ -12,6 +12,7 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Logout
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.MonitorWeight
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.AlertDialog
@@ -52,10 +53,16 @@ class ProfileViewModel(
private val gymApi: GymApi,
private val auth: AuthRepository,
private val repo: GymRepository,
private val settingsStore: eu.brassepc.fitnessdroid.data.SettingsStore,
) : ViewModel() {
val profile = MutableStateFlow<Profile?>(null)
val measurements = MutableStateFlow<List<BodyMeasurement>>(emptyList())
val message = MutableStateFlow<String?>(null)
val settings = settingsStore.settings
fun saveBodyData(heightCm: Double?, birthYear: Int?, isFemale: Boolean?) {
viewModelScope.launch { settingsStore.setBodyData(heightCm, birthYear, isFemale) }
}
init { load() }
@@ -115,7 +122,7 @@ class ProfileViewModel(
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val c = appContainer()
ProfileViewModel(c.gymApi, c.authRepository, c.gymRepository)
ProfileViewModel(c.gymApi, c.authRepository, c.gymRepository, c.settingsStore)
}
}
}
@@ -124,12 +131,18 @@ class ProfileViewModel(
@Composable
fun ProfileScreen(
onOpenSettings: () -> Unit,
onOpenWeigh: () -> Unit = {},
onOpenAbout: () -> Unit = {},
viewModel: ProfileViewModel = viewModel(factory = ProfileViewModel.Factory),
) {
val profile by viewModel.profile.collectAsStateWithLifecycle()
val measurements by viewModel.measurements.collectAsStateWithLifecycle()
val message by viewModel.message.collectAsStateWithLifecycle()
val settings by viewModel.settings.collectAsStateWithLifecycle(
eu.brassepc.fitnessdroid.data.AppSettings()
)
var showAddWeight by remember { mutableStateOf(false) }
var showBodyData by remember { mutableStateOf(false) }
var editTarget by remember { mutableStateOf<BodyMeasurement?>(null) }
if (showAddWeight) {
@@ -148,6 +161,19 @@ fun ProfileScreen(
)
}
if (showBodyData) {
BodyDataDialog(
initialHeight = settings.heightCm,
initialBirthYear = settings.birthYear,
initialIsFemale = settings.isFemale,
onSave = { h, y, f ->
viewModel.saveBodyData(h, y, f)
showBodyData = false
},
onDismiss = { showBodyData = false },
)
}
editTarget?.let { m ->
WeightDialog(
title = "Rätta mätning",
@@ -209,6 +235,15 @@ fun ProfileScreen(
}
Button(onClick = { showAddWeight = true }) { Text("Uppdatera") }
}
if (settings.scaleAddress != null) {
androidx.compose.material3.FilledTonalButton(
onClick = onOpenWeigh,
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Default.MonitorWeight, contentDescription = null)
Text("Väg dig med vågen", modifier = Modifier.padding(start = 8.dp))
}
}
message?.let {
Text(
it,
@@ -258,6 +293,35 @@ fun ProfileScreen(
}
}
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { showBodyData = true },
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("Kroppsdata", style = MaterialTheme.typography.titleSmall)
Text(
"Behövs för att vågen ska kunna räkna ut muskler/fett/vatten. " +
"Tryck för att ändra.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
ProfileRow("Längd", settings.heightCm?.let { "${it.compact()} cm" } ?: "Inte satt")
ProfileRow("Födelseår", settings.birthYear?.toString() ?: "Inte satt")
ProfileRow(
"Kön",
when (settings.isFemale) {
true -> "Kvinna"
false -> "Man"
null -> "Inte satt"
},
)
}
}
Card(
modifier = Modifier
.fillMaxWidth()
@@ -271,7 +335,28 @@ fun ProfileScreen(
Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) {
Text("Inställningar", style = MaterialTheme.typography.titleSmall)
Text(
"Vilotimer, viktsteg, stänger, inloggning",
"Vilotimer, viktsteg, stänger, våg, inloggning",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
Card(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onOpenAbout),
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(Icons.Default.Info, contentDescription = null)
Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) {
Text("Om appen", style = MaterialTheme.typography.titleSmall)
Text(
"Version, licens (GPL-3.0) och öppen källkod",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -409,6 +494,80 @@ private fun WeightDialog(
)
}
/** Längd/födelseår/kön — indata till BT-vågens kroppssammansättning. */
@Composable
private fun BodyDataDialog(
initialHeight: Double?,
initialBirthYear: Int?,
initialIsFemale: Boolean?,
onSave: (Double?, Int?, Boolean?) -> Unit,
onDismiss: () -> Unit,
) {
fun fmt(v: Double?) = v?.let { if (it % 1.0 == 0.0) it.toInt().toString() else it.toString() } ?: ""
var height by remember { mutableStateOf(fmt(initialHeight)) }
var birthYear by remember { mutableStateOf(initialBirthYear?.toString() ?: "") }
var isFemale by remember { mutableStateOf(initialIsFemale) }
val parsedHeight = height.trim().replace(',', '.').toDoubleOrNull()
val parsedYear = birthYear.trim().toIntOrNull()
val currentYear = java.time.LocalDate.now().year
val yearOk = parsedYear == null || parsedYear in 1900..currentYear
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Kroppsdata") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
"Skickas till vågen så att den kan räkna ut muskler, fett och vatten.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
value = height,
onValueChange = { height = it },
label = { Text("Längd (cm)") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = birthYear,
onValueChange = { birthYear = it },
label = { Text("Födelseår") },
singleLine = true,
isError = !yearOk,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
androidx.compose.material3.FilterChip(
selected = isFemale == false,
onClick = { isFemale = false },
label = { Text("Man") },
modifier = Modifier.weight(1f),
)
androidx.compose.material3.FilterChip(
selected = isFemale == true,
onClick = { isFemale = true },
label = { Text("Kvinna") },
modifier = Modifier.weight(1f),
)
}
}
},
confirmButton = {
Button(
onClick = { onSave(parsedHeight, parsedYear, isFemale) },
enabled = yearOk,
) { Text("Spara") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Avbryt") }
},
)
}
@Composable
private fun ProfileRow(label: String, value: String) {
Row(modifier = Modifier.fillMaxWidth()) {

View File

@@ -0,0 +1,569 @@
package eu.brassepc.fitnessdroid.ui.scale
import android.Manifest
import android.content.pm.PackageManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.layout.size
import androidx.compose.foundation.rememberScrollState
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.Bluetooth
import androidx.compose.material.icons.filled.BluetoothSearching
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.MonitorWeight
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
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.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
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 com.health.openscale.core.bluetooth.BluetoothEvent
import com.health.openscale.core.bluetooth.data.ScaleMeasurement
import com.health.openscale.core.service.ScannedDeviceInfo
import eu.brassepc.fitnessdroid.data.AppSettings
import eu.brassepc.fitnessdroid.data.GymRepository
import eu.brassepc.fitnessdroid.data.ScaleManager
import eu.brassepc.fitnessdroid.data.SettingsStore
import eu.brassepc.fitnessdroid.ui.appContainer
import eu.brassepc.fitnessdroid.ui.common.compact
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
/** Behörigheterna som BLE-skanning/anslutning kräver på Android 12+. */
val BLE_PERMISSIONS = arrayOf(
Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.BLUETOOTH_CONNECT,
)
/* ==================== Anslut våg (parning) ==================== */
class ScalePairViewModel(
private val scaleManager: ScaleManager,
private val settingsStore: SettingsStore,
) : ViewModel() {
val settings = settingsStore.settings
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), AppSettings())
val devices = scaleManager.scanner.scannedDevices
val isScanning = scaleManager.scanner.isScanning
val scanError = scaleManager.scanner.scanError
fun startScan() {
scaleManager.scanner.startScan(30_000)
}
fun stopScan() {
scaleManager.scanner.stopScan()
}
fun saveScale(device: ScannedDeviceInfo) {
viewModelScope.launch {
stopScan()
settingsStore.setScale(device.address, device.name, device.determinedHandlerDisplayName)
}
}
fun forgetScale() {
viewModelScope.launch { settingsStore.setScale(null, null, null) }
}
override fun onCleared() {
stopScan()
}
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val c = appContainer()
ScalePairViewModel(c.scaleManager, c.settingsStore)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ScalePairScreen(
onBack: () -> Unit,
viewModel: ScalePairViewModel = viewModel(factory = ScalePairViewModel.Factory),
) {
val settings by viewModel.settings.collectAsStateWithLifecycle()
val devices by viewModel.devices.collectAsStateWithLifecycle()
val isScanning by viewModel.isScanning.collectAsStateWithLifecycle()
val scanError by viewModel.scanError.collectAsStateWithLifecycle()
val context = LocalContext.current
var permissionDenied by remember { mutableStateOf(false) }
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { granted ->
if (granted.values.all { it }) viewModel.startScan() else permissionDenied = true
}
fun scanWithPermission() {
val missing = BLE_PERMISSIONS.any {
ContextCompat.checkSelfPermission(context, it) != PackageManager.PERMISSION_GRANTED
}
if (missing) permissionLauncher.launch(BLE_PERMISSIONS) else viewModel.startScan()
}
DisposableEffect(Unit) {
onDispose { viewModel.stopScan() }
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Bluetooth-våg") },
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),
) {
settings.scaleAddress?.let { address ->
Card(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Default.MonitorWeight,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) {
Text(
settings.scaleDriver ?: settings.scaleName ?: "Våg",
style = MaterialTheme.typography.titleSmall,
)
Text(
"${settings.scaleName ?: "?"} · $address",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
IconButton(onClick = viewModel::forgetScale) {
Icon(
Icons.Default.Delete,
contentDescription = "Ta bort våg",
tint = MaterialTheme.colorScheme.error,
)
}
}
}
}
if (settings.scaleAddress == null) {
Text(
"Sök efter din våg och välj den i listan. Kliv gärna på vågen under " +
"sökningen — många vågar syns bara när de är vakna.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
FilledTonalButton(
onClick = { if (isScanning) viewModel.stopScan() else scanWithPermission() },
modifier = Modifier.fillMaxWidth(),
) {
if (isScanning) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
)
Text("Söker… (tryck för att stoppa)", modifier = Modifier.padding(start = 10.dp))
} else {
Icon(Icons.Default.BluetoothSearching, contentDescription = null)
Text("Sök efter våg", modifier = Modifier.padding(start = 10.dp))
}
}
if (permissionDenied) {
Text(
"Bluetooth-behörighet nekades. Ge appen behörigheten \"Enheter i närheten\" " +
"i systeminställningarna för att kunna söka efter vågen.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
scanError?.let {
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error)
}
val supported = devices.filter { it.isSupported }
val others = devices.filterNot { it.isSupported }
if (supported.isNotEmpty()) {
Text("Vågar med stöd", style = MaterialTheme.typography.titleSmall)
supported.forEach { d -> DeviceRow(d, onClick = { viewModel.saveScale(d) }) }
}
if (others.isNotEmpty()) {
Text(
"Övriga enheter (ingen drivrutin matchar)",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
others.filter { it.name.isNotBlank() }.take(15).forEach { d ->
DeviceRow(d, onClick = null)
}
}
}
}
}
@Composable
private fun DeviceRow(device: ScannedDeviceInfo, onClick: (() -> Unit)?) {
Card(
modifier = Modifier
.fillMaxWidth()
.let { if (onClick != null) it.clickable(onClick = onClick) else it },
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
if (device.isSupported) Icons.Default.CheckCircle else Icons.Default.Bluetooth,
contentDescription = null,
tint = if (device.isSupported) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
)
Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) {
Text(
device.determinedHandlerDisplayName ?: device.name.ifBlank { device.address },
style = MaterialTheme.typography.titleSmall,
fontWeight = if (device.isSupported) FontWeight.Bold else FontWeight.Normal,
)
Text(
"${device.name.ifBlank { "namnlös" }} · ${device.address} · ${device.rssi} dBm",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (device.isSupported) {
Text(
"VÄLJ",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
)
}
}
}
}
/* ==================== Väg dig ==================== */
sealed class WeighState {
data object Idle : WeighState()
data class Working(val status: String) : WeighState()
data class Done(val measurement: ScaleMeasurement) : WeighState()
data class Failed(val message: String) : WeighState()
}
class WeighViewModel(
private val scaleManager: ScaleManager,
private val repo: GymRepository,
) : ViewModel() {
val state = MutableStateFlow<WeighState>(WeighState.Idle)
val saved = MutableStateFlow(false)
val missingBodyData = MutableStateFlow(false)
init {
viewModelScope.launch {
scaleManager.lastEvent.collect { event ->
// En färdig mätning ska inte skrivas över av senare events (t.ex. Disconnected)
if (state.value is WeighState.Done) return@collect
when (event) {
null -> {}
is BluetoothEvent.Listening ->
state.value = WeighState.Working("Lyssnar efter vågen — kliv på den…")
is BluetoothEvent.Connected ->
state.value = WeighState.Working("Ansluten — ställ dig barfota på vågen")
is BluetoothEvent.DeviceMessage ->
state.value = WeighState.Working(event.message)
is BluetoothEvent.MeasurementReceived -> {
state.value = WeighState.Done(event.measurement)
scaleManager.disconnect()
}
is BluetoothEvent.ConnectionFailed ->
state.value = WeighState.Failed("Kunde inte ansluta: ${event.error}")
is BluetoothEvent.Error ->
state.value = WeighState.Failed(event.error)
is BluetoothEvent.Disconnected -> {
if (state.value is WeighState.Working) {
state.value = WeighState.Failed("Vågen kopplade från innan mätningen blev klar")
}
}
is BluetoothEvent.BroadcastComplete, is BluetoothEvent.UserInteractionRequired -> {}
}
}
}
}
fun start() {
state.value = WeighState.Working("Söker efter vågen…")
saved.value = false
viewModelScope.launch {
missingBodyData.value = !scaleManager.hasBodyData()
val ok = scaleManager.connectSavedScale()
if (!ok) state.value = WeighState.Failed("Ingen våg är vald, eller så saknas drivrutin. Gå till Inställningar → Bluetooth-våg.")
}
}
fun stop() {
scaleManager.disconnect()
}
fun save(m: ScaleMeasurement) {
viewModelScope.launch {
fun pct(v: Float): Double? = if (v > 0f) String.format(java.util.Locale.US, "%.1f", v).toDouble() else null
try {
repo.addBodyMeasurement(
weightKg = String.format(java.util.Locale.US, "%.1f", m.weight).toDouble(),
dateIso = null,
musclePercent = pct(m.muscle),
fatPercent = pct(m.fat),
waterPercent = pct(m.water),
)
saved.value = true
} catch (e: Exception) {
state.value = WeighState.Failed("Kunde inte spara mätningen — offline?")
}
}
}
override fun onCleared() {
stop()
}
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val c = appContainer()
WeighViewModel(c.scaleManager, c.gymRepository)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun WeighScreen(
onBack: () -> Unit,
viewModel: WeighViewModel = viewModel(factory = WeighViewModel.Factory),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val saved by viewModel.saved.collectAsStateWithLifecycle()
val missingBodyData by viewModel.missingBodyData.collectAsStateWithLifecycle()
val context = LocalContext.current
var permissionDenied by remember { mutableStateOf(false) }
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { granted ->
if (granted.values.all { it }) viewModel.start() else permissionDenied = true
}
LaunchedEffect(Unit) {
val missing = BLE_PERMISSIONS.any {
ContextCompat.checkSelfPermission(context, it) != PackageManager.PERMISSION_GRANTED
}
if (missing) permissionLauncher.launch(BLE_PERMISSIONS) else viewModel.start()
}
DisposableEffect(Unit) {
onDispose { viewModel.stop() }
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Väg dig") },
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),
) {
if (missingBodyData) {
Card(modifier = Modifier.fillMaxWidth()) {
Text(
"Längd, födelseår eller kön saknas i profilen. Vågen behöver dem " +
"för att räkna ut muskler/fett/vatten — vikten fungerar ändå.",
modifier = Modifier.padding(12.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (permissionDenied) {
Text(
"Bluetooth-behörighet nekades — kan inte nå vågen.",
color = MaterialTheme.colorScheme.error,
)
}
when (val s = state) {
is WeighState.Idle -> {}
is WeighState.Working -> {
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.fillMaxWidth().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
CircularProgressIndicator()
Text(
s.status,
style = MaterialTheme.typography.titleMedium,
)
}
}
}
is WeighState.Failed -> {
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(s.message, color = MaterialTheme.colorScheme.error)
Button(onClick = viewModel::start, modifier = Modifier.fillMaxWidth()) {
Text("Försök igen")
}
}
}
}
is WeighState.Done -> {
val m = s.measurement
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.fillMaxWidth().padding(20.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.MonitorWeight,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
Text(
"${m.weight.toDouble().compact()} kg",
style = MaterialTheme.typography.displaySmall,
modifier = Modifier.padding(start = 12.dp),
)
}
ResultRow("Muskler", m.muscle, "%")
ResultRow("Fett", m.fat, "%")
ResultRow("Vatten", m.water, "%")
ResultRow("Benmassa", m.bone, " kg")
ResultRow("Bukfett (index)", m.visceralFat, "")
if (saved) {
Text(
"Sparad som kroppsmätning ✓",
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.titleSmall,
)
Button(onClick = onBack, modifier = Modifier.fillMaxWidth()) {
Text("Klar")
}
} else {
Button(
onClick = { viewModel.save(m) },
modifier = Modifier.fillMaxWidth(),
) { Text("Spara mätningen") }
TextButton(
onClick = viewModel::start,
modifier = Modifier.fillMaxWidth(),
) { Text("Väg om") }
}
}
}
}
}
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
Text(
"Vågen räknar ut kroppssammansättningen med hjälp av längd, " +
"födelseår och kön från profilen.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
private fun ResultRow(label: String, value: Float, suffix: String) {
if (value <= 0f) return
Row(modifier = Modifier.fillMaxWidth()) {
Text(
label,
modifier = Modifier.weight(1f),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text("${value.toDouble().compact()}$suffix")
}
}

View File

@@ -1,5 +1,6 @@
package eu.brassepc.fitnessdroid.ui.settings
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -96,6 +97,7 @@ class SettingsViewModel(
@Composable
fun SettingsScreen(
onBack: () -> Unit,
onOpenScale: () -> Unit = {},
viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.Factory),
) {
val settings by viewModel.settings.collectAsStateWithLifecycle()
@@ -120,6 +122,29 @@ fun SettingsScreen(
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
SettingsCard(title = "Bluetooth-våg") {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onOpenScale)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
settings.scaleDriver ?: "Ingen våg vald",
fontWeight = FontWeight.Medium,
)
Text(
settings.scaleAddress?.let { "Tryck för att byta eller ta bort" }
?: "Tryck för att söka och koppla en våg",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
SettingsCard(title = "Vilotimerns utseende") {
RadioRow(
label = "Helskärm",