Skip to content

Commit 4c360be

Browse files
Cawlummclaude
andcommitted
feat(mobile): Phase 1a — Workouts list + detail screens + routing
Faithful 1:1 port of web Workouts.tsx + WorkoutDetail.tsx (Fable spec, Sonnet impl, verified here). The read-mostly half of Workouts CRUD; add/edit forms + pickers are 1b. - Add a Workouts tab (Dumbbell) — order Home · Workouts · Weight · Settings — with a nested expo-router Stack at app/(tabs)/workouts/ (_layout, index, [id]). - Workouts list (index.tsx): PageHeader + summary stats (Total/This Month/Avg), 300ms debounced search, WorkoutCard list, empty state, and FlatList onEndReached pagination replacing the web IntersectionObserver hook. useFocusEffect reload keeps the list fresh after a detail-screen delete. - WorkoutDetail ([id].tsx): header + 3-stat strip (Duration/Sets/Volume), notes, per-exercise cards with set chips (reps×weight/BW, best-set highlight, rest badge), Alert.alert delete → back to list. - Port useServerInfiniteList to RN (same contract; loadMore() for onEndReached); local WorkoutCard + ExerciseImage (img→Dumbbell fallback) under components/workouts/; muscleColor util. OS Alert confirms replace the web kebab/portal menus. - Links to /workouts/new, /[id]/edit, /exercise/[id] are wired now (cast Href + TODO); those screens land in 1b / 2.5. Progression toast is a Phase 2 TODO. mobile tsc clean (typed routes regenerated). No new deps. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ccq3rvfb9ArzS2DidghJm5
1 parent 57601cf commit 4c360be

8 files changed

Lines changed: 767 additions & 1 deletion

File tree

mobile/app/(tabs)/_layout.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Tabs } from 'expo-router'
2-
import { House, ChartLine, Settings } from 'lucide-react-native'
2+
import { House, Dumbbell, ChartLine, Settings } from 'lucide-react-native'
33
import { useTheme } from '../../src/theme/useTheme'
44

55
export default function TabsLayout() {
@@ -20,6 +20,10 @@ export default function TabsLayout() {
2020
name="index"
2121
options={{ title: 'Home', tabBarIcon: ({ color, size }) => <House color={color} size={size} /> }}
2222
/>
23+
<Tabs.Screen
24+
name="workouts"
25+
options={{ title: 'Workouts', tabBarIcon: ({ color, size }) => <Dumbbell color={color} size={size} /> }}
26+
/>
2327
<Tabs.Screen
2428
name="weight"
2529
options={{ title: 'Weight', tabBarIcon: ({ color, size }) => <ChartLine color={color} size={size} /> }}
Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
import { useEffect, useState } from 'react'
2+
import { ActivityIndicator, Alert, Pressable, ScrollView, View } from 'react-native'
3+
import { router, useLocalSearchParams, type Href } from 'expo-router'
4+
import { format } from 'date-fns'
5+
import {
6+
AlertCircle, ArrowLeft, ChevronRight, Clock, Dumbbell, Edit2, Pause, TimerOff, Trash2, TrendingUp,
7+
} from 'lucide-react-native'
8+
import {
9+
apiErrorMessage, displayVolume, displayWeight, weightShort,
10+
type Workout, type Set as WorkoutSet,
11+
} from '@lyftr/shared'
12+
import { AppText, IconButton, Screen } from '../../../src/components/ui'
13+
import { ExerciseImage } from '../../../src/components/workouts/ExerciseImage'
14+
import { client, useSettingsStore } from '../../../src/lib/lyftr'
15+
import { useTheme } from '../../../src/theme/useTheme'
16+
import { muscleColor } from '../../../src/utils/exerciseUtils'
17+
18+
// TODO(phase-1b): EditWorkout screen lands next chunk — cast until the route exists.
19+
const editHref = (id: number) => `/workouts/${id}/edit` as unknown as Href
20+
// TODO(phase-2.5): exercise-detail screen. Until it lands this opens expo-router's
21+
// built-in Unmatched Route screen (deliberate — the nav is wired, back recovers).
22+
const exerciseHref = (exerciseId: number) => `/workouts/exercise/${exerciseId}` as unknown as Href
23+
24+
const restLabel = (s: number) => (s % 60 === 0 && s >= 60 ? `${s / 60}m` : `${s}s`)
25+
26+
function SetChip({ set, isBest, unit }: { set: WorkoutSet; isBest: boolean; unit: string }) {
27+
return (
28+
// Web's ring-1 → a real border; non-best chips get border-transparent so both
29+
// states are the same size (RN borders take layout space, rings don't).
30+
<View
31+
className={`px-2.5 py-1.5 rounded-lg ${
32+
isBest ? 'bg-brand-500/15 border border-brand-500/25' : 'bg-surface-raised border border-transparent'
33+
}`}
34+
>
35+
<AppText
36+
variant="caption"
37+
color={isBest ? 'brand' : 'secondary'}
38+
style={{ fontVariant: ['tabular-nums'] }}
39+
>
40+
{set.reps > 0 ? set.reps : '—'} × {set.weight > 0 ? `${displayWeight(set.weight, unit)} ${unit}` : 'BW'}
41+
</AppText>
42+
</View>
43+
)
44+
}
45+
46+
function MuscleBadge({ muscle }: { muscle: string }) {
47+
const { colors } = useTheme()
48+
const tint = muscleColor(muscle)
49+
return (
50+
<View className={`px-1.5 py-0.5 rounded ${tint?.chip ?? 'bg-surface-muted'}`}>
51+
{/* Tint via inline style — see exerciseUtils.ts for why not a className. */}
52+
<AppText variant="caption" style={{ color: tint?.text ?? colors.txMuted }}>
53+
{muscle}
54+
</AppText>
55+
</View>
56+
)
57+
}
58+
59+
export default function WorkoutDetail() {
60+
const { id } = useLocalSearchParams<{ id: string }>()
61+
const settings = useSettingsStore((s) => s.settings)
62+
const fetchSettings = useSettingsStore((s) => s.fetch)
63+
const wUnit = weightShort(settings.weight_unit)
64+
const restOn = settings.rest_enabled ?? true
65+
const { colors, brand, accent, isDark } = useTheme()
66+
67+
const [workout, setWorkout] = useState<Workout | null>(null)
68+
const [loading, setLoading] = useState(true)
69+
const [error, setError] = useState<string | null>(null)
70+
const [deleting, setDeleting] = useState(false)
71+
72+
useEffect(() => {
73+
fetchSettings()
74+
}, [fetchSettings])
75+
76+
useEffect(() => {
77+
let cancelled = false
78+
const load = async () => {
79+
try {
80+
const data = await client.workoutAPI.get(Number(id))
81+
if (!cancelled) setWorkout(data)
82+
} catch (err) {
83+
if (!cancelled) setError(apiErrorMessage(err, 'Failed to load workout'))
84+
} finally {
85+
if (!cancelled) setLoading(false)
86+
}
87+
}
88+
load()
89+
return () => { cancelled = true }
90+
}, [id])
91+
92+
// Deep links can land here with no history — fall back to the list route.
93+
const goBack = () => (router.canGoBack() ? router.back() : router.replace('/workouts'))
94+
95+
const confirmDelete = () => {
96+
if (!workout) return
97+
// Web's portal bottom-sheet confirm → the OS-native destructive Alert
98+
// (weight.tsx precedent; simplest faithful confirm on RN).
99+
Alert.alert('Delete Workout?', `"${workout.name}" will be permanently deleted.`, [
100+
{ text: 'Cancel', style: 'cancel' },
101+
{
102+
text: 'Delete',
103+
style: 'destructive',
104+
onPress: async () => {
105+
setDeleting(true)
106+
try {
107+
await client.workoutAPI.delete(workout.id)
108+
goBack() // list refetches on focus
109+
} catch {
110+
setDeleting(false)
111+
}
112+
},
113+
},
114+
])
115+
}
116+
117+
if (loading) {
118+
return (
119+
<Screen className="items-center justify-center">
120+
<ActivityIndicator color={accent} />
121+
</Screen>
122+
)
123+
}
124+
125+
if (error || !workout) {
126+
return (
127+
<Screen>
128+
<View className="gap-4 py-4">
129+
<Pressable onPress={goBack} hitSlop={8} className="flex-row items-center gap-2 self-start active:opacity-60">
130+
<ArrowLeft size={16} color={colors.txMuted} />
131+
<AppText variant="body" color="muted">Back</AppText>
132+
</Pressable>
133+
{/* Boxed request-error alert (authui AuthError pattern) */}
134+
<View className="flex-row items-center gap-2 rounded-xl border border-error-500/20 bg-error-500/10 p-4">
135+
<AlertCircle size={20} color={isDark ? brand.errorSoft : brand.error} />
136+
<AppText variant="body" color="error" className="flex-1">
137+
{error || 'Workout not found'}
138+
</AppText>
139+
</View>
140+
</View>
141+
</Screen>
142+
)
143+
}
144+
145+
const exs = workout.exercises ?? []
146+
const totalVolume = displayVolume(
147+
exs.reduce((s, ex) => s + (ex.sets ?? []).reduce((ss, set) => ss + set.reps * set.weight, 0), 0),
148+
wUnit
149+
)
150+
const totalSets = exs.reduce((s, ex) => s + (ex.sets ?? []).length, 0)
151+
const durationMin = Math.round(workout.duration / 60)
152+
153+
return (
154+
<Screen>
155+
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={{ paddingBottom: 32 }}>
156+
<View className="gap-5 py-4">
157+
{/* Back nav + actions (web's top row) */}
158+
<View className="flex-row items-center justify-between">
159+
<Pressable onPress={goBack} hitSlop={8} className="flex-row items-center gap-1.5 active:opacity-60">
160+
<ArrowLeft size={16} color={colors.txMuted} />
161+
<AppText variant="body" color="muted">Workouts</AppText>
162+
</Pressable>
163+
<View className="flex-row items-center gap-1">
164+
{/* IconButton has no bg-less brand variant; the chip 'brand' variant is
165+
the kit-conformant nearest to the web's bare brand pencil. */}
166+
<IconButton
167+
icon={Edit2}
168+
label="Edit workout"
169+
variant="brand"
170+
size="md"
171+
onPress={() => router.push(editHref(workout.id))}
172+
/>
173+
<IconButton
174+
icon={Trash2}
175+
label="Delete workout"
176+
variant="danger"
177+
size="md"
178+
onPress={confirmDelete}
179+
disabled={deleting}
180+
/>
181+
</View>
182+
</View>
183+
184+
{/* Header card */}
185+
<View className="bg-surface-raised border border-surface-border rounded-xl p-4">
186+
<View className="flex-row items-start gap-3">
187+
<ExerciseImage url={exs[0]?.exercise?.image_url} size="hero" />
188+
<View className="flex-1">
189+
<AppText variant="heading">{workout.name}</AppText>
190+
<AppText variant="body" color="muted" className="mt-0.5">
191+
{format(new Date(workout.started_at), 'EEEE, MMMM d, yyyy')}
192+
</AppText>
193+
</View>
194+
</View>
195+
196+
{/* Stats strip: Duration / Sets / Volume */}
197+
<View className="flex-row mt-4 pt-4 border-t border-surface-border">
198+
<View className="flex-1 items-center">
199+
<View className="flex-row items-center gap-1 mb-0.5">
200+
<Clock size={13} color={colors.txMuted} />
201+
<AppText variant="caption" color="muted">Duration</AppText>
202+
</View>
203+
<AppText variant="heading" style={{ fontVariant: ['tabular-nums'] }}>
204+
{durationMin}
205+
<AppText variant="caption" color="muted"> min</AppText>
206+
</AppText>
207+
</View>
208+
<View className="flex-1 items-center border-x border-surface-border">
209+
<View className="flex-row items-center gap-1 mb-0.5">
210+
<Dumbbell size={13} color={colors.txMuted} />
211+
<AppText variant="caption" color="muted">Sets</AppText>
212+
</View>
213+
<AppText variant="heading" style={{ fontVariant: ['tabular-nums'] }}>{totalSets}</AppText>
214+
</View>
215+
<View className="flex-1 items-center">
216+
<View className="flex-row items-center gap-1 mb-0.5">
217+
<TrendingUp size={13} color={colors.txMuted} />
218+
<AppText variant="caption" color="muted">Volume</AppText>
219+
</View>
220+
<AppText variant="heading" style={{ fontVariant: ['tabular-nums'] }}>
221+
{totalVolume > 0 ? totalVolume.toLocaleString() : '—'}
222+
{totalVolume > 0 && <AppText variant="caption" color="muted"> {wUnit}</AppText>}
223+
</AppText>
224+
</View>
225+
</View>
226+
227+
{workout.notes ? (
228+
<View className="mt-3 pt-3 border-t border-surface-border">
229+
<AppText variant="body" color="muted">{workout.notes}</AppText>
230+
</View>
231+
) : null}
232+
</View>
233+
234+
{/* Exercises */}
235+
{!restOn && (
236+
<View className="flex-row items-center gap-1.5 px-1">
237+
<TimerOff size={13} color={colors.txMuted} />
238+
<AppText variant="caption" color="muted">Rest timer is off — turn it on in Settings</AppText>
239+
</View>
240+
)}
241+
<View className="gap-3">
242+
{exs.map((ex) => {
243+
const sets = ex.sets ?? []
244+
const maxWeightLbs = sets.length > 0 ? Math.max(...sets.map((s) => s.weight || 0)) : 0
245+
const maxWeight = displayWeight(maxWeightLbs, wUnit)
246+
const exVol = displayVolume(sets.reduce((s, set) => s + (set.reps || 0) * (set.weight || 0), 0), wUnit)
247+
248+
return (
249+
<Pressable
250+
key={ex.id ?? ex.exercise_id}
251+
accessibilityRole="button"
252+
onPress={() => router.push(exerciseHref(ex.exercise_id))}
253+
// Card surface inlined (not <Card>): its baked-in p-4 can't be
254+
// overridden safely (two padding classes = stylesheet-order
255+
// roulette) and this card needs edge-to-edge inner sections.
256+
className="bg-surface-raised border border-surface-border rounded-xl overflow-hidden active:scale-[0.99]"
257+
>
258+
<View className="flex-row items-center gap-3 p-4">
259+
<ExerciseImage url={ex.exercise?.image_url} />
260+
<View className="flex-1">
261+
<AppText variant="subheading" numberOfLines={1}>{ex.exercise?.name}</AppText>
262+
<View className="flex-row items-center gap-2 mt-0.5">
263+
{ex.exercise?.muscle_group ? <MuscleBadge muscle={ex.exercise.muscle_group} /> : null}
264+
<AppText variant="caption" color="muted" numberOfLines={1} className="flex-shrink">
265+
{sets.length} sets{exVol > 0 ? ` · ${exVol.toLocaleString()} ${wUnit}` : ''}
266+
</AppText>
267+
</View>
268+
</View>
269+
{maxWeight > 0 && (
270+
<View className="items-end mr-1">
271+
<AppText variant="label" color="muted">best</AppText>
272+
<AppText variant="subheading" color="brand" style={{ fontVariant: ['tabular-nums'] }}>
273+
{maxWeight} {wUnit}
274+
</AppText>
275+
</View>
276+
)}
277+
<ChevronRight size={16} color={colors.txMuted} />
278+
</View>
279+
280+
{sets.length > 0 && (
281+
<View className="flex-row items-center gap-2 px-4 pb-4 pt-3 border-t border-surface-border/50">
282+
<View className="flex-1 flex-row flex-wrap gap-1.5">
283+
{sets.map((set, i) => (
284+
<SetChip key={i} set={set} isBest={set.weight === maxWeightLbs && maxWeightLbs > 0} unit={wUnit} />
285+
))}
286+
</View>
287+
{restOn && (
288+
ex.rest_seconds === 0 ? (
289+
<AppText variant="caption" color="muted">No rest</AppText>
290+
) : (
291+
<View className="flex-row items-center gap-1">
292+
<Pause size={13} color={colors.txMuted} />
293+
<AppText variant="caption" color="muted">{restLabel(ex.rest_seconds ?? 90)}</AppText>
294+
</View>
295+
)
296+
)}
297+
</View>
298+
)}
299+
</Pressable>
300+
)
301+
})}
302+
</View>
303+
</View>
304+
</ScrollView>
305+
</Screen>
306+
)
307+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { Stack } from 'expo-router'
2+
import { useTheme } from '../../../src/theme/useTheme'
3+
4+
// Nested stack under the Workouts tab: list → detail (→ forms in later phases).
5+
// contentStyle pins the card background to the app surface so push transitions
6+
// don't flash the platform default white/black — same reason (auth)/_layout pins
7+
// its background, but theme-aware here instead of hard-coded.
8+
export default function WorkoutsLayout() {
9+
const { colors } = useTheme()
10+
return (
11+
<Stack
12+
screenOptions={{
13+
headerShown: false,
14+
contentStyle: { backgroundColor: colors.base },
15+
}}
16+
/>
17+
)
18+
}

0 commit comments

Comments
 (0)