mirror of
https://github.com/DocNR/POWR.git
synced 2025-06-03 15:52:06 +00:00
updated template details and fixed favoriting workouts
This commit is contained in:
parent
b4dc79cc87
commit
0a0af436c0
62
CHANGELOG.md
62
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
|
||||
|
||||
|
@ -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 }) => (
|
||||
<User size={size} color={color} />
|
||||
<Home size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
@ -85,7 +85,7 @@ export default function TabLayout() {
|
||||
<Tabs.Screen
|
||||
name="history"
|
||||
options={{
|
||||
title: 'History',
|
||||
title: 'History',
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<History size={size} color={color} />
|
||||
),
|
||||
|
@ -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) {
|
||||
|
@ -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';
|
||||
|
@ -107,50 +107,6 @@ export default function ExercisesScreen() {
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Filter buttons */}
|
||||
<View className="flex-row px-4 pb-2 gap-2">
|
||||
<Button
|
||||
variant={activeFilter === null ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="rounded-full"
|
||||
onPress={() => setActiveFilter(null)}
|
||||
>
|
||||
<Text className={activeFilter === null ? "text-primary-foreground" : ""}>
|
||||
All
|
||||
</Text>
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeFilter === "strength" ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="rounded-full"
|
||||
onPress={() => setActiveFilter(activeFilter === "strength" ? null : "strength")}
|
||||
>
|
||||
<Text className={activeFilter === "strength" ? "text-primary-foreground" : ""}>
|
||||
Strength
|
||||
</Text>
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeFilter === "bodyweight" ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="rounded-full"
|
||||
onPress={() => setActiveFilter(activeFilter === "bodyweight" ? null : "bodyweight")}
|
||||
>
|
||||
<Text className={activeFilter === "bodyweight" ? "text-primary-foreground" : ""}>
|
||||
Bodyweight
|
||||
</Text>
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeFilter === "cardio" ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="rounded-full"
|
||||
onPress={() => setActiveFilter(activeFilter === "cardio" ? null : "cardio")}
|
||||
>
|
||||
<Text className={activeFilter === "cardio" ? "text-primary-foreground" : ""}>
|
||||
Cardio
|
||||
</Text>
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
{/* Exercises list */}
|
||||
<SimplifiedExerciseList
|
||||
exercises={exercises}
|
||||
|
@ -1,20 +1,21 @@
|
||||
// app/(tabs)/library/templates.tsx
|
||||
import React, { useState } from 'react';
|
||||
import { View, ScrollView, ActivityIndicator } from 'react-native';
|
||||
import { View, ScrollView } from 'react-native';
|
||||
import { router } from 'expo-router'; // Add this import
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import { Search, Plus } from 'lucide-react-native';
|
||||
import { FloatingActionButton } from '@/components/shared/FloatingActionButton';
|
||||
import { NewTemplateSheet } from '@/components/library/NewTemplateSheet';
|
||||
import { TemplateCard } from '@/components/templates/TemplateCard';
|
||||
import { TemplateDetails } from '@/components/templates/TemplateDetails';
|
||||
// Remove TemplateDetails import since we're not using it anymore
|
||||
import {
|
||||
Template,
|
||||
WorkoutTemplate,
|
||||
TemplateCategory,
|
||||
toWorkoutTemplate
|
||||
} from '@/types/templates';
|
||||
import { useWorkoutStore } from '@/stores/workoutStore';
|
||||
|
||||
const TEMPLATE_CATEGORIES: TemplateCategory[] = [
|
||||
'Full Body',
|
||||
@ -61,30 +62,63 @@ export default function TemplatesScreen() {
|
||||
const [templates, setTemplates] = useState(initialTemplates);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [activeCategory, setActiveCategory] = useState<TemplateCategory | null>(null);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<WorkoutTemplate | null>(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() {
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Category filters */}
|
||||
{/* // Category filters
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
@ -151,7 +185,7 @@ export default function TemplatesScreen() {
|
||||
</Button>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</ScrollView> */}
|
||||
|
||||
{/* Templates list */}
|
||||
<ScrollView>
|
||||
@ -207,16 +241,7 @@ export default function TemplatesScreen() {
|
||||
<View className="h-20" />
|
||||
</ScrollView>
|
||||
|
||||
{/* Template Details with tabs */}
|
||||
{selectedTemplate && (
|
||||
<TemplateDetails
|
||||
template={selectedTemplate}
|
||||
open={!!selectedTemplate}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSelectedTemplate(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Remove the TemplateDetails component since we're using router navigation now */}
|
||||
|
||||
<FloatingActionButton
|
||||
icon={Plus}
|
||||
|
@ -2,49 +2,58 @@
|
||||
import React from 'react'
|
||||
import { Stack } from 'expo-router'
|
||||
import { useTheme } from '@react-navigation/native';
|
||||
import { Platform } from 'react-native';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
|
||||
export default function WorkoutLayout() {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
contentStyle: {
|
||||
backgroundColor: theme.colors.background
|
||||
},
|
||||
presentation: 'modal', // Make all screens in this group modal by default
|
||||
animation: 'slide_from_bottom',
|
||||
gestureEnabled: true, // Allow gesture to dismiss
|
||||
gestureDirection: 'vertical', // Swipe down to dismiss
|
||||
}}
|
||||
>
|
||||
<Stack.Screen
|
||||
name="create"
|
||||
options={{
|
||||
// Modal presentation for create screen
|
||||
presentation: 'modal',
|
||||
animation: 'slide_from_bottom',
|
||||
<>
|
||||
<StatusBar style={Platform.OS === 'ios' ? 'light' : 'auto'} />
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
contentStyle: {
|
||||
backgroundColor: theme.colors.background
|
||||
},
|
||||
// Use standard card presentation for all screens
|
||||
presentation: 'card',
|
||||
// Standard animation
|
||||
animation: 'default',
|
||||
// Enable standard left-edge back gesture
|
||||
gestureEnabled: true,
|
||||
gestureDirection: 'vertical',
|
||||
gestureDirection: 'horizontal',
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="template-select"
|
||||
options={{
|
||||
presentation: 'modal',
|
||||
animation: 'slide_from_bottom',
|
||||
gestureEnabled: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="add-exercises"
|
||||
options={{
|
||||
presentation: 'modal',
|
||||
animation: 'slide_from_bottom',
|
||||
gestureEnabled: true,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
)
|
||||
>
|
||||
<Stack.Screen
|
||||
name="create"
|
||||
options={{
|
||||
presentation: 'card',
|
||||
animation: 'default',
|
||||
gestureEnabled: true,
|
||||
gestureDirection: 'horizontal',
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="add-exercises"
|
||||
options={{
|
||||
presentation: 'card',
|
||||
animation: 'default',
|
||||
gestureEnabled: true,
|
||||
gestureDirection: 'horizontal',
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="template-select"
|
||||
options={{
|
||||
presentation: 'card',
|
||||
animation: 'default',
|
||||
gestureEnabled: true,
|
||||
gestureDirection: 'horizontal',
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
@ -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<BaseExercise[]>([]);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
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 (
|
||||
<TabScreen>
|
||||
<View className="flex-1 pt-12">
|
||||
{/* Close button in the top right */}
|
||||
<View className="absolute top-12 right-4 z-10">
|
||||
<View style={{ flex: 1, paddingTop: insets.top }}>
|
||||
{/* Standard header with back button */}
|
||||
<View className="px-4 py-3 flex-row items-center border-b border-border">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onPress={() => router.back()}
|
||||
>
|
||||
<X className="text-foreground" />
|
||||
<ChevronLeft className="text-foreground" />
|
||||
</Button>
|
||||
<Text className="text-xl font-semibold ml-2">Add Exercises</Text>
|
||||
</View>
|
||||
|
||||
<View className="px-4 pt-4 pb-2">
|
||||
<Text className="text-xl font-semibold mb-4">Add Exercises</Text>
|
||||
<Input
|
||||
placeholder="Search exercises..."
|
||||
value={search}
|
||||
|
@ -1,7 +1,7 @@
|
||||
// app/(workout)/create.tsx
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View, ScrollView, StyleSheet, TextInput } from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import { router, useNavigation } from 'expo-router';
|
||||
import { TabScreen } from '@/components/layout/TabScreen';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@ -16,14 +16,22 @@ import {
|
||||
AlertDialogCancel
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { useWorkoutStore } from '@/stores/workoutStore';
|
||||
import { ArrowLeft, Plus, Pause, Play, MoreHorizontal, CheckCircle2, Dumbbell } from 'lucide-react-native';
|
||||
import { Plus, Pause, Play, MoreHorizontal, CheckCircle2, Dumbbell, ChevronLeft } from 'lucide-react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import EditableText from '@/components/EditableText';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { generateId } from '@/utils/ids';
|
||||
import { WorkoutSet } from '@/types/workout';
|
||||
import { FloatingActionButton } from '@/components/shared/FloatingActionButton';
|
||||
import { formatTime } from '@/utils/formatTime';
|
||||
import { ParamListBase } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
|
||||
// Define styles outside of component
|
||||
const styles = StyleSheet.create({
|
||||
timerText: {
|
||||
fontVariant: ['tabular-nums']
|
||||
}
|
||||
});
|
||||
|
||||
export default function CreateWorkoutScreen() {
|
||||
const {
|
||||
@ -46,14 +54,31 @@ export default function CreateWorkoutScreen() {
|
||||
maximizeWorkout
|
||||
} = useWorkoutStore.getState();
|
||||
|
||||
type CreateScreenNavigationProp = NativeStackNavigationProp<ParamListBase>;
|
||||
const navigation = useNavigation<CreateScreenNavigationProp>();
|
||||
|
||||
// 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 (
|
||||
<TabScreen>
|
||||
<View style={{ flex: 1, paddingTop: insets.top }}>
|
||||
{/* Swipe indicator and back button */}
|
||||
<View className="w-full items-center py-2">
|
||||
<View className="w-10 h-1 rounded-full bg-muted-foreground/30" />
|
||||
{/* Header with back button */}
|
||||
<View className="px-4 py-3 flex-row items-center justify-between border-b border-border">
|
||||
<View className="flex-row items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onPress={() => {
|
||||
minimizeWorkout();
|
||||
router.back();
|
||||
}}
|
||||
>
|
||||
<ChevronLeft className="text-foreground" />
|
||||
</Button>
|
||||
<Text className="text-xl font-semibold ml-2">Back</Text>
|
||||
</View>
|
||||
|
||||
<Button
|
||||
variant="purple"
|
||||
className="px-4"
|
||||
onPress={() => completeWorkout()}
|
||||
disabled={!hasExercises}
|
||||
>
|
||||
<Text className="text-white font-medium">Finish</Text>
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
{/* Header with Title and Finish Button */}
|
||||
<View className="px-4 py-3 border-b border-border">
|
||||
{/* Top row with minimize and finish buttons */}
|
||||
<View className="flex-row justify-between items-center mb-2">
|
||||
{/* Full-width workout title */}
|
||||
<View className="px-4 py-3">
|
||||
<EditableText
|
||||
value={activeWorkout.title}
|
||||
onChangeText={(newTitle) => updateWorkoutTitle(newTitle)}
|
||||
placeholder="Workout Title"
|
||||
textStyle={{
|
||||
fontSize: 24,
|
||||
fontWeight: '700',
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Timer Display */}
|
||||
<View className="flex-row items-center px-4 pb-3 border-b border-border">
|
||||
<Text style={styles.timerText} className={cn(
|
||||
"text-2xl font-mono",
|
||||
status === 'paused' ? "text-muted-foreground" : "text-foreground"
|
||||
)}>
|
||||
{formatTime(elapsedTime)}
|
||||
</Text>
|
||||
|
||||
{status === 'active' ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="flex-row items-center"
|
||||
onPress={handleMinimize}
|
||||
size="icon"
|
||||
className="ml-2"
|
||||
onPress={pauseWorkout}
|
||||
>
|
||||
<ArrowLeft className="mr-1 text-foreground" size={18} />
|
||||
<Text className="text-foreground">Minimize</Text>
|
||||
<Pause className="text-foreground" />
|
||||
</Button>
|
||||
|
||||
) : (
|
||||
<Button
|
||||
variant="purple"
|
||||
className="px-4"
|
||||
onPress={() => completeWorkout()}
|
||||
disabled={!hasExercises}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="ml-2"
|
||||
onPress={resumeWorkout}
|
||||
>
|
||||
<Text className="text-white font-medium">Finish</Text>
|
||||
<Play className="text-foreground" />
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
{/* Full-width workout title */}
|
||||
<View className="mb-3">
|
||||
<EditableText
|
||||
value={activeWorkout.title}
|
||||
onChangeText={(newTitle) => updateWorkoutTitle(newTitle)}
|
||||
placeholder="Workout Title"
|
||||
textStyle={{
|
||||
fontSize: 24,
|
||||
fontWeight: '700',
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Timer Display */}
|
||||
<View className="flex-row items-center">
|
||||
<Text style={styles.timerText} className={cn(
|
||||
"text-2xl font-mono",
|
||||
status === 'paused' ? "text-muted-foreground" : "text-foreground"
|
||||
)}>
|
||||
{formatTime(elapsedTime)}
|
||||
</Text>
|
||||
|
||||
{status === 'active' ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="ml-2"
|
||||
onPress={pauseWorkout}
|
||||
>
|
||||
<Pause className="text-foreground" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="ml-2"
|
||||
onPress={resumeWorkout}
|
||||
>
|
||||
<Play className="text-foreground" />
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Content Area */}
|
||||
@ -317,43 +333,43 @@ export default function CreateWorkoutScreen() {
|
||||
{previousSet ? `${previousSet.weight}×${previousSet.reps}` : '—'}
|
||||
</Text>
|
||||
|
||||
{/* Weight Input */}
|
||||
<View className="flex-1 px-2">
|
||||
<TextInput
|
||||
className={cn(
|
||||
"bg-secondary h-10 rounded-md px-3 text-center text-foreground",
|
||||
set.isCompleted && "bg-primary/10"
|
||||
)}
|
||||
value={set.weight ? set.weight.toString() : ''}
|
||||
onChangeText={(text) => {
|
||||
const weight = text === '' ? 0 : parseFloat(text);
|
||||
if (!isNaN(weight)) {
|
||||
updateSet(exerciseIndex, setIndex, { weight });
|
||||
}
|
||||
}}
|
||||
keyboardType="numeric"
|
||||
selectTextOnFocus
|
||||
/>
|
||||
</View>
|
||||
{/* Weight Input */}
|
||||
<View className="flex-1 px-2">
|
||||
<TextInput
|
||||
className={cn(
|
||||
"bg-secondary h-10 rounded-md px-3 text-center text-foreground",
|
||||
set.isCompleted && "bg-primary/10"
|
||||
)}
|
||||
value={set.weight ? set.weight.toString() : ''}
|
||||
onChangeText={(text) => {
|
||||
const weight = text === '' ? 0 : parseFloat(text);
|
||||
if (!isNaN(weight)) {
|
||||
updateSet(exerciseIndex, setIndex, { weight });
|
||||
}
|
||||
}}
|
||||
keyboardType="numeric"
|
||||
selectTextOnFocus
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Reps Input */}
|
||||
<View className="flex-1 px-2">
|
||||
<TextInput
|
||||
className={cn(
|
||||
"bg-secondary h-10 rounded-md px-3 text-center text-foreground",
|
||||
set.isCompleted && "bg-primary/10"
|
||||
)}
|
||||
value={set.reps ? set.reps.toString() : ''}
|
||||
onChangeText={(text) => {
|
||||
const reps = text === '' ? 0 : parseInt(text, 10);
|
||||
if (!isNaN(reps)) {
|
||||
updateSet(exerciseIndex, setIndex, { reps });
|
||||
}
|
||||
}}
|
||||
keyboardType="numeric"
|
||||
selectTextOnFocus
|
||||
/>
|
||||
</View>
|
||||
{/* Reps Input */}
|
||||
<View className="flex-1 px-2">
|
||||
<TextInput
|
||||
className={cn(
|
||||
"bg-secondary h-10 rounded-md px-3 text-center text-foreground",
|
||||
set.isCompleted && "bg-primary/10"
|
||||
)}
|
||||
value={set.reps ? set.reps.toString() : ''}
|
||||
onChangeText={(text) => {
|
||||
const reps = text === '' ? 0 : parseInt(text, 10);
|
||||
if (!isNaN(reps)) {
|
||||
updateSet(exerciseIndex, setIndex, { reps });
|
||||
}
|
||||
}}
|
||||
keyboardType="numeric"
|
||||
selectTextOnFocus
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Complete Button */}
|
||||
<Button
|
||||
@ -385,16 +401,23 @@ export default function CreateWorkoutScreen() {
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{/* Cancel Button - only shown at the bottom when exercises exist */}
|
||||
<View className="mt-4 mb-8">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onPress={() => setShowCancelDialog(true)}
|
||||
>
|
||||
<Text className="text-foreground">Cancel Workout</Text>
|
||||
</Button>
|
||||
</View>
|
||||
{/* Add Exercises Button */}
|
||||
<Button
|
||||
variant="purple"
|
||||
className="w-full mb-4"
|
||||
onPress={() => router.push('/(workout)/add-exercises')}
|
||||
>
|
||||
<Text className="text-white font-medium">Add Exercises</Text>
|
||||
</Button>
|
||||
|
||||
{/* Cancel Button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full mb-8"
|
||||
onPress={() => setShowCancelDialog(true)}
|
||||
>
|
||||
<Text className="text-foreground">Cancel Workout</Text>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
// Empty State with nice message and icon
|
||||
@ -427,20 +450,6 @@ export default function CreateWorkoutScreen() {
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{/* Add Exercise FAB - only shown when exercises exist */}
|
||||
{hasExercises && (
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
right: 16,
|
||||
bottom: insets.bottom + 16
|
||||
}}>
|
||||
<FloatingActionButton
|
||||
icon={Plus}
|
||||
onPress={() => router.push('/(workout)/add-exercises')}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Cancel Workout Dialog */}
|
||||
@ -464,10 +473,4 @@ export default function CreateWorkoutScreen() {
|
||||
</AlertDialog>
|
||||
</TabScreen>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
timerText: {
|
||||
fontVariant: ['tabular-nums']
|
||||
}
|
||||
});
|
||||
}
|
@ -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 (
|
||||
<ScrollView className="flex-1 p-4">
|
||||
<Text className="text-lg font-semibold mb-4">Recent Templates</Text>
|
||||
|
||||
{MOCK_TEMPLATES.map(template => (
|
||||
<Card key={template.id} className="mb-4">
|
||||
<CardContent className="p-4">
|
||||
<Text className="text-lg font-semibold">{template.title}</Text>
|
||||
<Text className="text-sm text-muted-foreground mb-2">{template.category}</Text>
|
||||
|
||||
{/* Exercise Preview */}
|
||||
<View className="mb-4">
|
||||
{template.exercises.map((exercise, index) => (
|
||||
<Text key={index} className="text-sm text-muted-foreground">
|
||||
{exercise.title} - {exercise.sets}×{exercise.reps}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
<TabScreen>
|
||||
<View style={{ flex: 1, paddingTop: insets.top }}>
|
||||
{/* Standard header with back button */}
|
||||
<View className="px-4 py-3 flex-row items-center border-b border-border">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onPress={() => router.back()}
|
||||
>
|
||||
<ChevronLeft className="text-foreground" />
|
||||
</Button>
|
||||
<Text className="text-xl font-semibold ml-2">Select Template</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView className="flex-1 px-4 pt-4">
|
||||
<Text className="text-lg font-semibold mb-4">Recent Templates</Text>
|
||||
|
||||
<View className="gap-3">
|
||||
{MOCK_TEMPLATES.map(template => (
|
||||
<Card key={template.id} className="mb-4">
|
||||
<CardContent className="p-4">
|
||||
<Text className="text-lg font-semibold">{template.title}</Text>
|
||||
<Text className="text-sm text-muted-foreground mb-2">{template.category}</Text>
|
||||
|
||||
{/* Exercise Preview */}
|
||||
<View className="mb-4">
|
||||
{template.exercises.map((exercise, index) => (
|
||||
<Text key={index} className="text-sm text-muted-foreground">
|
||||
{exercise.title} - {exercise.sets}×{exercise.reps}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<Button onPress={() => handleSelectTemplate(template)}>
|
||||
<Text>Start Workout</Text>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</ScrollView>
|
||||
<Button onPress={() => handleSelectTemplate(template)}>
|
||||
<Text className="text-primary-foreground">Start Workout</Text>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</TabScreen>
|
||||
);
|
||||
}
|
283
app/(workout)/template/[id]/_layout.tsx
Normal file
283
app/(workout)/template/[id]/_layout.tsx
Normal file
@ -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<WorkoutTemplate | null>(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 (
|
||||
<TabScreen>
|
||||
<View style={{ flex: 1, paddingTop: insets.top }}>
|
||||
<View className="px-4 py-3 flex-row items-center border-b border-border">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onPress={handleGoBack}
|
||||
>
|
||||
<ChevronLeft className="text-foreground" />
|
||||
</Button>
|
||||
<Text className="text-xl font-semibold ml-2">
|
||||
Template Details
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<ActivityIndicator size="large" className="mb-4" />
|
||||
<Text className="text-muted-foreground">Loading template...</Text>
|
||||
</View>
|
||||
</View>
|
||||
</TabScreen>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TabScreen>
|
||||
<View style={{ flex: 1, paddingTop: insets.top }}>
|
||||
{/* Header with back button, title and Start button */}
|
||||
<View className="px-4 py-3 flex-row items-center justify-between border-b border-border">
|
||||
<View className="flex-row items-center flex-1 mr-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onPress={handleGoBack}
|
||||
>
|
||||
<ChevronLeft className="text-foreground" />
|
||||
</Button>
|
||||
<Text className="text-xl font-semibold ml-2" numberOfLines={1}>
|
||||
{workoutTemplate.title}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="flex-row items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onPress={handleToggleFavorite}
|
||||
className="mr-2"
|
||||
>
|
||||
<Heart
|
||||
className={isFavorite ? "text-primary" : "text-muted-foreground"}
|
||||
fill={isFavorite ? "currentColor" : "none"}
|
||||
size={22}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
{/* Updated to match Add Exercises button */}
|
||||
<Button
|
||||
variant="purple"
|
||||
className="flex-row items-center justify-center"
|
||||
onPress={handleStartWorkout}
|
||||
>
|
||||
<Text className="text-white font-medium">Start Workout</Text>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Context provider to pass template to the tabs */}
|
||||
<TemplateContext.Provider value={{ template: workoutTemplate }}>
|
||||
<Tab.Navigator
|
||||
screenOptions={{
|
||||
// Match exact library tab styling
|
||||
tabBarActiveTintColor: theme.colors.tabIndicator,
|
||||
tabBarInactiveTintColor: theme.colors.tabInactive,
|
||||
tabBarLabelStyle: {
|
||||
fontSize: 14,
|
||||
textTransform: 'capitalize',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
tabBarIndicatorStyle: {
|
||||
backgroundColor: theme.colors.tabIndicator,
|
||||
height: 2,
|
||||
},
|
||||
tabBarStyle: {
|
||||
backgroundColor: theme.colors.background,
|
||||
elevation: 0,
|
||||
shadowOpacity: 0,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
tabBarPressColor: theme.colors.primary,
|
||||
}}
|
||||
>
|
||||
<Tab.Screen
|
||||
name="index"
|
||||
component={OverviewTab}
|
||||
options={{ title: 'Overview' }}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="social"
|
||||
component={SocialTab}
|
||||
options={{ title: 'Social' }}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="history"
|
||||
component={HistoryTab}
|
||||
options={{ title: 'History' }}
|
||||
/>
|
||||
</Tab.Navigator>
|
||||
</TemplateContext.Provider>
|
||||
</View>
|
||||
</TabScreen>
|
||||
);
|
||||
}
|
||||
|
||||
// Create a context to share the template with the tab screens
|
||||
interface TemplateContextType {
|
||||
template: WorkoutTemplate | null;
|
||||
}
|
||||
|
||||
export const TemplateContext = React.createContext<TemplateContextType>({
|
||||
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;
|
||||
}
|
180
app/(workout)/template/[id]/history.tsx
Normal file
180
app/(workout)/template/[id]/history.tsx
Normal file
@ -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 (
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
contentContainerStyle={{ paddingBottom: 20 }}
|
||||
>
|
||||
<View className="gap-6 p-4">
|
||||
{/* Performance Summary */}
|
||||
<View>
|
||||
<Text className="text-base font-semibold text-foreground mb-4">Performance Summary</Text>
|
||||
<Card className="bg-card">
|
||||
<CardContent className="p-4">
|
||||
<View className="flex-row justify-between">
|
||||
<View className="items-center">
|
||||
<Text className="text-xs text-muted-foreground">Total Workouts</Text>
|
||||
<Text className="text-xl font-semibold">{mockWorkoutHistory.length}</Text>
|
||||
</View>
|
||||
|
||||
<View className="items-center">
|
||||
<Text className="text-xs text-muted-foreground">Avg Duration</Text>
|
||||
<Text className="text-xl font-semibold">
|
||||
{Math.round(mockWorkoutHistory.reduce((acc, w) => acc + w.duration, 0) / mockWorkoutHistory.length)} min
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="items-center">
|
||||
<Text className="text-xs text-muted-foreground">Completion</Text>
|
||||
<Text className="text-xl font-semibold">
|
||||
{Math.round(mockWorkoutHistory.filter(w => w.completed).length / mockWorkoutHistory.length * 100)}%
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</View>
|
||||
|
||||
{/* History List */}
|
||||
<View>
|
||||
<Text className="text-base font-semibold text-foreground mb-4">Workout History</Text>
|
||||
|
||||
{isLoading ? (
|
||||
<View className="items-center justify-center py-8">
|
||||
<ActivityIndicator size="small" className="mb-2" />
|
||||
<Text className="text-muted-foreground">Loading history...</Text>
|
||||
</View>
|
||||
) : mockWorkoutHistory.length > 0 ? (
|
||||
<View className="gap-4">
|
||||
{mockWorkoutHistory.map((workout) => (
|
||||
<Card key={workout.id} className="overflow-hidden">
|
||||
<CardContent className="p-4">
|
||||
<View className="flex-row justify-between mb-2">
|
||||
<Text className="font-semibold">{formatDate(workout.date)}</Text>
|
||||
<Badge variant={workout.completed ? "default" : "outline"}>
|
||||
<Text>{workout.completed ? "Completed" : "Incomplete"}</Text>
|
||||
</Badge>
|
||||
</View>
|
||||
|
||||
<View className="flex-row justify-between mb-3">
|
||||
<View>
|
||||
<Text className="text-xs text-muted-foreground">Duration</Text>
|
||||
<Text className="text-sm">{workout.duration} min</Text>
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<Text className="text-xs text-muted-foreground">Sets</Text>
|
||||
<Text className="text-sm">
|
||||
{workout.exercises.reduce((acc, ex) => acc + ex.sets, 0)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<Text className="text-xs text-muted-foreground">Volume</Text>
|
||||
<Text className="text-sm">
|
||||
{workout.exercises.reduce((acc, ex) => acc + (ex.sets * ex.reps * ex.weight), 0)} lbs
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{workout.notes && (
|
||||
<Text className="text-sm text-muted-foreground mb-3">
|
||||
{workout.notes}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Button variant="outline" size="sm" className="w-full">
|
||||
<Text>View Details</Text>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<View className="bg-muted p-8 rounded-lg items-center justify-center">
|
||||
<Calendar size={24} className="text-muted-foreground mb-2" />
|
||||
<Text className="text-muted-foreground text-center">
|
||||
No workout history available yet
|
||||
</Text>
|
||||
<Text className="text-sm text-muted-foreground text-center mt-1">
|
||||
Complete a workout using this template to see your history
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
219
app/(workout)/template/[id]/index.tsx
Normal file
219
app/(workout)/template/[id]/index.tsx
Normal file
@ -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 (
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
contentContainerStyle={{ paddingBottom: 20 }}
|
||||
>
|
||||
<View className="gap-6 p-4">
|
||||
{/* Basic Info Section */}
|
||||
<View className="flex-row items-center gap-2">
|
||||
<Badge
|
||||
variant={sourceType === 'local' ? 'outline' : 'secondary'}
|
||||
className="capitalize"
|
||||
>
|
||||
<Text>{sourceType === 'local' ? 'My Template' : sourceType === 'powr' ? 'POWR Template' : 'Nostr Template'}</Text>
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="capitalize bg-muted"
|
||||
>
|
||||
<Text>{type}</Text>
|
||||
</Badge>
|
||||
</View>
|
||||
|
||||
<Separator className="bg-border" />
|
||||
|
||||
{/* Category Section */}
|
||||
<View className="flex-row items-center gap-2">
|
||||
<View className="w-8 h-8 items-center justify-center rounded-md bg-muted">
|
||||
<Target size={18} className="text-muted-foreground" />
|
||||
</View>
|
||||
<View>
|
||||
<Text className="text-sm text-muted-foreground">Category</Text>
|
||||
<Text className="text-base font-medium text-foreground">{category}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Description Section */}
|
||||
{description && (
|
||||
<View>
|
||||
<Text className="text-base font-semibold text-foreground mb-2">Description</Text>
|
||||
<Text className="text-base text-muted-foreground leading-relaxed">{description}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Exercises Section */}
|
||||
<View>
|
||||
<View className="flex-row items-center gap-2 mb-2">
|
||||
<Dumbbell size={16} className="text-muted-foreground" />
|
||||
<Text className="text-base font-semibold text-foreground">Exercises</Text>
|
||||
</View>
|
||||
<View className="gap-2">
|
||||
{exercises.map((exerciseConfig, index) => (
|
||||
<View key={index} className="bg-card p-3 rounded-lg">
|
||||
<Text className="text-base font-medium text-foreground">
|
||||
{exerciseConfig.exercise.title}
|
||||
</Text>
|
||||
<Text className="text-sm text-muted-foreground">
|
||||
{exerciseConfig.targetSets} sets × {exerciseConfig.targetReps} reps
|
||||
</Text>
|
||||
{exerciseConfig.notes && (
|
||||
<Text className="text-sm text-muted-foreground mt-1">
|
||||
{exerciseConfig.notes}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Tags Section */}
|
||||
{tags.length > 0 && (
|
||||
<View>
|
||||
<View className="flex-row items-center gap-2 mb-2">
|
||||
<Hash size={16} className="text-muted-foreground" />
|
||||
<Text className="text-base font-semibold text-foreground">Tags</Text>
|
||||
</View>
|
||||
<View className="flex-row flex-wrap gap-2">
|
||||
{tags.map(tag => (
|
||||
<Badge key={tag} variant="secondary">
|
||||
<Text>{tag}</Text>
|
||||
</Badge>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Workout Parameters Section */}
|
||||
<View>
|
||||
<View className="flex-row items-center gap-2 mb-2">
|
||||
<Clock size={16} className="text-muted-foreground" />
|
||||
<Text className="text-base font-semibold text-foreground">Workout Parameters</Text>
|
||||
</View>
|
||||
<View className="gap-2">
|
||||
{template.rounds && (
|
||||
<View className="flex-row">
|
||||
<Text className="text-sm text-muted-foreground w-40">Rounds:</Text>
|
||||
<Text className="text-sm text-foreground">{template.rounds}</Text>
|
||||
</View>
|
||||
)}
|
||||
{template.duration && (
|
||||
<View className="flex-row">
|
||||
<Text className="text-sm text-muted-foreground w-40">Duration:</Text>
|
||||
<Text className="text-sm text-foreground">{formatTime(template.duration * 1000)}</Text>
|
||||
</View>
|
||||
)}
|
||||
{template.interval && (
|
||||
<View className="flex-row">
|
||||
<Text className="text-sm text-muted-foreground w-40">Interval:</Text>
|
||||
<Text className="text-sm text-foreground">{formatTime(template.interval * 1000)}</Text>
|
||||
</View>
|
||||
)}
|
||||
{template.restBetweenRounds && (
|
||||
<View className="flex-row">
|
||||
<Text className="text-sm text-muted-foreground w-40">Rest Between Rounds:</Text>
|
||||
<Text className="text-sm text-foreground">
|
||||
{formatTime(template.restBetweenRounds * 1000)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{metadata?.averageDuration && (
|
||||
<View className="flex-row">
|
||||
<Text className="text-sm text-muted-foreground w-40">Average Completion Time:</Text>
|
||||
<Text className="text-sm text-foreground">
|
||||
{Math.round(metadata.averageDuration / 60)} minutes
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Usage Stats Section */}
|
||||
{metadata && (
|
||||
<View>
|
||||
<View className="flex-row items-center gap-2 mb-2">
|
||||
<Calendar size={16} className="text-muted-foreground" />
|
||||
<Text className="text-base font-semibold text-foreground">Usage</Text>
|
||||
</View>
|
||||
<View className="gap-2">
|
||||
{metadata.useCount && (
|
||||
<Text className="text-base text-muted-foreground">
|
||||
Used {metadata.useCount} times
|
||||
</Text>
|
||||
)}
|
||||
{metadata.lastUsed && (
|
||||
<Text className="text-base text-muted-foreground">
|
||||
Last used: {new Date(metadata.lastUsed).toLocaleDateString()}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<View className="gap-3 mt-2">
|
||||
{isEditable ? (
|
||||
<Button variant="outline" className="w-full" onPress={() => console.log('Edit template')}>
|
||||
<Edit2 size={18} className="mr-2" />
|
||||
<Text>Edit Template</Text>
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" className="w-full" onPress={() => console.log('Fork template')}>
|
||||
<Copy size={18} className="mr-2" />
|
||||
<Text>Save as My Template</Text>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button variant="outline" className="w-full" onPress={() => console.log('Share template')}>
|
||||
<Share2 size={18} className="mr-2" />
|
||||
<Text>Share Template</Text>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
189
app/(workout)/template/[id]/social.tsx
Normal file
189
app/(workout)/template/[id]/social.tsx
Normal file
@ -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 (
|
||||
<Card className="mb-4">
|
||||
<CardContent className="p-4">
|
||||
{/* User info and timestamp */}
|
||||
<View className="flex-row mb-3">
|
||||
<Avatar className="h-10 w-10 mr-3" alt={`${item.userName}'s profile picture`}>
|
||||
<AvatarImage source={{ uri: item.userAvatar }} />
|
||||
<AvatarFallback>
|
||||
<Text className="text-sm">{item.userName.substring(0, 2)}</Text>
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<View className="flex-1">
|
||||
<View className="flex-row justify-between">
|
||||
<Text className="font-semibold">{item.userName}</Text>
|
||||
<Text className="text-xs text-muted-foreground">
|
||||
{item.timestamp.toLocaleDateString()}
|
||||
</Text>
|
||||
</View>
|
||||
<Text className="text-xs text-muted-foreground">
|
||||
{item.pubkey.substring(0, 10)}...
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Post content */}
|
||||
<Text className="mb-3">{item.content}</Text>
|
||||
|
||||
{/* Workout metrics */}
|
||||
<View className="flex-row justify-between mb-3 p-3 bg-muted rounded-lg">
|
||||
<View className="items-center">
|
||||
<Text className="text-xs text-muted-foreground">Duration</Text>
|
||||
<Text className="font-semibold">{item.metrics.duration} min</Text>
|
||||
</View>
|
||||
|
||||
<View className="items-center">
|
||||
<Text className="text-xs text-muted-foreground">Volume</Text>
|
||||
<Text className="font-semibold">{item.metrics.volume} lbs</Text>
|
||||
</View>
|
||||
|
||||
<View className="items-center">
|
||||
<Text className="text-xs text-muted-foreground">Exercises</Text>
|
||||
<Text className="font-semibold">{item.metrics.exercises}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Actions */}
|
||||
<View className="flex-row justify-between items-center">
|
||||
<View className="flex-row items-center">
|
||||
<Button variant="ghost" size="sm" className="p-1">
|
||||
<ThumbsUp size={16} className="text-muted-foreground mr-1" />
|
||||
<Text className="text-muted-foreground">{item.likes}</Text>
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="p-1">
|
||||
<MessageCircle size={16} className="text-muted-foreground mr-1" />
|
||||
<Text className="text-muted-foreground">{item.comments}</Text>
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
<Button variant="outline" size="sm">
|
||||
<Text>View Workout</Text>
|
||||
</Button>
|
||||
</View>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SocialTab() {
|
||||
const template = useTemplate();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
contentContainerStyle={{ paddingBottom: 20 }}
|
||||
>
|
||||
<View className="p-4">
|
||||
<View className="flex-row justify-between items-center mb-4">
|
||||
<Text className="text-base font-semibold text-foreground">
|
||||
Recent Activity
|
||||
</Text>
|
||||
<Badge variant="outline">
|
||||
<Text>Nostr</Text>
|
||||
</Badge>
|
||||
</View>
|
||||
|
||||
{isLoading ? (
|
||||
<View className="items-center justify-center py-8">
|
||||
<ActivityIndicator size="small" className="mb-2" />
|
||||
<Text className="text-muted-foreground">Loading activity...</Text>
|
||||
</View>
|
||||
) : mockSocialFeed.length > 0 ? (
|
||||
mockSocialFeed.map((item) => (
|
||||
<SocialFeedItem key={item.id} item={item} />
|
||||
))
|
||||
) : (
|
||||
<View className="items-center justify-center py-8 bg-muted rounded-lg">
|
||||
<MessageCircle size={24} className="text-muted-foreground mb-2" />
|
||||
<Text className="text-muted-foreground text-center">No social activity found</Text>
|
||||
<Text className="text-xs text-muted-foreground text-center mt-1">
|
||||
This workout hasn't been shared on Nostr yet
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
@ -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);
|
||||
|
@ -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 (
|
||||
<TouchableOpacity onPress={onPress} activeOpacity={0.7}>
|
||||
<TouchableOpacity onPress={handleTemplatePress} activeOpacity={0.7}>
|
||||
<Card className="mx-4">
|
||||
<CardContent className="p-4">
|
||||
<View className="flex-row justify-between items-start">
|
||||
|
@ -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 (
|
||||
<ScrollView
|
||||
className="flex-1 px-4"
|
||||
contentContainerStyle={{ paddingBottom: 20 }}
|
||||
>
|
||||
<View className="gap-6 py-4">
|
||||
{/* Basic Info Section */}
|
||||
<View className="flex-row items-center gap-2">
|
||||
<Badge
|
||||
variant={sourceType === 'local' ? 'outline' : 'secondary'}
|
||||
className="capitalize"
|
||||
>
|
||||
<Text>{getSourceDisplay(template)}</Text>
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="capitalize bg-muted"
|
||||
>
|
||||
<Text>{type}</Text>
|
||||
</Badge>
|
||||
</View>
|
||||
|
||||
<Separator className="bg-border" />
|
||||
|
||||
{/* Category Section */}
|
||||
<View className="flex-row items-center gap-2">
|
||||
<View className="w-8 h-8 items-center justify-center rounded-md bg-muted">
|
||||
<Target size={18} className="text-muted-foreground" />
|
||||
</View>
|
||||
<View>
|
||||
<Text className="text-sm text-muted-foreground">Category</Text>
|
||||
<Text className="text-base font-medium text-foreground">{category}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Description Section */}
|
||||
{description && (
|
||||
<View>
|
||||
<Text className="text-base font-semibold text-foreground mb-2">Description</Text>
|
||||
<Text className="text-base text-muted-foreground leading-relaxed">{description}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Exercises Section */}
|
||||
<View>
|
||||
<View className="flex-row items-center gap-2 mb-2">
|
||||
<Dumbbell size={16} className="text-muted-foreground" />
|
||||
<Text className="text-base font-semibold text-foreground">Exercises</Text>
|
||||
</View>
|
||||
<View className="gap-2">
|
||||
{exercises.map((exerciseConfig, index) => (
|
||||
<View key={index} className="bg-card p-3 rounded-lg">
|
||||
<Text className="text-base font-medium text-foreground">
|
||||
{exerciseConfig.exercise.title}
|
||||
</Text>
|
||||
<Text className="text-sm text-muted-foreground">
|
||||
{exerciseConfig.targetSets} sets × {exerciseConfig.targetReps} reps
|
||||
</Text>
|
||||
{exerciseConfig.notes && (
|
||||
<Text className="text-sm text-muted-foreground mt-1">
|
||||
{exerciseConfig.notes}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Tags Section */}
|
||||
{tags.length > 0 && (
|
||||
<View>
|
||||
<View className="flex-row items-center gap-2 mb-2">
|
||||
<Hash size={16} className="text-muted-foreground" />
|
||||
<Text className="text-base font-semibold text-foreground">Tags</Text>
|
||||
</View>
|
||||
<View className="flex-row flex-wrap gap-2">
|
||||
{tags.map(tag => (
|
||||
<Badge key={tag} variant="secondary">
|
||||
<Text>{tag}</Text>
|
||||
</Badge>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Usage Stats Section */}
|
||||
{metadata && (
|
||||
<View>
|
||||
<View className="flex-row items-center gap-2 mb-2">
|
||||
<Calendar size={16} className="text-muted-foreground" />
|
||||
<Text className="text-base font-semibold text-foreground">Usage</Text>
|
||||
</View>
|
||||
<View className="gap-2">
|
||||
{metadata.useCount && (
|
||||
<Text className="text-base text-muted-foreground">
|
||||
Used {metadata.useCount} times
|
||||
</Text>
|
||||
)}
|
||||
{metadata.lastUsed && (
|
||||
<Text className="text-base text-muted-foreground">
|
||||
Last used: {new Date(metadata.lastUsed).toLocaleDateString()}
|
||||
</Text>
|
||||
)}
|
||||
{metadata.averageDuration && (
|
||||
<Text className="text-base text-muted-foreground">
|
||||
Average duration: {Math.round(metadata.averageDuration / 60)} minutes
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Edit Button */}
|
||||
{onEdit && (
|
||||
<Button
|
||||
onPress={onEdit}
|
||||
className="w-full mt-2"
|
||||
>
|
||||
<Edit2 size={18} className="mr-2 text-primary-foreground" />
|
||||
<Text className="text-primary-foreground font-semibold">
|
||||
Edit Template
|
||||
</Text>
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
// History Tab Component
|
||||
function HistoryTab({ template }: { template: WorkoutTemplate }) {
|
||||
return (
|
||||
<ScrollView
|
||||
className="flex-1 px-4"
|
||||
contentContainerStyle={{ paddingBottom: 20 }}
|
||||
>
|
||||
<View className="gap-6 py-4">
|
||||
{/* Performance Stats */}
|
||||
<View>
|
||||
<Text className="text-base font-semibold text-foreground mb-4">Performance Summary</Text>
|
||||
<View className="gap-4">
|
||||
<View className="bg-card p-4 rounded-lg">
|
||||
<Text className="text-sm text-muted-foreground mb-1">Usage Stats</Text>
|
||||
<View className="flex-row justify-between mt-2">
|
||||
<View>
|
||||
<Text className="text-sm text-muted-foreground">Total Workouts</Text>
|
||||
<Text className="text-lg font-semibold text-foreground">
|
||||
{template.metadata?.useCount || 0}
|
||||
</Text>
|
||||
</View>
|
||||
<View>
|
||||
<Text className="text-sm text-muted-foreground">Avg. Duration</Text>
|
||||
<Text className="text-lg font-semibold text-foreground">
|
||||
{template.metadata?.averageDuration
|
||||
? `${Math.round(template.metadata.averageDuration / 60)}m`
|
||||
: '--'}
|
||||
</Text>
|
||||
</View>
|
||||
<View>
|
||||
<Text className="text-sm text-muted-foreground">Completion Rate</Text>
|
||||
<Text className="text-lg font-semibold text-foreground">--</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Progress Chart Placeholder */}
|
||||
<View className="bg-card p-4 rounded-lg">
|
||||
<Text className="text-sm text-muted-foreground mb-4">Progress Over Time</Text>
|
||||
<View className="h-40 items-center justify-center">
|
||||
<LineChart size={24} className="text-muted-foreground mb-2" />
|
||||
<Text className="text-sm text-muted-foreground">
|
||||
Progress tracking coming soon
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* History List */}
|
||||
<View>
|
||||
<Text className="text-base font-semibold text-foreground mb-4">Recent Workouts</Text>
|
||||
<View className="gap-3">
|
||||
{/* Placeholder for when no history exists */}
|
||||
<View className="bg-muted p-8 rounded-lg items-center justify-center">
|
||||
<ClipboardList size={24} className="text-muted-foreground mb-2" />
|
||||
<Text className="text-muted-foreground text-center">
|
||||
No workout history available yet
|
||||
</Text>
|
||||
<Text className="text-sm text-muted-foreground text-center mt-1">
|
||||
Complete a workout using this template to see your history
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<ScrollView
|
||||
className="flex-1 px-4"
|
||||
contentContainerStyle={{ paddingBottom: 20 }}
|
||||
>
|
||||
<View className="gap-6 py-4">
|
||||
{/* Workout Configuration */}
|
||||
<View>
|
||||
<Text className="text-base font-semibold text-foreground mb-4">Workout Settings</Text>
|
||||
<View className="gap-4">
|
||||
<View className="bg-card p-4 rounded-lg">
|
||||
<Text className="text-sm text-muted-foreground mb-1">Workout Type</Text>
|
||||
<Text className="text-base font-medium text-foreground capitalize">{type}</Text>
|
||||
</View>
|
||||
|
||||
{rounds && (
|
||||
<View className="bg-card p-4 rounded-lg">
|
||||
<Text className="text-sm text-muted-foreground mb-1">Number of Rounds</Text>
|
||||
<Text className="text-base font-medium text-foreground">{rounds}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{duration && (
|
||||
<View className="bg-card p-4 rounded-lg">
|
||||
<Text className="text-sm text-muted-foreground mb-1">Total Duration</Text>
|
||||
<Text className="text-base font-medium text-foreground">
|
||||
{formatTime(duration)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{interval && (
|
||||
<View className="bg-card p-4 rounded-lg">
|
||||
<Text className="text-sm text-muted-foreground mb-1">Interval Time</Text>
|
||||
<Text className="text-base font-medium text-foreground">
|
||||
{formatTime(interval)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{restBetweenRounds && (
|
||||
<View className="bg-card p-4 rounded-lg">
|
||||
<Text className="text-sm text-muted-foreground mb-1">Rest Between Rounds</Text>
|
||||
<Text className="text-base font-medium text-foreground">
|
||||
{formatTime(restBetweenRounds)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Sync Settings */}
|
||||
<View>
|
||||
<Text className="text-base font-semibold text-foreground mb-4">Sync Settings</Text>
|
||||
<View className="bg-card p-4 rounded-lg">
|
||||
<Text className="text-sm text-muted-foreground mb-1">Template Source</Text>
|
||||
<Text className="text-base font-medium text-foreground">
|
||||
{getSourceDisplay(template)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
export function TemplateDetails({
|
||||
template,
|
||||
open,
|
||||
onOpenChange,
|
||||
onEdit
|
||||
}: TemplateDetailsProps) {
|
||||
const theme = useTheme() as CustomTheme;
|
||||
|
||||
return (
|
||||
<Sheet isOpen={open} onClose={() => onOpenChange(false)}>
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
<Text className="text-xl font-bold text-foreground">{template.title}</Text>
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<SheetContent>
|
||||
<View style={{ flex: 1, minHeight: 400 }} className="rounded-t-[10px]">
|
||||
<Tab.Navigator
|
||||
style={{ flex: 1 }}
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: theme.colors.tabIndicator,
|
||||
tabBarInactiveTintColor: theme.colors.tabInactive,
|
||||
tabBarLabelStyle: {
|
||||
fontSize: 13,
|
||||
textTransform: 'capitalize',
|
||||
fontWeight: 'bold',
|
||||
marginHorizontal: -4,
|
||||
},
|
||||
tabBarIndicatorStyle: {
|
||||
backgroundColor: theme.colors.tabIndicator,
|
||||
height: 2,
|
||||
},
|
||||
tabBarStyle: {
|
||||
backgroundColor: theme.colors.background,
|
||||
elevation: 0,
|
||||
shadowOpacity: 0,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
tabBarPressColor: theme.colors.primary,
|
||||
}}
|
||||
>
|
||||
<Tab.Screen
|
||||
name="overview"
|
||||
options={{ title: 'Overview' }}
|
||||
>
|
||||
{() => <OverviewTab template={template} onEdit={onEdit} />}
|
||||
</Tab.Screen>
|
||||
<Tab.Screen
|
||||
name="history"
|
||||
options={{ title: 'History' }}
|
||||
>
|
||||
{() => <HistoryTab template={template} />}
|
||||
</Tab.Screen>
|
||||
<Tab.Screen
|
||||
name="settings"
|
||||
options={{ title: 'Settings' }}
|
||||
>
|
||||
{() => <SettingsTab template={template} />}
|
||||
</Tab.Screen>
|
||||
</Tab.Navigator>
|
||||
</View>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
@ -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<FavoriteItem[]>;
|
||||
addFavorite: (template: WorkoutTemplate) => Promise<void>;
|
||||
removeFavorite: (templateId: string) => Promise<void>;
|
||||
checkFavoriteStatus: (templateId: string) => boolean;
|
||||
loadFavorites: () => Promise<void>;
|
||||
|
||||
// New template management
|
||||
startWorkoutFromTemplate: (templateId: string) => Promise<void>;
|
||||
@ -121,7 +124,8 @@ const initialState: ExtendedWorkoutState = {
|
||||
},
|
||||
isActive: false,
|
||||
isMinimized: false,
|
||||
favorites: []
|
||||
favoriteIds: [],
|
||||
favoritesLoaded: false
|
||||
};
|
||||
|
||||
const useWorkoutStoreBase = create<ExtendedWorkoutState & ExtendedWorkoutActions>()((set, get) => ({
|
||||
@ -228,10 +232,13 @@ const useWorkoutStoreBase = create<ExtendedWorkoutState & ExtendedWorkoutActions
|
||||
// This would be the place to implement storage cleanup if needed
|
||||
await get().clearAutoSave();
|
||||
|
||||
// Reset to initial state
|
||||
// Reset to initial state, but preserve favorites
|
||||
const favoriteIds = get().favoriteIds;
|
||||
const favoritesLoaded = get().favoritesLoaded;
|
||||
set({
|
||||
...initialState,
|
||||
favorites: get().favorites // Preserve favorites when resetting
|
||||
favoriteIds,
|
||||
favoritesLoaded
|
||||
});
|
||||
},
|
||||
|
||||
@ -490,29 +497,144 @@ const useWorkoutStoreBase = create<ExtendedWorkoutState & ExtendedWorkoutActions
|
||||
});
|
||||
},
|
||||
|
||||
// Favorite Management
|
||||
// Favorite Management with SQLite persistence - IMPROVED VERSION
|
||||
loadFavorites: async () => {
|
||||
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<ExtendedWorkoutState & ExtendedWorkoutActions
|
||||
clearAutoSave: async () => {
|
||||
// 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<ExtendedWorkoutState & ExtendedWorkoutActions
|
||||
|
||||
reset: () => {
|
||||
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<WorkoutTemplate | null> {
|
||||
// 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);
|
||||
|
Loading…
x
Reference in New Issue
Block a user