Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions SparkyFitnessMobile/__tests__/hooks/useWidgetSync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
51 changes: 47 additions & 4 deletions SparkyFitnessMobile/src/hooks/useWidgetSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> = T & { lastUpdated: number };

const iosAppGroup = (
Constants.expoConfig?.extra as { iosAppGroup?: string } | undefined
)?.iosAppGroup;
Expand Down Expand Up @@ -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,
Expand All @@ -102,7 +137,7 @@ export function useWidgetSync(summary: DailySummary | undefined): void {

if (lastAndroidCalorieSnapshotKeyRef.current !== calorieSnapshotKey) {
lastAndroidCalorieSnapshotKeyRef.current = calorieSnapshotKey;
const caloriePayload = {
const caloriePayload: AndroidWidgetPayload<AndroidCalorieSnapshot> = {
...calorieSnapshot,
lastUpdated,
};
Expand All @@ -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<AndroidMacroSnapshot> = {
...macroSnapshot,
lastUpdated,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -304,20 +307,50 @@ 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) {
null
}
}

/**
* 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

Expand All @@ -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 {
Expand Down
Loading