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" }