diff --git a/app/build.gradle.kts b/app/build.gradle.kts index daa19d0..b1d4fcc 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -63,6 +63,7 @@ android { } buildFeatures { compose = true + buildConfig = true } } diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/FitnessDroidApplication.kt b/app/src/main/java/eu/brassepc/fitnessdroid/FitnessDroidApplication.kt index b0c230c..ce7d489 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/FitnessDroidApplication.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/FitnessDroidApplication.kt @@ -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() { diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/data/ScaleManager.kt b/app/src/main/java/eu/brassepc/fitnessdroid/data/ScaleManager.kt new file mode 100644 index 0000000..5af5d3e --- /dev/null +++ b/app/src/main/java/eu/brassepc/fitnessdroid/data/ScaleManager.kt @@ -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(null) + val lastEvent: StateFlow = _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 + } +} diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/data/SettingsStore.kt b/app/src/main/java/eu/brassepc/fitnessdroid/data/SettingsStore.kt index c0ccea9..448f2e8 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/data/SettingsStore.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/data/SettingsStore.kt @@ -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 = 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") } } diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/AppRoot.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/AppRoot.kt index 978038b..e03d007 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/ui/AppRoot.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/AppRoot.kt @@ -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() }, diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/about/AboutScreen.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/about/AboutScreen.kt new file mode 100644 index 0000000..b3df07b --- /dev/null +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/about/AboutScreen.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/profile/ProfileScreen.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/profile/ProfileScreen.kt index 1b134be..b5f05dc 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/ui/profile/ProfileScreen.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/profile/ProfileScreen.kt @@ -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(null) val measurements = MutableStateFlow>(emptyList()) val message = MutableStateFlow(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(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()) { diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/scale/ScaleScreens.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/scale/ScaleScreens.kt new file mode 100644 index 0000000..7ddc293 --- /dev/null +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/scale/ScaleScreens.kt @@ -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.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") + } +} diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/settings/SettingsScreen.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/settings/SettingsScreen.kt index 8cb4d33..f2e6939 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/settings/SettingsScreen.kt @@ -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",