feat: shared components, hooks, and audio engine
Components: - StyledText: unified text component replacing 5 local copies - GlassCard: reusable glassmorphic card with blur background - VideoPlayer: expo-video wrapper with preview/background modes and gradient fallback when no video URL Hooks: - useTimer: extracted timer engine with phase transitions (PREP → WORK → REST → repeat → COMPLETE), calorie tracking, and actions (start, pause, resume, skip, stop) - useHaptics: centralized haptic feedback respecting user settings - useAudio: expo-av sound effects (countdown beep, phase ding, completion chime) respecting soundEffects setting Audio assets: 3 generated WAV files for timer events. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
11
src/shared/components/CLAUDE.md
Normal file
11
src/shared/components/CLAUDE.md
Normal file
@@ -0,0 +1,11 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Feb 18, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #4889 | 4:46 PM | 🟣 | Created GlassCard component with iOS 18.4 inspired glassmorphism | ~174 |
|
||||
</claude-mem-context>
|
||||
105
src/shared/components/GlassCard.tsx
Normal file
105
src/shared/components/GlassCard.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* GlassCard - Liquid Glass Container
|
||||
* iOS 18.4 inspired glassmorphism
|
||||
*/
|
||||
|
||||
import { ReactNode } from 'react'
|
||||
import { StyleSheet, View, ViewStyle } from 'react-native'
|
||||
import { BlurView } from 'expo-blur'
|
||||
|
||||
import { DARK, GLASS, SHADOW, BORDER } from '../constants/colors'
|
||||
import { RADIUS } from '../constants/borderRadius'
|
||||
|
||||
type GlassVariant = 'base' | 'elevated' | 'inset' | 'tinted'
|
||||
|
||||
interface GlassCardProps {
|
||||
children: ReactNode
|
||||
variant?: GlassVariant
|
||||
style?: ViewStyle
|
||||
hasBlur?: boolean
|
||||
blurIntensity?: number
|
||||
}
|
||||
|
||||
const variantStyles: Record<GlassVariant, ViewStyle> = {
|
||||
base: {
|
||||
backgroundColor: GLASS.BASE.backgroundColor,
|
||||
borderColor: GLASS.BASE.borderColor,
|
||||
borderWidth: GLASS.BASE.borderWidth,
|
||||
},
|
||||
elevated: {
|
||||
backgroundColor: GLASS.ELEVATED.backgroundColor,
|
||||
borderColor: GLASS.ELEVATED.borderColor,
|
||||
borderWidth: GLASS.ELEVATED.borderWidth,
|
||||
},
|
||||
inset: {
|
||||
backgroundColor: GLASS.INSET.backgroundColor,
|
||||
borderColor: GLASS.INSET.borderColor,
|
||||
borderWidth: GLASS.INSET.borderWidth,
|
||||
},
|
||||
tinted: {
|
||||
backgroundColor: GLASS.TINTED.backgroundColor,
|
||||
borderColor: GLASS.TINTED.borderColor,
|
||||
borderWidth: GLASS.TINTED.borderWidth,
|
||||
},
|
||||
}
|
||||
|
||||
const shadowStyles: Record<GlassVariant, ViewStyle> = {
|
||||
base: SHADOW.sm,
|
||||
elevated: SHADOW.md,
|
||||
inset: {},
|
||||
tinted: SHADOW.sm,
|
||||
}
|
||||
|
||||
export function GlassCard({
|
||||
children,
|
||||
variant = 'base',
|
||||
style,
|
||||
hasBlur = true,
|
||||
blurIntensity = GLASS.BLUR_MEDIUM,
|
||||
}: GlassCardProps) {
|
||||
const glassStyle = variantStyles[variant]
|
||||
const shadowStyle = shadowStyles[variant]
|
||||
|
||||
if (hasBlur) {
|
||||
return (
|
||||
<View style={[styles.container, glassStyle, shadowStyle, style]}>
|
||||
<BlurView
|
||||
intensity={blurIntensity}
|
||||
tint="dark"
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
<View style={styles.content}>{children}</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.container, glassStyle, shadowStyle, style]}>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
borderRadius: RADIUS.GLASS_CARD,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
})
|
||||
|
||||
// Preset components for common use cases
|
||||
|
||||
export function GlassCardElevated(props: Omit<GlassCardProps, 'variant'>) {
|
||||
return <GlassCard {...props} variant="elevated" />
|
||||
}
|
||||
|
||||
export function GlassCardInset(props: Omit<GlassCardProps, 'variant'>) {
|
||||
return <GlassCard {...props} variant="inset" hasBlur={false} />
|
||||
}
|
||||
|
||||
export function GlassCardTinted(props: Omit<GlassCardProps, 'variant'>) {
|
||||
return <GlassCard {...props} variant="tinted" />
|
||||
}
|
||||
50
src/shared/components/StyledText.tsx
Normal file
50
src/shared/components/StyledText.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* TabataFit StyledText
|
||||
* Unified text component — replaces 5 local copies
|
||||
*/
|
||||
|
||||
import { Text as RNText, TextStyle, StyleProp } from 'react-native'
|
||||
import { TEXT } from '../constants/colors'
|
||||
|
||||
type FontWeight = 'regular' | 'medium' | 'semibold' | 'bold'
|
||||
|
||||
const WEIGHT_MAP: Record<FontWeight, TextStyle['fontWeight']> = {
|
||||
regular: '400',
|
||||
medium: '500',
|
||||
semibold: '600',
|
||||
bold: '700',
|
||||
}
|
||||
|
||||
interface StyledTextProps {
|
||||
children: React.ReactNode
|
||||
size?: number
|
||||
weight?: FontWeight
|
||||
color?: string
|
||||
style?: StyleProp<TextStyle>
|
||||
numberOfLines?: number
|
||||
}
|
||||
|
||||
export function StyledText({
|
||||
children,
|
||||
size = 17,
|
||||
weight = 'regular',
|
||||
color = TEXT.PRIMARY,
|
||||
style,
|
||||
numberOfLines,
|
||||
}: StyledTextProps) {
|
||||
return (
|
||||
<RNText
|
||||
style={[
|
||||
{
|
||||
fontSize: size,
|
||||
fontWeight: WEIGHT_MAP[weight],
|
||||
color,
|
||||
},
|
||||
style,
|
||||
]}
|
||||
numberOfLines={numberOfLines}
|
||||
>
|
||||
{children}
|
||||
</RNText>
|
||||
)
|
||||
}
|
||||
78
src/shared/components/VideoPlayer.tsx
Normal file
78
src/shared/components/VideoPlayer.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* TabataFit VideoPlayer Component
|
||||
* Looping muted preview mode (detail) + full-screen background mode (player)
|
||||
* Falls back to gradient when no video URL
|
||||
*/
|
||||
|
||||
import { useRef, useEffect, useCallback } from 'react'
|
||||
import { View, StyleSheet } from 'react-native'
|
||||
import { useVideoPlayer, VideoView } from 'expo-video'
|
||||
import { LinearGradient } from 'expo-linear-gradient'
|
||||
import { BRAND } from '../constants/colors'
|
||||
|
||||
interface VideoPlayerProps {
|
||||
/** HLS or MP4 video URL */
|
||||
videoUrl?: string
|
||||
/** Fallback gradient colors when no video */
|
||||
gradientColors?: readonly [string, string, ...string[]]
|
||||
/** Looping muted preview (detail) or full-screen background (player) */
|
||||
mode?: 'preview' | 'background'
|
||||
/** Whether to play the video */
|
||||
isPlaying?: boolean
|
||||
style?: object
|
||||
}
|
||||
|
||||
export function VideoPlayer({
|
||||
videoUrl,
|
||||
gradientColors = [BRAND.PRIMARY, BRAND.PRIMARY_DARK],
|
||||
mode = 'preview',
|
||||
isPlaying = true,
|
||||
style,
|
||||
}: VideoPlayerProps) {
|
||||
const player = useVideoPlayer(videoUrl ?? null, (p) => {
|
||||
p.loop = true
|
||||
p.muted = mode === 'preview'
|
||||
p.volume = mode === 'background' ? 0.3 : 0
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!player || !videoUrl) return
|
||||
if (isPlaying) {
|
||||
player.play()
|
||||
} else {
|
||||
player.pause()
|
||||
}
|
||||
}, [isPlaying, player, videoUrl])
|
||||
|
||||
// No video URL — show gradient fallback
|
||||
if (!videoUrl) {
|
||||
return (
|
||||
<View style={[styles.container, style]}>
|
||||
<LinearGradient
|
||||
colors={gradientColors}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.container, style]}>
|
||||
<VideoView
|
||||
player={player}
|
||||
style={StyleSheet.absoluteFill}
|
||||
contentFit={mode === 'background' ? 'cover' : 'cover'}
|
||||
nativeControls={false}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#000',
|
||||
},
|
||||
})
|
||||
8
src/shared/hooks/index.ts
Normal file
8
src/shared/hooks/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* TabataFit Shared Hooks
|
||||
*/
|
||||
|
||||
export { useTimer } from './useTimer'
|
||||
export type { TimerPhase } from './useTimer'
|
||||
export { useHaptics } from './useHaptics'
|
||||
export { useAudio } from './useAudio'
|
||||
74
src/shared/hooks/useAudio.ts
Normal file
74
src/shared/hooks/useAudio.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* TabataFit Audio Hook
|
||||
* Sound effects for timer events using expo-av
|
||||
* Respects userStore soundEffects setting
|
||||
*/
|
||||
|
||||
import { useRef, useEffect, useCallback } from 'react'
|
||||
import { Audio } from 'expo-av'
|
||||
import { useUserStore } from '../stores'
|
||||
|
||||
// Audio assets
|
||||
const SOUNDS = {
|
||||
countdown: require('../../../assets/audio/countdown.wav'),
|
||||
phaseStart: require('../../../assets/audio/phase-start.wav'),
|
||||
complete: require('../../../assets/audio/complete.wav'),
|
||||
}
|
||||
|
||||
type SoundKey = keyof typeof SOUNDS
|
||||
|
||||
export function useAudio() {
|
||||
const soundEnabled = useUserStore((s) => s.settings.soundEffects)
|
||||
const loadedSounds = useRef<Record<string, Audio.Sound>>({})
|
||||
|
||||
// Configure audio session
|
||||
useEffect(() => {
|
||||
Audio.setAudioModeAsync({
|
||||
playsInSilentModeIOS: true,
|
||||
staysActiveInBackground: false,
|
||||
shouldDuckAndroid: true,
|
||||
})
|
||||
|
||||
return () => {
|
||||
// Unload all sounds on cleanup
|
||||
Object.values(loadedSounds.current).forEach(sound => {
|
||||
sound.unloadAsync().catch(() => {})
|
||||
})
|
||||
loadedSounds.current = {}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const getSound = useCallback(async (key: SoundKey): Promise<Audio.Sound | null> => {
|
||||
if (loadedSounds.current[key]) {
|
||||
return loadedSounds.current[key]
|
||||
}
|
||||
try {
|
||||
const { sound } = await Audio.Sound.createAsync(SOUNDS[key])
|
||||
loadedSounds.current[key] = sound
|
||||
return sound
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const play = useCallback(async (key: SoundKey) => {
|
||||
if (!soundEnabled) return
|
||||
const sound = await getSound(key)
|
||||
if (!sound) return
|
||||
try {
|
||||
await sound.setPositionAsync(0)
|
||||
await sound.playAsync()
|
||||
} catch {
|
||||
// Sound may have been unloaded
|
||||
}
|
||||
}, [soundEnabled, getSound])
|
||||
|
||||
return {
|
||||
/** Short beep for countdown ticks (3, 2, 1) */
|
||||
countdownBeep: useCallback(() => play('countdown'), [play]),
|
||||
/** Ding for phase transitions (work → rest, rest → work) */
|
||||
phaseStart: useCallback(() => play('phaseStart'), [play]),
|
||||
/** Celebration chime on workout completion */
|
||||
workoutComplete: useCallback(() => play('complete'), [play]),
|
||||
}
|
||||
}
|
||||
45
src/shared/hooks/useHaptics.ts
Normal file
45
src/shared/hooks/useHaptics.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* TabataFit Haptics Hook
|
||||
* Centralized haptic feedback respecting user settings
|
||||
*/
|
||||
|
||||
import { useCallback } from 'react'
|
||||
import * as Haptics from 'expo-haptics'
|
||||
import { useUserStore } from '../stores'
|
||||
|
||||
export function useHaptics() {
|
||||
const haptics = useUserStore((s) => s.settings.haptics)
|
||||
|
||||
const phaseChange = useCallback(() => {
|
||||
if (!haptics) return
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy)
|
||||
}, [haptics])
|
||||
|
||||
const buttonTap = useCallback(() => {
|
||||
if (!haptics) return
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium)
|
||||
}, [haptics])
|
||||
|
||||
const countdownTick = useCallback(() => {
|
||||
if (!haptics) return
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)
|
||||
}, [haptics])
|
||||
|
||||
const workoutComplete = useCallback(() => {
|
||||
if (!haptics) return
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success)
|
||||
}, [haptics])
|
||||
|
||||
const selection = useCallback(() => {
|
||||
if (!haptics) return
|
||||
Haptics.selectionAsync()
|
||||
}, [haptics])
|
||||
|
||||
return {
|
||||
phaseChange,
|
||||
buttonTap,
|
||||
countdownTick,
|
||||
workoutComplete,
|
||||
selection,
|
||||
}
|
||||
}
|
||||
170
src/shared/hooks/useTimer.ts
Normal file
170
src/shared/hooks/useTimer.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* TabataFit Timer Hook
|
||||
* Extracted from player/[id].tsx — reusable timer logic
|
||||
*/
|
||||
|
||||
import { useRef, useEffect, useCallback } from 'react'
|
||||
import { usePlayerStore } from '../stores'
|
||||
import type { Workout } from '../types'
|
||||
|
||||
export type TimerPhase = 'PREP' | 'WORK' | 'REST' | 'COMPLETE'
|
||||
|
||||
interface UseTimerReturn {
|
||||
phase: TimerPhase
|
||||
timeRemaining: number
|
||||
currentRound: number
|
||||
totalRounds: number
|
||||
currentExercise: string
|
||||
nextExercise: string | undefined
|
||||
progress: number
|
||||
isPaused: boolean
|
||||
isRunning: boolean
|
||||
isComplete: boolean
|
||||
calories: number
|
||||
start: () => void
|
||||
pause: () => void
|
||||
resume: () => void
|
||||
skip: () => void
|
||||
stop: () => void
|
||||
}
|
||||
|
||||
export function useTimer(workout: Workout | null): UseTimerReturn {
|
||||
const store = usePlayerStore()
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
// Load workout into store on mount
|
||||
useEffect(() => {
|
||||
if (workout) {
|
||||
store.loadWorkout(workout)
|
||||
}
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current)
|
||||
}
|
||||
}, [workout?.id])
|
||||
|
||||
const w = workout ?? {
|
||||
prepTime: 10,
|
||||
workTime: 20,
|
||||
restTime: 10,
|
||||
rounds: 8,
|
||||
exercises: [{ name: 'Ready', duration: 20 }],
|
||||
}
|
||||
|
||||
// Calculate phase duration
|
||||
const phaseDuration =
|
||||
store.phase === 'PREP' ? w.prepTime :
|
||||
store.phase === 'WORK' ? w.workTime :
|
||||
store.phase === 'REST' ? w.restTime : 0
|
||||
|
||||
const progress = phaseDuration > 0 ? 1 - store.timeRemaining / phaseDuration : 1
|
||||
|
||||
// Exercise index based on current round
|
||||
const exerciseIndex = (store.currentRound - 1) % w.exercises.length
|
||||
const currentExercise = w.exercises[exerciseIndex]?.name ?? ''
|
||||
const nextExercise = w.exercises[(exerciseIndex + 1) % w.exercises.length]?.name
|
||||
|
||||
// Timer tick
|
||||
useEffect(() => {
|
||||
if (!store.isRunning || store.isPaused || store.phase === 'COMPLETE') {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
intervalRef.current = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
intervalRef.current = setInterval(() => {
|
||||
const s = usePlayerStore.getState()
|
||||
if (s.timeRemaining <= 1) {
|
||||
// Phase transition
|
||||
if (s.phase === 'PREP') {
|
||||
store.setPhase('WORK')
|
||||
store.setTimeRemaining(w.workTime)
|
||||
} else if (s.phase === 'WORK') {
|
||||
// Add calories for completed work phase
|
||||
const caloriesPerRound = workout ? Math.round(workout.calories / workout.rounds) : 5
|
||||
store.addCalories(caloriesPerRound)
|
||||
|
||||
store.setPhase('REST')
|
||||
store.setTimeRemaining(w.restTime)
|
||||
} else if (s.phase === 'REST') {
|
||||
if (s.currentRound >= (workout?.rounds ?? 8)) {
|
||||
store.setPhase('COMPLETE')
|
||||
store.setTimeRemaining(0)
|
||||
store.setRunning(false)
|
||||
} else {
|
||||
store.setPhase('WORK')
|
||||
store.setTimeRemaining(w.workTime)
|
||||
store.setCurrentRound(s.currentRound + 1)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
store.setTimeRemaining(s.timeRemaining - 1)
|
||||
}
|
||||
}, 1000)
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
intervalRef.current = null
|
||||
}
|
||||
}
|
||||
}, [store.isRunning, store.isPaused, store.phase])
|
||||
|
||||
const start = useCallback(() => {
|
||||
store.setRunning(true)
|
||||
store.setPaused(false)
|
||||
}, [])
|
||||
|
||||
const pause = useCallback(() => {
|
||||
store.setPaused(true)
|
||||
}, [])
|
||||
|
||||
const resume = useCallback(() => {
|
||||
store.setPaused(false)
|
||||
}, [])
|
||||
|
||||
const skip = useCallback(() => {
|
||||
const s = usePlayerStore.getState()
|
||||
if (s.phase === 'PREP') {
|
||||
store.setPhase('WORK')
|
||||
store.setTimeRemaining(w.workTime)
|
||||
} else if (s.phase === 'WORK') {
|
||||
store.setPhase('REST')
|
||||
store.setTimeRemaining(w.restTime)
|
||||
} else if (s.phase === 'REST') {
|
||||
if (s.currentRound >= (workout?.rounds ?? 8)) {
|
||||
store.setPhase('COMPLETE')
|
||||
store.setTimeRemaining(0)
|
||||
store.setRunning(false)
|
||||
} else {
|
||||
store.setPhase('WORK')
|
||||
store.setTimeRemaining(w.workTime)
|
||||
store.setCurrentRound(s.currentRound + 1)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const stop = useCallback(() => {
|
||||
store.reset()
|
||||
}, [])
|
||||
|
||||
return {
|
||||
phase: store.phase as TimerPhase,
|
||||
timeRemaining: store.timeRemaining,
|
||||
currentRound: store.currentRound,
|
||||
totalRounds: workout?.rounds ?? 8,
|
||||
currentExercise,
|
||||
nextExercise: store.phase === 'REST' ? nextExercise : undefined,
|
||||
progress,
|
||||
isPaused: store.isPaused,
|
||||
isRunning: store.isRunning,
|
||||
isComplete: store.phase === 'COMPLETE',
|
||||
calories: store.calories,
|
||||
start,
|
||||
pause,
|
||||
resume,
|
||||
skip,
|
||||
stop,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user