diff --git a/apps/mobile/lib/api.dart b/apps/mobile/lib/api.dart index faf3011..aa46a27 100644 --- a/apps/mobile/lib/api.dart +++ b/apps/mobile/lib/api.dart @@ -25,6 +25,14 @@ class Api { final FlutterSecureStorage _storage = const FlutterSecureStorage(); String? token; + /// Last successful GET response per path. Screens seed their state from it so + /// switching tabs shows data instantly while a fresh copy loads behind it. + final Map> _cache = {}; + + Map? peek(String path) => _cache[path]; + + void clearCache() => _cache.clear(); + Future _sessionFile() async { final dir = await getApplicationSupportDirectory(); if (!await dir.exists()) { @@ -109,6 +117,8 @@ class Api { Future setToken(String? value) async { token = value; + // Login, logout and session expiry change whose data the cache holds. + clearCache(); try { if (value == null) { await _storage.delete(key: 'token'); @@ -176,6 +186,7 @@ class Api { if (sent.statusCode >= 400) { throw ApiException(data['error'] as String? ?? 'server_error', statusCode: sent.statusCode); } + if (method.toUpperCase() == 'GET') _cache[path] = data; return data; } } diff --git a/apps/mobile/lib/main.dart b/apps/mobile/lib/main.dart index 665857e..319d5be 100644 --- a/apps/mobile/lib/main.dart +++ b/apps/mobile/lib/main.dart @@ -969,8 +969,8 @@ class TodayScreen extends StatefulWidget { const _meals = ['breakfast', 'lunch', 'dinner', 'snacks']; class _TodayScreenState extends State { - Map? day; - Map? fasting; + late Map? day = widget.api.peek('/api/days/today'); + late Map? fasting = widget.api.peek('/api/fasting'); String error = ''; Timer? ticker; @@ -1020,6 +1020,7 @@ class _TodayScreenState extends State { void initState() { super.initState(); AppleHealth.synced.addListener(_onHealthSynced); + _ensureTicker(); unawaited(load()); } @@ -2004,7 +2005,7 @@ class FastingScreen extends StatefulWidget { } class _FastingScreenState extends State { - Map? fasting; + late Map? fasting = widget.api.peek('/api/fasting'); String error = ''; Timer? ticker; @@ -2250,7 +2251,7 @@ class WeekScreen extends StatefulWidget { } class _WeekScreenState extends State { - Map? week; + late Map? week = widget.api.peek('/api/weeks'); String? on; @override @@ -2260,8 +2261,11 @@ class _WeekScreenState extends State { } Future load() async { - final query = on == null ? '' : '?on=$on'; - final data = await widget.api.send('GET', '/api/weeks$query'); + final path = '/api/weeks${on == null ? '' : '?on=$on'}'; + final cached = widget.api.peek(path); + if (cached != null && week != cached) setState(() => week = cached); + final data = await widget.api.send('GET', path); + if (!mounted || path != '/api/weeks${on == null ? '' : '?on=$on'}') return; setState(() => week = data); } @@ -2548,7 +2552,9 @@ class WeightScreen extends StatefulWidget { } class _WeightScreenState extends State { - List> weights = []; + late List> weights = + ((widget.api.peek('/api/weights')?['weights'] as List?) ?? const []) + .cast>(); final controller = TextEditingController(); String error = ''; @@ -2759,11 +2765,17 @@ class SettingsScreen extends StatefulWidget { } class _SettingsScreenState extends State { - Map? profile; - Map? fasting; - Map? healthState; - final stepGoal = TextEditingController(); - final exerciseGoal = TextEditingController(); + late Map? profile = + (widget.api.peek('/api/profile')?['profile'] as Map?) + ?.cast(); + late Map? fasting = widget.api.peek('/api/fasting'); + late Map? healthState = widget.api.peek('/api/apple-health'); + late final stepGoal = TextEditingController( + text: healthState == null ? '' : '${healthState!['stepGoal']}', + ); + late final exerciseGoal = TextEditingController( + text: healthState == null ? '' : '${healthState!['exerciseGoalMinutes']}', + ); String healthError = ''; bool healthBusy = false; bool autoPublish = false; @@ -3602,6 +3614,14 @@ class _PermissionsScreenState extends State { @override void initState() { super.initState(); + final cached = widget.api.peek('/api/rbac/permissions'); + if (cached != null) { + operations = ((cached['operations'] as List?) ?? []).cast(); + permissions = ((cached['permissions'] as Map?) ?? {}).map( + (key, value) => MapEntry(key.toString(), value == true), + ); + loading = false; + } unawaited(load()); } diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 05c8ffa..a0605f6 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -3,6 +3,7 @@ import { createPortal } from "react-dom"; import { Link, NavLink, Navigate, Route, Routes, useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom"; import { api, + peek, ApiError, type AppleHealthState, type DayEntry, @@ -459,11 +460,11 @@ const MEAL_COLOR: Record = { function Today({ canVerifyFoods, onFastingChange }: { canVerifyFoods: boolean; onFastingChange: () => void }) { const { t } = useI18n(); - const [loadedDay, setDay] = useState(null); + const [loadedDay, setDay] = useState(() => peek("/api/days/today")); // Removed entry waiting out its Undo window; deleted on the server afterwards. const [pendingRemoval, setPendingRemoval] = useState(null); const removalTimer = useRef(null); - const [fasting, setFasting] = useState(null); + const [fasting, setFasting] = useState(() => peek("/api/fasting")); const [now, setNow] = useState(Date.now()); const [loggingMeal, setLoggingMeal] = useState(null); const [query, setQuery] = useState(""); @@ -878,7 +879,7 @@ function mealLabel(t: Copy, meal: MealCategory) { function FastingPage({ onFastingChange }: { onFastingChange: () => void }) { const { t } = useI18n(); - const [fasting, setFasting] = useState(null); + const [fasting, setFasting] = useState(() => peek("/api/fasting")); const [error, setError] = useState(""); const [now, setNow] = useState(Date.now()); @@ -1102,12 +1103,14 @@ function WeekPage() { const { t } = useI18n(); const [params, setParams] = useSearchParams(); const on = params.get("on") ?? ""; - const [week, setWeek] = useState(null); + const weekPath = `/api/weeks${on ? `?on=${on}` : ""}`; + const [week, setWeek] = useState(() => peek(weekPath)); const [error, setError] = useState(""); useEffect(() => { - const query = on ? `?on=${on}` : ""; - api(`/api/weeks${query}`).then(setWeek).catch((err) => setError(message(t, err))); - }, [on, t]); + const cached = peek(weekPath); + if (cached) setWeek(cached); + api(weekPath).then(setWeek).catch((err) => setError(message(t, err))); + }, [weekPath, t]); if (!week) return error ?

{error}

:

…

; const today = todayJerusalemDay(); const isCurrentWeek = addDays(week.sunday, 7) > today; @@ -1176,7 +1179,7 @@ function formatKcal(value: number): string { function WeightPage() { const { t } = useI18n(); - const [weights, setWeights] = useState([]); + const [weights, setWeights] = useState(() => peek<{ weights: WeightPoint[] }>("/api/weights")?.weights ?? []); const [error, setError] = useState(""); async function load() { const data = await api<{ weights: WeightPoint[] }>("/api/weights"); @@ -1258,17 +1261,19 @@ function SettingsPage({ me, setMe, onFastingChange, onLogout }: { me: Me; setMe: const { section } = useParams(); const navigate = useNavigate(); const { mode: themeMode, setMode: setThemeMode } = useTheme(); - const [profile, setProfile] = useState(null); - const [fasting, setFasting] = useState(null); - const [fastingEnabledDraft, setFastingEnabledDraft] = useState(false); - const [reminderOffsets, setReminderOffsets] = useState([0]); + const cachedProfile = peek<{ profile: Profile }>("/api/profile"); + const cachedFasting = peek("/api/fasting"); + const [profile, setProfile] = useState(cachedProfile?.profile ?? null); + const [fasting, setFasting] = useState(cachedFasting); + const [fastingEnabledDraft, setFastingEnabledDraft] = useState(cachedFasting?.enabled ?? false); + const [reminderOffsets, setReminderOffsets] = useState(cachedFasting?.reminderOffsets ?? [0]); const [autoPublish, setAutoPublish] = useState(me.user.autoPublishFoods); const [currentPassword, setCurrentPassword] = useState(""); const [newPassword, setNewPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); const [passwordStatus, setPasswordStatus] = useState(""); const [passwordError, setPasswordError] = useState(""); - const [health, setHealth] = useState(null); + const [health, setHealth] = useState(() => peek("/api/apple-health")); const [error, setError] = useState(""); const pwChecks = checkSignupPassword(newPassword, confirmPassword); const canReadHealth = me.permissions.includes("appleHealth:read"); @@ -1623,7 +1628,7 @@ function Chevron({ back = false }: { back?: boolean }) { function PermissionsPage() { const { t } = useI18n(); - const [permissions, setPermissions] = useState | null>(null); + const [permissions, setPermissions] = useState | null>(() => peek("/api/rbac/permissions")?.permissions ?? null); const [error, setError] = useState(""); useEffect(() => { @@ -1684,7 +1689,7 @@ function FoodForm() { const { id } = useParams(); const [params] = useSearchParams(); const navigate = useNavigate(); - const [food, setFood] = useState(null); + const [food, setFood] = useState(() => (id ? peek<{ food: Food }>(`/api/foods/${id}`)?.food ?? null : null)); const [error, setError] = useState(""); const barcode = params.get("barcode") ?? food?.barcode ?? ""; const returnMeal = params.get("meal"); diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 61feb80..dcbd2f5 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -17,7 +17,21 @@ export function getStoredToken(): string | null { } } +// Last successful GET response per path. Pages seed their state from it so +// navigating back shows data instantly while a fresh copy loads behind it. +const responseCache = new Map(); + +export function peek(path: string): T | null { + return (responseCache.get(path) as T | undefined) ?? null; +} + +export function clearApiCache(): void { + responseCache.clear(); +} + export function setStoredToken(token: string | null): void { + // Login, logout and session expiry all change who the cached data belongs to. + clearApiCache(); try { if (token) { localStorage.setItem(TOKEN_KEY, token); @@ -51,6 +65,7 @@ export async function api(path: string, init: RequestInit = {}): Promise { if (typeof data.token === "string" && data.token.length > 0) { setStoredToken(data.token); } + if (!init.method || init.method.toUpperCase() === "GET") responseCache.set(path, data); return data as T; }