diff --git a/android_app/app/build.gradle.kts b/android_app/app/build.gradle.kts index c640a33a..42441c31 100644 --- a/android_app/app/build.gradle.kts +++ b/android_app/app/build.gradle.kts @@ -186,6 +186,11 @@ dependencies { implementation(libs.compose.charts) implementation(libs.compose.charts.m3) + // Glance + implementation(libs.androidx.glance) + implementation(libs.androidx.glance.appwidget) + implementation(libs.androidx.glance.material3) + // Compose reorderable implementation(libs.compose.reorderable) implementation(libs.compose.material.icons.extended) diff --git a/android_app/app/src/main/AndroidManifest.xml b/android_app/app/src/main/AndroidManifest.xml index f19df0f1..9991d5a4 100644 --- a/android_app/app/src/main/AndroidManifest.xml +++ b/android_app/app/src/main/AndroidManifest.xml @@ -75,6 +75,24 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementCrudUseCases.kt b/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementCrudUseCases.kt index 7ea057e2..86edd485 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementCrudUseCases.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementCrudUseCases.kt @@ -17,10 +17,15 @@ */ package com.health.openscale.core.usecase +import android.content.Context import com.health.openscale.core.data.Measurement import com.health.openscale.core.data.MeasurementValue import com.health.openscale.core.database.DatabaseRepository +import com.health.openscale.ui.widget.MeasurementWidget +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext import java.util.Date import javax.inject.Inject import javax.inject.Singleton @@ -34,6 +39,7 @@ import javax.inject.Singleton */ @Singleton class MeasurementCrudUseCases @Inject constructor( + @ApplicationContext private val appContext: Context, private val databaseRepository: DatabaseRepository, private val sync: SyncUseCases ) { @@ -66,6 +72,8 @@ class MeasurementCrudUseCases @Inject constructor( sync.triggerSyncInsert(measurement, values,"com.health.openscale.sync") sync.triggerSyncInsert(measurement, values,"com.health.openscale.sync.oss") + MeasurementWidget.refreshAll(appContext) + newId } else { // Update path @@ -92,6 +100,8 @@ class MeasurementCrudUseCases @Inject constructor( sync.triggerSyncUpdate(measurement, values, "com.health.openscale.sync") sync.triggerSyncUpdate(measurement, values,"com.health.openscale.sync.oss") + MeasurementWidget.refreshAll(appContext) + measurement.id } } @@ -110,6 +120,8 @@ class MeasurementCrudUseCases @Inject constructor( databaseRepository.deleteMeasurement(measurement) sync.triggerSyncDelete(Date(measurement.timestamp), "com.health.openscale.sync") sync.triggerSyncDelete(Date(measurement.timestamp), "com.health.openscale.sync.oss") + + MeasurementWidget.refreshAll(appContext) } suspend fun recalculateDerivedValuesForMeasurement(measurementId: Int) { diff --git a/android_app/app/src/main/java/com/health/openscale/ui/widget/MeasurementWidget.kt b/android_app/app/src/main/java/com/health/openscale/ui/widget/MeasurementWidget.kt new file mode 100644 index 00000000..f661d50b --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/ui/widget/MeasurementWidget.kt @@ -0,0 +1,325 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.ui.widget + +import android.content.Context +import android.content.Intent +import androidx.annotation.DrawableRes +import androidx.compose.runtime.* +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.datastore.preferences.core.Preferences +import androidx.glance.GlanceId +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.action.clickable +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import androidx.glance.appwidget.action.actionStartActivity +import androidx.glance.appwidget.cornerRadius +import androidx.glance.appwidget.provideContent +import androidx.glance.background +import androidx.glance.currentState +import androidx.glance.layout.* +import androidx.glance.state.PreferencesGlanceStateDefinition +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextStyle +import androidx.glance.unit.ColorProvider +import com.health.openscale.MainActivity +import com.health.openscale.R +import com.health.openscale.core.data.EvaluationState +import com.health.openscale.core.data.InputFieldType +import com.health.openscale.core.data.MeasurementTypeIcon +import com.health.openscale.core.data.MeasurementTypeKey +import com.health.openscale.core.facade.MeasurementFacade +import com.health.openscale.core.facade.UserFacade +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext +import java.text.DecimalFormat +import androidx.compose.ui.graphics.Color +import androidx.glance.appwidget.state.updateAppWidgetState + +class MeasurementWidget : GlanceAppWidget() { + // Enable currentState() inside provideContent + override val stateDefinition = PreferencesGlanceStateDefinition + + companion object { + /** Recompose all widget instances. */ + suspend fun refreshAll(context: Context) { + withContext(Dispatchers.IO) { + val gm = GlanceAppWidgetManager(context) + val ids = gm.getGlanceIds(MeasurementWidget::class.java) + if (ids.isEmpty()) return@withContext + + ids.forEach { glanceId -> + updateAppWidgetState( + context = context, + glanceId = glanceId + ) { prefs -> + prefs[WidgetPrefs.KEY_TRIGGER] = (prefs[WidgetPrefs.KEY_TRIGGER] ?: 0) + 1 + } + // update triggers provideGlance/provideContent recomposition + MeasurementWidget().update(context, glanceId) + } + } + } + } + + override suspend fun provideGlance(context: Context, id: GlanceId) { + // Preload strings (no stringResource in Glance content) + val txtNoUser = context.getString(R.string.no_user_selected_title) + val txtNoMeas = context.getString(R.string.no_measurements_title) + val txtNA = context.getString(R.string.not_available) + + // Hilt entry points once, outside of Composable + val entry = runCatching { + EntryPointAccessors.fromApplication(context.applicationContext, WidgetEntryPoint::class.java) + }.getOrNull() + val userFacade = entry?.userFacade() + val measurementFacade = entry?.measurementFacade() + + provideContent { + GlanceTheme { + // 1) Read per-instance Glance state + val prefs = currentState() + val selectedTypeId = prefs[WidgetPrefs.KEY_TYPE] + val selectedThemeIx = prefs[WidgetPrefs.KEY_THEME] ?: WidgetTheme.LIGHT.ordinal + val trigger = prefs[WidgetPrefs.KEY_TRIGGER] + + // Text colors based on selected Light/Dark theme + val isDark = selectedThemeIx == WidgetTheme.DARK.ordinal + val textColor = if (isDark) + ColorProvider(Color(0xFF111111)) + else + ColorProvider(Color(0xFFECECEC)) + val subTextColor = if (isDark) + ColorProvider(Color(0xFF111111)) + else + ColorProvider(Color(0xFFECECEC)) + + // 2) UI state + var uiPayload by remember { mutableStateOf(null) } + var userMissing by remember { mutableStateOf(false) } + + // 3) Reload when state changes + LaunchedEffect(trigger,selectedTypeId, selectedThemeIx) { + uiPayload = null + userMissing = false + + withContext(Dispatchers.IO) { + val userId: Int? = runCatching { userFacade?.observeSelectedUser()?.first()?.id }.getOrNull() + if (userId == null || measurementFacade == null) { + userMissing = (userId == null) + return@withContext + } + + val allTypes = measurementFacade.getAllMeasurementTypes().first() + val all = measurementFacade.getMeasurementsForUser(userId).first() + + val targetType = selectedTypeId?.let { sel -> allTypes.firstOrNull { it.id == sel } } + ?: allTypes.firstOrNull { it.key == MeasurementTypeKey.WEIGHT } + ?: allTypes.firstOrNull { it.isEnabled && (it.inputType == InputFieldType.FLOAT || it.inputType == InputFieldType.INT) } + + uiPayload = targetType?.let { t -> + val valuesDesc = all.mapNotNull { mwv -> + val v = mwv.values.firstOrNull { it.type.id == t.id } ?: return@mapNotNull null + val num = v.value.floatValue ?: v.value.intValue?.toFloat() ?: return@mapNotNull null + mwv.measurement.timestamp to num + }.sortedByDescending { it.first } + + if (valuesDesc.isEmpty()) null else { + val current = valuesDesc[0].second + val previous = valuesDesc.getOrNull(1)?.second + + val df = DecimalFormat("#0.0") + val dfSigned = DecimalFormat("+#0.0;-#0.0") + + val unitLabel = t.unit.displayName + val valueWithUnit = buildString { + append(df.format(current)) + if (unitLabel.isNotBlank()) append(" ").append(unitLabel) + } + + val deltaArrow = previous?.let { prev -> + when { + current > prev + 1e-4 -> "↗" + current < prev - 1e-4 -> "↘" + else -> "" + } + } + val deltaText = previous?.let { + val signed = dfSigned.format(current - it) + listOfNotNull(deltaArrow?.takeIf { it.isNotBlank() }, signed).joinToString(" ") + + if (unitLabel.isNotBlank()) " $unitLabel" else "" + } ?: "" + + val implausiblePercent = unitLabel == "%" && (current < 0f || current > 100f) + val (symbol, evalState) = when { + implausiblePercent -> "!" to EvaluationState.UNDEFINED + previous == null -> "●" to EvaluationState.UNDEFINED + current > previous + 1e-4 -> "▲" to EvaluationState.HIGH + current < previous - 1e-4 -> "▼" to EvaluationState.LOW + else -> "●" to EvaluationState.NORMAL + } + + DisplayData( + label = t.getDisplayName(context), + icon = t.icon, + badgeColor = if (t.color != 0) + ColorProvider(androidx.compose.ui.graphics.Color(t.color)) else null, + symbol = symbol, + evaluationState = evalState, + valueWithUnit = valueWithUnit, + deltaText = deltaText + ) + } + } + } + } + + val themeColors = GlanceTheme.colors + val launch = Intent(context, MainActivity::class.java) + + Box( + modifier = GlanceModifier + .fillMaxSize() + .padding(8.dp) + .clickable(actionStartActivity(launch)), + contentAlignment = Alignment.Center + ) { + when { + userMissing -> Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(text = txtNoUser, style = TextStyle(color = textColor)) + } + uiPayload == null -> Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(text = txtNoMeas, style = TextStyle(color = textColor)) + Text(text = txtNA, style = TextStyle(color = subTextColor)) + } + else -> ValueWithDeltaRow( + icon = uiPayload!!.icon, + iconContentDescription = context.getString( + R.string.measurement_type_icon_desc, uiPayload!!.label + ), + label = uiPayload!!.label, + symbol = uiPayload!!.symbol, + evaluationState = uiPayload!!.evaluationState, + valueWithUnit = uiPayload!!.valueWithUnit, + deltaText = uiPayload!!.deltaText, + circleColor = uiPayload!!.badgeColor ?: themeColors.secondary, + textColor = textColor, + subTextColor = subTextColor, + symbolColor = ColorProvider(uiPayload!!.evaluationState.toColor()) + ) + } + } + } + } + } +} + +class MeasurementWidgetReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = MeasurementWidget() +} + +/* Hilt entry point */ +@EntryPoint +@InstallIn(SingletonComponent::class) +interface WidgetEntryPoint { + fun userFacade(): UserFacade + fun measurementFacade(): MeasurementFacade +} + +/* Payload & UI */ +private data class DisplayData( + val label: String, + val icon: MeasurementTypeIcon, + val badgeColor: ColorProvider?, + val symbol: String, + val evaluationState: EvaluationState, + val valueWithUnit: String, + val deltaText: String +) + +@Composable +private fun ValueWithDeltaRow( + icon: MeasurementTypeIcon, + iconContentDescription: String, + label: String, + symbol: String, + evaluationState: EvaluationState, + valueWithUnit: String, + deltaText: String, + circleColor: ColorProvider, + textColor: ColorProvider, + subTextColor: ColorProvider, + symbolColor: ColorProvider +) { + Row(modifier = GlanceModifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { + GlanceRoundMeasurementIcon(icon, iconContentDescription, 28.dp, circleColor, R.drawable.ic_weight) + Spacer(GlanceModifier.size(10.dp)) + Column { + Text(text = label, style = TextStyle(fontWeight = FontWeight.Medium, color = textColor)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text(text = valueWithUnit, style = TextStyle(color = textColor)) + Spacer(GlanceModifier.size(6.dp)) + Text(text = symbol, style = TextStyle(color = symbolColor)) + } + if (deltaText.isNotBlank()) { + Text(text = deltaText, style = TextStyle(fontSize = 12.sp, color = subTextColor)) + } + } + } +} + + +@Composable +private fun GlanceRoundMeasurementIcon( + icon: MeasurementTypeIcon, + contentDescription: String, + size: Dp, + circleColor: ColorProvider, + @DrawableRes fallbackDrawable: Int +) { + val resId = when (val r = icon.resource) { + is MeasurementTypeIcon.IconResource.PainterResource -> r.id + is MeasurementTypeIcon.IconResource.VectorResource -> fallbackDrawable + } + Box( + modifier = GlanceModifier + .size(size + 18.dp) + .cornerRadius((size + 18.dp) / 2) + .background(circleColor), + contentAlignment = Alignment.Center + ) { + Image( + provider = ImageProvider(resId), + contentDescription = contentDescription, + modifier = GlanceModifier.size(size) + ) + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/ui/widget/MeasurementWidgetConfigActivity.kt b/android_app/app/src/main/java/com/health/openscale/ui/widget/MeasurementWidgetConfigActivity.kt new file mode 100644 index 00000000..57f867a2 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/ui/widget/MeasurementWidgetConfigActivity.kt @@ -0,0 +1,287 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.ui.widget + +import android.appwidget.AppWidgetManager +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Done +import androidx.compose.material3.* +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.glance.appwidget.state.updateAppWidgetState +import androidx.glance.state.PreferencesGlanceStateDefinition +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.viewModelScope +import com.health.openscale.R +import com.health.openscale.core.data.InputFieldType +import com.health.openscale.core.data.MeasurementType +import com.health.openscale.core.data.MeasurementTypeIcon +import com.health.openscale.core.data.MeasurementTypeKey +import com.health.openscale.core.usecase.MeasurementQueryUseCases +import com.health.openscale.ui.components.RoundMeasurementIcon +import dagger.hilt.android.AndroidEntryPoint +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +enum class WidgetTheme { LIGHT, DARK } + +/** Shared widget preference keys */ +object WidgetPrefs { + val KEY_TYPE = androidx.datastore.preferences.core.intPreferencesKey("widget_selectedTypeId") + val KEY_THEME = androidx.datastore.preferences.core.intPreferencesKey("widget_selectedTheme") + val KEY_TRIGGER = androidx.datastore.preferences.core.intPreferencesKey("widget_trigger") + +} + +@AndroidEntryPoint +class MeasurementWidgetConfigActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Get the appWidgetId from host (Launcher) + val appWidgetId = intent?.extras?.getInt( + AppWidgetManager.EXTRA_APPWIDGET_ID, + AppWidgetManager.INVALID_APPWIDGET_ID + ) ?: AppWidgetManager.INVALID_APPWIDGET_ID + + if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { + setResult(RESULT_CANCELED) + finish() + return + } + + // Immediately set result to CANCELED, will be changed to OK when user confirms + setResult( + RESULT_CANCELED, + Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) + ) + + setContent { + MaterialTheme { + WidgetConfigScreen( + onCancel = { finish() }, + onConfirm = { selectedTypeId, selectedTheme -> + saveSelectionAndFinish(appWidgetId, selectedTypeId, selectedTheme) + } + ) + } + } + } + + /** Save user choice, update widget instance immediately, and return result to host */ + private fun saveSelectionAndFinish( + appWidgetId: Int?, + selectedTypeId: Int?, + selectedTheme: WidgetTheme + ) { + val ctx = this + lifecycleScope.launch { + val gm = GlanceAppWidgetManager(ctx) + val glanceId = gm.getGlanceIds(MeasurementWidget::class.java) + .firstOrNull { it != null && gm.getAppWidgetId(it) == appWidgetId } + + if (glanceId == null || appWidgetId == null || + appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID + ) { + setResult(RESULT_CANCELED) + finish() + return@launch + } + + // Write per-widget Glance state + updateAppWidgetState( + context = ctx, + definition = PreferencesGlanceStateDefinition, + glanceId = glanceId + ) { prefs -> + prefs.toMutablePreferences().apply { + if (selectedTypeId != null) { + this[WidgetPrefs.KEY_TYPE] = selectedTypeId + } else { + this.remove(WidgetPrefs.KEY_TYPE) + } + this[WidgetPrefs.KEY_THEME] = selectedTheme.ordinal + this[WidgetPrefs.KEY_TRIGGER] = 0 + } + } + + // Trigger immediate update of this widget instance + MeasurementWidget().update(ctx, glanceId) + + // Return OK to host + val result = Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) + setResult(RESULT_OK, result) + finish() + } + } +} + +@HiltViewModel +class MeasurementWidgetConfigViewModel @Inject constructor( + query: MeasurementQueryUseCases +) : androidx.lifecycle.ViewModel() { + // Only expose enabled numeric measurement types + val types = query.getAllMeasurementTypes() + .map { + it.filter { t -> t.isEnabled && (t.inputType == InputFieldType.FLOAT || t.inputType == InputFieldType.INT) } + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun WidgetConfigScreen( + onCancel: () -> Unit, + onConfirm: (selectedTypeId: Int?, selectedTheme: WidgetTheme) -> Unit +) { + val vm: MeasurementWidgetConfigViewModel = androidx.hilt.navigation.compose.hiltViewModel() + val types by vm.types.collectAsState() + + var selectedId by remember { mutableStateOf(null) } + var selectedTheme by remember { mutableStateOf(WidgetTheme.LIGHT) } + + // Default selection: WEIGHT if available, else first entry + LaunchedEffect(types) { + selectedId = types.firstOrNull { it.key == MeasurementTypeKey.WEIGHT }?.id + ?: types.firstOrNull()?.id + selectedTheme = WidgetTheme.LIGHT + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.measurement_type_settings_title)) }, + navigationIcon = { + IconButton(onClick = onCancel) { + Icon(Icons.Default.Close, contentDescription = stringResource(R.string.cancel_button)) + } + }, + actions = { + IconButton( + onClick = { onConfirm(selectedId, selectedTheme) }, + enabled = selectedId != null + ) { + Icon(Icons.Default.Done, contentDescription = stringResource(R.string.confirm_button)) + } + } + ) + } + ) { inner -> + Column(Modifier.fillMaxSize().padding(inner)) { + ThemePicker( + selected = selectedTheme, + onSelected = { selectedTheme = it }, + modifier = Modifier.fillMaxWidth() + ) + Divider(Modifier.padding(vertical = 12.dp)) + if (types.isEmpty()) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text(text = stringResource(R.string.no_entries_found)) + } + } else { + LazyColumn(Modifier.fillMaxWidth().weight(1f)) { + items(types, key = { it.id }) { type -> + TypeRow( + type = type, + selected = selectedId == type.id, + onClick = { selectedId = type.id } + ) + HorizontalDivider( + Modifier, + DividerDefaults.Thickness, + DividerDefaults.color + ) + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ThemePicker( + selected: WidgetTheme, + onSelected: (WidgetTheme) -> Unit, + modifier: Modifier = Modifier +) { + // Segmented buttons to pick Light/Dark + val options = listOf(WidgetTheme.LIGHT, WidgetTheme.DARK) + SingleChoiceSegmentedButtonRow(modifier = modifier.padding(horizontal = 16.dp)) { + options.forEachIndexed { index, item -> + val label = when (item) { + WidgetTheme.LIGHT -> stringResource(R.string.theme_light) + WidgetTheme.DARK -> stringResource(R.string.theme_dark) + } + SegmentedButton( + selected = selected == item, + onClick = { onSelected(item) }, + shape = SegmentedButtonDefaults.itemShape(index, options.size), + icon = { if (selected == item) Icon(Icons.Default.Check, null) } + ) { Text(text = label) } + } + } +} + +@Composable +private fun TypeRow(type: MeasurementType, selected: Boolean, onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Use existing RoundMeasurementIcon and colorize background with type.color if available + RoundMeasurementIcon( + icon = type.icon ?: MeasurementTypeIcon.IC_DEFAULT, + size = 24.dp, + backgroundTint = if (type.color != 0) Color(type.color) else MaterialTheme.colorScheme.secondaryContainer + ) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Text(text = type.getDisplayName(LocalContext.current)) + Text( + text = type.unit.displayName, + style = MaterialTheme.typography.bodySmall.copy(color = MaterialTheme.colorScheme.onSurfaceVariant) + ) + } + if (selected) Icon(Icons.Default.Check, null, tint = MaterialTheme.colorScheme.primary) + } +} diff --git a/android_app/app/src/main/res/values-de/strings.xml b/android_app/app/src/main/res/values-de/strings.xml index 76f55e05..3ccaa7d3 100644 --- a/android_app/app/src/main/res/values-de/strings.xml +++ b/android_app/app/src/main/res/values-de/strings.xml @@ -4,6 +4,8 @@ Keine N.V. - + Hell + Dunkel Aktivieren diff --git a/android_app/app/src/main/res/values/strings.xml b/android_app/app/src/main/res/values/strings.xml index 56ab8dc8..56602445 100644 --- a/android_app/app/src/main/res/values/strings.xml +++ b/android_app/app/src/main/res/values/strings.xml @@ -5,6 +5,8 @@ None N/A - + Light + Dark Enable diff --git a/android_app/app/src/main/res/xml/measurement_widget.xml b/android_app/app/src/main/res/xml/measurement_widget.xml new file mode 100644 index 00000000..5608df32 --- /dev/null +++ b/android_app/app/src/main/res/xml/measurement_widget.xml @@ -0,0 +1,10 @@ + + diff --git a/android_app/gradle/libs.versions.toml b/android_app/gradle/libs.versions.toml index 1043cbf3..997b0feb 100644 --- a/android_app/gradle/libs.versions.toml +++ b/android_app/gradle/libs.versions.toml @@ -21,6 +21,7 @@ composeCharts = "2.1.3" composeReorderable = "3.0.0" compose-material = "1.7.8" constraintlayout-compose = "1.1.1" +glance = "1.1.1" kotlinCsv = "1.10.0" blessedKotlin = "3.0.9" blessedJava = "2.5.2" @@ -59,6 +60,9 @@ compose-charts = { group = "com.patrykandpatrick.vico", name = "compose", versio compose-charts-m3 = { group = "com.patrykandpatrick.vico", name = "compose-m3", version.ref = "composeCharts" } compose-reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "composeReorderable" } compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "compose-material" } +androidx-glance = { group = "androidx.glance", name = "glance", version.ref = "glance" } +androidx-glance-appwidget = { group = "androidx.glance", name = "glance-appwidget", version.ref = "glance" } +androidx-glance-material3 = { group = "androidx.glance", name = "glance-material3", version.ref = "glance" } kotlin-csv-jvm = { group = "com.github.doyaaaaaken", name = "kotlin-csv-jvm", version.ref = "kotlinCsv" } blessed-kotlin = { group = "com.github.weliem", name = "blessed-kotlin", version.ref = "blessedKotlin" } blessed-java = { group = "com.github.weliem", name = "blessed-android", version.ref = "blessedJava" }