mirror of
https://github.com/oliexdev/openScale.git
synced 2025-08-30 19:49:59 +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)
|
||||||
implementation(libs.compose.charts.m3)
|
implementation(libs.compose.charts.m3)
|
||||||
|
|
||||||
|
// Glance
|
||||||
|
implementation(libs.androidx.glance)
|
||||||
|
implementation(libs.androidx.glance.appwidget)
|
||||||
|
implementation(libs.androidx.glance.material3)
|
||||||
|
|
||||||
// Compose reorderable
|
// Compose reorderable
|
||||||
implementation(libs.compose.reorderable)
|
implementation(libs.compose.reorderable)
|
||||||
implementation(libs.compose.material.icons.extended)
|
implementation(libs.compose.material.icons.extended)
|
||||||
|
@@ -75,6 +75,24 @@
|
|||||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</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>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
@@ -17,10 +17,15 @@
|
|||||||
*/
|
*/
|
||||||
package com.health.openscale.core.usecase
|
package com.health.openscale.core.usecase
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import com.health.openscale.core.data.Measurement
|
import com.health.openscale.core.data.Measurement
|
||||||
import com.health.openscale.core.data.MeasurementValue
|
import com.health.openscale.core.data.MeasurementValue
|
||||||
import com.health.openscale.core.database.DatabaseRepository
|
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.flow.first
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
@@ -34,6 +39,7 @@ import javax.inject.Singleton
|
|||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class MeasurementCrudUseCases @Inject constructor(
|
class MeasurementCrudUseCases @Inject constructor(
|
||||||
|
@ApplicationContext private val appContext: Context,
|
||||||
private val databaseRepository: DatabaseRepository,
|
private val databaseRepository: DatabaseRepository,
|
||||||
private val sync: SyncUseCases
|
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")
|
||||||
sync.triggerSyncInsert(measurement, values,"com.health.openscale.sync.oss")
|
sync.triggerSyncInsert(measurement, values,"com.health.openscale.sync.oss")
|
||||||
|
|
||||||
|
MeasurementWidget.refreshAll(appContext)
|
||||||
|
|
||||||
newId
|
newId
|
||||||
} else {
|
} else {
|
||||||
// Update path
|
// 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")
|
||||||
sync.triggerSyncUpdate(measurement, values,"com.health.openscale.sync.oss")
|
sync.triggerSyncUpdate(measurement, values,"com.health.openscale.sync.oss")
|
||||||
|
|
||||||
|
MeasurementWidget.refreshAll(appContext)
|
||||||
|
|
||||||
measurement.id
|
measurement.id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -110,6 +120,8 @@ class MeasurementCrudUseCases @Inject constructor(
|
|||||||
databaseRepository.deleteMeasurement(measurement)
|
databaseRepository.deleteMeasurement(measurement)
|
||||||
sync.triggerSyncDelete(Date(measurement.timestamp), "com.health.openscale.sync")
|
sync.triggerSyncDelete(Date(measurement.timestamp), "com.health.openscale.sync")
|
||||||
sync.triggerSyncDelete(Date(measurement.timestamp), "com.health.openscale.sync.oss")
|
sync.triggerSyncDelete(Date(measurement.timestamp), "com.health.openscale.sync.oss")
|
||||||
|
|
||||||
|
MeasurementWidget.refreshAll(appContext)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun recalculateDerivedValuesForMeasurement(measurementId: Int) {
|
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="text_none">Keine</string>
|
||||||
<string name="not_available">N.V.</string> <!-- N.V. = Nicht verfügbar -->
|
<string name="not_available">N.V.</string> <!-- N.V. = Nicht verfügbar -->
|
||||||
<string name="placeholder_empty_value">-</string>
|
<string name="placeholder_empty_value">-</string>
|
||||||
|
<string name="theme_light">Hell</string>
|
||||||
|
<string name="theme_dark">Dunkel</string>
|
||||||
|
|
||||||
<!-- Generische UI-Elemente & Aktionen -->
|
<!-- Generische UI-Elemente & Aktionen -->
|
||||||
<string name="enable_button">Aktivieren</string>
|
<string name="enable_button">Aktivieren</string>
|
||||||
|
@@ -5,6 +5,8 @@
|
|||||||
<string name="text_none">None</string>
|
<string name="text_none">None</string>
|
||||||
<string name="not_available">N/A</string>
|
<string name="not_available">N/A</string>
|
||||||
<string name="placeholder_empty_value">-</string>
|
<string name="placeholder_empty_value">-</string>
|
||||||
|
<string name="theme_light">Light</string>
|
||||||
|
<string name="theme_dark">Dark</string>
|
||||||
|
|
||||||
<!-- Generic UI Elements & Actions -->
|
<!-- Generic UI Elements & Actions -->
|
||||||
<string name="enable_button">Enable</string>
|
<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"
|
composeReorderable = "3.0.0"
|
||||||
compose-material = "1.7.8"
|
compose-material = "1.7.8"
|
||||||
constraintlayout-compose = "1.1.1"
|
constraintlayout-compose = "1.1.1"
|
||||||
|
glance = "1.1.1"
|
||||||
kotlinCsv = "1.10.0"
|
kotlinCsv = "1.10.0"
|
||||||
blessedKotlin = "3.0.9"
|
blessedKotlin = "3.0.9"
|
||||||
blessedJava = "2.5.2"
|
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-charts-m3 = { group = "com.patrykandpatrick.vico", name = "compose-m3", version.ref = "composeCharts" }
|
||||||
compose-reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "composeReorderable" }
|
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" }
|
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" }
|
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-kotlin = { group = "com.github.weliem", name = "blessed-kotlin", version.ref = "blessedKotlin" }
|
||||||
blessed-java = { group = "com.github.weliem", name = "blessed-android", version.ref = "blessedJava" }
|
blessed-java = { group = "com.github.weliem", name = "blessed-android", version.ref = "blessedJava" }
|
||||||
|
Reference in New Issue
Block a user