diff --git a/CHANGELOG.md b/CHANGELOG.md index 570a3c4..6e57fe2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Zustand workout store for state management + - Created comprehensive workout state store with Zustand + - Implemented selectors for efficient state access + - Added workout persistence and recovery + - Built automatic timer management with background support + - Developed minimization and maximization functionality +- Workout tracking implementation with real-time tracking + - Added workout timer with proper background handling + - Implemented rest timer functionality + - Added exercise set tracking with weight and reps + - Created workout minimization and maximization system + - Implemented active workout bar for minimized workouts - SQLite database implementation with development seeding - Successfully integrated SQLite with proper transaction handling - Added mock exercise library with 10 initial exercises @@ -38,8 +50,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Favorite template functionality - Template categories and filtering - Quick-start template actions +- Full-screen template details with tab navigation + - Replaced bottom sheet with dedicated full-screen layout + - Implemented material top tabs for content organization + - Added Overview, History, and Social tabs + - Improved template information hierarchy + - Added contextual action buttons based on template source + - Enhanced social sharing capabilities + - Improved workout history visualization ### Changed +- Improved workout screen navigation consistency + - Standardized screen transitions and gestures + - Added back buttons for clearer navigation + - Implemented proper workout state persistence +- Enhanced exercise selection interface + - Updated add-exercises screen with cleaner UI + - Added multi-select functionality for bulk exercise addition + - Implemented exercise search and filtering - Improved exercise library interface - Removed "Recent Exercises" section for cleaner UI - Added alphabetical section organization @@ -62,8 +90,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added category pills for filtering - Improved spacing and layout - Better visual hierarchy for favorites +- Migrated from React Context to Zustand for state management + - Improved performance with fine-grained rendering + - Enhanced developer experience with simpler API + - Better type safety with TypeScript integration + - Added persistent workout state for recovery +- Redesigned template details experience + - Migrated from bottom sheet to full-screen layout + - Restructured content with tab-based navigation + - Added dedicated header with prominent action buttons + - Improved attribution and source indication + - Enhanced visual separation between template metadata and content ### Fixed +- Workout navigation gesture handling issues +- Workout timer inconsistency during app background state - Exercise deletion functionality - Keyboard overlap issues in exercise creation form - SQLite transaction nesting issues @@ -73,6 +114,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Template category spacing issues - Exercise list rendering on iOS - Database reset and reseeding behavior +- Template details UI overflow issues +- Navigation inconsistencies between template screens +- Content rendering issues in bottom sheet components ### Technical Details 1. Database Schema Enforcement: @@ -99,11 +143,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added DevSeederService for development data - Enhanced error handling and logging +5. Workout State Management with Zustand: + - Implemented selector pattern for performance optimization + - Added module-level timer references for background operation + - Created workout persistence with auto-save functionality + - Developed state recovery for crash protection + - Added support for future Nostr integration + - Implemented workout minimization for multi-tasking + +6. Template Details UI Architecture: + - Implemented MaterialTopTabNavigator for content organization + - Created screen-specific components for each tab + - Developed conditional rendering based on template source + - Implemented context-aware action buttons + - Added proper navigation state handling + ### Migration Notes - Exercise creation now enforces schema constraints - Input validation prevents invalid data entry - Enhanced error messages provide better debugging information - Template management requires updated type definitions +- Workout state now persists across app restarts +- Component access to workout state requires new selector pattern +- Template details navigation has changed from modal to screen-based approach ## [0.1.0] - 2024-02-09 diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 6d4df20..5182a81 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -3,7 +3,7 @@ import React, { useEffect } from 'react'; import { Platform, View } from 'react-native'; import { Tabs, useNavigation } from 'expo-router'; import { useTheme } from '@react-navigation/native'; -import { Dumbbell, Library, Users, History, User } from 'lucide-react-native'; +import { Dumbbell, Library, Users, History, User, Home } from 'lucide-react-native'; import type { CustomTheme } from '@/lib/theme'; import ActiveWorkoutBar from '@/components/workout/ActiveWorkoutBar'; import { useWorkoutStore } from '@/stores/workoutStore'; @@ -51,7 +51,7 @@ export default function TabLayout() { options={{ title: 'Profile', tabBarIcon: ({ color, size }) => ( - + ), }} /> @@ -85,7 +85,7 @@ export default function TabLayout() { ( ), diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 59a2d4d..27da755 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -1,6 +1,7 @@ // app/(tabs)/index.tsx -import { useState, useEffect } from 'react' +import React, { useState, useEffect, useCallback } from 'react'; import { ScrollView, View } from 'react-native' +import { useFocusEffect } from '@react-navigation/native'; import { router } from 'expo-router' import { AlertDialog, @@ -17,7 +18,6 @@ import Header from '@/components/Header' import HomeWorkout from '@/components/workout/HomeWorkout' import FavoriteTemplate from '@/components/workout/FavoriteTemplate' import { useWorkoutStore } from '@/stores/workoutStore' -import type { WorkoutTemplate } from '@/types/templates' import { Text } from '@/components/ui/text' import { getRandomWorkoutTitle } from '@/utils/workoutTitles' @@ -59,39 +59,40 @@ export default function WorkoutScreen() { endWorkout } = useWorkoutStore(); - useEffect(() => { - loadFavorites(); - }, []); + useFocusEffect( + useCallback(() => { + loadFavorites(); + return () => {}; // Cleanup function + }, []) + ); const loadFavorites = async () => { setIsLoadingFavorites(true); try { const favorites = await getFavorites(); - const workoutTemplates = favorites - .filter(f => f.content && f.content.id && checkFavoriteStatus(f.content.id)) - .map(f => { - const content = f.content; - return { - id: content.id, - title: content.title || 'Untitled Workout', - description: content.description || '', - exercises: content.exercises.map(ex => ({ - title: ex.exercise.title, - sets: ex.targetSets, - reps: ex.targetReps - })), - exerciseCount: content.exercises.length, - duration: content.metadata?.averageDuration, - isFavorited: true, - lastUsed: content.metadata?.lastUsed, - source: content.availability.source.includes('nostr') - ? 'nostr' - : content.availability.source.includes('powr') - ? 'powr' - : 'local' - } as FavoriteTemplateData; - }); + const workoutTemplates = favorites.map(f => { + const content = f.content; + return { + id: content.id, + title: content.title || 'Untitled Workout', + description: content.description || '', + exercises: content.exercises.map(ex => ({ + title: ex.exercise.title, + sets: ex.targetSets, + reps: ex.targetReps + })), + exerciseCount: content.exercises.length, + duration: content.metadata?.averageDuration, + isFavorited: true, + lastUsed: content.metadata?.lastUsed, + source: content.availability.source.includes('nostr') + ? 'nostr' + : content.availability.source.includes('powr') + ? 'powr' + : 'local' + } as FavoriteTemplateData; + }); setFavoriteWorkouts(workoutTemplates); } catch (error) { diff --git a/app/(tabs)/library/_layout.tsx b/app/(tabs)/library/_layout.tsx index 47b5c46..8c1f807 100644 --- a/app/(tabs)/library/_layout.tsx +++ b/app/(tabs)/library/_layout.tsx @@ -1,7 +1,6 @@ // app/(tabs)/library/_layout.tsx import { View } from 'react-native'; import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs'; -import { Text } from '@/components/ui/text'; import { ThemeToggle } from '@/components/ThemeToggle'; import { SearchPopover } from '@/components/library/SearchPopover'; import { FilterPopover } from '@/components/library/FilterPopover'; diff --git a/app/(tabs)/library/exercises.tsx b/app/(tabs)/library/exercises.tsx index 5b525bc..2ca3b1c 100644 --- a/app/(tabs)/library/exercises.tsx +++ b/app/(tabs)/library/exercises.tsx @@ -107,50 +107,6 @@ export default function ExercisesScreen() { - {/* Filter buttons */} - - - - - - - {/* Exercises list */} (null); - const [selectedTemplate, setSelectedTemplate] = useState(null); + // Remove selectedTemplate state since we're not using it anymore const handleDelete = (id: string) => { setTemplates(current => current.filter(t => t.id !== id)); }; + // Update to navigate to the template details screen const handleTemplatePress = (template: Template) => { - setSelectedTemplate(toWorkoutTemplate(template)); + router.push(`/template/${template.id}`); }; - const handleStartWorkout = (template: Template) => { - // TODO: Navigate to workout screen with template - console.log('Starting workout with template:', template); + const handleStartWorkout = async (template: Template) => { + try { + // Use the workoutStore action to start a workout from template + await useWorkoutStore.getState().startWorkoutFromTemplate(template.id); + + // Navigate to the active workout screen + router.push('/(workout)/create'); + } catch (error) { + console.error("Error starting workout:", error); + } }; - const handleFavorite = (template: Template) => { - setTemplates(current => - current.map(t => - t.id === template.id - ? { ...t, isFavorite: !t.isFavorite } - : t - ) - ); - }; + const handleFavorite = async (template: Template) => { + try { + const workoutTemplate = toWorkoutTemplate(template); + const isFavorite = useWorkoutStore.getState().checkFavoriteStatus(template.id); + + if (isFavorite) { + await useWorkoutStore.getState().removeFavorite(template.id); + } else { + await useWorkoutStore.getState().addFavorite(workoutTemplate); + } + + // Update local state to reflect change + setTemplates(current => + current.map(t => + t.id === template.id + ? { ...t, isFavorite: !isFavorite } + : t + ) + ); + } catch (error) { + console.error('Error toggling favorite status:', error); + } + }; + + useFocusEffect( + React.useCallback(() => { + // Refresh template favorite status when tab gains focus + setTemplates(current => current.map(template => ({ + ...template, + isFavorite: useWorkoutStore.getState().checkFavoriteStatus(template.id) + }))); + return () => {}; + }, []) + ); const handleAddTemplate = (template: Template) => { setTemplates(prev => [...prev, template]); @@ -120,7 +154,7 @@ export default function TemplatesScreen() { - {/* Category filters */} +{/* // Category filters ))} - + */} {/* Templates list */} @@ -207,16 +241,7 @@ export default function TemplatesScreen() { - {/* Template Details with tabs */} - {selectedTemplate && ( - { - if (!open) setSelectedTemplate(null); - }} - /> - )} + {/* Remove the TemplateDetails component since we're using router navigation now */} - + + - - - - ) + > + + + + + + ); } \ No newline at end of file diff --git a/app/(workout)/add-exercises.tsx b/app/(workout)/add-exercises.tsx index 31b3ba0..cf08535 100644 --- a/app/(workout)/add-exercises.tsx +++ b/app/(workout)/add-exercises.tsx @@ -10,8 +10,9 @@ import { useWorkoutStore } from '@/stores/workoutStore'; import { useSQLiteContext } from 'expo-sqlite'; import { LibraryService } from '@/lib/db/services/LibraryService'; import { TabScreen } from '@/components/layout/TabScreen'; -import { X } from 'lucide-react-native'; +import { ChevronLeft } from 'lucide-react-native'; import { BaseExercise } from '@/types/exercise'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; export default function AddExercisesScreen() { const db = useSQLiteContext(); @@ -19,6 +20,7 @@ export default function AddExercisesScreen() { const [exercises, setExercises] = useState([]); const [selectedIds, setSelectedIds] = useState([]); const [search, setSearch] = useState(''); + const insets = useSafeAreaInsets(); const { addExercises } = useWorkoutStore(); @@ -53,26 +55,26 @@ export default function AddExercisesScreen() { const selectedExercises = exercises.filter(e => selectedIds.includes(e.id)); addExercises(selectedExercises); - // Just go back - this will dismiss the modal and return to create screen + // Go back to create screen router.back(); }; return ( - - {/* Close button in the top right */} - + + {/* Standard header with back button */} + + Add Exercises - Add Exercises ; + const navigation = useNavigation(); + // Check if we're coming from minimized state when component mounts useEffect(() => { if (isMinimized) { maximizeWorkout(); } + }, [isMinimized, maximizeWorkout]); + + // Handle back navigation + useEffect(() => { + const unsubscribe = navigation.addListener('beforeRemove', (e) => { + // If we have an active workout, just minimize it before continuing + if (activeWorkout && !isMinimized) { + // Call minimizeWorkout to update the state + minimizeWorkout(); + + // Let the navigation continue naturally + // Don't call router.back() here to avoid recursion + } + }); - // No need to set up a timer here as it's now managed by the store - }, [isMinimized]); + return unsubscribe; + }, [navigation, activeWorkout, isMinimized, minimizeWorkout]); const [showCancelDialog, setShowCancelDialog] = useState(false); const insets = useSafeAreaInsets(); @@ -104,12 +129,6 @@ export default function CreateWorkoutScreen() { }); }; - // Handler for minimizing workout and going back - const handleMinimize = () => { - minimizeWorkout(); - router.back(); - }; - // Show empty state when no workout is active if (!activeWorkout) { return ( @@ -172,76 +191,73 @@ export default function CreateWorkoutScreen() { return ( - {/* Swipe indicator and back button */} - - + {/* Header with back button */} + + + + Back + + + - {/* Header with Title and Finish Button */} - - {/* Top row with minimize and finish buttons */} - + {/* Full-width workout title */} + + updateWorkoutTitle(newTitle)} + placeholder="Workout Title" + textStyle={{ + fontSize: 24, + fontWeight: '700', + }} + /> + + + {/* Timer Display */} + + + {formatTime(elapsedTime)} + + + {status === 'active' ? ( - + ) : ( - - - {/* Full-width workout title */} - - updateWorkoutTitle(newTitle)} - placeholder="Workout Title" - textStyle={{ - fontSize: 24, - fontWeight: '700', - }} - /> - - - {/* Timer Display */} - - - {formatTime(elapsedTime)} - - - {status === 'active' ? ( - - ) : ( - - )} - + )} {/* Content Area */} @@ -317,43 +333,43 @@ export default function CreateWorkoutScreen() { {previousSet ? `${previousSet.weight}×${previousSet.reps}` : '—'} - {/* Weight Input */} - - { - const weight = text === '' ? 0 : parseFloat(text); - if (!isNaN(weight)) { - updateSet(exerciseIndex, setIndex, { weight }); - } - }} - keyboardType="numeric" - selectTextOnFocus - /> - + {/* Weight Input */} + + { + const weight = text === '' ? 0 : parseFloat(text); + if (!isNaN(weight)) { + updateSet(exerciseIndex, setIndex, { weight }); + } + }} + keyboardType="numeric" + selectTextOnFocus + /> + - {/* Reps Input */} - - { - const reps = text === '' ? 0 : parseInt(text, 10); - if (!isNaN(reps)) { - updateSet(exerciseIndex, setIndex, { reps }); - } - }} - keyboardType="numeric" - selectTextOnFocus - /> - + {/* Reps Input */} + + { + const reps = text === '' ? 0 : parseInt(text, 10); + if (!isNaN(reps)) { + updateSet(exerciseIndex, setIndex, { reps }); + } + }} + keyboardType="numeric" + selectTextOnFocus + /> + {/* Complete Button */} - + {/* Add Exercises Button */} + + + {/* Cancel Button */} + ) : ( // Empty State with nice message and icon @@ -427,20 +450,6 @@ export default function CreateWorkoutScreen() { )} - - {/* Add Exercise FAB - only shown when exercises exist */} - {hasExercises && ( - - router.push('/(workout)/add-exercises')} - /> - - )} {/* Cancel Workout Dialog */} @@ -464,10 +473,4 @@ export default function CreateWorkoutScreen() { ); -} - -const styles = StyleSheet.create({ - timerText: { - fontVariant: ['tabular-nums'] - } -}); \ No newline at end of file +} \ No newline at end of file diff --git a/app/(workout)/template-select.tsx b/app/(workout)/template-select.tsx index 2e5eec4..1f89eb3 100644 --- a/app/(workout)/template-select.tsx +++ b/app/(workout)/template-select.tsx @@ -2,12 +2,15 @@ import React from 'react'; import { View, ScrollView } from 'react-native'; import { Text } from '@/components/ui/text'; -import { useRouter } from 'expo-router'; import { useWorkoutStore } from '@/stores/workoutStore'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; +import { TabScreen } from '@/components/layout/TabScreen'; +import { ChevronLeft } from 'lucide-react-native'; import { generateId } from '@/utils/ids'; -import type { TemplateType } from '@/types/templates'; +import type { TemplateType } from '@/types/templates'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useRouter } from 'expo-router'; // Temporary mock data - replace with actual template data const MOCK_TEMPLATES = [ @@ -37,6 +40,7 @@ const MOCK_TEMPLATES = [ export default function TemplateSelectScreen() { const router = useRouter(); + const insets = useSafeAreaInsets(); const startWorkout = useWorkoutStore.use.startWorkout(); const handleSelectTemplate = (template: typeof MOCK_TEMPLATES[0]) => { @@ -76,34 +80,54 @@ export default function TemplateSelectScreen() { created_at: Date.now() })) }); - router.back(); + + // Navigate directly to the create screen instead of going back + router.push('/(workout)/create'); }; return ( - - Recent Templates - - {MOCK_TEMPLATES.map(template => ( - - - {template.title} - {template.category} - - {/* Exercise Preview */} - - {template.exercises.map((exercise, index) => ( - - {exercise.title} - {exercise.sets}×{exercise.reps} - - ))} - + + + {/* Standard header with back button */} + + + Select Template + + + + Recent Templates + + + {MOCK_TEMPLATES.map(template => ( + + + {template.title} + {template.category} + + {/* Exercise Preview */} + + {template.exercises.map((exercise, index) => ( + + {exercise.title} - {exercise.sets}×{exercise.reps} + + ))} + - - - - ))} - + + + + ))} + + + + ); } \ No newline at end of file diff --git a/app/(workout)/template/[id]/_layout.tsx b/app/(workout)/template/[id]/_layout.tsx new file mode 100644 index 0000000..a1a9dce --- /dev/null +++ b/app/(workout)/template/[id]/_layout.tsx @@ -0,0 +1,283 @@ +// app/(workout)/template/[id]/_layout.tsx +import React, { useState, useEffect } from 'react'; +import { View, ActivityIndicator } from 'react-native'; +import { useLocalSearchParams, router } from 'expo-router'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useTheme } from '@react-navigation/native'; +import { useSQLiteContext } from 'expo-sqlite'; +import { useWorkoutStore } from '@/stores/workoutStore'; +import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs'; +import { + ChevronLeft, + Play, + Heart +} from 'lucide-react-native'; + +// UI Components +import { Text } from '@/components/ui/text'; +import { Button } from '@/components/ui/button'; +import { TabScreen } from '@/components/layout/TabScreen'; + +// Import tab screens +import OverviewTab from './index'; +import SocialTab from './social'; +import HistoryTab from './history'; + +// Types +import { WorkoutTemplate } from '@/types/templates'; +import type { CustomTheme } from '@/lib/theme'; + +// Create the tab navigator +const Tab = createMaterialTopTabNavigator(); + +export default function TemplateDetailsLayout() { + const params = useLocalSearchParams(); + const [isLoading, setIsLoading] = useState(true); + const [workoutTemplate, setWorkoutTemplate] = useState(null); + const [isFavorite, setIsFavorite] = useState(false); + + const templateId = typeof params.id === 'string' ? params.id : ''; + const db = useSQLiteContext(); + const insets = useSafeAreaInsets(); + const theme = useTheme() as CustomTheme; + + // Use the workoutStore + const { startWorkoutFromTemplate, checkFavoriteStatus, addFavorite, removeFavorite } = useWorkoutStore(); + + useEffect(() => { + async function loadTemplate() { + try { + setIsLoading(true); + + if (!templateId) { + router.replace('/'); + return; + } + + // Check if it's a favorite + const isFav = checkFavoriteStatus(templateId); + setIsFavorite(isFav); + + // If it's a favorite, get it from favorites + if (isFav) { + const favorites = await useWorkoutStore.getState().getFavorites(); + const favoriteTemplate = favorites.find(f => f.id === templateId); + + if (favoriteTemplate) { + setWorkoutTemplate(favoriteTemplate.content); + setIsLoading(false); + return; + } + } + + // If not found in favorites or not a favorite, fetch from database + // TODO: Implement fetching from database if needed + + // For now, create a mock template if we couldn't find it + if (!workoutTemplate) { + // This is a fallback mock. In production, you'd show an error + const mockTemplate: WorkoutTemplate = { + id: templateId, + title: "Sample Workout", + type: "strength", + category: "Full Body", + exercises: [{ + exercise: { + id: "ex1", + title: "Barbell Squat", + type: "strength", + category: "Legs", + tags: ["compound", "legs"], + availability: { source: ["local"] }, + created_at: Date.now() + }, + targetSets: 3, + targetReps: 8 + }], + isPublic: false, + tags: ["strength", "beginner"], + version: 1, + created_at: Date.now(), + availability: { source: ["local"] } + }; + + setWorkoutTemplate(mockTemplate); + } + + setIsLoading(false); + } catch (error) { + console.error("Error loading template:", error); + setIsLoading(false); + } + } + + loadTemplate(); + }, [templateId, db]); + + const handleStartWorkout = async () => { + if (!workoutTemplate) return; + + try { + // Use the workoutStore action to start a workout from template + await startWorkoutFromTemplate(workoutTemplate.id); + + // Navigate to the active workout screen + router.push('/(workout)/create'); + } catch (error) { + console.error("Error starting workout:", error); + } + }; + + const handleGoBack = () => { + router.back(); + }; + + const handleToggleFavorite = async () => { + if (!workoutTemplate) return; + + try { + if (isFavorite) { + await removeFavorite(workoutTemplate.id); + } else { + await addFavorite(workoutTemplate); + } + + setIsFavorite(!isFavorite); + } catch (error) { + console.error("Error toggling favorite:", error); + } + }; + + if (isLoading || !workoutTemplate) { + return ( + + + + + + Template Details + + + + + Loading template... + + + + ); + } + + return ( + + + {/* Header with back button, title and Start button */} + + + + + {workoutTemplate.title} + + + + + + + {/* Updated to match Add Exercises button */} + + + + + {/* Context provider to pass template to the tabs */} + + + + + + + + + + ); +} + +// Create a context to share the template with the tab screens +interface TemplateContextType { + template: WorkoutTemplate | null; +} + +export const TemplateContext = React.createContext({ + template: null +}); + +// Custom hook to access the template +export function useTemplate() { + const context = React.useContext(TemplateContext); + if (!context.template) { + throw new Error('useTemplate must be used within a TemplateContext.Provider'); + } + return context.template; +} \ No newline at end of file diff --git a/app/(workout)/template/[id]/history.tsx b/app/(workout)/template/[id]/history.tsx new file mode 100644 index 0000000..74ff1eb --- /dev/null +++ b/app/(workout)/template/[id]/history.tsx @@ -0,0 +1,180 @@ +// app/(workout)/template/[id]/history.tsx +import React, { useState, useEffect } from 'react'; +import { View, ScrollView, ActivityIndicator } from 'react-native'; +import { Text } from '@/components/ui/text'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent } from '@/components/ui/card'; +import { Calendar } from 'lucide-react-native'; +import { useTemplate } from './_layout'; + +// Format date helper +const formatDate = (date: Date) => { + return date.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric' + }); +}; + +// Mock workout history - this would come from your database in a real app +const mockWorkoutHistory = [ + { + id: 'hist1', + date: new Date(2024, 1, 25), + duration: 62, // minutes + completed: true, + notes: "Increased weight on squats by 10lbs", + exercises: [ + { name: "Barbell Squat", sets: 3, reps: 8, weight: 215 }, + { name: "Bench Press", sets: 3, reps: 8, weight: 175 }, + { name: "Bent Over Row", sets: 3, reps: 8, weight: 155 } + ] + }, + { + id: 'hist2', + date: new Date(2024, 1, 18), + duration: 58, // minutes + completed: true, + exercises: [ + { name: "Barbell Squat", sets: 3, reps: 8, weight: 205 }, + { name: "Bench Press", sets: 3, reps: 8, weight: 175 }, + { name: "Bent Over Row", sets: 3, reps: 8, weight: 155 } + ] + }, + { + id: 'hist3', + date: new Date(2024, 1, 11), + duration: 65, // minutes + completed: false, + notes: "Stopped early due to time constraints", + exercises: [ + { name: "Barbell Squat", sets: 3, reps: 8, weight: 205 }, + { name: "Bench Press", sets: 3, reps: 8, weight: 170 }, + { name: "Bent Over Row", sets: 2, reps: 8, weight: 150 } + ] + } +]; + +export default function HistoryTab() { + const template = useTemplate(); + const [isLoading, setIsLoading] = useState(false); + + // Simulate loading history data + useEffect(() => { + const loadHistory = async () => { + setIsLoading(true); + // Simulate loading delay + await new Promise(resolve => setTimeout(resolve, 500)); + setIsLoading(false); + }; + + loadHistory(); + }, [template.id]); + + return ( + + + {/* Performance Summary */} + + Performance Summary + + + + + Total Workouts + {mockWorkoutHistory.length} + + + + Avg Duration + + {Math.round(mockWorkoutHistory.reduce((acc, w) => acc + w.duration, 0) / mockWorkoutHistory.length)} min + + + + + Completion + + {Math.round(mockWorkoutHistory.filter(w => w.completed).length / mockWorkoutHistory.length * 100)}% + + + + + + + + {/* History List */} + + Workout History + + {isLoading ? ( + + + Loading history... + + ) : mockWorkoutHistory.length > 0 ? ( + + {mockWorkoutHistory.map((workout) => ( + + + + {formatDate(workout.date)} + + {workout.completed ? "Completed" : "Incomplete"} + + + + + + Duration + {workout.duration} min + + + + Sets + + {workout.exercises.reduce((acc, ex) => acc + ex.sets, 0)} + + + + + Volume + + {workout.exercises.reduce((acc, ex) => acc + (ex.sets * ex.reps * ex.weight), 0)} lbs + + + + + {workout.notes && ( + + {workout.notes} + + )} + + + + + ))} + + ) : ( + + + + No workout history available yet + + + Complete a workout using this template to see your history + + + )} + + + + ); +} \ No newline at end of file diff --git a/app/(workout)/template/[id]/index.tsx b/app/(workout)/template/[id]/index.tsx new file mode 100644 index 0000000..34d4253 --- /dev/null +++ b/app/(workout)/template/[id]/index.tsx @@ -0,0 +1,219 @@ +// app/(workout)/template/[id]/index.tsx +import React from 'react'; +import { View, ScrollView } from 'react-native'; +import { Text } from '@/components/ui/text'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Separator } from '@/components/ui/separator'; +import { Card, CardContent } from '@/components/ui/card'; +import { useTemplate } from './_layout'; +import { formatTime } from '@/utils/formatTime'; +import { + Edit2, + Copy, + Share2, + Dumbbell, + Target, + Calendar, + Hash, + Clock, + Award +} from 'lucide-react-native'; + +export default function OverviewTab() { + const template = useTemplate(); + + const { + title, + type, + category, + description, + exercises = [], + tags = [], + metadata, + availability + } = template; + + // Calculate source type from availability + const sourceType = availability.source.includes('nostr') + ? 'nostr' + : availability.source.includes('powr') + ? 'powr' + : 'local'; + + const isEditable = sourceType === 'local'; + + return ( + + + {/* Basic Info Section */} + + + {sourceType === 'local' ? 'My Template' : sourceType === 'powr' ? 'POWR Template' : 'Nostr Template'} + + + {type} + + + + + + {/* Category Section */} + + + + + + Category + {category} + + + + {/* Description Section */} + {description && ( + + Description + {description} + + )} + + {/* Exercises Section */} + + + + Exercises + + + {exercises.map((exerciseConfig, index) => ( + + + {exerciseConfig.exercise.title} + + + {exerciseConfig.targetSets} sets × {exerciseConfig.targetReps} reps + + {exerciseConfig.notes && ( + + {exerciseConfig.notes} + + )} + + ))} + + + + {/* Tags Section */} + {tags.length > 0 && ( + + + + Tags + + + {tags.map(tag => ( + + {tag} + + ))} + + + )} + + {/* Workout Parameters Section */} + + + + Workout Parameters + + + {template.rounds && ( + + Rounds: + {template.rounds} + + )} + {template.duration && ( + + Duration: + {formatTime(template.duration * 1000)} + + )} + {template.interval && ( + + Interval: + {formatTime(template.interval * 1000)} + + )} + {template.restBetweenRounds && ( + + Rest Between Rounds: + + {formatTime(template.restBetweenRounds * 1000)} + + + )} + {metadata?.averageDuration && ( + + Average Completion Time: + + {Math.round(metadata.averageDuration / 60)} minutes + + + )} + + + + {/* Usage Stats Section */} + {metadata && ( + + + + Usage + + + {metadata.useCount && ( + + Used {metadata.useCount} times + + )} + {metadata.lastUsed && ( + + Last used: {new Date(metadata.lastUsed).toLocaleDateString()} + + )} + + + )} + + {/* Action Buttons */} + + {isEditable ? ( + + ) : ( + + )} + + + + + + ); +} \ No newline at end of file diff --git a/app/(workout)/template/[id]/social.tsx b/app/(workout)/template/[id]/social.tsx new file mode 100644 index 0000000..c777a09 --- /dev/null +++ b/app/(workout)/template/[id]/social.tsx @@ -0,0 +1,189 @@ +// app/(workout)/template/[id]/social.tsx +import React, { useState } from 'react'; +import { View, ScrollView, ActivityIndicator } from 'react-native'; +import { Text } from '@/components/ui/text'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent } from '@/components/ui/card'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { + MessageCircle, + ThumbsUp +} from 'lucide-react-native'; +import { useTemplate } from './_layout'; + +// Mock social feed data +const mockSocialFeed = [ + { + id: 'social1', + userName: 'FitnessFanatic', + userAvatar: 'https://randomuser.me/api/portraits/men/32.jpg', + pubkey: 'npub1q8s7vw...', + timestamp: new Date(Date.now() - 3600000 * 2), // 2 hours ago + content: 'Just crushed this Full Body workout! New PR on bench press 🎉', + metrics: { + duration: 58, // in minutes + volume: 4500, // total weight + exercises: 5 + }, + likes: 12, + comments: 3 + }, + { + id: 'social2', + userName: 'StrengthJourney', + userAvatar: 'https://randomuser.me/api/portraits/women/44.jpg', + pubkey: 'npub1z92r3...', + timestamp: new Date(Date.now() - 3600000 * 24), // 1 day ago + content: 'Modified this workout with extra leg exercises. Feeling the burn!', + metrics: { + duration: 65, + volume: 5200, + exercises: "5+2" + }, + likes: 8, + comments: 1 + }, + { + id: 'social3', + userName: 'GymCoach', + userAvatar: 'https://randomuser.me/api/portraits/men/62.jpg', + pubkey: 'npub1xne8q...', + timestamp: new Date(Date.now() - 3600000 * 48), // 2 days ago + content: 'Great template for beginners! I recommend starting with lighter weights.', + metrics: { + duration: 45, + volume: 3600, + exercises: 5 + }, + likes: 24, + comments: 7 + }, + { + id: 'social4', + userName: 'WeightLifter', + userAvatar: 'https://randomuser.me/api/portraits/women/28.jpg', + pubkey: 'npub1r72df...', + timestamp: new Date(Date.now() - 3600000 * 72), // 3 days ago + content: 'Second time doing this workout. Improved my squat form significantly!', + metrics: { + duration: 52, + volume: 4100, + exercises: 5 + }, + likes: 15, + comments: 2 + } +]; + +// Social Feed Item Component +function SocialFeedItem({ item }: { item: typeof mockSocialFeed[0] }) { + return ( + + + {/* User info and timestamp */} + + + + + {item.userName.substring(0, 2)} + + + + + + {item.userName} + + {item.timestamp.toLocaleDateString()} + + + + {item.pubkey.substring(0, 10)}... + + + + + {/* Post content */} + {item.content} + + {/* Workout metrics */} + + + Duration + {item.metrics.duration} min + + + + Volume + {item.metrics.volume} lbs + + + + Exercises + {item.metrics.exercises} + + + + {/* Actions */} + + + + + + + + + + + ); +} + +export default function SocialTab() { + const template = useTemplate(); + const [isLoading, setIsLoading] = useState(false); + + return ( + + + + + Recent Activity + + + Nostr + + + + {isLoading ? ( + + + Loading activity... + + ) : mockSocialFeed.length > 0 ? ( + mockSocialFeed.map((item) => ( + + )) + ) : ( + + + No social activity found + + This workout hasn't been shared on Nostr yet + + + )} + + + ); +} \ No newline at end of file diff --git a/app/_layout.tsx b/app/_layout.tsx index b9ff0d0..144ae55 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -15,6 +15,7 @@ import { ErrorBoundary } from '@/components/ErrorBoundary'; import { SettingsDrawerProvider } from '@/lib/contexts/SettingsDrawerContext'; import SettingsDrawer from '@/components/SettingsDrawer'; import { useNDKStore } from '@/lib/stores/ndk'; +import { useWorkoutStore } from '@/stores/workoutStore'; const LIGHT_THEME = { ...DefaultTheme, @@ -42,6 +43,9 @@ export default function RootLayout() { // Initialize NDK await init(); + // Load favorites from SQLite + await useWorkoutStore.getState().loadFavorites(); + setIsInitialized(true); } catch (error) { console.error('Failed to initialize:', error); diff --git a/components/templates/TemplateCard.tsx b/components/templates/TemplateCard.tsx index c549f03..d3c0453 100644 --- a/components/templates/TemplateCard.tsx +++ b/components/templates/TemplateCard.tsx @@ -1,6 +1,7 @@ // components/templates/TemplateCard.tsx import React from 'react'; import { View, TouchableOpacity, Platform } from 'react-native'; +import { router } from 'expo-router'; // Add this import import { Text } from '@/components/ui/text'; import { Card, CardContent } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; @@ -54,8 +55,13 @@ export function TemplateCard({ setShowDeleteAlert(false); }; + // Handle navigation to template details + const handleTemplatePress = () => { + router.push(`/template/${id}`); + }; + return ( - + diff --git a/components/templates/TemplateDetails.tsx b/components/templates/TemplateDetails.tsx deleted file mode 100644 index efe972b..0000000 --- a/components/templates/TemplateDetails.tsx +++ /dev/null @@ -1,400 +0,0 @@ -// components/templates/TemplateDetails.tsx -import React from 'react'; -import { View, ScrollView } from 'react-native'; -import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs'; -import { Text } from '@/components/ui/text'; -import { Button } from '@/components/ui/button'; -import { Badge } from '@/components/ui/badge'; -import { Separator } from '@/components/ui/separator'; -import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; -import { - Edit2, - Dumbbell, - Target, - Calendar, - Hash, - ClipboardList, - Settings, - LineChart -} from 'lucide-react-native'; -import { WorkoutTemplate, getSourceDisplay } from '@/types/templates'; -import { useTheme } from '@react-navigation/native'; -import type { CustomTheme } from '@/lib/theme'; - -const Tab = createMaterialTopTabNavigator(); - -interface TemplateDetailsProps { - template: WorkoutTemplate; - open: boolean; - onOpenChange: (open: boolean) => void; - onEdit?: () => void; -} - -// Overview Tab Component -function OverviewTab({ template, onEdit }: { template: WorkoutTemplate; onEdit?: () => void }) { - const { - title, - type, - category, - description, - exercises = [], - tags = [], - metadata, - availability - } = template; - - // Calculate source type from availability - const sourceType = availability.source.includes('nostr') - ? 'nostr' - : availability.source.includes('powr') - ? 'powr' - : 'local'; - - return ( - - - {/* Basic Info Section */} - - - {getSourceDisplay(template)} - - - {type} - - - - - - {/* Category Section */} - - - - - - Category - {category} - - - - {/* Description Section */} - {description && ( - - Description - {description} - - )} - - {/* Exercises Section */} - - - - Exercises - - - {exercises.map((exerciseConfig, index) => ( - - - {exerciseConfig.exercise.title} - - - {exerciseConfig.targetSets} sets × {exerciseConfig.targetReps} reps - - {exerciseConfig.notes && ( - - {exerciseConfig.notes} - - )} - - ))} - - - - {/* Tags Section */} - {tags.length > 0 && ( - - - - Tags - - - {tags.map(tag => ( - - {tag} - - ))} - - - )} - - {/* Usage Stats Section */} - {metadata && ( - - - - Usage - - - {metadata.useCount && ( - - Used {metadata.useCount} times - - )} - {metadata.lastUsed && ( - - Last used: {new Date(metadata.lastUsed).toLocaleDateString()} - - )} - {metadata.averageDuration && ( - - Average duration: {Math.round(metadata.averageDuration / 60)} minutes - - )} - - - )} - - {/* Edit Button */} - {onEdit && ( - - )} - - - ); -} - -// History Tab Component -function HistoryTab({ template }: { template: WorkoutTemplate }) { - return ( - - - {/* Performance Stats */} - - Performance Summary - - - Usage Stats - - - Total Workouts - - {template.metadata?.useCount || 0} - - - - Avg. Duration - - {template.metadata?.averageDuration - ? `${Math.round(template.metadata.averageDuration / 60)}m` - : '--'} - - - - Completion Rate - -- - - - - - {/* Progress Chart Placeholder */} - - Progress Over Time - - - - Progress tracking coming soon - - - - - - - {/* History List */} - - Recent Workouts - - {/* Placeholder for when no history exists */} - - - - No workout history available yet - - - Complete a workout using this template to see your history - - - - - - - ); -} - -// Settings Tab Component -function SettingsTab({ template }: { template: WorkoutTemplate }) { - const { - type, - rounds, - duration, - interval, - restBetweenRounds, - } = template; - - // Helper function to format seconds into MM:SS - const formatTime = (seconds: number) => { - const mins = Math.floor(seconds / 60); - const secs = seconds % 60; - return `${mins}:${secs.toString().padStart(2, '0')}`; - }; - - return ( - - - {/* Workout Configuration */} - - Workout Settings - - - Workout Type - {type} - - - {rounds && ( - - Number of Rounds - {rounds} - - )} - - {duration && ( - - Total Duration - - {formatTime(duration)} - - - )} - - {interval && ( - - Interval Time - - {formatTime(interval)} - - - )} - - {restBetweenRounds && ( - - Rest Between Rounds - - {formatTime(restBetweenRounds)} - - - )} - - - - {/* Sync Settings */} - - Sync Settings - - Template Source - - {getSourceDisplay(template)} - - - - - - ); -} - -export function TemplateDetails({ - template, - open, - onOpenChange, - onEdit -}: TemplateDetailsProps) { - const theme = useTheme() as CustomTheme; - - return ( - onOpenChange(false)}> - - - {template.title} - - - - - - - {() => } - - - {() => } - - - {() => } - - - - - - ); -} \ No newline at end of file diff --git a/stores/workoutStore.ts b/stores/workoutStore.ts index 13d7eaf..9e1f4c0 100644 --- a/stores/workoutStore.ts +++ b/stores/workoutStore.ts @@ -18,6 +18,7 @@ import type { TemplateExerciseConfig } from '@/types/templates'; import type { BaseExercise } from '@/types/exercise'; +import { openDatabaseSync } from 'expo-sqlite'; const AUTO_SAVE_INTERVAL = 30000; // 30 seconds @@ -34,7 +35,8 @@ interface FavoriteItem { interface ExtendedWorkoutState extends WorkoutState { isActive: boolean; isMinimized: boolean; - favorites: FavoriteItem[]; + favoriteIds: string[]; // Only store IDs in memory + favoritesLoaded: boolean; } interface WorkoutActions { @@ -85,11 +87,12 @@ interface ExtendedWorkoutActions extends WorkoutActions { // Timer Actions from original implementation tick: (elapsed: number) => void; - // New favorite management + // New favorite management with persistence getFavorites: () => Promise; addFavorite: (template: WorkoutTemplate) => Promise; removeFavorite: (templateId: string) => Promise; checkFavoriteStatus: (templateId: string) => boolean; + loadFavorites: () => Promise; // New template management startWorkoutFromTemplate: (templateId: string) => Promise; @@ -121,7 +124,8 @@ const initialState: ExtendedWorkoutState = { }, isActive: false, isMinimized: false, - favorites: [] + favoriteIds: [], + favoritesLoaded: false }; const useWorkoutStoreBase = create()((set, get) => ({ @@ -228,10 +232,13 @@ const useWorkoutStoreBase = create { + try { + const db = openDatabaseSync('powr.db'); + + // Ensure favorites table exists + await db.execAsync(` + CREATE TABLE IF NOT EXISTS favorites ( + id TEXT PRIMARY KEY, + content_type TEXT NOT NULL, + content_id TEXT NOT NULL, + content TEXT NOT NULL, + created_at INTEGER NOT NULL, + UNIQUE(content_type, content_id) + ); + CREATE INDEX IF NOT EXISTS idx_favorites_content ON favorites(content_type, content_id); + `); + + // Load just the IDs from SQLite + const result = await db.getAllAsync<{ + content_id: string + }>(`SELECT content_id FROM favorites WHERE content_type = 'template'`); + + const favoriteIds = result.map(item => item.content_id); + + set({ + favoriteIds, + favoritesLoaded: true + }); + + console.log(`Loaded ${favoriteIds.length} favorite IDs from database`); + // Don't return anything (void) + } catch (error) { + console.error('Error loading favorites:', error); + set({ favoritesLoaded: true }); // Mark as loaded even on error + } + }, + getFavorites: async () => { - const { favorites } = get(); - return favorites; + const { favoriteIds, favoritesLoaded } = get(); + + // If favorites haven't been loaded from database yet, load them + if (!favoritesLoaded) { + await get().loadFavorites(); + } + + // If no favorites, return empty array + if (get().favoriteIds.length === 0) { + return []; + } + + // Query the full content from SQLite + try { + const db = openDatabaseSync('powr.db'); + + // Generate placeholders for the SQL query + const placeholders = get().favoriteIds.map(() => '?').join(','); + + // Get full content for all favorited templates + const result = await db.getAllAsync<{ + id: string, + content_type: string, + content_id: string, + content: string, + created_at: number + }>(`SELECT * FROM favorites WHERE content_type = 'template' AND content_id IN (${placeholders})`, + get().favoriteIds + ); + + return result.map(item => ({ + id: item.content_id, + content: JSON.parse(item.content), + addedAt: item.created_at + })); + } catch (error) { + console.error('Error fetching favorites content:', error); + return []; + } }, addFavorite: async (template: WorkoutTemplate) => { - const favorites = [...get().favorites]; - favorites.push({ - id: template.id, - content: template, - addedAt: Date.now() - }); - set({ favorites }); + try { + const db = openDatabaseSync('powr.db'); + const now = Date.now(); + + // Add to SQLite database + await db.runAsync( + `INSERT OR REPLACE INTO favorites (id, content_type, content_id, content, created_at) + VALUES (?, ?, ?, ?, ?)`, + [ + generateId('local'), // Generate a unique ID for the favorite entry + 'template', + template.id, + JSON.stringify(template), + now + ] + ); + + // Update just the ID in memory state + set(state => { + // Only add if not already present + if (!state.favoriteIds.includes(template.id)) { + return { favoriteIds: [...state.favoriteIds, template.id] }; + } + return state; + }); + + console.log(`Added template "${template.title}" to favorites`); + } catch (error) { + console.error('Error adding favorite:', error); + throw error; + } }, removeFavorite: async (templateId: string) => { - const favorites = get().favorites.filter(f => f.id !== templateId); - set({ favorites }); + try { + const db = openDatabaseSync('powr.db'); + + // Remove from SQLite database + await db.runAsync( + `DELETE FROM favorites WHERE content_type = 'template' AND content_id = ?`, + [templateId] + ); + + // Update IDs in memory state + set(state => ({ + favoriteIds: state.favoriteIds.filter(id => id !== templateId) + })); + + console.log(`Removed template with ID "${templateId}" from favorites`); + } catch (error) { + console.error('Error removing favorite:', error); + throw error; + } }, checkFavoriteStatus: (templateId: string) => { - return get().favorites.some(f => f.id === templateId); + return get().favoriteIds.includes(templateId); }, endWorkout: async () => { @@ -525,7 +647,16 @@ const useWorkoutStoreBase = create { // TODO: Implement clearing autosave from storage get().stopWorkoutTimer(); // Make sure to stop the timer - set(initialState); + + // Preserve favorites when resetting + const favoriteIds = get().favoriteIds; + const favoritesLoaded = get().favoritesLoaded; + + set({ + ...initialState, + favoriteIds, + favoritesLoaded + }); }, // New actions for minimized state @@ -539,17 +670,39 @@ const useWorkoutStoreBase = create { get().stopWorkoutTimer(); // Make sure to stop the timer - set(initialState); + + // Preserve favorites when resetting + const favoriteIds = get().favoriteIds; + const favoritesLoaded = get().favoritesLoaded; + + set({ + ...initialState, + favoriteIds, + favoritesLoaded + }); } })); // Helper functions async function getTemplate(templateId: string): Promise { - // This is a placeholder - you'll need to implement actual template fetching - // from your database/storage service try { - // Example implementation: - // return await db.getTemplate(templateId); + // Try to get it from favorites in the database + const db = openDatabaseSync('powr.db'); + + const result = await db.getFirstAsync<{ + content: string + }>( + `SELECT content FROM favorites WHERE content_type = 'template' AND content_id = ?`, + [templateId] + ); + + if (result && result.content) { + return JSON.parse(result.content); + } + + // If not found in favorites, could implement fetching from template database + // Example: return await db.getTemplate(templateId); + console.log('Template not found in favorites:', templateId); return null; } catch (error) { console.error('Error fetching template:', error);