1
0
mirror of https://github.com/oliexdev/openScale.git synced 2025-08-30 03:30:30 +02:00

This commit introduces a new home screen widget that displays the latest value for a selected measurement type.

This commit is contained in:
oliexdev
2025-08-29 12:21:43 +02:00
parent 2901a3a520
commit 7bce0be76b
9 changed files with 665 additions and 0 deletions

View File

@@ -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)

View File

@@ -75,6 +75,24 @@
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<receiver
android:name="com.health.openscale.ui.widget.MeasurementWidgetReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/measurement_widget" />
</receiver>
<activity
android:name=".ui.widget.MeasurementWidgetConfigActivity"
android:exported="true"
android:label="@string/app_name">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -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) {

View File

@@ -0,0 +1,325 @@
/*
* openScale
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
*
* 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 <https://www.gnu.org/licenses/>.
*/
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<Preferences>() 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<Preferences>()
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<DisplayData?>(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)
)
}
}

View File

@@ -0,0 +1,287 @@
/*
* openScale
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
*
* 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 <https://www.gnu.org/licenses/>.
*/
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<Int?>(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)
}
}

View File

@@ -4,6 +4,8 @@
<string name="text_none">Keine</string>
<string name="not_available">N.V.</string> <!-- N.V. = Nicht verfügbar -->
<string name="placeholder_empty_value">-</string>
<string name="theme_light">Hell</string>
<string name="theme_dark">Dunkel</string>
<!-- Generische UI-Elemente & Aktionen -->
<string name="enable_button">Aktivieren</string>

View File

@@ -5,6 +5,8 @@
<string name="text_none">None</string>
<string name="not_available">N/A</string>
<string name="placeholder_empty_value">-</string>
<string name="theme_light">Light</string>
<string name="theme_dark">Dark</string>
<!-- Generic UI Elements & Actions -->
<string name="enable_button">Enable</string>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="110dp"
android:minHeight="40dp"
android:updatePeriodMillis="0"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
android:initialLayout="@layout/glance_default_loading_layout"
android:configure="com.health.openscale.ui.widget.MeasurementWidgetConfigActivity"
/>

View File

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