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:
@@ -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)
|
||||
|
@@ -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>
|
@@ -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) {
|
||||
|
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
@@ -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)
|
||||
}
|
||||
}
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
10
android_app/app/src/main/res/xml/measurement_widget.xml
Normal file
10
android_app/app/src/main/res/xml/measurement_widget.xml
Normal 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"
|
||||
/>
|
@@ -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" }
|
||||
|
Reference in New Issue
Block a user