Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
8 changes: 8 additions & 0 deletions SparkyFitnessMobile/src/hooks/useWidgetSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,13 +128,21 @@ 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,
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,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
};
const macroSnapshotKey = JSON.stringify(macroSnapshot);
if (lastAndroidMacroSnapshotKeyRef.current === macroSnapshotKey) return;
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