Skip to content

Commit 57601cf

Browse files
Cawlummclaude
andcommitted
feat(mobile): Phase 0 — port workoutSession store to @lyftr/shared + Toast primitive
First phase of the faithful web→mobile Workouts port (Fable orchestrated the spec, Sonnet implemented it, verified here). Foundation only — no screens yet. - Port the workoutSession Zustand store into packages/shared as a factory createWorkoutSession(storage): a 1:1 port of web/src/stores/workoutSession.ts whose only real change is sync localStorage → async StorageAdapter. State starts empty + an async hydrate()/isHydrated restores the persisted session + gym UI (matching the auth/theme store pattern); mutating actions keep the web's synchronous signatures and persist fire-and-forget. Rest-timer FSM (pause/resume/adjust-clamp/auto-finish) is preserved exactly and stays in-memory — never persisted. - Port the store's behavior test (jest + memoryStorage): 18 tests covering the rest FSM, buildPayload #40 progression metadata, ephemeral-rest, and the new hydrate path. - Add program_set_id? to shared ActiveSessionSet (web had it; buildPayload/#40 need it). - Wire useWorkoutSession in mobile/src/lib/lyftr.ts + hydrate in app/_layout.tsx (not gated on first paint — screens read the store's own isHydrated). - Add ui/Toast.tsx (mobile analog of web Toast: variant icon-chip + border, Reanimated FadeInUp/Down, docks above the tab bar) + warning/warningSoft theme tokens. - expo-keep-awake dep added (for the active-session phase). shared: tsc clean, jest 29/29 green. mobile: tsc clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ccq3rvfb9ArzS2DidghJm5
1 parent 0010d0e commit 57601cf

13 files changed

Lines changed: 667 additions & 42 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,6 @@ web/e2e/.auth/
5959
web/test-results/
6060
web/playwright-report/
6161
.playwright-mcp/
62+
63+
# Local Fable/Sonnet porting specs (working artifacts, not committed)
64+
.workouts-port/

mobile/CONVENTIONS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ spinners for partial data.
143143
| `SectionHeader` | `SectionHeader` | built |
144144
| `StepperTile` | `StepperTile` | built |
145145
| `EmptyState` | `EmptyState` | built |
146-
| `Toast` | | **to-build** (RN: absolutely-positioned above tab bar; no portal needed) |
146+
| `Toast` | `Toast` | built |
147147
| `DateInput` || **to-build** (needs `@react-native-community/datetimepicker`; add the dep only when a screen needs it) |
148148
| `.card` CSS class | `Card` | built |
149149
| `.btn` CSS classes | `Button` | built |

mobile/app/_layout.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
PlusJakartaSans_700Bold,
1414
PlusJakartaSans_800ExtraBold,
1515
} from '@expo-google-fonts/plus-jakarta-sans'
16-
import { useAuthStore, useServerStore, useThemeStore } from '../src/lib/lyftr'
16+
import { useAuthStore, useServerStore, useThemeStore, useWorkoutSession } from '../src/lib/lyftr'
1717
import { useTheme } from '../src/theme/useTheme'
1818

1919
// Root layout: hydrate persisted state once, then gate routes on auth. Unauthed users
@@ -22,6 +22,7 @@ export default function RootLayout() {
2222
const hydrateAuth = useAuthStore((s) => s.hydrate)
2323
const hydrateServer = useServerStore((s) => s.hydrate)
2424
const hydrateTheme = useThemeStore((s) => s.hydrate)
25+
const hydrateWorkout = useWorkoutSession((s) => s.hydrate)
2526
const isHydrated = useAuthStore((s) => s.isHydrated)
2627
const themeHydrated = useThemeStore((s) => s.isHydrated)
2728
const isAuthed = useAuthStore((s) => s.isAuthenticated)
@@ -42,7 +43,8 @@ export default function RootLayout() {
4243
hydrateAuth()
4344
hydrateServer()
4445
hydrateTheme()
45-
}, [hydrateAuth, hydrateServer, hydrateTheme])
46+
hydrateWorkout()
47+
}, [hydrateAuth, hydrateServer, hydrateTheme, hydrateWorkout])
4648

4749
// Drive NativeWind's className theming from the same store the inline-styled
4850
// screens read, so `dark:`/CSS-var tokens flip together with useTheme().

mobile/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"expo-constants": "~18.0.13",
2626
"expo-font": "~14.0.12",
2727
"expo-haptics": "~15.0.8",
28+
"expo-keep-awake": "~15.0.8",
2829
"expo-linear-gradient": "~15.0.8",
2930
"expo-linking": "~8.0.12",
3031
"expo-router": "~6.0.24",

mobile/src/components/ui/Toast.tsx

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { useEffect } from 'react'
2+
import { Pressable, View } from 'react-native'
3+
import Animated, { FadeInUp, FadeOutDown } from 'react-native-reanimated'
4+
import { X, ChevronRight } from 'lucide-react-native'
5+
import type { LucideIcon } from 'lucide-react-native'
6+
import { useTheme } from '../../theme/useTheme'
7+
import { AppText } from './Typography'
8+
9+
export type ToastVariant = 'default' | 'success' | 'brand' | 'warning' | 'error'
10+
11+
interface Props {
12+
title: string
13+
description?: string
14+
icon?: LucideIcon
15+
variant?: ToastVariant
16+
/** When set, the body is tappable (a chevron affordance is shown). */
17+
onPress?: () => void
18+
onDismiss: () => void
19+
/** Auto-dismiss delay; pass 0 to keep it until dismissed. Default 4s. */
20+
autoDismissMs?: number
21+
/** Extra classes on the absolute wrapper (e.g. to raise the dock point). */
22+
className?: string
23+
}
24+
25+
// Semantic colour applied only to the icon chip + border, keeping the text in the
26+
// normal tx-* hierarchy — a fully tinted toast reads as obnoxious over content.
27+
const VARIANTS: Record<ToastVariant, { border: string; chip: string }> = {
28+
default: { border: 'border-surface-border', chip: 'bg-surface-muted border-surface-border' },
29+
success: { border: 'border-success-500/20', chip: 'bg-success-500/10 border-success-500/20' },
30+
brand: { border: 'border-brand-500/20', chip: 'bg-brand-500/10 border-brand-500/20' },
31+
warning: { border: 'border-warning-500/20', chip: 'bg-warning-500/10 border-warning-500/20' },
32+
error: { border: 'border-error-500/20', chip: 'bg-error-500/10 border-error-500/20' },
33+
}
34+
35+
// A dismissible floating toast — the app's shared transient notification, mirror of
36+
// web ui/Toast. No portal needed on RN: render it as the LAST child of the screen
37+
// root and the absolute wrapper docks it just above the tab bar (screen content is
38+
// laid out above the bar, so bottom-3 clears it without a height constant).
39+
export function Toast({
40+
title,
41+
description,
42+
icon: Icon,
43+
variant = 'default',
44+
onPress,
45+
onDismiss,
46+
autoDismissMs = 4000,
47+
className = '',
48+
}: Props) {
49+
const { colors, brand, accent, isDark } = useTheme()
50+
51+
// Armed once on mount — parent re-renders must not extend the window (0 disables).
52+
useEffect(() => {
53+
if (!autoDismissMs) return
54+
const id = setTimeout(onDismiss, autoDismissMs)
55+
return () => clearTimeout(id)
56+
// eslint-disable-next-line react-hooks/exhaustive-deps
57+
}, [])
58+
59+
// Icon `color` is a component prop, so it must come from the theme object (not a
60+
// className). Soft shades read on dark but wash out on light — pick via isDark
61+
// (same legibility rule as IconButton's danger variant).
62+
const ICON_COLOR: Record<ToastVariant, string> = {
63+
default: colors.txSecondary,
64+
success: isDark ? brand.successSoft : brand.success,
65+
brand: accent,
66+
warning: isDark ? brand.warningSoft : brand.warning,
67+
error: isDark ? brand.errorSoft : brand.error,
68+
}
69+
const v = VARIANTS[variant]
70+
71+
const body = (
72+
<>
73+
{Icon && (
74+
<View className={`w-8 h-8 rounded-full border items-center justify-center ${v.chip}`}>
75+
<Icon size={16} color={ICON_COLOR[variant]} />
76+
</View>
77+
)}
78+
<View className="flex-1">
79+
<AppText variant="subheading">{title}</AppText>
80+
{description ? (
81+
<AppText variant="caption" color="muted" numberOfLines={1}>
82+
{description}
83+
</AppText>
84+
) : null}
85+
</View>
86+
{onPress && <ChevronRight size={16} color={colors.txMuted} />}
87+
</>
88+
)
89+
90+
return (
91+
<Animated.View
92+
entering={FadeInUp.duration(200)}
93+
exiting={FadeOutDown.duration(150)}
94+
accessibilityRole="alert"
95+
className={`absolute bottom-3 left-3 right-3 z-50 ${className}`}
96+
>
97+
<View className={`flex-row items-center gap-3 rounded-2xl px-4 py-3 bg-surface-raised border shadow-lg ${v.border}`}>
98+
{onPress ? (
99+
<Pressable onPress={onPress} className="flex-row items-center gap-3 flex-1 active:scale-95">
100+
{body}
101+
</Pressable>
102+
) : (
103+
<View className="flex-row items-center gap-3 flex-1">{body}</View>
104+
)}
105+
<Pressable
106+
onPress={onDismiss}
107+
accessibilityLabel="Dismiss"
108+
hitSlop={8}
109+
className="p-1.5 -m-1.5 active:opacity-60"
110+
>
111+
<X size={16} color={colors.txMuted} />
112+
</Pressable>
113+
</View>
114+
</Animated.View>
115+
)
116+
}

mobile/src/components/ui/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,7 @@ export { StepperTile } from './StepperTile'
3030
export { Stat } from './Stat'
3131
export { ListRow } from './ListRow'
3232
export { EmptyState } from './EmptyState'
33+
34+
// Feedback
35+
export { Toast } from './Toast'
36+
export type { ToastVariant } from './Toast'

mobile/src/lib/lyftr.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
createServerStore,
88
createSettingsStore,
99
createThemeStore,
10+
createWorkoutSession,
1011
} from '@lyftr/shared'
1112
import { storage } from './storage'
1213

@@ -26,5 +27,8 @@ export const useServerStore = createServerStore(storage)
2627
export const useSettingsStore = createSettingsStore(client, storage)
2728
// Light-first on mobile (per product); mirrors the web's theme logic + 'theme' key.
2829
export const useThemeStore = createThemeStore(storage, 'light')
30+
// Workout session state (active workout + gym UI position) — device-local via
31+
// AsyncStorage; rest-timer state is in-memory only (see the store).
32+
export const useWorkoutSession = createWorkoutSession(storage)
2933

3034
export { storage }

mobile/src/theme/theme.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,6 @@ export const brand = {
4949
successSoft: '#4ade80',
5050
error: '#ef4444',
5151
errorSoft: '#f87171',
52+
warning: '#eab308',
53+
warningSoft: '#facc15',
5254
}

0 commit comments

Comments
 (0)