diff --git a/assets/audio/complete.wav b/assets/audio/complete.wav new file mode 100644 index 0000000..8a4dac7 Binary files /dev/null and b/assets/audio/complete.wav differ diff --git a/assets/audio/countdown.wav b/assets/audio/countdown.wav new file mode 100644 index 0000000..4540ad3 Binary files /dev/null and b/assets/audio/countdown.wav differ diff --git a/assets/audio/phase-start.wav b/assets/audio/phase-start.wav new file mode 100644 index 0000000..9db0128 Binary files /dev/null and b/assets/audio/phase-start.wav differ diff --git a/src/shared/components/CLAUDE.md b/src/shared/components/CLAUDE.md new file mode 100644 index 0000000..1003e4f --- /dev/null +++ b/src/shared/components/CLAUDE.md @@ -0,0 +1,11 @@ + +# Recent Activity + + + +### Feb 18, 2026 + +| ID | Time | T | Title | Read | +|----|------|---|-------|------| +| #4889 | 4:46 PM | 🟣 | Created GlassCard component with iOS 18.4 inspired glassmorphism | ~174 | + \ No newline at end of file diff --git a/src/shared/components/GlassCard.tsx b/src/shared/components/GlassCard.tsx new file mode 100644 index 0000000..f1c992f --- /dev/null +++ b/src/shared/components/GlassCard.tsx @@ -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 = { + 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 = { + 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 ( + + + {children} + + ) + } + + return ( + + {children} + + ) +} + +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) { + return +} + +export function GlassCardInset(props: Omit) { + return +} + +export function GlassCardTinted(props: Omit) { + return +} diff --git a/src/shared/components/StyledText.tsx b/src/shared/components/StyledText.tsx new file mode 100644 index 0000000..c761203 --- /dev/null +++ b/src/shared/components/StyledText.tsx @@ -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 = { + regular: '400', + medium: '500', + semibold: '600', + bold: '700', +} + +interface StyledTextProps { + children: React.ReactNode + size?: number + weight?: FontWeight + color?: string + style?: StyleProp + numberOfLines?: number +} + +export function StyledText({ + children, + size = 17, + weight = 'regular', + color = TEXT.PRIMARY, + style, + numberOfLines, +}: StyledTextProps) { + return ( + + {children} + + ) +} diff --git a/src/shared/components/VideoPlayer.tsx b/src/shared/components/VideoPlayer.tsx new file mode 100644 index 0000000..214b09f --- /dev/null +++ b/src/shared/components/VideoPlayer.tsx @@ -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 ( + + + + ) + } + + return ( + + + + ) +} + +const styles = StyleSheet.create({ + container: { + overflow: 'hidden', + backgroundColor: '#000', + }, +}) diff --git a/src/shared/hooks/index.ts b/src/shared/hooks/index.ts new file mode 100644 index 0000000..a7e7e08 --- /dev/null +++ b/src/shared/hooks/index.ts @@ -0,0 +1,8 @@ +/** + * TabataFit Shared Hooks + */ + +export { useTimer } from './useTimer' +export type { TimerPhase } from './useTimer' +export { useHaptics } from './useHaptics' +export { useAudio } from './useAudio' diff --git a/src/shared/hooks/useAudio.ts b/src/shared/hooks/useAudio.ts new file mode 100644 index 0000000..e0a899e --- /dev/null +++ b/src/shared/hooks/useAudio.ts @@ -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>({}) + + // 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 => { + 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]), + } +} diff --git a/src/shared/hooks/useHaptics.ts b/src/shared/hooks/useHaptics.ts new file mode 100644 index 0000000..9a48916 --- /dev/null +++ b/src/shared/hooks/useHaptics.ts @@ -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, + } +} diff --git a/src/shared/hooks/useTimer.ts b/src/shared/hooks/useTimer.ts new file mode 100644 index 0000000..4273a6c --- /dev/null +++ b/src/shared/hooks/useTimer.ts @@ -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 | 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, + } +}