diff --git a/SparkyFitnessMobile/__tests__/hooks/useWidgetSync.test.ts b/SparkyFitnessMobile/__tests__/hooks/useWidgetSync.test.ts index 1ead73da3..df97a7522 100644 --- a/SparkyFitnessMobile/__tests__/hooks/useWidgetSync.test.ts +++ b/SparkyFitnessMobile/__tests__/hooks/useWidgetSync.test.ts @@ -210,10 +210,44 @@ describe('useWidgetSync', () => { fat: 55, calories: 1540, remaining: 460, + // The widget's per-macro bars need each goal to show real progress; without + // them it can only compare macros against each other, which barely moves + // across the day (#2228). + proteinGoal: 150, + carbsGoal: 200, + fatGoal: 65, }); expect(androidReloadMacro).toHaveBeenCalledTimes(1); }); + it('re-pushes the macro snapshot when only a macro goal changes', async () => { + Object.defineProperty(Platform, 'OS', { + get: () => 'android', + configurable: true, + }); + + const { rerender } = renderHook( + ({ summary }) => useWidgetSync(summary), + { initialProps: { summary: makeSummary() } }, + ); + await flushWidgetPush(); + expect(androidSetMacroSnapshot).toHaveBeenCalledTimes(1); + + // Consumption is identical, so a snapshot keyed only on consumed grams + // would dedupe this away and leave the bars rendering against a stale goal. + rerender({ + summary: makeSummary({ protein: { consumed: 92, goal: 180 } }), + }); + await flushWidgetPush(); + + expect(androidSetMacroSnapshot).toHaveBeenCalledTimes(2); + const latest = JSON.parse( + androidSetMacroSnapshot.mock.calls[1][0] as string, + ); + expect(latest).toMatchObject({ protein: 92, proteinGoal: 180 }); + expect(androidReloadMacro).toHaveBeenCalledTimes(2); + }); + it('skips Android pushes when only non-rendered summary fields change', async () => { Object.defineProperty(Platform, 'OS', { get: () => 'android', diff --git a/SparkyFitnessMobile/src/hooks/useWidgetSync.ts b/SparkyFitnessMobile/src/hooks/useWidgetSync.ts index 6c731beb0..01164d564 100644 --- a/SparkyFitnessMobile/src/hooks/useWidgetSync.ts +++ b/SparkyFitnessMobile/src/hooks/useWidgetSync.ts @@ -14,6 +14,41 @@ const CALORIE_SNAPSHOT_KEY = 'calorieSnapshot'; const MACRO_WIDGET_KIND = 'macroWidget'; const MACRO_SNAPSHOT_KEY = 'macroSnapshot'; +/** + * The Android widget snapshot contracts, mirrored by `parseSnapshot` in + * `CalorieWidget.kt.tmpl` and `MacroWidget.kt.tmpl`. + * + * Declared explicitly so a field cannot be renamed or dropped on this side + * without a type error. They cannot make the boundary type-safe on their own: + * `setCalorieSnapshot`/`setMacroSnapshot` take a `string`, and the Kotlin + * readers match these keys by name, so the two halves still have to be kept in + * step by hand. + * + * Optional fields are dropped by `JSON.stringify` rather than serialized as + * null, which is what the widgets' `optionalDouble` reads as "not supplied". + */ +interface AndroidCalorieSnapshot { + date: string; + remaining: number; + goal: number; + progress: number; +} + +interface AndroidMacroSnapshot { + date: string; + protein: number; + carbs: number; + fat: number; + calories: number; + remaining?: number; + proteinGoal: number; + carbsGoal: number; + fatGoal: number; +} + +/** A snapshot plus its push timestamp, which is deliberately not part of the dedupe key. */ +type AndroidWidgetPayload = T & { lastUpdated: number }; + const iosAppGroup = ( Constants.expoConfig?.extra as { iosAppGroup?: string } | undefined )?.iosAppGroup; @@ -92,7 +127,7 @@ export function useWidgetSync(summary: DailySummary | undefined): void { const { goal, remaining, progress } = balance; const clampedProgress = goal > 0 ? Math.max(0, Math.min(1, progress / 100)) : 0; - const calorieSnapshot = { + const calorieSnapshot: AndroidCalorieSnapshot = { date, remaining, goal, @@ -102,7 +137,7 @@ export function useWidgetSync(summary: DailySummary | undefined): void { if (lastAndroidCalorieSnapshotKeyRef.current !== calorieSnapshotKey) { lastAndroidCalorieSnapshotKeyRef.current = calorieSnapshotKey; - const caloriePayload = { + const caloriePayload: AndroidWidgetPayload = { ...calorieSnapshot, lastUpdated, }; @@ -128,19 +163,27 @@ export function useWidgetSync(summary: DailySummary | undefined): void { } } - const macroSnapshot = { + // Goals ride along so the widget's per-macro bars can show progress + // toward each goal. Without them the widget can only compare a macro + // against the day's other macros, which barely moves as the day fills up + // (#2228). Not sent on iOS: that widget draws a composition ring, where + // the three shares summing to one is the intended reading. + const macroSnapshot: AndroidMacroSnapshot = { date, protein: summary.protein.consumed, carbs: summary.carbs.consumed, fat: summary.fat.consumed, calories: summary.caloriesConsumed, remaining: balance?.remaining, + proteinGoal: summary.protein.goal, + carbsGoal: summary.carbs.goal, + fatGoal: summary.fat.goal, }; const macroSnapshotKey = JSON.stringify(macroSnapshot); if (lastAndroidMacroSnapshotKeyRef.current === macroSnapshotKey) return; lastAndroidMacroSnapshotKeyRef.current = macroSnapshotKey; - const macroPayload = { + const macroPayload: AndroidWidgetPayload = { ...macroSnapshot, lastUpdated, }; diff --git a/SparkyFitnessMobile/targets/android-widget/kotlin/com/sparkyapps/sparkyfitness/widget/MacroWidget.kt.tmpl b/SparkyFitnessMobile/targets/android-widget/kotlin/com/sparkyapps/sparkyfitness/widget/MacroWidget.kt.tmpl index 702c32943..d37f0645f 100644 --- a/SparkyFitnessMobile/targets/android-widget/kotlin/com/sparkyapps/sparkyfitness/widget/MacroWidget.kt.tmpl +++ b/SparkyFitnessMobile/targets/android-widget/kotlin/com/sparkyapps/sparkyfitness/widget/MacroWidget.kt.tmpl @@ -267,6 +267,9 @@ class MacroWidget : GlanceAppWidget() { val fat: Double, val calories: Double, val remaining: Double?, + val proteinGoal: Double?, + val carbsGoal: Double?, + val fatGoal: Double?, val lastUpdated: Long, ) { val proteinKcal: Double = protein * 4.0 @@ -304,11 +307,10 @@ class MacroWidget : GlanceAppWidget() { carbs = obj.safeDouble("carbs"), fat = obj.safeDouble("fat"), calories = obj.safeDouble("calories"), - remaining = if (obj.has("remaining")) { - obj.safeDouble("remaining") - } else { - null - }, + remaining = obj.optionalDouble("remaining"), + proteinGoal = obj.optionalDouble("proteinGoal"), + carbsGoal = obj.optionalDouble("carbsGoal"), + fatGoal = obj.optionalDouble("fatGoal"), lastUpdated = obj.optLong("lastUpdated", 0L), ) } catch (e: Exception) { @@ -316,8 +318,39 @@ class MacroWidget : GlanceAppWidget() { } } + /** + * Progress toward each macro's own goal, which is what a per-macro bar + * reads as. + * + * These are three independent bars, so the share-of-total this used to show + * was the wrong shape entirely: those three fractions always sum to one, and + * a day's macro ratio barely shifts as the day fills up, so the bars looked + * frozen while the kcal header moved (#2228). The iOS widget keeps the + * share-of-total maths because it draws one segmented ring, where the three + * shares summing to one is the intended reading. + */ private fun MacroSnapshot?.progressFor(kind: MacroKind): Float { val snapshot = this ?: return 0f + + val consumed = when (kind) { + MacroKind.PROTEIN -> snapshot.protein + MacroKind.CARBS -> snapshot.carbs + MacroKind.FAT -> snapshot.fat + } + val goal = when (kind) { + MacroKind.PROTEIN -> snapshot.proteinGoal + MacroKind.CARBS -> snapshot.carbsGoal + MacroKind.FAT -> snapshot.fatGoal + } + + if (goal != null && goal > 0.0) { + return (consumed / goal).coerceIn(0.0, 1.0).toFloat() + } + + // No goal in the snapshot: either it predates the goal fields (the app + // has not refreshed the widget since updating) or the user has no goal + // set for this macro. Fall back to the old share-of-total so the bar + // still carries some signal rather than collapsing to empty. val total = snapshot.macroKcalTotal if (total <= 0.0) return 0f @@ -334,6 +367,13 @@ class MacroWidget : GlanceAppWidget() { return if (value.isFinite()) value else 0.0 } + /** Absent, JSON null and non-finite all read as "not supplied". */ + private fun JSONObject.optionalDouble(name: String): Double? { + if (!has(name) || isNull(name)) return null + val value = optDouble(name, Double.NaN) + return if (value.isFinite()) value else null + } + private fun isToday(date: String): Boolean { if (date.isBlank()) return false return try {