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:
@@ -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() {
|
||||
|
||||
110
app/src/main/java/eu/brassepc/fitnessdroid/data/ScaleManager.kt
Normal file
110
app/src/main/java/eu/brassepc/fitnessdroid/data/ScaleManager.kt
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() },
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user