updated template details and fixed favoriting workouts

This commit is contained in:
DocNR 2025-02-26 23:30:00 -05:00
parent b4dc79cc87
commit 0a0af436c0
18 changed files with 1458 additions and 743 deletions

View File

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

View File

@ -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} />
),

View File

@ -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) {

View File

@ -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';

View File

@ -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}

View File

@ -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}

View File

@ -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>
</>
);
}

View File

@ -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}

View File

@ -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']
}
});
}

View File

@ -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>
);
}

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

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

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

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

View File

@ -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);

View File

@ -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">

View File

@ -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>
);
}

View File

@ -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);