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:
Millian Lamiaux
2026-02-20 13:23:50 +01:00
parent 5477ecb852
commit 13faf21b8d
11 changed files with 541 additions and 0 deletions

BIN
assets/audio/complete.wav Normal file

Binary file not shown.

BIN
assets/audio/countdown.wav Normal file

Binary file not shown.

Binary file not shown.

View 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>

View 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" />
}

View 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>
)
}

View 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',
},
})

View 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'

View 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]),
}
}

View 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,
}
}

View 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,
}
}