From e26be6f8621916923ad1a08fed04980433665d03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CCodewithCJ=E2=80=9D?= <“chandrasjr@gmail.com”> Date: Tue, 25 Aug 2026 19:30:41 -0400 Subject: [PATCH 1/2] fix(mobile): show goal progress in the Android macro widget bars The macro widget's three bars rendered each macro's share of total macro calories rather than progress toward its goal. Those 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. With 87g protein, 184g carbs and 94g fat the bars drew 18/38/44% where the dashboard showed 81/77/114%. The widget had no way to do better: the snapshot only ever carried consumed grams, never the goals. - Send proteinGoal, carbsGoal and fatGoal in the Android macro snapshot. The push dedupe key is the stringified snapshot, so a goal-only change now re-pushes instead of being skipped - Draw each bar as consumed/goal, falling back to the old share-of-total when a goal is missing, so snapshots written by an older build and macros with no goal set degrade instead of collapsing to empty - Parse the optional doubles through one helper, so absent, JSON null and non-finite all read as "not supplied" The iOS widget keeps the share-of-total maths: it draws one segmented ring, where three shares summing to one is the intended reading. Fixes #2228 --- .../__tests__/hooks/useWidgetSync.test.ts | 34 +++++++++++++ .../src/hooks/useWidgetSync.ts | 8 +++ .../sparkyfitness/widget/MacroWidget.kt.tmpl | 50 +++++++++++++++++-- 3 files changed, 87 insertions(+), 5 deletions(-) diff --git a/SparkyFitnessMobile/__tests__/hooks/useWidgetSync.test.ts b/SparkyFitnessMobile/__tests__/hooks/useWidgetSync.test.ts index 1ead73da30..df97a7522f 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 6c731beb01..457199c9b3 100644 --- a/SparkyFitnessMobile/src/hooks/useWidgetSync.ts +++ b/SparkyFitnessMobile/src/hooks/useWidgetSync.ts @@ -128,6 +128,11 @@ export function useWidgetSync(summary: DailySummary | undefined): void { } } + // 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 = { date, protein: summary.protein.consumed, @@ -135,6 +140,9 @@ export function useWidgetSync(summary: DailySummary | undefined): void { 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; 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 702c32943b..d37f0645fa 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 { From 0ab386cce8a858ccc880a42292c54fe9c17000aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CCodewithCJ=E2=80=9D?= <“chandrasjr@gmail.com”> Date: Tue, 25 Aug 2026 19:42:51 -0400 Subject: [PATCH 2/2] refactor(mobile): declare the Android widget snapshot payloads explicitly Both Android widget payloads were inferred object literals, so a renamed or dropped field passed typecheck and only surfaced as a widget rendering a stale or empty value. Typing them turns that into a compile error. - Add AndroidCalorieSnapshot and AndroidMacroSnapshot, mirroring the keys parseSnapshot reads in CalorieWidget.kt.tmpl and MacroWidget.kt.tmpl - Wrap the pushed payloads in AndroidWidgetPayload, which adds the lastUpdated timestamp that is deliberately absent from the dedupe key This does not make the boundary type-safe: setCalorieSnapshot and setMacroSnapshot take a string, and the Kotlin readers match these keys by name, so both halves still have to be kept in step by hand. --- .../src/hooks/useWidgetSync.ts | 43 +++++++++++++++++-- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/SparkyFitnessMobile/src/hooks/useWidgetSync.ts b/SparkyFitnessMobile/src/hooks/useWidgetSync.ts index 457199c9b3..01164d5645 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, }; @@ -133,7 +168,7 @@ export function useWidgetSync(summary: DailySummary | undefined): void { // 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 = { + const macroSnapshot: AndroidMacroSnapshot = { date, protein: summary.protein.consumed, carbs: summary.carbs.consumed, @@ -148,7 +183,7 @@ export function useWidgetSync(summary: DailySummary | undefined): void { if (lastAndroidMacroSnapshotKeyRef.current === macroSnapshotKey) return; lastAndroidMacroSnapshotKeyRef.current = macroSnapshotKey; - const macroPayload = { + const macroPayload: AndroidWidgetPayload = { ...macroSnapshot, lastUpdated, };