Initial commit: POWR rebuild with React Native and Expo

This commit is contained in:
DocNR 2025-02-04 11:13:04 -05:00
parent d06c90d922
commit e7ed5374c1
67 changed files with 7934 additions and 387 deletions

78
CHANGELOG.md Normal file
View File

@ -0,0 +1,78 @@
# Changelog
All notable changes to the POWR project will be documented in this file.
## [Unreleased]
### 2024-02-04
#### Added
- Complete template database schema
- Extended LibraryService with template support:
- Template fetching
- Exercise to template conversion
- Template exercise handling
- Type-safe library content handling
#### Changed
- Updated database schema to version 3
- Enhanced template type system
- Improved exercise loading in library
#### Technical Details
1. Database Schema Updates:
- Added templates table with metadata support
- Added template_exercises junction table
- Added template_tags table
- Added proper constraints and foreign keys
2. Type System Improvements:
- Added LibraryContent interface for unified content handling
- Enhanced exercise types with format support
- Added proper type assertions for template data
3. Library Service Enhancements:
- Added getTemplates method with proper typing
- Added getTemplate helper methods
- Improved error handling and transaction support
#### Migration Notes
- New database tables for templates require migration
- Template data structure now supports future Nostr integration
- Library views should be updated to use new content types
### 2024-02-03
#### Added
- Unified type system for exercises, workouts, and templates
- New database utilities and service layer
- Nostr integration utilities:
- Event type definitions
- Data transformers for Nostr compatibility
- Validation utilities for Nostr events
#### Changed
- Refactored WorkoutContext to use new unified types
- Updated LibraryService to use new database utilities
- Consolidated exercise types into a single source of truth
#### Technical Details
1. Type System Updates:
- Created BaseExercise type as foundation
- Added WorkoutExercise and TemplateExercise types
- Implemented SyncableContent interface for Nostr compatibility
2. Database Improvements:
- Added new DbService class for better transaction handling
- Updated schema for exercise format storage
- Added migration system
3. Nostr Integration:
- Added event validation utilities
- Created transformers for converting between local and Nostr formats
- Added utility functions for tag handling
#### Migration Notes
- Database schema version increased to 2
- Exercise format data will need migration
- Existing code using old exercise types will need updates

View File

@ -5,9 +5,14 @@
"version": "1.0.0", "version": "1.0.0",
"orientation": "portrait", "orientation": "portrait",
"icon": "./assets/images/icon.png", "icon": "./assets/images/icon.png",
"scheme": "myapp", "scheme": "powr",
"userInterfaceStyle": "automatic", "userInterfaceStyle": "automatic",
"newArchEnabled": true, "newArchEnabled": true,
"assets": [
"assets/fonts",
"assets/images",
"assets/videos"
],
"ios": { "ios": {
"supportsTablet": true "supportsTablet": true
}, },
@ -22,6 +27,9 @@
"output": "static", "output": "static",
"favicon": "./assets/images/favicon.png" "favicon": "./assets/images/favicon.png"
}, },
"dependencies": {
"react-native-pager-view": "6.2.0"
},
"plugins": [ "plugins": [
"expo-router", "expo-router",
[ [
@ -32,7 +40,8 @@
"resizeMode": "contain", "resizeMode": "contain",
"backgroundColor": "#ffffff" "backgroundColor": "#ffffff"
} }
] ],
"expo-sqlite"
], ],
"experiments": { "experiments": {
"typedRoutes": true "typedRoutes": true

View File

@ -1,45 +1,77 @@
import { Tabs } from 'expo-router'; // app/(tabs)/_layout.tsx
import React from 'react'; import React from 'react';
import { Platform } from 'react-native'; import { Platform } from 'react-native';
import { Tabs } from 'expo-router';
import { HapticTab } from '@/components/HapticTab';
import { IconSymbol } from '@/components/ui/IconSymbol';
import TabBarBackground from '@/components/ui/TabBarBackground';
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
import { Dumbbell, Library, Users, History, User } from 'lucide-react-native';
export default function TabLayout() { export default function TabLayout() {
const colorScheme = useColorScheme(); const { colors } = useColorScheme();
return ( return (
<Tabs <Tabs
screenOptions={{ screenOptions={{
tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint,
headerShown: false, headerShown: false,
tabBarButton: HapticTab, tabBarStyle: {
tabBarBackground: TabBarBackground, backgroundColor: colors.background,
tabBarStyle: Platform.select({ borderTopColor: colors.border,
ios: { borderTopWidth: Platform.OS === 'ios' ? 0.5 : 1,
// Use a transparent background on iOS to show the blur effect elevation: 0,
position: 'absolute', shadowOpacity: 0,
}, },
default: {}, tabBarActiveTintColor: colors.primary,
}), tabBarInactiveTintColor: colors.textSecondary,
tabBarShowLabel: true,
tabBarLabelStyle: {
fontSize: 12,
marginBottom: Platform.OS === 'ios' ? 0 : 4,
},
}}> }}>
<Tabs.Screen <Tabs.Screen
name="index" name="index"
options={{ options={{
title: 'Home', title: 'Workout',
tabBarIcon: ({ color }) => <IconSymbol size={28} name="house.fill" color={color} />, tabBarIcon: ({ color, size }) => (
<Dumbbell size={size} color={color} />
),
}} }}
/> />
<Tabs.Screen <Tabs.Screen
name="explore" name="library"
options={{ options={{
title: 'Explore', title: 'Library',
tabBarIcon: ({ color }) => <IconSymbol size={28} name="paperplane.fill" color={color} />, tabBarIcon: ({ color, size }) => (
<Library size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="social"
options={{
title: 'Social',
tabBarIcon: ({ color, size }) => (
<Users size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="history"
options={{
title: 'History',
tabBarIcon: ({ color, size }) => (
<History size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="profile"
options={{
title: 'Profile',
tabBarIcon: ({ color, size }) => (
<User size={size} color={color} />
),
}} }}
/> />
</Tabs> </Tabs>
); );
} }

View File

@ -1,109 +0,0 @@
import { StyleSheet, Image, Platform } from 'react-native';
import { Collapsible } from '@/components/Collapsible';
import { ExternalLink } from '@/components/ExternalLink';
import ParallaxScrollView from '@/components/ParallaxScrollView';
import { ThemedText } from '@/components/ThemedText';
import { ThemedView } from '@/components/ThemedView';
import { IconSymbol } from '@/components/ui/IconSymbol';
export default function TabTwoScreen() {
return (
<ParallaxScrollView
headerBackgroundColor={{ light: '#D0D0D0', dark: '#353636' }}
headerImage={
<IconSymbol
size={310}
color="#808080"
name="chevron.left.forwardslash.chevron.right"
style={styles.headerImage}
/>
}>
<ThemedView style={styles.titleContainer}>
<ThemedText type="title">Explore</ThemedText>
</ThemedView>
<ThemedText>This app includes example code to help you get started.</ThemedText>
<Collapsible title="File-based routing">
<ThemedText>
This app has two screens:{' '}
<ThemedText type="defaultSemiBold">app/(tabs)/index.tsx</ThemedText> and{' '}
<ThemedText type="defaultSemiBold">app/(tabs)/explore.tsx</ThemedText>
</ThemedText>
<ThemedText>
The layout file in <ThemedText type="defaultSemiBold">app/(tabs)/_layout.tsx</ThemedText>{' '}
sets up the tab navigator.
</ThemedText>
<ExternalLink href="https://docs.expo.dev/router/introduction">
<ThemedText type="link">Learn more</ThemedText>
</ExternalLink>
</Collapsible>
<Collapsible title="Android, iOS, and web support">
<ThemedText>
You can open this project on Android, iOS, and the web. To open the web version, press{' '}
<ThemedText type="defaultSemiBold">w</ThemedText> in the terminal running this project.
</ThemedText>
</Collapsible>
<Collapsible title="Images">
<ThemedText>
For static images, you can use the <ThemedText type="defaultSemiBold">@2x</ThemedText> and{' '}
<ThemedText type="defaultSemiBold">@3x</ThemedText> suffixes to provide files for
different screen densities
</ThemedText>
<Image source={require('@/assets/images/react-logo.png')} style={{ alignSelf: 'center' }} />
<ExternalLink href="https://reactnative.dev/docs/images">
<ThemedText type="link">Learn more</ThemedText>
</ExternalLink>
</Collapsible>
<Collapsible title="Custom fonts">
<ThemedText>
Open <ThemedText type="defaultSemiBold">app/_layout.tsx</ThemedText> to see how to load{' '}
<ThemedText style={{ fontFamily: 'SpaceMono' }}>
custom fonts such as this one.
</ThemedText>
</ThemedText>
<ExternalLink href="https://docs.expo.dev/versions/latest/sdk/font">
<ThemedText type="link">Learn more</ThemedText>
</ExternalLink>
</Collapsible>
<Collapsible title="Light and dark mode components">
<ThemedText>
This template has light and dark mode support. The{' '}
<ThemedText type="defaultSemiBold">useColorScheme()</ThemedText> hook lets you inspect
what the user's current color scheme is, and so you can adjust UI colors accordingly.
</ThemedText>
<ExternalLink href="https://docs.expo.dev/develop/user-interface/color-themes/">
<ThemedText type="link">Learn more</ThemedText>
</ExternalLink>
</Collapsible>
<Collapsible title="Animations">
<ThemedText>
This template includes an example of an animated component. The{' '}
<ThemedText type="defaultSemiBold">components/HelloWave.tsx</ThemedText> component uses
the powerful <ThemedText type="defaultSemiBold">react-native-reanimated</ThemedText>{' '}
library to create a waving hand animation.
</ThemedText>
{Platform.select({
ios: (
<ThemedText>
The <ThemedText type="defaultSemiBold">components/ParallaxScrollView.tsx</ThemedText>{' '}
component provides a parallax effect for the header image.
</ThemedText>
),
})}
</Collapsible>
</ParallaxScrollView>
);
}
const styles = StyleSheet.create({
headerImage: {
color: '#808080',
bottom: -90,
left: -35,
position: 'absolute',
},
titleContainer: {
flexDirection: 'row',
gap: 8,
},
});

9
app/(tabs)/history.tsx Normal file
View File

@ -0,0 +1,9 @@
import { View, Text } from 'react-native';
export default function HistoryScreen() {
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<Text>History Screen</Text>
</View>
);
}

View File

@ -1,74 +1,9 @@
import { Image, StyleSheet, Platform } from 'react-native'; import { View, Text } from 'react-native';
import { HelloWave } from '@/components/HelloWave';
import ParallaxScrollView from '@/components/ParallaxScrollView';
import { ThemedText } from '@/components/ThemedText';
import { ThemedView } from '@/components/ThemedView';
export default function HomeScreen() { export default function HomeScreen() {
return ( return (
<ParallaxScrollView <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
headerBackgroundColor={{ light: '#A1CEDC', dark: '#1D3D47' }} <Text>Home Screen</Text>
headerImage={ </View>
<Image
source={require('@/assets/images/partial-react-logo.png')}
style={styles.reactLogo}
/>
}>
<ThemedView style={styles.titleContainer}>
<ThemedText type="title">Welcome!</ThemedText>
<HelloWave />
</ThemedView>
<ThemedView style={styles.stepContainer}>
<ThemedText type="subtitle">Step 1: Try it</ThemedText>
<ThemedText>
Edit <ThemedText type="defaultSemiBold">app/(tabs)/index.tsx</ThemedText> to see changes.
Press{' '}
<ThemedText type="defaultSemiBold">
{Platform.select({
ios: 'cmd + d',
android: 'cmd + m',
web: 'F12'
})}
</ThemedText>{' '}
to open developer tools.
</ThemedText>
</ThemedView>
<ThemedView style={styles.stepContainer}>
<ThemedText type="subtitle">Step 2: Explore</ThemedText>
<ThemedText>
Tap the Explore tab to learn more about what's included in this starter app.
</ThemedText>
</ThemedView>
<ThemedView style={styles.stepContainer}>
<ThemedText type="subtitle">Step 3: Get a fresh start</ThemedText>
<ThemedText>
When you're ready, run{' '}
<ThemedText type="defaultSemiBold">npm run reset-project</ThemedText> to get a fresh{' '}
<ThemedText type="defaultSemiBold">app</ThemedText> directory. This will move the current{' '}
<ThemedText type="defaultSemiBold">app</ThemedText> to{' '}
<ThemedText type="defaultSemiBold">app-example</ThemedText>.
</ThemedText>
</ThemedView>
</ParallaxScrollView>
); );
} }
const styles = StyleSheet.create({
titleContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
stepContainer: {
gap: 8,
marginBottom: 8,
},
reactLogo: {
height: 178,
width: 290,
bottom: 0,
left: 0,
position: 'absolute',
},
});

350
app/(tabs)/library.tsx Normal file
View File

@ -0,0 +1,350 @@
// app/(tabs)/library.tsx
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { View, TouchableOpacity, StyleSheet, Platform } from 'react-native';
import { useColorScheme } from '@/hooks/useColorScheme';
import { router, useLocalSearchParams } from 'expo-router';
import { Plus } from 'lucide-react-native';
import { ThemedText } from '@/components/ThemedText';
import TabLayout from '@/components/TabLayout';
import { useFabPosition } from '@/hooks/useFabPosition';
import { libraryService } from '@/services/LibraryService';
// Components
import MyLibrary from '@/components/library/MyLibrary';
import Programs from '@/components/library/Programs';
import Discover from '@/components/library/Discover';
import ContentPreviewModal from '@/components/library/ContentPreviewModal';
import AddContentModal from '@/components/library/AddContentModal';
import SearchBar from '@/components/library/SearchBar';
import FilterSheet, { FilterOptions } from '@/components/library/FilterSheet';
import FloatingActionButton from '@/components/shared/FloatingActionButton';
import Pager, { PagerRef } from '@/components/pager';
// Types
import { LibraryContent } from '@/types/exercise';
import { spacing } from '@/styles/sharedStyles';
type LibrarySection = 'my-library' | 'programs' | 'discover';
interface TabItem {
key: LibrarySection;
label: string;
index: number;
}
const TABS: TabItem[] = [
{ key: 'my-library', label: 'My Library', index: 0 },
{ key: 'programs', label: 'Programs', index: 1 },
{ key: 'discover', label: 'Discover', index: 2 }
];
export default function LibraryScreen() {
const { colors } = useColorScheme();
const fabPosition = useFabPosition();
const searchParams = useLocalSearchParams();
const pagerRef = useRef<PagerRef>(null);
const [mounted, setMounted] = useState(false);
// State
const [content, setContent] = useState<LibraryContent[]>([]);
const [filteredContent, setFilteredContent] = useState<LibraryContent[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [selectedContent, setSelectedContent] = useState<LibraryContent | null>(null);
const [showPreview, setShowPreview] = useState(false);
const [showAddContent, setShowAddContent] = useState(false);
const [showFilters, setShowFilters] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [filterOptions, setFilterOptions] = useState<FilterOptions>({
contentType: [],
source: [],
category: [],
equipment: []
});
useEffect(() => {
setMounted(true);
return () => setMounted(false);
}, []);
// Initialize with default section from searchParams
const currentSection = searchParams.section as LibrarySection | undefined;
const defaultSection = TABS.find(tab => tab.key === currentSection) ?? TABS[0];
const [activeSection, setActiveSection] = useState<number>(defaultSection.index);
// Load library content
const loadContent = useCallback(async () => {
if (mounted) {
setIsLoading(true);
try {
const [exercises, templates] = await Promise.all([
libraryService.getExercises(),
libraryService.getTemplates()
]);
const exerciseContent: LibraryContent[] = exercises.map(exercise => ({
id: exercise.id,
title: exercise.title,
type: 'exercise',
description: exercise.description,
category: exercise.category,
equipment: exercise.equipment,
source: 'local',
tags: exercise.tags,
created_at: exercise.created_at,
availability: {
source: ['local']
}
}));
setContent([...exerciseContent, ...templates]);
} catch (error) {
console.error('Error loading library content:', error);
} finally {
setIsLoading(false);
}
}
}, [mounted]);
useEffect(() => {
loadContent();
}, [loadContent]);
useEffect(() => {
const filtered = content.filter(item => {
if (searchQuery) {
const searchLower = searchQuery.toLowerCase();
const matchesSearch =
item.title.toLowerCase().includes(searchLower) ||
(item.description?.toLowerCase() || '').includes(searchLower) ||
item.tags.some(tag => tag.toLowerCase().includes(searchLower));
if (!matchesSearch) return false;
}
if (filterOptions.contentType.length > 0) {
if (!filterOptions.contentType.includes(item.type)) return false;
}
if (filterOptions.source.length > 0) {
if (!filterOptions.source.includes(item.source)) return false;
}
if (filterOptions.category.length > 0 && item.type === 'exercise') {
if (!filterOptions.category.includes(item.category || '')) return false;
}
if (filterOptions.equipment.length > 0 && item.type === 'exercise') {
if (!filterOptions.equipment.includes(item.equipment || '')) return false;
}
return true;
});
setFilteredContent(filtered);
}, [content, searchQuery, filterOptions]);
useEffect(() => {
if (currentSection) {
const tab = TABS.find(t => t.key === currentSection);
if (tab && tab.index !== activeSection) {
setActiveSection(tab.index);
pagerRef.current?.setPage(tab.index);
}
}
}, [currentSection, activeSection]);
const handlePageSelected = useCallback((e: { nativeEvent: { position: number } }) => {
const newIndex = e.nativeEvent.position;
setActiveSection(newIndex);
const newSection = TABS[newIndex];
if (newSection) {
router.setParams({ section: newSection.key });
}
}, []);
const handleContentPress = (content: LibraryContent) => {
setSelectedContent(content);
setShowPreview(true);
};
const handleFavoritePress = async (content: LibraryContent) => {
try {
console.log('Favorite pressed:', content.id);
await loadContent();
} catch (error) {
console.error('Error handling favorite:', error);
}
};
const handleAddContent = (type: 'exercise' | 'template') => {
setShowAddContent(false);
if (type === 'exercise') {
router.push('/(workout)/new-exercise' as const);
} else {
router.push({
pathname: '/(workout)/create-template' as const,
});
}
};
const handleTabPress = useCallback((index: number) => {
pagerRef.current?.setPage(index);
setActiveSection(index);
}, []);
return (
<TabLayout>
<View style={[styles.container, { backgroundColor: colors.background }]}>
<View style={[styles.segmentsContainer, { borderBottomColor: colors.border }]}>
{TABS.map((tab) => (
<TouchableOpacity
key={tab.key}
style={[
styles.segmentButton,
activeSection === tab.index && [
styles.activeSegment,
{ borderBottomColor: colors.primary }
]
]}
onPress={() => handleTabPress(tab.index)}
>
<ThemedText
style={[
styles.segmentText,
{ color: activeSection === tab.index ? colors.primary : colors.textSecondary }
]}
>
{tab.label}
</ThemedText>
</TouchableOpacity>
))}
</View>
<View style={[styles.searchBarContainer, { backgroundColor: colors.background }]}>
<SearchBar
value={searchQuery}
onChangeText={setSearchQuery}
onFilterPress={() => setShowFilters(true)}
/>
</View>
<Pager
ref={pagerRef}
style={styles.contentContainer}
initialPage={activeSection}
onPageSelected={handlePageSelected}
>
<View key="my-library" style={styles.pageContainer}>
<MyLibrary
savedContent={filteredContent}
onContentPress={handleContentPress}
onFavoritePress={handleFavoritePress}
isLoading={isLoading}
isVisible
/>
</View>
<View key="programs" style={styles.pageContainer}>
<Programs
content={[]}
onContentPress={handleContentPress}
onFavoritePress={handleFavoritePress}
isVisible
/>
</View>
<View key="discover" style={styles.pageContainer}>
<Discover
content={[]}
onContentPress={handleContentPress}
onFavoritePress={handleFavoritePress}
isVisible
/>
</View>
</Pager>
{!showFilters && (
<FloatingActionButton
icon={Plus}
onPress={() => setShowAddContent(true)}
style={{ bottom: fabPosition.bottom }}
/>
)}
<ContentPreviewModal
isVisible={showPreview}
content={selectedContent}
onClose={() => setShowPreview(false)}
onSave={() => setShowPreview(false)}
/>
<AddContentModal
isVisible={showAddContent}
onClose={() => setShowAddContent(false)}
onSelect={handleAddContent}
/>
<FilterSheet
isVisible={showFilters}
options={filterOptions}
onClose={() => setShowFilters(false)}
onApply={(options) => {
setFilterOptions(options);
setShowFilters(false);
}}
/>
</View>
</TabLayout>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
segmentsContainer: {
flexDirection: 'row',
borderBottomWidth: 1,
position: 'absolute',
top: Platform.OS === 'ios' ? 10 : 0,
left: 0,
right: 0,
zIndex: 1,
height: 40,
},
searchBarContainer: {
position: 'absolute',
top: Platform.OS === 'ios' ? 50 : 40,
left: 0,
right: 0,
zIndex: 2,
paddingHorizontal: spacing.medium,
paddingVertical: spacing.small,
},
pageContainer: {
flex: 1,
width: '100%',
height: '100%',
},
contentContainer: {
flex: 1,
paddingTop: Platform.OS === 'ios' ? 110 : 90,
},
segmentButton: {
flex: 1,
alignItems: 'center',
justifyContent: 'flex-end',
paddingBottom: 5,
borderBottomWidth: 2,
borderBottomColor: 'transparent',
height: '100%',
},
activeSegment: {
borderBottomWidth: 3,
},
segmentText: {
fontSize: 16,
fontWeight: '500',
paddingHorizontal: spacing.small,
},
});

9
app/(tabs)/profile.tsx Normal file
View File

@ -0,0 +1,9 @@
import { View, Text } from 'react-native';
export default function ProfileScreen() {
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<Text>Profile Screen</Text>
</View>
);
}

9
app/(tabs)/social.tsx Normal file
View File

@ -0,0 +1,9 @@
import { View, Text } from 'react-native';
export default function SocialScreen() {
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<Text>Social Screen</Text>
</View>
);
}

View File

@ -0,0 +1,340 @@
// app/(workout)/add-exercises.tsx
import React, { useState, useEffect, useMemo } from 'react';
import {
View, ScrollView, StyleSheet, Platform, TextInput,
TouchableOpacity, KeyboardAvoidingView, FlatList,
ActivityIndicator
} from 'react-native';
import { Feather } from '@expo/vector-icons';
import { useColorScheme } from '@/hooks/useColorScheme';
import { router, useLocalSearchParams } from 'expo-router';
import { useWorkout } from '@/contexts/WorkoutContext';
import { ThemedText } from '@/components/ThemedText';
import { spacing } from '@/styles/sharedStyles';
import { SafeAreaView } from 'react-native-safe-area-context';
import Animated, { FadeIn } from 'react-native-reanimated';
import { libraryService } from '@/services/LibraryService';
import { BaseExercise, ExerciseCategory } from '@/types/exercise';
import { NostrEventKind } from '@/types/events';
type ExerciseFormat = {
weight?: number;
reps?: number;
rpe?: number;
set_type?: 'warmup' | 'normal' | 'drop' | 'failure';
};
type FilterCategory = ExerciseCategory | 'All';
type ScreenMode = 'workout' | 'template';
const CATEGORIES: FilterCategory[] = ['All', 'Push', 'Pull', 'Legs', 'Core'];
const DEFAULT_RELAY = 'wss://powr.relay'; // This should come from config
export default function AddExercisesScreen() {
const { addExercise } = useWorkout();
const { colors } = useColorScheme();
const params = useLocalSearchParams();
const mode = (params.mode as ScreenMode) || 'workout';
const [exercises, setExercises] = useState<BaseExercise[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [selectedCategory, setSelectedCategory] = useState<FilterCategory>('All');
const [selectedExercises, setSelectedExercises] = useState<Map<string, ExerciseFormat>>(new Map());
useEffect(() => {
const loadExercises = async () => {
try {
const data = await libraryService.getExercises();
setExercises(data);
} catch (error) {
console.error('Error loading exercises:', error);
} finally {
setIsLoading(false);
}
};
loadExercises();
}, []);
const filteredExercises = useMemo(() => {
return exercises.filter((exercise) => {
const matchesSearch = exercise.title.toLowerCase().includes(searchQuery.toLowerCase());
const matchesCategory = selectedCategory === 'All' || exercise.category === selectedCategory;
return matchesSearch && matchesCategory;
});
}, [exercises, searchQuery, selectedCategory]);
const handleAddSelectedExercises = async () => {
try {
if (selectedExercises.size === 0) return;
const selectedExerciseData = exercises
.filter(exercise => selectedExercises.has(exercise.id))
.map(exercise => ({
...exercise,
reference: `${NostrEventKind.EXERCISE_TEMPLATE}:${exercise.id}:${DEFAULT_RELAY}`,
format: selectedExercises.get(exercise.id) || {}
}));
if (mode === 'template') {
router.push({
pathname: '/(workout)/create-template',
params: {
exercises: encodeURIComponent(JSON.stringify(selectedExerciseData))
}
});
} else {
for (const exercise of selectedExerciseData) {
await addExercise(exercise);
}
router.back();
}
} catch (error) {
console.error('Error handling exercises:', error);
}
};
if (isLoading) {
return (
<View style={[styles.loadingContainer, { backgroundColor: colors.background }]}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
);
}
return (
<View style={[styles.root, { backgroundColor: colors.background }]}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
style={styles.container}
>
<SafeAreaView edges={['top']} style={styles.header}>
<View style={[styles.headerContent, { borderBottomColor: colors.border }]}>
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
<Feather name="x" size={24} color={colors.text} />
</TouchableOpacity>
<ThemedText type="title" style={styles.headerTitle}>
{mode === 'workout' ? 'Add Exercise' : 'Select Exercises for Template'}
</ThemedText>
</View>
<View style={[styles.searchContainer, { backgroundColor: colors.cardBg }]}>
<Feather name="search" size={20} color={colors.textSecondary} />
<TextInput
style={[styles.searchInput, { color: colors.text }]}
placeholder="Search exercises..."
placeholderTextColor={colors.textSecondary}
value={searchQuery}
onChangeText={setSearchQuery}
/>
</View>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.categoriesContent}
>
{CATEGORIES.map((category) => (
<TouchableOpacity
key={category}
style={[
styles.categoryTab,
{ backgroundColor: colors.cardBg },
selectedCategory === category && { backgroundColor: colors.primary }
]}
onPress={() => setSelectedCategory(category)}
>
<ThemedText
style={[
styles.categoryText,
{ color: selectedCategory === category ? colors.background : colors.textSecondary }
]}
>
{category}
</ThemedText>
</TouchableOpacity>
))}
</ScrollView>
</SafeAreaView>
<FlatList
data={filteredExercises}
contentContainerStyle={styles.listContent}
renderItem={({ item: exercise }) => (
<TouchableOpacity
style={[
styles.exerciseCard,
{ backgroundColor: colors.cardBg },
selectedExercises.has(exercise.id) && {
backgroundColor: `${colors.primary}15`,
borderColor: colors.primary,
borderWidth: 1,
}
]}
onPress={() => {
setSelectedExercises(prev => {
const updated = new Map(prev);
if (updated.has(exercise.id)) {
updated.delete(exercise.id);
} else {
updated.set(exercise.id, {});
}
return updated;
});
}}
>
<View>
<ThemedText style={styles.exerciseName}>{exercise.title}</ThemedText>
<View style={styles.exerciseDetails}>
<ThemedText style={styles.exerciseCategory}>{exercise.category}</ThemedText>
<ThemedText style={styles.exerciseEquipment}>{exercise.equipment}</ThemedText>
</View>
</View>
<Feather
name={selectedExercises.has(exercise.id) ? 'check-circle' : 'plus-circle'}
size={24}
color={selectedExercises.has(exercise.id) ? colors.primary : colors.textSecondary}
/>
</TouchableOpacity>
)}
keyExtractor={(item) => item.id}
/>
{selectedExercises.size > 0 && (
<Animated.View
entering={FadeIn}
style={[styles.footer, { backgroundColor: colors.background }]}
>
<SafeAreaView edges={['bottom']}>
<TouchableOpacity
style={[styles.addButton, { backgroundColor: colors.primary }]}
onPress={handleAddSelectedExercises}
>
<ThemedText style={styles.addButtonText}>
{mode === 'workout'
? `Add ${selectedExercises.size} Exercise${
selectedExercises.size > 1 ? 's' : ''
}`
: 'Next: Configure Sets'}
</ThemedText>
</TouchableOpacity>
</SafeAreaView>
</Animated.View>
)}
</KeyboardAvoidingView>
</View>
);
}
const styles = StyleSheet.create({
root: {
flex: 1,
},
container: {
flex: 1,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
header: {
zIndex: 10,
},
headerContent: {
flexDirection: 'row',
alignItems: 'center',
padding: spacing.medium,
borderBottomWidth: 1,
},
headerTitle: {
fontSize: 20,
fontWeight: '600',
flex: 1,
marginLeft: spacing.medium,
},
backButton: {
padding: spacing.small,
},
searchContainer: {
flexDirection: 'row',
alignItems: 'center',
margin: spacing.medium,
padding: spacing.medium,
borderRadius: 12,
},
searchInput: {
flex: 1,
marginLeft: spacing.small,
fontSize: 16,
},
categoriesContent: {
padding: spacing.medium,
gap: spacing.small,
},
categoryTab: {
paddingHorizontal: spacing.medium,
paddingVertical: spacing.small,
marginRight: spacing.small,
borderRadius: 16,
},
categoryText: {
fontSize: 14,
fontWeight: '500',
},
listContent: {
padding: spacing.medium,
},
exerciseCard: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: spacing.medium,
marginBottom: spacing.small,
borderRadius: 12,
},
exerciseName: {
fontSize: 16,
fontWeight: '600',
marginBottom: 4,
},
exerciseDetails: {
flexDirection: 'row',
gap: spacing.small,
},
exerciseCategory: {
fontSize: 14,
opacity: 0.7,
},
exerciseEquipment: {
fontSize: 14,
opacity: 0.7,
},
footer: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
borderTopWidth: 1,
borderTopColor: 'rgba(0, 0, 0, 0.1)',
shadowColor: '#000',
shadowOffset: {
width: 0,
height: -2,
},
shadowOpacity: 0.1,
shadowRadius: 3,
elevation: 5,
},
addButton: {
margin: spacing.medium,
padding: spacing.medium,
borderRadius: 12,
alignItems: 'center',
},
addButtonText: {
color: '#FFFFFF',
fontSize: 16,
fontWeight: '600',
},
});

View File

@ -0,0 +1,351 @@
// app/(workout)/create-template.tsx
import React, { useState } from 'react';
import {
View, ScrollView, StyleSheet, Platform, TextInput,
TouchableOpacity, Alert, KeyboardAvoidingView
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Feather } from '@expo/vector-icons';
import { useColorScheme } from '@/hooks/useColorScheme';
import { router, useLocalSearchParams } from 'expo-router';
import { useWorkout } from '@/contexts/WorkoutContext';
import { Input } from '@/components/form/Input';
import { Select } from '@/components/form/Select';
import { ThemedText } from '@/components/ThemedText';
import EditableText from '@/components/EditableText';
import { spacing } from '@/styles/sharedStyles';
import { generateId } from '@/utils/ids';
import { BaseExercise } from '@/types/exercise';
import { WorkoutTemplate, TemplateCategory } from '@/types/workout';
import { NostrEventKind } from '@/types/events';
const WORKOUT_TYPES: Array<{ label: string; value: WorkoutTemplate['type'] }> = [
{ label: 'Strength', value: 'strength' },
{ label: 'Circuit', value: 'circuit' },
{ label: 'EMOM', value: 'emom' },
{ label: 'AMRAP', value: 'amrap' }
];
const TEMPLATE_CATEGORIES: Array<{ label: string; value: TemplateCategory }> = [
{ label: 'Full Body', value: 'Full Body' },
{ label: 'Upper/Lower', value: 'Upper/Lower' },
{ label: 'Push/Pull/Legs', value: 'Push/Pull/Legs' },
{ label: 'Custom', value: 'Custom' }
];
interface CreateTemplateScreenProps {
initialExercises?: BaseExercise[];
}
function CreateTemplateScreen({ initialExercises = [] }: CreateTemplateScreenProps) {
const { colors } = useColorScheme();
const params = useLocalSearchParams();
const { saveTemplate } = useWorkout();
const parsedExercises = params.exercises ?
JSON.parse(decodeURIComponent(params.exercises as string)) as BaseExercise[] :
initialExercises;
// Form state matching Nostr spec
const [title, setTitle] = useState('New Template');
const [description, setDescription] = useState('');
const [workoutType, setWorkoutType] = useState<WorkoutTemplate['type']>('strength');
const [category, setCategory] = useState<TemplateCategory>('Custom');
const [exercises, setExercises] = useState<BaseExercise[]>(parsedExercises);
const [rounds, setRounds] = useState('');
const [duration, setDuration] = useState('');
const [intervalTime, setIntervalTime] = useState('');
const [restBetweenRounds, setRestBetweenRounds] = useState('');
const [tags, setTags] = useState<string[]>([]);
const [error, setError] = useState<string | null>(null);
const handleWorkoutTypeChange = (value: string | string[]) => {
if (typeof value === 'string') {
setWorkoutType(value as WorkoutTemplate['type']);
}
};
const handleCategoryChange = (value: string | string[]) => {
if (typeof value === 'string') {
setCategory(value as TemplateCategory);
}
};
const handleSave = async () => {
try {
if (!title.trim()) {
Alert.alert('Error', 'Template must have a title');
return;
}
if (exercises.length === 0) {
Alert.alert('Error', 'Template must include at least one exercise');
return;
}
// Create template following NIP-XX spec
const template: WorkoutTemplate = {
id: generateId(),
title: title.trim(),
type: workoutType,
description: description,
category: category,
exercises: exercises.map(exercise => ({
exercise,
targetSets: 0,
targetReps: 0,
})),
tags: tags,
rounds: rounds ? parseInt(rounds) : undefined,
duration: duration ? parseInt(duration) * 60 : undefined,
interval: intervalTime ? parseInt(intervalTime) : undefined,
restBetweenRounds: restBetweenRounds ? parseInt(restBetweenRounds) : undefined,
isPublic: false,
created_at: Date.now(),
availability: {
source: ['local']
},
notes: ''
};
await saveTemplate(template);
router.back();
} catch (error) {
console.error('Error saving template:', error);
setError('Failed to save template. Please try again.');
}
};
return (
<SafeAreaView
style={[styles.container, { backgroundColor: colors.background }]}
edges={['top', 'left', 'right']}
>
<View style={[styles.header, { backgroundColor: colors.background, borderBottomColor: colors.border }]}>
<View style={styles.headerRow}>
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
<Feather name="arrow-left" size={24} color={colors.text} />
</TouchableOpacity>
<TouchableOpacity
style={[styles.saveButton, { backgroundColor: colors.primary }]}
onPress={handleSave}
>
<ThemedText style={[styles.saveButtonText, { color: colors.background }]}>
Save Template
</ThemedText>
</TouchableOpacity>
</View>
</View>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
style={styles.keyboardAvoidView}
>
<ScrollView
style={styles.scrollContainer}
contentContainerStyle={styles.scrollContent}
>
<View style={styles.titleSection}>
<EditableText
value={title}
onChangeText={setTitle}
style={styles.titleContainer}
textStyle={[styles.title, { color: colors.text }]}
placeholder="Template Name"
/>
</View>
<View style={[styles.section, { backgroundColor: colors.cardBg }]}>
<Select
label="Workout Type"
value={workoutType}
onValueChange={handleWorkoutTypeChange}
items={WORKOUT_TYPES}
required
/>
<Select
label="Category"
value={category}
onValueChange={handleCategoryChange}
items={TEMPLATE_CATEGORIES}
required
/>
<Input
label="Description"
value={description}
onChangeText={setDescription}
multiline
numberOfLines={4}
placeholder="Add a description for your template"
/>
</View>
{workoutType !== 'strength' && (
<View style={[styles.section, { backgroundColor: colors.cardBg }]}>
<ThemedText type="subtitle" style={styles.sectionTitle}>
Workout Parameters
</ThemedText>
<Input
label="Number of Rounds"
value={rounds}
onChangeText={setRounds}
keyboardType="numeric"
placeholder="e.g., 5"
/>
<Input
label="Total Duration (minutes)"
value={duration}
onChangeText={setDuration}
keyboardType="numeric"
placeholder="e.g., 20"
/>
<Input
label="Interval Time (seconds)"
value={intervalTime}
onChangeText={setIntervalTime}
keyboardType="numeric"
placeholder="e.g., 40"
/>
<Input
label="Rest Between Rounds (seconds)"
value={restBetweenRounds}
onChangeText={setRestBetweenRounds}
keyboardType="numeric"
placeholder="e.g., 60"
/>
</View>
)}
<View style={[styles.section, { backgroundColor: colors.cardBg }]}>
<ThemedText type="subtitle" style={styles.sectionTitle}>
Exercises ({exercises.length})
</ThemedText>
{exercises.map((exercise: BaseExercise, index: number) => (
<View
key={exercise.id}
style={[styles.exerciseCard, { borderBottomColor: colors.border }]}
>
<View style={styles.exerciseHeader}>
<ThemedText style={styles.exerciseName}>
{exercise.title}
</ThemedText>
<TouchableOpacity
onPress={() => {
const newExercises = [...exercises];
newExercises.splice(index, 1);
setExercises(newExercises);
}}
>
<Feather name="trash-2" size={20} color={colors.error} />
</TouchableOpacity>
</View>
</View>
))}
<TouchableOpacity
style={[styles.addButton, { backgroundColor: colors.primary }]}
onPress={() => {
router.push({
pathname: '/(workout)/add-exercises' as const,
params: { mode: 'template' }
});
}}
>
<ThemedText style={[styles.addButtonText, { color: colors.background }]}>
Add Exercise
</ThemedText>
</TouchableOpacity>
</View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
keyboardAvoidView: {
flex: 1,
},
header: {
paddingHorizontal: spacing.medium,
paddingVertical: spacing.small,
borderBottomWidth: 1,
},
headerRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
backButton: {
padding: spacing.small,
},
saveButton: {
paddingVertical: spacing.small,
paddingHorizontal: spacing.medium,
borderRadius: 8,
},
saveButtonText: {
fontWeight: '600',
fontSize: 16,
},
scrollContainer: {
flex: 1,
},
scrollContent: {
padding: spacing.medium,
},
titleSection: {
marginBottom: spacing.medium,
},
titleContainer: {
flex: 1,
},
title: {
fontSize: 32,
fontWeight: 'bold',
},
section: {
marginBottom: spacing.medium,
padding: spacing.medium,
borderRadius: 12,
gap: spacing.medium,
},
sectionTitle: {
fontSize: 18,
fontWeight: '600',
},
exerciseCard: {
paddingVertical: spacing.medium,
borderBottomWidth: 1,
},
exerciseHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
exerciseName: {
fontSize: 16,
fontWeight: '600',
},
addButton: {
padding: spacing.medium,
borderRadius: 8,
alignItems: 'center',
marginTop: spacing.small,
},
addButtonText: {
fontSize: 16,
fontWeight: '600',
},
});
export default CreateTemplateScreen;

View File

@ -0,0 +1,226 @@
// app/(workout)/new-exercise.tsx
import React, { useState } from 'react';
import { View, ScrollView, StyleSheet, Platform } from 'react-native';
import { useRouter } from 'expo-router';
import { useColorScheme } from '@/hooks/useColorScheme';
import { ThemedText } from '@/components/ThemedText';
import { Input } from '@/components/form/Input';
import { Select } from '@/components/form/Select';
import { Button } from '@/components/form/Button';
import { LibraryService } from '@/services/LibraryService';
import { spacing } from '@/styles/sharedStyles';
import { generateId } from '@/utils/ids';
// Types based on NIP-XX spec
const EQUIPMENT_OPTIONS = [
{ label: 'Barbell', value: 'barbell' },
{ label: 'Dumbbell', value: 'dumbbell' },
{ label: 'Bodyweight', value: 'bodyweight' },
{ label: 'Machine', value: 'machine' },
{ label: 'Cardio', value: 'cardio' }
];
const DIFFICULTY_OPTIONS = [
{ label: 'Beginner', value: 'beginner' },
{ label: 'Intermediate', value: 'intermediate' },
{ label: 'Advanced', value: 'advanced' }
];
const MUSCLE_GROUP_OPTIONS = [
{ label: 'Chest', value: 'chest' },
{ label: 'Back', value: 'back' },
{ label: 'Legs', value: 'legs' },
{ label: 'Shoulders', value: 'shoulders' },
{ label: 'Arms', value: 'arms' },
{ label: 'Core', value: 'core' }
];
const MOVEMENT_TYPE_OPTIONS = [
{ label: 'Push', value: 'push' },
{ label: 'Pull', value: 'pull' },
{ label: 'Squat', value: 'squat' },
{ label: 'Hinge', value: 'hinge' },
{ label: 'Carry', value: 'carry' }
];
export default function NewExerciseScreen() {
const router = useRouter();
const { colors } = useColorScheme();
// Form state matching Nostr spec
const [title, setTitle] = useState('');
const [equipment, setEquipment] = useState('');
const [difficulty, setDifficulty] = useState('');
const [muscleGroups, setMuscleGroups] = useState<string[]>([]);
const [movementTypes, setMovementTypes] = useState<string[]>([]);
const [instructions, setInstructions] = useState('');
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async () => {
try {
setError(null);
setIsSubmitting(true);
if (!title.trim()) {
setError('Exercise name is required');
return;
}
if (!equipment) {
setError('Equipment type is required');
return;
}
// Create exercise template following NIP-XX spec
const exerciseTemplate = {
id: generateId(), // UUID for template identification
title: title.trim(),
type: 'exercise',
format: ['weight', 'reps', 'rpe', 'set_type'], // Required format params
format_units: ['kg', 'count', '0-10', 'warmup|normal|drop|failure'], // Required unit definitions
equipment,
difficulty,
content: instructions.trim(), // Form instructions in content field
tags: [
['d', generateId()], // Required UUID tag
['title', title.trim()],
['equipment', equipment],
...muscleGroups.map(group => ['t', group]),
...movementTypes.map(type => ['t', type]),
['format', 'weight', 'reps', 'rpe', 'set_type'],
['format_units', 'kg', 'count', '0-10', 'warmup|normal|drop|failure'],
difficulty ? ['difficulty', difficulty] : [],
].filter(tag => tag.length > 0), // Remove empty tags
source: 'local',
created_at: Date.now(),
availability: {
source: ['local']
}
};
await LibraryService.addExercise(exerciseTemplate);
router.back();
} catch (err) {
console.error('Error creating exercise:', err);
setError('Failed to create exercise. Please try again.');
} finally {
setIsSubmitting(false);
}
};
return (
<ScrollView
style={[styles.container, { backgroundColor: colors.background }]}
contentContainerStyle={styles.content}
>
<View style={styles.form}>
<View style={styles.section}>
<ThemedText type="subtitle" style={styles.sectionTitle}>
Basic Information
</ThemedText>
<Input
label="Exercise Name"
value={title}
onChangeText={setTitle}
placeholder="e.g., Barbell Back Squat"
required
/>
<Select
label="Equipment"
value={equipment}
onValueChange={setEquipment}
items={EQUIPMENT_OPTIONS}
placeholder="Select equipment type"
required
/>
<Select
label="Difficulty"
value={difficulty}
onValueChange={setDifficulty}
items={DIFFICULTY_OPTIONS}
placeholder="Select difficulty level"
/>
</View>
<View style={styles.section}>
<ThemedText type="subtitle" style={styles.sectionTitle}>
Categorization
</ThemedText>
<Select
label="Muscle Groups"
value={muscleGroups}
onValueChange={setMuscleGroups}
items={MUSCLE_GROUP_OPTIONS}
placeholder="Select muscle groups"
multiple
/>
<Select
label="Movement Types"
value={movementTypes}
onValueChange={setMovementTypes}
items={MOVEMENT_TYPE_OPTIONS}
placeholder="Select movement types"
multiple
/>
</View>
<View style={styles.section}>
<ThemedText type="subtitle" style={styles.sectionTitle}>
Instructions
</ThemedText>
<Input
label="Form Instructions"
value={instructions}
onChangeText={setInstructions}
placeholder="Describe proper form and execution..."
multiline
numberOfLines={4}
/>
</View>
{error && (
<ThemedText style={[styles.error, { color: colors.error }]}>
{error}
</ThemedText>
)}
<View style={styles.buttonContainer}>
<Button
title="Create Exercise"
onPress={handleSubmit}
loading={isSubmitting}
disabled={isSubmitting}
/>
</View>
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
content: {
padding: spacing.medium,
},
form: {
gap: spacing.large,
},
section: {
gap: spacing.medium,
},
sectionTitle: {
marginBottom: spacing.small,
},
error: {
textAlign: 'center',
marginTop: spacing.small,
},
buttonContainer: {
marginTop: spacing.large,
paddingBottom: Platform.OS === 'ios' ? spacing.xl : spacing.large,
},
});

View File

@ -1,39 +1,127 @@
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native'; // app/_layout.tsx
import { useFonts } from 'expo-font'; import React, { useEffect, useState } from 'react';
import { Platform } from 'react-native';
import { Stack } from 'expo-router'; import { Stack } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen'; import { NavigationContainer } from '@react-navigation/native';
import { StatusBar } from 'expo-status-bar'; import { AppearanceProvider } from '@/contexts/AppearanceContext';
import { useEffect } from 'react'; import { WorkoutProvider } from '@/contexts/WorkoutContext';
import 'react-native-reanimated'; import { schema } from '@/utils/db/schema';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
import * as ExpoSplashScreen from 'expo-splash-screen';
import SplashScreen from '@/components/SplashScreen';
import { SafeAreaProvider } from 'react-native-safe-area-context';
// Prevent the splash screen from auto-hiding before asset loading is complete. // Prevent auto-hide of splash screen
SplashScreen.preventAutoHideAsync(); ExpoSplashScreen.preventAutoHideAsync();
export default function RootLayout() { function RootLayoutNav() {
const colorScheme = useColorScheme(); const { colors } = useColorScheme();
const [loaded] = useFonts({ const [isReady, setIsReady] = React.useState(false);
SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'), const [showSplash, setShowSplash] = useState(true);
});
useEffect(() => { useEffect(() => {
if (loaded) { async function initializeApp() {
SplashScreen.hideAsync(); try {
await schema.createTables();
await schema.migrate();
} catch (error) {
console.error('Error initializing database:', error);
} finally {
setIsReady(true);
}
} }
}, [loaded]);
initializeApp();
}, []);
if (!loaded) { const onSplashAnimationComplete = async () => {
setShowSplash(false);
await ExpoSplashScreen.hideAsync();
};
if (!isReady) {
return null; return null;
} }
if (showSplash) {
return <SplashScreen onAnimationComplete={onSplashAnimationComplete} />;
}
return ( return (
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}> <Stack
<Stack> screenOptions={{
<Stack.Screen name="(tabs)" options={{ headerShown: false }} /> headerStyle: {
<Stack.Screen name="+not-found" /> backgroundColor: colors.background,
</Stack> },
<StatusBar style="auto" /> headerTintColor: colors.text,
</ThemeProvider> contentStyle: {
backgroundColor: colors.background,
},
}}
>
<Stack.Screen
name="(tabs)"
options={{ headerShown: false }}
/>
<Stack.Screen
name="(workout)/new-exercise"
options={{
headerShown: false,
presentation: Platform.select({
ios: 'modal',
android: 'card',
default: 'transparentModal'
}),
animation: Platform.select({
ios: 'slide_from_bottom',
default: 'none'
})
}}
/>
<Stack.Screen
name="(workout)/add-exercises"
options={{
headerShown: false,
presentation: Platform.select({
ios: 'modal',
android: 'card',
default: 'transparentModal'
}),
animation: Platform.select({
ios: 'slide_from_bottom',
default: 'none'
})
}}
/>
<Stack.Screen
name="(workout)/create-template"
options={{
headerShown: false,
presentation: Platform.select({
ios: 'modal',
android: 'card',
default: 'transparentModal'
}),
animation: Platform.select({
ios: 'slide_from_bottom',
default: 'none'
})
}}
/>
</Stack>
); );
} }
export default function RootLayout() {
return (
<SafeAreaProvider>
<NavigationContainer>
<AppearanceProvider>
<WorkoutProvider>
<RootLayoutNav />
</WorkoutProvider>
</AppearanceProvider>
</NavigationContainer>
</SafeAreaProvider>
);
}

BIN
assets/videos/splash.mov Normal file

Binary file not shown.

16
babel.config.js Normal file
View File

@ -0,0 +1,16 @@
module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
plugins: [
[
'module-resolver',
{
alias: {
'@': '.',
},
},
],
],
};
};

View File

@ -1,33 +1,41 @@
// components/Collapsible.tsx
import { PropsWithChildren, useState } from 'react'; import { PropsWithChildren, useState } from 'react';
import { StyleSheet, TouchableOpacity } from 'react-native'; import { StyleSheet, TouchableOpacity } from 'react-native';
import { ThemedText } from '@/components/ThemedText'; import { ThemedText } from '@/components/ThemedText';
import { ThemedView } from '@/components/ThemedView'; import { ThemedView } from '@/components/ThemedView';
import { IconSymbol } from '@/components/ui/IconSymbol'; import { IconSymbol } from '@/components/ui/IconSymbol';
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
export function Collapsible({ children, title }: PropsWithChildren & { title: string }) { interface CollapsibleProps {
title: string;
}
export function Collapsible({ children, title }: PropsWithChildren<CollapsibleProps>) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const theme = useColorScheme() ?? 'light'; const { colors } = useColorScheme();
return ( return (
<ThemedView> <ThemedView>
<TouchableOpacity <TouchableOpacity
style={styles.heading} style={styles.heading}
onPress={() => setIsOpen((value) => !value)} onPress={() => setIsOpen((value) => !value)}
activeOpacity={0.8}> activeOpacity={0.8}
>
<IconSymbol <IconSymbol
name="chevron.right" name="chevron.right"
size={18} size={18}
weight="medium" weight="medium"
color={theme === 'light' ? Colors.light.icon : Colors.dark.icon} color={colors.text}
style={{ transform: [{ rotate: isOpen ? '90deg' : '0deg' }] }} style={{ transform: [{ rotate: isOpen ? '90deg' : '0deg' }] }}
/> />
<ThemedText style={styles.title}>{title}</ThemedText>
<ThemedText type="defaultSemiBold">{title}</ThemedText>
</TouchableOpacity> </TouchableOpacity>
{isOpen && <ThemedView style={styles.content}>{children}</ThemedView>}
{isOpen && (
<ThemedView style={styles.content}>
{children}
</ThemedView>
)}
</ThemedView> </ThemedView>
); );
} }
@ -42,4 +50,8 @@ const styles = StyleSheet.create({
marginTop: 6, marginTop: 6,
marginLeft: 24, marginLeft: 24,
}, },
}); title: {
fontSize: 16,
fontWeight: '600',
},
});

129
components/EditableText.tsx Normal file
View File

@ -0,0 +1,129 @@
// components/EditableText.tsx
import React, { useState, useRef } from 'react';
import {
Text, TextInput, TouchableOpacity, StyleSheet, View,
Platform, StyleProp, ViewStyle, TextStyle
} from 'react-native';
import { Feather } from '@expo/vector-icons';
import { useColorScheme } from '@/hooks/useColorScheme';
interface EditableTextProps {
value: string;
onChangeText: (text: string) => void;
style?: StyleProp<ViewStyle>;
textStyle?: StyleProp<TextStyle>;
inputStyle?: StyleProp<TextStyle>;
placeholder?: string;
}
export default function EditableText({
value,
onChangeText,
style,
textStyle,
inputStyle,
placeholder
}: EditableTextProps) {
const { colors } = useColorScheme();
const [isEditing, setIsEditing] = useState(false);
const [tempValue, setTempValue] = useState(value);
const inputRef = useRef<TextInput>(null);
const handleSubmit = () => {
if (tempValue.trim()) {
onChangeText(tempValue);
} else {
setTempValue(value);
}
setIsEditing(false);
};
return (
<View style={[styles.container, style]}>
{isEditing ? (
<View style={styles.inputContainer}>
<TextInput
ref={inputRef}
value={tempValue}
onChangeText={setTempValue}
onBlur={handleSubmit}
onSubmitEditing={handleSubmit}
autoFocus
selectTextOnFocus
style={[styles.input, textStyle, inputStyle]}
placeholder={placeholder}
placeholderTextColor={colors.textSecondary}
/>
<TouchableOpacity
onPress={handleSubmit}
style={styles.checkButton}
>
<Feather name="check" size={20} color={colors.primary} />
</TouchableOpacity>
</View>
) : (
<TouchableOpacity
onPress={() => setIsEditing(true)}
style={styles.textContainer}
activeOpacity={0.7}
>
<Text style={[styles.text, textStyle]} numberOfLines={1}>
{value}
</Text>
<View style={styles.editContainer}>
<View style={[styles.editButton, { backgroundColor: colors.cardBg }]}>
<Feather name="edit-2" size={14} color={colors.textSecondary} />
<Text style={[styles.editText, { color: colors.textSecondary }]}>Edit</Text>
</View>
</View>
</TouchableOpacity>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flexDirection: 'column',
},
inputContainer: {
flexDirection: 'row',
alignItems: 'center',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 8,
},
textContainer: {
flexDirection: 'column',
padding: 8,
borderRadius: 8,
},
input: {
flex: 1,
fontSize: 32,
fontWeight: 'bold',
padding: 0,
},
text: {
fontSize: 32,
fontWeight: 'bold',
},
editContainer: {
marginTop: 4,
},
editButton: {
flexDirection: 'row',
alignItems: 'center',
alignSelf: 'flex-start',
padding: 4,
borderRadius: 4,
},
editText: {
fontSize: 12,
marginLeft: 4,
},
checkButton: {
padding: 8,
marginLeft: 8,
},
});

View File

@ -1,24 +1,47 @@
import { Link } from 'expo-router'; // components/ExternalLink.tsx
import { openBrowserAsync } from 'expo-web-browser'; import { Link, Href } from 'expo-router';
import * as WebBrowser from 'expo-web-browser';
import { type ComponentProps } from 'react'; import { type ComponentProps } from 'react';
import { Platform } from 'react-native'; import { Platform } from 'react-native';
type Props = Omit<ComponentProps<typeof Link>, 'href'> & { href: string }; type Props = Omit<ComponentProps<typeof Link>, 'href'> & {
href: Href<string | object>;
};
export function ExternalLink({ href, ...rest }: Props) { export function ExternalLink({ href, ...rest }: Props) {
const handlePress = async (event: any) => {
if (Platform.OS !== 'web') {
event.preventDefault();
// Convert href to string based on its type
let url: string;
if (typeof href === 'string') {
url = href;
} else if (typeof href === 'object' && href !== null) {
// Handle route objects from expo-router
if ('pathname' in href) {
url = href.pathname;
} else {
url = String(href);
}
} else {
url = String(href);
}
try {
await WebBrowser.openBrowserAsync(url);
} catch (error) {
console.error('Error opening external link:', error);
}
}
};
return ( return (
<Link <Link
target="_blank" target="_blank"
{...rest} {...rest}
href={href} href={href}
onPress={async (event) => { onPress={handlePress}
if (Platform.OS !== 'web') {
// Prevent the default behavior of linking to the default browser on native.
event.preventDefault();
// Open the link in an in-app browser.
await openBrowserAsync(href);
}
}}
/> />
); );
} }

View File

@ -23,7 +23,7 @@ export default function ParallaxScrollView({
headerImage, headerImage,
headerBackgroundColor, headerBackgroundColor,
}: Props) { }: Props) {
const colorScheme = useColorScheme() ?? 'light'; const { colorScheme } = useColorScheme();
const scrollRef = useAnimatedRef<Animated.ScrollView>(); const scrollRef = useAnimatedRef<Animated.ScrollView>();
const scrollOffset = useScrollViewOffset(scrollRef); const scrollOffset = useScrollViewOffset(scrollRef);
const bottom = useBottomTabOverflow(); const bottom = useBottomTabOverflow();
@ -54,7 +54,7 @@ export default function ParallaxScrollView({
<Animated.View <Animated.View
style={[ style={[
styles.header, styles.header,
{ backgroundColor: headerBackgroundColor[colorScheme] }, { backgroundColor: headerBackgroundColor[colorScheme as 'light' | 'dark'] },
headerAnimatedStyle, headerAnimatedStyle,
]}> ]}>
{headerImage} {headerImage}

View File

@ -0,0 +1,77 @@
// components/SplashScreen.tsx
import React, { useEffect, useState } from 'react';
import { View, StyleSheet, Platform, Dimensions } from 'react-native';
import { Video, ResizeMode } from 'expo-av';
import { useColorScheme as useDeviceColorScheme } from 'react-native';
import Animated, {
useAnimatedStyle,
withTiming,
Easing,
useSharedValue,
runOnJS
} from 'react-native-reanimated';
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
interface SplashScreenProps {
onAnimationComplete: () => void;
}
export default function SplashScreen({ onAnimationComplete }: SplashScreenProps) {
const deviceColorScheme = useDeviceColorScheme();
const backgroundColor = deviceColorScheme === 'dark' ? '#000000' : '#FFFFFF';
const [videoFinished, setVideoFinished] = useState(false);
const opacity = useSharedValue(1);
useEffect(() => {
if (videoFinished) {
opacity.value = withTiming(0, {
duration: 500,
easing: Easing.out(Easing.ease),
}, (finished) => {
if (finished) {
runOnJS(onAnimationComplete)();
}
});
}
}, [videoFinished]);
const animatedStyle = useAnimatedStyle(() => ({
opacity: opacity.value
}));
return (
<Animated.View
style={[
styles.container,
{ backgroundColor },
animatedStyle
]}
>
<Video
source={require('@/assets/videos/splash.mov')}
style={styles.video}
resizeMode={ResizeMode.CONTAIN}
shouldPlay
isLooping={false}
onPlaybackStatusUpdate={(status) => {
if (status.isLoaded && status.didJustFinish) {
setVideoFinished(true);
}
}}
/>
</Animated.View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
video: {
width: '100%',
height: Platform.OS === 'web' ? SCREEN_HEIGHT : '100%',
}
});

62
components/TabLayout.tsx Normal file
View File

@ -0,0 +1,62 @@
// components/TabLayout.tsx
import React from 'react';
import { View, StyleSheet } from 'react-native';
import { useAppearance } from '@/contexts/AppearanceContext';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { ThemedText } from './ThemedText';
interface TabLayoutProps {
children: React.ReactNode;
title?: string;
rightElement?: React.ReactNode;
}
export default function TabLayout({ children, title, rightElement }: TabLayoutProps) {
const { colors } = useAppearance();
const insets = useSafeAreaInsets();
return (
<View
style={[
styles.container,
{
backgroundColor: colors.background,
paddingTop: insets.top
}
]}
>
{title && (
<View style={[styles.header, { borderBottomColor: colors.border }]}>
<View style={styles.titleContainer}>
<ThemedText type="title">{title}</ThemedText>
</View>
{rightElement && (
<View style={styles.rightElement}>
{rightElement}
</View>
)}
</View>
)}
{children}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: 16,
borderBottomWidth: 1,
},
titleContainer: {
flex: 1,
},
rightElement: {
marginLeft: 16,
},
});

View File

@ -1,60 +1,59 @@
import { Text, type TextProps, StyleSheet } from 'react-native'; // components/ThemedText.tsx
import React from 'react';
import { Text, TextProps, StyleSheet } from 'react-native';
import { useAppearance } from '@/contexts/AppearanceContext';
import { useThemeColor } from '@/hooks/useThemeColor'; export type TextType = 'default' | 'title' | 'subtitle' | 'link' | 'error';
export type ThemedTextProps = TextProps & { interface ThemedTextProps extends TextProps {
lightColor?: string; type?: TextType;
darkColor?: string; children: React.ReactNode;
type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link'; }
};
export function ThemedText({ export function ThemedText({
style, style,
lightColor, type = 'default',
darkColor, children,
type = 'default', ...props
...rest
}: ThemedTextProps) { }: ThemedTextProps) {
const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text'); const { colors } = useAppearance();
const baseStyle = { color: colors.text };
const typeStyle = styles[type] || {};
if (type === 'link') {
baseStyle.color = colors.primary;
} else if (type === 'error') {
baseStyle.color = 'red';
}
return ( return (
<Text <Text
style={[ style={[baseStyle, typeStyle, style]}
{ color }, {...props}
type === 'default' ? styles.default : undefined, >
type === 'title' ? styles.title : undefined, {children}
type === 'defaultSemiBold' ? styles.defaultSemiBold : undefined, </Text>
type === 'subtitle' ? styles.subtitle : undefined,
type === 'link' ? styles.link : undefined,
style,
]}
{...rest}
/>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
default: { default: {
fontSize: 16, fontSize: 16,
lineHeight: 24,
},
defaultSemiBold: {
fontSize: 16,
lineHeight: 24,
fontWeight: '600',
}, },
title: { title: {
fontSize: 32, fontSize: 20,
fontWeight: 'bold', fontWeight: '600',
lineHeight: 32,
}, },
subtitle: { subtitle: {
fontSize: 20, fontSize: 16,
fontWeight: 'bold', fontWeight: '500',
}, },
link: { link: {
lineHeight: 30,
fontSize: 16, fontSize: 16,
color: '#0a7ea4', textDecorationLine: 'underline',
}, },
}); error: {
fontSize: 14,
},
});

140
components/form/Button.tsx Normal file
View File

@ -0,0 +1,140 @@
// components/form/Button.tsx
import React from 'react';
import {
TouchableOpacity,
ActivityIndicator,
StyleSheet,
StyleProp,
ViewStyle,
TextStyle
} from 'react-native';
import { ThemedText } from '@/components/ThemedText';
import { useColorScheme } from '@/hooks/useColorScheme';
import { spacing } from '@/styles/sharedStyles';
interface ButtonProps {
title: string;
onPress: () => void;
variant?: 'primary' | 'secondary' | 'outline';
size?: 'small' | 'medium' | 'large';
disabled?: boolean;
loading?: boolean;
style?: StyleProp<ViewStyle>;
textStyle?: StyleProp<TextStyle>;
}
export function Button({
title,
onPress,
variant = 'primary',
size = 'medium',
disabled,
loading,
style,
textStyle,
}: ButtonProps) {
const { colors } = useColorScheme();
const getBackgroundColor = () => {
if (disabled) return colors.textSecondary;
switch (variant) {
case 'primary':
return colors.primary;
case 'secondary':
return colors.cardBg;
case 'outline':
return 'transparent';
default:
return colors.primary;
}
};
const getTextColor = () => {
if (disabled) return colors.background;
switch (variant) {
case 'primary':
return colors.background;
case 'secondary':
return colors.text;
case 'outline':
return colors.primary;
default:
return colors.background;
}
};
const getBorderColor = () => {
if (variant === 'outline') {
return disabled ? colors.textSecondary : colors.primary;
}
return 'transparent';
};
const getSizeStyle = (): ViewStyle => {
switch (size) {
case 'small':
return {
paddingVertical: spacing.small,
paddingHorizontal: spacing.medium
};
case 'large':
return {
paddingVertical: spacing.large,
paddingHorizontal: spacing.xl
};
default:
return {
paddingVertical: spacing.medium,
paddingHorizontal: spacing.large
};
}
};
return (
<TouchableOpacity
onPress={onPress}
disabled={disabled || loading}
style={[
styles.button,
{
backgroundColor: getBackgroundColor(),
borderColor: getBorderColor(),
},
getSizeStyle(),
style
]}
>
{loading ? (
<ActivityIndicator
color={getTextColor()}
size="small"
/>
) : (
<ThemedText
style={[
styles.text,
{ color: getTextColor() },
size === 'small' && { fontSize: 14 },
size === 'large' && { fontSize: 18 },
textStyle
]}
>
{title}
</ThemedText>
)}
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
button: {
borderRadius: 8,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
},
text: {
fontSize: 16,
fontWeight: '600',
},
});

77
components/form/Input.tsx Normal file
View File

@ -0,0 +1,77 @@
// components/form/Input.tsx
import React from 'react';
import { TextInput, TextInputProps, View, StyleSheet } from 'react-native';
import { ThemedText } from '@/components/ThemedText';
import { useColorScheme } from '@/hooks/useColorScheme';
import { spacing } from '@/styles/sharedStyles';
interface InputProps extends TextInputProps {
label?: string;
error?: string;
required?: boolean;
}
export const Input = React.forwardRef<TextInput, InputProps>(({
label,
error,
required,
style,
...props
}, ref) => {
const { colors } = useColorScheme();
return (
<View style={styles.container}>
{label && (
<View style={styles.labelContainer}>
<ThemedText style={styles.label}>
{label}
{required && <ThemedText style={{ color: colors.error }}> *</ThemedText>}
</ThemedText>
</View>
)}
<TextInput
ref={ref}
style={[
styles.input,
{
color: colors.text,
backgroundColor: colors.cardBg,
borderColor: error ? colors.error : colors.border
},
style
]}
placeholderTextColor={colors.textSecondary}
{...props}
/>
{error && (
<ThemedText style={[styles.errorText, { color: colors.error }]}>
{error}
</ThemedText>
)}
</View>
);
});
const styles = StyleSheet.create({
container: {
gap: spacing.small,
},
labelContainer: {
flexDirection: 'row',
},
label: {
fontSize: 16,
fontWeight: '500',
},
input: {
padding: spacing.medium,
borderRadius: 8,
borderWidth: 1,
fontSize: 16,
},
errorText: {
fontSize: 14,
},
});

207
components/form/Select.tsx Normal file
View File

@ -0,0 +1,207 @@
// components/form/Select.tsx
import React, { useState } from 'react';
import { View, TouchableOpacity, StyleSheet, Modal, ScrollView } from 'react-native';
import { Feather } from '@expo/vector-icons';
import { ThemedText } from '@/components/ThemedText';
import { useColorScheme } from '@/hooks/useColorScheme';
import { spacing } from '@/styles/sharedStyles';
export interface SelectOption {
label: string;
value: string;
}
interface SelectProps {
label?: string;
value: string | string[];
onValueChange: (value: string | string[]) => void;
items: SelectOption[];
placeholder?: string;
required?: boolean;
multiple?: boolean;
error?: string;
}
export function Select({
label,
value,
onValueChange,
items,
placeholder = 'Select...',
required,
multiple,
error
}: SelectProps) {
const { colors } = useColorScheme();
const [isOpen, setIsOpen] = useState(false);
const selectedLabels = React.useMemo(() => {
if (multiple && Array.isArray(value)) {
return value
.map(v => items.find(item => item.value === v)?.label)
.filter(Boolean)
.join(', ');
}
return items.find(item => item.value === value)?.label;
}, [value, items, multiple]);
const handleSelect = (selectedValue: string) => {
if (multiple) {
const currentValue = Array.isArray(value) ? value : [];
const newValue = currentValue.includes(selectedValue)
? currentValue.filter(v => v !== selectedValue)
: [...currentValue, selectedValue];
onValueChange(newValue);
} else {
onValueChange(selectedValue);
setIsOpen(false);
}
};
return (
<View style={styles.container}>
{label && (
<View style={styles.labelContainer}>
<ThemedText style={styles.label}>
{label}
{required && <ThemedText style={{ color: colors.error }}> *</ThemedText>}
</ThemedText>
</View>
)}
<TouchableOpacity
style={[
styles.select,
{
backgroundColor: colors.cardBg,
borderColor: error ? colors.error : colors.border
}
]}
onPress={() => setIsOpen(true)}
>
<ThemedText style={[
styles.selectText,
!selectedLabels && { color: colors.textSecondary }
]}>
{selectedLabels || placeholder}
</ThemedText>
<Feather name="chevron-down" size={20} color={colors.textSecondary} />
</TouchableOpacity>
<Modal
visible={isOpen}
transparent
animationType="slide"
onRequestClose={() => setIsOpen(false)}
>
<View style={[styles.modalOverlay, { backgroundColor: 'rgba(0,0,0,0.5)' }]}>
<View style={[styles.modalContent, { backgroundColor: colors.background }]}>
<View style={[styles.modalHeader, { borderBottomColor: colors.border }]}>
<TouchableOpacity onPress={() => setIsOpen(false)}>
<Feather name="x" size={24} color={colors.text} />
</TouchableOpacity>
<ThemedText style={styles.modalTitle}>{label || 'Select'}</ThemedText>
{multiple && (
<TouchableOpacity onPress={() => setIsOpen(false)}>
<ThemedText style={{ color: colors.primary }}>Done</ThemedText>
</TouchableOpacity>
)}
</View>
<ScrollView>
{items.map((item) => {
const isSelected = multiple
? Array.isArray(value) && value.includes(item.value)
: value === item.value;
return (
<TouchableOpacity
key={item.value}
style={[
styles.option,
isSelected && { backgroundColor: colors.primary + '20' }
]}
onPress={() => handleSelect(item.value)}
>
<ThemedText style={[
styles.optionText,
isSelected && { color: colors.primary }
]}>
{item.label}
</ThemedText>
{isSelected && (
<Feather name="check" size={20} color={colors.primary} />
)}
</TouchableOpacity>
);
})}
</ScrollView>
</View>
</View>
</Modal>
{error && (
<ThemedText style={[styles.errorText, { color: colors.error }]}>
{error}
</ThemedText>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
gap: spacing.small,
},
labelContainer: {
flexDirection: 'row',
},
label: {
fontSize: 16,
fontWeight: '500',
},
select: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: spacing.medium,
borderRadius: 8,
borderWidth: 1,
},
selectText: {
fontSize: 16,
},
errorText: {
fontSize: 14,
},
modalOverlay: {
flex: 1,
justifyContent: 'flex-end',
},
modalContent: {
maxHeight: '80%',
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
},
modalHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: spacing.medium,
borderBottomWidth: 1,
},
modalTitle: {
fontSize: 18,
fontWeight: '600',
},
option: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: spacing.medium,
borderBottomWidth: StyleSheet.hairlineWidth,
},
optionText: {
fontSize: 16,
},
});

View File

@ -0,0 +1,114 @@
// components/library/AddContentModal.tsx
import React from 'react';
import { View, TouchableOpacity, StyleSheet } from 'react-native';
import Modal from 'react-native-modal';
import { useColorScheme } from '@/hooks/useColorScheme';
import { Feather } from '@expo/vector-icons';
import { spacing } from '@/styles/sharedStyles';
import { ThemedText } from '@/components/ThemedText';
interface AddContentModalProps {
isVisible: boolean;
onClose: () => void;
onSelect: (type: 'exercise' | 'template') => void;
}
export default function AddContentModal({ isVisible, onClose, onSelect }: AddContentModalProps) {
const { colors } = useColorScheme();
return (
<Modal
isVisible={isVisible}
onBackdropPress={onClose}
onSwipeComplete={onClose}
swipeDirection={['down']}
style={styles.modal}
>
<View style={[styles.container, { backgroundColor: colors.background }]}>
<View style={styles.handle} />
<ThemedText type="title" style={styles.title}>Add to Library</ThemedText>
<TouchableOpacity
style={[styles.option, { backgroundColor: colors.cardBg }]}
onPress={() => onSelect('exercise')}
>
<View style={styles.optionContent}>
<View style={[styles.iconContainer, { backgroundColor: colors.primary + '20' }]}>
<Feather name="activity" size={24} color={colors.primary} />
</View>
<View style={styles.textContainer}>
<ThemedText type="subtitle">New Exercise</ThemedText>
<ThemedText>Add a custom exercise to your library</ThemedText>
</View>
</View>
<Feather name="chevron-right" size={24} color={colors.textSecondary} />
</TouchableOpacity>
<TouchableOpacity
style={[styles.option, { backgroundColor: colors.cardBg }]}
onPress={() => onSelect('template')}
>
<View style={styles.optionContent}>
<View style={[styles.iconContainer, { backgroundColor: colors.primary + '20' }]}>
<Feather name="layout" size={24} color={colors.primary} />
</View>
<View style={styles.textContainer}>
<ThemedText type="subtitle">New Template</ThemedText>
<ThemedText>Create a workout template</ThemedText>
</View>
</View>
<Feather name="chevron-right" size={24} color={colors.textSecondary} />
</TouchableOpacity>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
modal: {
margin: 0,
justifyContent: 'flex-end',
},
container: {
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
padding: spacing.medium,
},
handle: {
width: 36,
height: 5,
backgroundColor: '#D1D5DB',
borderRadius: 3,
alignSelf: 'center',
marginBottom: spacing.large,
},
title: {
marginBottom: spacing.large,
textAlign: 'center',
},
option: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: spacing.medium,
borderRadius: 12,
marginBottom: spacing.medium,
},
optionContent: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
iconContainer: {
width: 40,
height: 40,
borderRadius: 20,
justifyContent: 'center',
alignItems: 'center',
marginRight: spacing.medium,
},
textContainer: {
flex: 1,
},
});

View File

@ -0,0 +1,188 @@
// components/library/ContentPreviewModal.tsx
import React from 'react';
import { View, Modal, ScrollView, TouchableOpacity, StyleSheet } from 'react-native';
import { LibraryContent } from '@/types/exercise';
import { useColorScheme } from '@/hooks/useColorScheme';
import { spacing } from '@/styles/sharedStyles';
import { Feather } from '@expo/vector-icons';
import { ThemedText } from '@/components/ThemedText';
interface ContentPreviewModalProps {
isVisible: boolean;
content: LibraryContent | null;
onClose: () => void;
onSave?: () => void;
}
export default function ContentPreviewModal({
isVisible,
content,
onClose,
onSave
}: ContentPreviewModalProps) {
const { colors } = useColorScheme();
if (!content) return null;
const isExercise = content.type === 'exercise';
const isWorkout = content.type === 'workout';
return (
<Modal
visible={isVisible}
animationType="slide"
transparent={true}
onRequestClose={onClose}
>
<View style={[styles.modalOverlay, { backgroundColor: 'rgba(0,0,0,0.5)' }]}>
<View style={[styles.modalContent, { backgroundColor: colors.background }]}>
{/* Header */}
<View style={[styles.header, { borderBottomColor: colors.border }]}>
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
<Feather name="x" size={24} color={colors.text} />
</TouchableOpacity>
<View style={styles.titleContainer}>
<ThemedText type="title">{content.title}</ThemedText>
</View>
{onSave && (
<TouchableOpacity onPress={onSave} style={styles.saveButton}>
<Feather name="bookmark" size={24} color={colors.primary} />
</TouchableOpacity>
)}
</View>
<ScrollView style={styles.content}>
{/* Author Info */}
<View style={styles.authorSection}>
<ThemedText type="subtitle">
{content.author?.name || 'Anonymous'}
</ThemedText>
{content.source === 'pow' && (
<View style={[styles.verifiedBadge, { backgroundColor: colors.primary + '20' }]}>
<Feather name="check-circle" size={16} color={colors.primary} />
<ThemedText style={[styles.verifiedText, { color: colors.primary }]}>
POW Verified
</ThemedText>
</View>
)}
</View>
{/* Description */}
{content.description && (
<ThemedText style={styles.description}>
{content.description}
</ThemedText>
)}
{/* Exercise-specific content */}
{isExercise && (
<View style={styles.section}>
<ThemedText type="subtitle" style={styles.sectionTitle}>
Instructions
</ThemedText>
{/* Exercise instructions here */}
</View>
)}
{/* Workout-specific content */}
{isWorkout && (
<View style={styles.section}>
<ThemedText type="subtitle" style={styles.sectionTitle}>
Exercises
</ThemedText>
{/* Workout exercises list here */}
</View>
)}
{/* Tags */}
<View style={styles.tags}>
{content.tags.map(tag => (
<View
key={tag}
style={[styles.tag, { backgroundColor: colors.primary + '20' }]}
>
<ThemedText style={[styles.tagText, { color: colors.primary }]}>
{tag}
</ThemedText>
</View>
))}
</View>
</ScrollView>
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
modalOverlay: {
flex: 1,
justifyContent: 'flex-end',
},
modalContent: {
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
maxHeight: '80%',
},
header: {
flexDirection: 'row',
alignItems: 'center',
padding: spacing.medium,
borderBottomWidth: 1,
},
closeButton: {
padding: spacing.small,
},
titleContainer: {
flex: 1,
alignItems: 'center',
},
saveButton: {
padding: spacing.small,
},
content: {
padding: spacing.medium,
},
authorSection: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: spacing.medium,
gap: spacing.small,
},
verifiedBadge: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: spacing.small,
paddingVertical: 4,
borderRadius: 12,
},
verifiedText: {
fontSize: 12,
fontWeight: '500',
},
description: {
marginBottom: spacing.large,
},
section: {
marginBottom: spacing.large,
},
sectionTitle: {
marginBottom: spacing.small,
},
tags: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.small,
marginTop: spacing.medium,
},
tag: {
paddingHorizontal: spacing.small,
paddingVertical: 4,
borderRadius: 12,
},
tagText: {
fontSize: 12,
fontWeight: '500',
},
});

View File

@ -0,0 +1,118 @@
// components/library/Discover.tsx
import React from 'react';
import { View, FlatList, StyleSheet, Platform } from 'react-native';
import { Feather } from '@expo/vector-icons';
import { useColorScheme } from '@/hooks/useColorScheme';
import { LibraryContent } from '@/types/exercise';
import LibraryContentCard from '@/components/library/LibraryContentCard';
import { spacing } from '@/styles/sharedStyles';
import { ThemedText } from '@/components/ThemedText';
interface DiscoverProps {
content: LibraryContent[];
onContentPress: (content: LibraryContent) => void;
onFavoritePress: (content: LibraryContent) => Promise<void>;
isVisible?: boolean;
}
export default function Discover({
content,
onContentPress,
onFavoritePress,
isVisible = true
}: DiscoverProps) {
const { colors } = useColorScheme();
// Don't render anything if not visible
if (!isVisible) {
return null;
}
// Separate exercises and workouts
const exercises = content.filter(content => content.type === 'exercise');
const workouts = content.filter(content => content.type === 'workout');
const renderSection = (title: string, items: LibraryContent[]) => {
if (items.length === 0) return null;
return (
<View style={styles.section}>
<ThemedText type="title" style={styles.sectionTitle}>
{title}
</ThemedText>
<FlatList
data={items}
renderItem={({ item }) => (
<LibraryContentCard
content={item}
onPress={() => onContentPress(item)}
onFavoritePress={() => onFavoritePress(item)}
/>
)}
keyExtractor={item => item.id}
scrollEnabled={false}
/>
</View>
);
};
const EmptyState = () => (
<View style={styles.emptyState}>
<Feather name="compass" size={48} color={colors.textSecondary} />
<ThemedText type="title" style={styles.emptyStateTitle}>
Nothing to Discover
</ThemedText>
<ThemedText type="subtitle" style={styles.emptyStateText}>
Community content will appear here
</ThemedText>
</View>
);
if (content.length === 0) {
return <EmptyState />;
}
return (
<FlatList
data={[1]}
renderItem={() => null}
ListHeaderComponent={
<>
{exercises.length > 0 && renderSection('Community Exercises', exercises)}
{workouts.length > 0 && renderSection('Community Workouts', workouts)}
</>
}
contentContainerStyle={styles.contentContainer}
/>
);
}
const styles = StyleSheet.create({
contentContainer: {
paddingHorizontal: spacing.medium,
paddingBottom: Platform.OS === 'ios' ? 120 : 100,
},
section: {
marginBottom: spacing.large,
},
sectionTitle: {
marginBottom: spacing.medium,
},
emptyState: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: spacing.xl * 2,
},
emptyStateTitle: {
marginTop: spacing.medium,
marginBottom: spacing.small,
},
emptyStateText: {
textAlign: 'center',
maxWidth: '80%',
},
hidden: {
display: 'none',
},
});

View File

@ -0,0 +1,193 @@
// components/library/FilterSheet.tsx
import React from 'react';
import { View, TouchableOpacity, StyleSheet } from 'react-native';
import Modal from 'react-native-modal';
import { useColorScheme } from '@/hooks/useColorScheme';
import { spacing } from '@/styles/sharedStyles';
import { ThemedText } from '@/components/ThemedText';
export interface FilterOptions {
contentType: string[];
source: string[];
category: string[];
equipment: string[];
}
interface FilterSheetProps {
isVisible: boolean;
options: FilterOptions;
onClose: () => void;
onApply: (options: FilterOptions) => void;
}
export default function FilterSheet({
isVisible,
options,
onClose,
onApply
}: FilterSheetProps) {
const { colors } = useColorScheme();
const [selectedOptions, setSelectedOptions] =
React.useState<FilterOptions>(options);
const toggleOption = (
category: keyof FilterOptions,
value: string
) => {
setSelectedOptions(prev => {
const currentArray = prev[category] || [];
const newArray = currentArray.includes(value)
? currentArray.filter(v => v !== value)
: [...currentArray, value];
return {
...prev,
[category]: newArray
};
});
};
const renderFilterSection = (
title: string,
category: keyof FilterOptions,
values: string[]
) => (
<View style={styles.section}>
<ThemedText type="subtitle" style={styles.sectionTitle}>
{title}
</ThemedText>
<View style={styles.optionsGrid}>
{values.map(value => (
<TouchableOpacity
key={value}
style={[
styles.optionButton,
selectedOptions[category]?.includes(value) && {
backgroundColor: colors.primary + '20'
}
]}
onPress={() => toggleOption(category, value)}
>
<ThemedText
style={[
styles.optionText,
selectedOptions[category]?.includes(value) && {
color: colors.primary
}
]}
>
{value}
</ThemedText>
</TouchableOpacity>
))}
</View>
</View>
);
return (
<Modal
isVisible={isVisible}
onBackdropPress={onClose}
onSwipeComplete={onClose}
swipeDirection={['down']}
propagateSwipe={true}
style={styles.modal}
>
<View style={[styles.container, { backgroundColor: colors.background }]}>
<View style={styles.handle} />
<ThemedText type="title" style={styles.title}>
Filter Library
</ThemedText>
{renderFilterSection('Content Type', 'contentType', [
'exercise', 'workout', 'program'
])}
{renderFilterSection('Source', 'source', [
'local', 'pow', 'nostr'
])}
{renderFilterSection('Category', 'category', [
'Strength', 'Cardio', 'Flexibility', 'Recovery'
])}
<View style={styles.buttons}>
<TouchableOpacity
style={[styles.button, { backgroundColor: colors.cardBg }]}
onPress={onClose}
>
<ThemedText>Cancel</ThemedText>
</TouchableOpacity>
<TouchableOpacity
style={[styles.button, { backgroundColor: colors.primary }]}
onPress={() => {
onApply(selectedOptions);
onClose();
}}
>
<ThemedText style={{ color: '#FFFFFF' }}>
Apply Filters
</ThemedText>
</TouchableOpacity>
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
modal: {
margin: 0,
justifyContent: 'flex-end',
},
container: {
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
padding: spacing.medium,
},
handle: {
width: 36,
height: 5,
borderRadius: 3,
backgroundColor: '#D1D5DB',
alignSelf: 'center',
marginBottom: spacing.medium,
},
title: {
marginBottom: spacing.large,
},
section: {
marginBottom: spacing.large,
},
sectionTitle: {
marginBottom: spacing.small,
},
optionsGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.small,
},
optionButton: {
paddingHorizontal: spacing.medium,
paddingVertical: spacing.small,
borderRadius: 16,
backgroundColor: '#374151',
},
optionText: {
fontSize: 14,
},
buttons: {
flexDirection: 'row',
gap: spacing.small,
marginTop: spacing.large,
},
button: {
flex: 1,
height: 48,
borderRadius: 12,
justifyContent: 'center',
alignItems: 'center',
},
});

View File

@ -0,0 +1,139 @@
// components/library/LibraryContentCard.tsx
import React from 'react';
import { View, TouchableOpacity, StyleSheet } from 'react-native';
import { Feather } from '@expo/vector-icons';
import { useColorScheme } from '@/hooks/useColorScheme';
import { LibraryContent } from '@/types/exercise';
import { spacing } from '@/styles/sharedStyles';
import { ThemedText } from '@/components/ThemedText';
export interface LibraryContentCardProps {
content: LibraryContent;
onPress: () => void;
onFavoritePress: () => void;
isVerified?: boolean;
}
export default function LibraryContentCard({
content,
onPress,
onFavoritePress,
isVerified
}: LibraryContentCardProps) {
const { colors } = useColorScheme();
return (
<TouchableOpacity
style={[styles.container, { backgroundColor: colors.cardBg }]}
onPress={onPress}
activeOpacity={0.7}
>
<View style={styles.header}>
<View style={styles.titleContainer}>
<ThemedText type="subtitle">
{content.title}
</ThemedText>
{isVerified && (
<View style={styles.verifiedBadge}>
<Feather name="check-circle" size={16} color={colors.primary} />
<ThemedText style={[styles.verifiedText, { color: colors.primary }]}>
POW Verified
</ThemedText>
</View>
)}
</View>
<TouchableOpacity
onPress={onFavoritePress}
style={styles.favoriteButton}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Feather
name="star"
size={24}
color={colors.textSecondary}
/>
</TouchableOpacity>
</View>
{content.description && (
<ThemedText
style={styles.description}
numberOfLines={2}
>
{content.description}
</ThemedText>
)}
<View style={styles.footer}>
<View style={styles.tags}>
{content.tags.map(tag => (
<View
key={tag}
style={[styles.tag, { backgroundColor: colors.primary + '20' }]}
>
<ThemedText style={[styles.tagText, { color: colors.primary }]}>
{tag}
</ThemedText>
</View>
))}
</View>
</View>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
container: {
borderRadius: 12,
padding: spacing.medium,
marginBottom: spacing.small,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: spacing.small,
},
titleContainer: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.small,
},
favoriteButton: {
padding: spacing.small,
marginRight: -spacing.small,
marginTop: -spacing.small,
},
description: {
marginBottom: spacing.small,
},
footer: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
tags: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.small,
},
tag: {
paddingHorizontal: spacing.small,
paddingVertical: 4,
borderRadius: 12,
},
tagText: {
fontSize: 12,
fontWeight: '500',
},
verifiedBadge: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
verifiedText: {
fontSize: 12,
fontWeight: '500',
},
});

View File

@ -0,0 +1,120 @@
// components/library/MyLibrary.tsx
import React from 'react';
import { View, FlatList, StyleSheet, Platform } from 'react-native';
import { Feather } from '@expo/vector-icons';
import { useColorScheme } from '@/hooks/useColorScheme';
import { LibraryContent } from '@/types/exercise';
import LibraryContentCard from '@/components/library/LibraryContentCard';
import { spacing } from '@/styles/sharedStyles';
import { ThemedText } from '@/components/ThemedText';
interface MyLibraryProps {
savedContent: LibraryContent[];
onContentPress: (content: LibraryContent) => void;
onFavoritePress: (content: LibraryContent) => Promise<void>;
isLoading?: boolean;
isVisible?: boolean;
}
// components/library/MyLibrary.tsx
export default function MyLibrary({
savedContent,
onContentPress,
onFavoritePress,
isVisible = true
}: MyLibraryProps) {
const { colors } = useColorScheme();
// Don't render anything if not visible
if (!isVisible) {
return null;
}
// Separate exercises and workouts
const exercises = savedContent.filter(content => content.type === 'exercise');
const workouts = savedContent.filter(content => content.type === 'workout');
const renderSection = (title: string, items: LibraryContent[]) => {
if (items.length === 0) return null;
return (
<View style={styles.section}>
<ThemedText type="title" style={styles.sectionTitle}>
{title}
</ThemedText>
<FlatList
data={items}
renderItem={({ item }) => (
<LibraryContentCard
content={item}
onPress={() => onContentPress(item)}
onFavoritePress={() => onFavoritePress(item)}
/>
)}
keyExtractor={item => item.id}
scrollEnabled={false}
/>
</View>
);
};
const EmptyState = () => (
<View style={styles.emptyState}>
<Feather name="folder" size={48} color={colors.textSecondary} />
<ThemedText type="title" style={styles.emptyStateTitle}>
So empty!
</ThemedText>
<ThemedText type="subtitle" style={styles.emptyStateText}>
Add content from Programs or Discover
</ThemedText>
</View>
);
if (savedContent.length === 0) {
return <EmptyState />;
}
return (
<FlatList
data={[1]}
renderItem={() => null}
ListHeaderComponent={
<>
{exercises.length > 0 && renderSection('Exercises', exercises)}
{workouts.length > 0 && renderSection('Workouts', workouts)}
</>
}
contentContainerStyle={styles.contentContainer}
/>
);
}
const styles = StyleSheet.create({
contentContainer: {
paddingHorizontal: spacing.medium,
paddingBottom: Platform.OS === 'ios' ? 120 : 100,
},
section: {
marginBottom: spacing.large,
},
sectionTitle: {
marginBottom: spacing.medium,
},
emptyState: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: spacing.xl * 2,
},
emptyStateTitle: {
marginTop: spacing.medium,
marginBottom: spacing.small,
},
emptyStateText: {
textAlign: 'center',
maxWidth: '80%',
},
hidden: {
display: 'none',
},
});

View File

@ -0,0 +1,119 @@
// components/library/Programs.tsx
import React from 'react';
import { View, FlatList, StyleSheet, Platform } from 'react-native';
import { Feather } from '@expo/vector-icons';
import { useColorScheme } from '@/hooks/useColorScheme';
import { LibraryContent } from '@/types/exercise';
import LibraryContentCard from '@/components/library/LibraryContentCard';
import { spacing } from '@/styles/sharedStyles';
import { ThemedText } from '@/components/ThemedText';
interface ProgramsProps {
content: LibraryContent[];
onContentPress: (content: LibraryContent) => void;
onFavoritePress: (content: LibraryContent) => Promise<void>;
isVisible?: boolean;
}
export default function Programs({
content,
onContentPress,
onFavoritePress,
isVisible = true
}: ProgramsProps) {
const { colors } = useColorScheme();
// Don't render anything if not visible
if (!isVisible) {
return null;
}
// Separate exercises and workouts
const exercises = content.filter(content => content.type === 'exercise');
const workouts = content.filter(content => content.type === 'workout');
const renderSection = (title: string, items: LibraryContent[]) => {
if (items.length === 0) return null;
return (
<View style={styles.section}>
<ThemedText type="title" style={styles.sectionTitle}>
{title}
</ThemedText>
<FlatList
data={items}
renderItem={({ item }) => (
<LibraryContentCard
content={item}
onPress={() => onContentPress(item)}
onFavoritePress={() => onFavoritePress(item)}
isVerified={true}
/>
)}
keyExtractor={item => item.id}
scrollEnabled={false}
/>
</View>
);
};
const EmptyState = () => (
<View style={styles.emptyState}>
<Feather name="package" size={48} color={colors.textSecondary} />
<ThemedText type="title" style={styles.emptyStateTitle}>
No Programs
</ThemedText>
<ThemedText type="subtitle" style={styles.emptyStateText}>
Training programs will appear here
</ThemedText>
</View>
);
if (content.length === 0) {
return <EmptyState />;
}
return (
<FlatList
data={[1]}
renderItem={() => null}
ListHeaderComponent={
<>
{exercises.length > 0 && renderSection('Exercises', exercises)}
{workouts.length > 0 && renderSection('Workouts', workouts)}
</>
}
contentContainerStyle={styles.contentContainer}
/>
);
}
const styles = StyleSheet.create({
contentContainer: {
paddingHorizontal: spacing.medium,
paddingBottom: Platform.OS === 'ios' ? 120 : 100,
},
section: {
marginBottom: spacing.large,
},
sectionTitle: {
marginBottom: spacing.medium,
},
emptyState: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: spacing.xl * 2,
},
emptyStateTitle: {
marginTop: spacing.medium,
marginBottom: spacing.small,
},
emptyStateText: {
textAlign: 'center',
maxWidth: '80%',
},
hidden: {
display: 'none',
},
});

View File

@ -0,0 +1,64 @@
// components/library/SearchBar.tsx
import React from 'react';
import { View, TextInput, TouchableOpacity, StyleSheet } from 'react-native';
import { Feather } from '@expo/vector-icons';
import { useColorScheme } from '@/hooks/useColorScheme';
import { spacing } from '@/styles/sharedStyles';
interface SearchBarProps {
value: string;
onChangeText: (text: string) => void;
onFilterPress: () => void;
}
export default function SearchBar({ value, onChangeText, onFilterPress }: SearchBarProps) {
const { colors } = useColorScheme();
return (
<View style={styles.container}>
<View style={[styles.searchContainer, { backgroundColor: colors.cardBg }]}>
<Feather name="search" size={20} color={colors.textSecondary} />
<TextInput
style={[styles.input, { color: colors.text }]}
placeholder="Search library..."
placeholderTextColor={colors.textSecondary}
value={value}
onChangeText={onChangeText}
/>
</View>
<TouchableOpacity
style={[styles.filterButton, { backgroundColor: colors.cardBg }]}
onPress={onFilterPress}
>
<Feather name="sliders" size={20} color={colors.textSecondary} />
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
gap: spacing.small,
},
searchContainer: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.medium,
height: 44,
borderRadius: 12,
},
input: {
flex: 1,
marginLeft: spacing.small,
fontSize: 16,
},
filterButton: {
width: 44,
height: 44,
borderRadius: 12,
justifyContent: 'center',
alignItems: 'center',
},
});

14
components/pager/index.ts Normal file
View File

@ -0,0 +1,14 @@
// components/pager/index.ts
import { Platform } from 'react-native';
import type { PagerProps, PagerRef } from './types';
let PagerComponent: React.ForwardRefExoticComponent<PagerProps & React.RefAttributes<PagerRef>>;
if (Platform.OS === 'web') {
PagerComponent = require('./pager.web').default;
} else {
PagerComponent = require('./pager.native').default;
}
export type { PagerProps, PagerRef, PageSelectedEvent } from './types';
export default PagerComponent;

View File

@ -0,0 +1,8 @@
// components/pager/pager.native.tsx
import React from 'react';
import PagerView from 'react-native-pager-view';
import type { PagerProps, PagerRef } from './types';
const NativePager: React.ForwardRefExoticComponent<PagerProps> = PagerView as unknown as React.ForwardRefExoticComponent<PagerProps>;
export default NativePager;

View File

@ -0,0 +1,58 @@
// components/pager/pager.web.tsx
import React from 'react';
import { ScrollView, StyleSheet } from 'react-native';
import type { PagerProps, PagerRef, PageSelectedEvent } from './types';
const Pager = React.forwardRef<PagerRef, PagerProps>(
({ children, onPageSelected, initialPage = 0, style }, ref) => {
const scrollRef = React.useRef<ScrollView>(null);
const [currentPage, setCurrentPage] = React.useState(initialPage);
React.useImperativeHandle(ref, () => ({
setPage: (pageNumber: number) => {
const scrollView = scrollRef.current;
if (scrollView) {
const width = scrollView.getInnerViewNode?.()?.getBoundingClientRect?.()?.width ?? 0;
scrollView.scrollTo({ x: pageNumber * width, animated: true });
}
},
scrollTo: (options) => {
scrollRef.current?.scrollTo(options);
}
}));
const handleScroll = (event: any) => {
const offsetX = event.nativeEvent.contentOffset.x;
const page = Math.round(offsetX / event.nativeEvent.layoutMeasurement.width);
if (page !== currentPage) {
setCurrentPage(page);
onPageSelected?.({
nativeEvent: { position: page }
} as PageSelectedEvent);
}
};
return (
<ScrollView
ref={scrollRef}
horizontal
pagingEnabled
showsHorizontalScrollIndicator={false}
onScroll={handleScroll}
scrollEventThrottle={16}
style={[styles.container, style]}
>
{children}
</ScrollView>
);
}
);
export default Pager;
const styles = StyleSheet.create({
container: {
flex: 1,
},
});

20
components/pager/types.ts Normal file
View File

@ -0,0 +1,20 @@
// components/pager/types.ts
import { StyleProp, ViewStyle } from 'react-native';
export interface PageSelectedEvent {
nativeEvent: {
position: number;
};
}
export interface PagerProps {
children: React.ReactNode[];
style?: StyleProp<ViewStyle>;
initialPage?: number;
onPageSelected?: (e: PageSelectedEvent) => void;
}
export interface PagerRef {
setPage: (page: number) => void;
scrollTo?: (options: { x: number; animated?: boolean }) => void;
}

View File

@ -0,0 +1,86 @@
// components/shared/FloatingActionButton.tsx
import React from 'react';
import { TouchableOpacity, StyleSheet, Platform, ViewStyle } from 'react-native';
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
import { spacing } from '@/styles/sharedStyles';
import { LucideIcon } from 'lucide-react-native';
import Animated, {
SharedValue,
useAnimatedStyle,
interpolate,
Extrapolate
} from 'react-native-reanimated';
interface FABProps {
onPress: () => void;
icon: LucideIcon;
scrollY?: SharedValue<number>;
style?: ViewStyle;
}
export default function FloatingActionButton({
onPress,
icon: Icon,
scrollY,
style
}: FABProps) {
const { colors } = useColorScheme();
const animatedStyle = useAnimatedStyle(() => {
if (!scrollY) return {};
return {
transform: [{
translateY: interpolate(
scrollY.value,
[0, 100],
[0, 100],
Extrapolate.CLAMP
),
}],
opacity: interpolate(
scrollY.value,
[0, 100],
[1, 0],
Extrapolate.CLAMP
),
};
});
return (
<Animated.View style={[styles.fabContainer, animatedStyle, style]}>
<TouchableOpacity
style={[styles.fab, { backgroundColor: colors.primary }]}
onPress={onPress}
activeOpacity={0.8}
>
<Icon size={24} color="#FFFFFF" />
</TouchableOpacity>
</Animated.View>
);
}
const styles = StyleSheet.create({
fabContainer: {
position: 'absolute',
right: spacing.medium,
bottom: Platform.OS === 'ios' ? 100 : 80,
zIndex: 1000,
},
fab: {
width: 56,
height: 56,
borderRadius: 28,
alignItems: 'center',
justifyContent: 'center',
elevation: 4,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.25,
shadowRadius: 3.84,
},
});

View File

@ -3,7 +3,7 @@
import MaterialIcons from '@expo/vector-icons/MaterialIcons'; import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { SymbolWeight } from 'expo-symbols'; import { SymbolWeight } from 'expo-symbols';
import React from 'react'; import React from 'react';
import { OpaqueColorValue, StyleProp, ViewStyle } from 'react-native'; import { OpaqueColorValue, StyleProp, ViewStyle, TextStyle } from 'react-native';
// Add your SFSymbol to MaterialIcons mappings here. // Add your SFSymbol to MaterialIcons mappings here.
const MAPPING = { const MAPPING = {
@ -36,7 +36,7 @@ export function IconSymbol({
name: IconSymbolName; name: IconSymbolName;
size?: number; size?: number;
color: string | OpaqueColorValue; color: string | OpaqueColorValue;
style?: StyleProp<ViewStyle>; style?: StyleProp<TextStyle>;
weight?: SymbolWeight; weight?: SymbolWeight;
}) { }) {
return <MaterialIcons color={color} size={size} name={MAPPING[name]} style={style} />; return <MaterialIcons color={color} size={size} name={MAPPING[name]} style={style} />;

View File

@ -1,26 +1,30 @@
/** // constants/Colors.ts
* Below are the colors that are used in the app. The colors are defined in the light and dark mode. import { ThemeColors } from '@/types/theme';
* There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
*/
const tintColorLight = '#0a7ea4'; const tintColorLight = '#2563eb';
const tintColorDark = '#fff'; const tintColorDark = '#60a5fa';
export const Colors = { export const Colors: Record<string, ThemeColors> = {
light: { light: {
text: '#11181C', primary: tintColorLight,
background: '#fff', background: '#ffffff',
tint: tintColorLight, cardBg: '#f3f4f6',
icon: '#687076', text: '#1f2937',
tabIconDefault: '#687076', textSecondary: '#6b7280',
border: '#e5e7eb',
tabIconDefault: '#6b7280',
tabIconSelected: tintColorLight, tabIconSelected: tintColorLight,
error: '#dc2626', // Added error color (red-600)
}, },
dark: { dark: {
text: '#ECEDEE', primary: tintColorDark,
background: '#151718', background: '#1f2937',
tint: tintColorDark, cardBg: '#374151',
icon: '#9BA1A6', text: '#f3f4f6',
tabIconDefault: '#9BA1A6', textSecondary: '#9ca3af',
border: '#4b5563',
tabIconDefault: '#9ca3af',
tabIconSelected: tintColorDark, tabIconSelected: tintColorDark,
error: '#ef4444', // Added error color (red-500, slightly lighter for dark mode)
}, },
}; };

View File

@ -0,0 +1,132 @@
// contexts/AppearanceContext.tsx
import React, { createContext, useContext, useState, useEffect } from 'react';
import { useColorScheme as useSystemColorScheme } from 'react-native';
import { Colors } from '@/constants/Colors';
import type { ColorScheme, ThemeName, ThemeColors } from '@/types/theme';
import AsyncStorage from '@react-native-async-storage/async-storage';
interface AppearanceContextType {
colorScheme: ColorScheme;
theme: ThemeName;
useSystemTheme: boolean;
systemColorScheme: ColorScheme;
colors: ThemeColors;
setColorScheme: (scheme: ColorScheme) => void;
setTheme: (theme: ThemeName) => void;
setUseSystemTheme: (use: boolean) => void;
}
const APPEARANCE_STORAGE_KEY = '@appearance';
interface StoredAppearance {
theme: ThemeName;
colorScheme: ColorScheme;
useSystemTheme: boolean;
}
const defaultAppearance: StoredAppearance = {
theme: 'default',
colorScheme: 'light',
useSystemTheme: true,
};
const AppearanceContext = createContext<AppearanceContextType | null>(null);
export function AppearanceProvider({ children }: { children: React.ReactNode }) {
const systemColorScheme = useSystemColorScheme() as ColorScheme || 'light';
const [appearance, setAppearance] = useState<StoredAppearance>(defaultAppearance);
const [isLoading, setIsLoading] = useState(true);
// Load saved appearance settings
useEffect(() => {
async function loadAppearance() {
try {
const stored = await AsyncStorage.getItem(APPEARANCE_STORAGE_KEY);
if (stored) {
setAppearance(JSON.parse(stored));
}
} catch (error) {
console.error('Error loading appearance settings:', error);
} finally {
setIsLoading(false);
}
}
loadAppearance();
}, []);
// Save appearance settings when they change
useEffect(() => {
if (!isLoading) {
AsyncStorage.setItem(APPEARANCE_STORAGE_KEY, JSON.stringify(appearance));
}
}, [appearance, isLoading]);
// Update color scheme when system theme changes
useEffect(() => {
if (appearance.useSystemTheme && systemColorScheme) {
setAppearance(prev => ({
...prev,
colorScheme: systemColorScheme,
}));
}
}, [appearance.useSystemTheme, systemColorScheme]);
const setColorScheme = (scheme: ColorScheme) => {
setAppearance(prev => ({
...prev,
colorScheme: scheme,
useSystemTheme: false,
}));
};
const setTheme = (theme: ThemeName) => {
setAppearance(prev => ({
...prev,
theme,
}));
};
const setUseSystemTheme = (use: boolean) => {
setAppearance(prev => ({
...prev,
useSystemTheme: use,
colorScheme: use ? systemColorScheme : prev.colorScheme,
}));
};
const value = {
colorScheme: appearance.colorScheme,
theme: appearance.theme,
useSystemTheme: appearance.useSystemTheme,
systemColorScheme,
colors: Colors[appearance.colorScheme],
setColorScheme,
setTheme,
setUseSystemTheme,
};
if (isLoading) {
// You might want to show a loading indicator here
return null;
}
return (
<AppearanceContext.Provider value={value}>
{children}
</AppearanceContext.Provider>
);
}
export function useAppearance() {
const context = useContext(AppearanceContext);
if (!context) {
throw new Error('useAppearance must be used within an AppearanceProvider');
}
return context;
}
// Utility hook for components that only need colors
export function useColors() {
const { colors } = useAppearance();
return colors;
}

285
contexts/WorkoutContext.tsx Normal file
View File

@ -0,0 +1,285 @@
// contexts/WorkoutContext.tsx
import React, { createContext, useContext, useReducer, useEffect } from 'react';
import { BaseExercise, WorkoutExercise, WorkoutSet } from '@/types/exercise';
import { WorkoutState } from '@/types/workout';
import { WorkoutTemplate } from '@/types/template';
import { generateId } from '@/utils/ids';
type WorkoutAction =
| { type: 'START_WORKOUT'; payload: { title: string } }
| { type: 'END_WORKOUT' }
| { type: 'PAUSE_WORKOUT' }
| { type: 'RESUME_WORKOUT' }
| { type: 'ADD_EXERCISE'; payload: BaseExercise }
| { type: 'UPDATE_EXERCISE'; payload: Partial<WorkoutExercise> & { id: string } }
| { type: 'REMOVE_EXERCISE'; payload: { id: string } }
| { type: 'ADD_SET'; payload: { exerciseId: string; set: WorkoutSet } }
| { type: 'UPDATE_SET'; payload: { exerciseId: string; setId: string; updates: Partial<WorkoutSet> } }
| { type: 'COMPLETE_SET'; payload: { exerciseId: string; setId: string } }
| { type: 'UPDATE_NOTES'; payload: string }
| { type: 'UPDATE_TITLE'; payload: string }
| { type: 'START_FROM_TEMPLATE'; payload: WorkoutTemplate }
| { type: 'RESTORE_STATE'; payload: WorkoutState }
| { type: 'SAVE_TEMPLATE'; payload: WorkoutTemplate };
interface WorkoutContextType extends WorkoutState {
startWorkout: (title: string) => void;
endWorkout: () => Promise<void>;
pauseWorkout: () => void;
resumeWorkout: () => void;
addExercise: (exercise: BaseExercise) => void;
updateExercise: (exerciseId: string, updates: Partial<WorkoutExercise>) => void;
removeExercise: (exerciseId: string) => void;
addSet: (exerciseId: string, set: WorkoutSet) => void;
updateSet: (exerciseId: string, setId: string, updates: Partial<WorkoutSet>) => void;
completeSet: (exerciseId: string, setId: string) => void;
updateNotes: (notes: string) => void;
updateTitle: (title: string) => void;
startFromTemplate: (template: WorkoutTemplate) => void;
saveTemplate: (template: WorkoutTemplate) => Promise<void>;
}
const initialState: WorkoutState = {
id: generateId(),
title: '',
startTime: null,
endTime: null,
isActive: false,
exercises: [],
notes: '',
totalTime: 0,
isPaused: false,
created_at: Date.now(),
totalWeight: 0,
availability: {
source: ['local']
}
};
const WorkoutContext = createContext<WorkoutContextType | null>(null);
function workoutReducer(state: WorkoutState, action: WorkoutAction): WorkoutState {
switch (action.type) {
case 'START_WORKOUT':
return {
...initialState,
id: generateId(),
title: action.payload.title,
startTime: new Date(),
isActive: true,
created_at: Date.now(),
availability: {
source: ['local']
}
};
case 'END_WORKOUT': {
const endTime = new Date();
const totalTime = state.startTime
? Math.floor((endTime.getTime() - state.startTime.getTime()) / 1000)
: 0;
return {
...state,
isActive: false,
endTime,
totalTime
};
}
case 'PAUSE_WORKOUT':
return { ...state, isPaused: true };
case 'RESUME_WORKOUT':
return { ...state, isPaused: false };
case 'ADD_EXERCISE': {
// Convert BaseExercise to WorkoutExercise
const workoutExercise: WorkoutExercise = {
...action.payload,
sets: [],
};
return {
...state,
exercises: [...state.exercises, workoutExercise]
};
}
case 'UPDATE_EXERCISE':
return {
...state,
exercises: state.exercises.map(ex =>
ex.id === action.payload.id
? { ...ex, ...action.payload }
: ex
)
};
case 'REMOVE_EXERCISE':
return {
...state,
exercises: state.exercises.filter(ex => ex.id !== action.payload.id),
totalWeight: state.exercises.reduce((total, ex) => {
if (ex.id === action.payload.id) {
return total - ex.sets.reduce((setTotal, set) =>
setTotal + (set.weight || 0) * (set.reps || 0), 0);
}
return total;
}, state.totalWeight)
};
case 'ADD_SET': {
const newSet = action.payload.set;
const setWeight = (newSet.weight || 0) * (newSet.reps || 0);
return {
...state,
exercises: state.exercises.map(ex =>
ex.id === action.payload.exerciseId
? { ...ex, sets: [...ex.sets, action.payload.set] }
: ex
),
totalWeight: state.totalWeight + setWeight
};
}
case 'UPDATE_SET': {
const exercise = state.exercises.find(ex => ex.id === action.payload.exerciseId);
const oldSet = exercise?.sets.find(set => set.id === action.payload.setId);
const oldWeight = oldSet ? (oldSet.weight || 0) * (oldSet.reps || 0) : 0;
const newWeight = action.payload.updates.weight !== undefined && action.payload.updates.reps !== undefined
? (action.payload.updates.weight || 0) * (action.payload.updates.reps || 0)
: action.payload.updates.weight !== undefined
? (action.payload.updates.weight || 0) * ((oldSet?.reps || 0))
: action.payload.updates.reps !== undefined
? ((oldSet?.weight || 0)) * (action.payload.updates.reps || 0)
: oldWeight;
return {
...state,
exercises: state.exercises.map(ex =>
ex.id === action.payload.exerciseId
? {
...ex,
sets: ex.sets.map(set =>
set.id === action.payload.setId
? { ...set, ...action.payload.updates }
: set
)
}
: ex
),
totalWeight: state.totalWeight - oldWeight + newWeight
};
}
case 'COMPLETE_SET':
return {
...state,
exercises: state.exercises.map(ex =>
ex.id === action.payload.exerciseId
? {
...ex,
sets: ex.sets.map(set =>
set.id === action.payload.setId
? { ...set, isCompleted: !set.isCompleted }
: set
)
}
: ex
)
};
case 'UPDATE_NOTES':
return { ...state, notes: action.payload };
case 'UPDATE_TITLE':
return { ...state, title: action.payload };
case 'START_FROM_TEMPLATE':
const template = action.payload;
return {
...initialState,
id: generateId(),
title: template.title,
startTime: new Date(),
isActive: true,
created_at: Date.now(),
templateSource: {
id: template.id,
title: template.title,
category: template.category
},
availability: {
source: ['local']
}
};
case 'RESTORE_STATE':
return action.payload;
case 'SAVE_TEMPLATE':
return state;
default:
return state;
}
}
export function WorkoutProvider({ children }: { children: React.ReactNode }) {
const [state, dispatch] = useReducer(workoutReducer, initialState);
useEffect(() => {
if (state.isActive) {
const saveState = async () => {
try {
// Save to AsyncStorage implementation pending
} catch (error) {
console.error('Error auto-saving workout:', error);
}
};
const interval = setInterval(saveState, 30000);
return () => clearInterval(interval);
}
}, [state]);
const contextValue: WorkoutContextType = {
...state,
startWorkout: (title) => dispatch({ type: 'START_WORKOUT', payload: { title } }),
endWorkout: async () => {
dispatch({ type: 'END_WORKOUT' });
},
pauseWorkout: () => dispatch({ type: 'PAUSE_WORKOUT' }),
resumeWorkout: () => dispatch({ type: 'RESUME_WORKOUT' }),
addExercise: (exercise) => dispatch({ type: 'ADD_EXERCISE', payload: exercise }),
updateExercise: (id, updates) => dispatch({ type: 'UPDATE_EXERCISE', payload: { id, ...updates } }),
removeExercise: (id) => dispatch({ type: 'REMOVE_EXERCISE', payload: { id } }),
addSet: (exerciseId, set) => dispatch({ type: 'ADD_SET', payload: { exerciseId, set } }),
updateSet: (exerciseId, setId, updates) =>
dispatch({ type: 'UPDATE_SET', payload: { exerciseId, setId, updates } }),
completeSet: (exerciseId, setId) =>
dispatch({ type: 'COMPLETE_SET', payload: { exerciseId, setId } }),
updateNotes: (notes) => dispatch({ type: 'UPDATE_NOTES', payload: notes }),
updateTitle: (title) => dispatch({ type: 'UPDATE_TITLE', payload: title }),
startFromTemplate: (template) => dispatch({ type: 'START_FROM_TEMPLATE', payload: template }),
saveTemplate: async (template) => {
dispatch({ type: 'SAVE_TEMPLATE', payload: template });
}
};
return (
<WorkoutContext.Provider value={contextValue}>
{children}
</WorkoutContext.Provider>
);
}
export function useWorkout() {
const context = useContext(WorkoutContext);
if (!context) {
throw new Error('useWorkout must be used within a WorkoutProvider');
}
return context;
}

195
docs/nostr-exercise-nip.md Normal file
View File

@ -0,0 +1,195 @@
# NIP-XX: Workout Events
`draft` `optional`
This specification defines workout events for fitness tracking. These workout events support both planning (templates) and recording (completed activities).
## Event Kinds
### Exercise Template (kind: 33401)
Defines reusable exercise definitions. These should remain public to enable discovery and sharing. The `content` field contains detailed form instructions and notes.
#### Required Tags
* `d` - UUID for template identification
* `title` - Exercise name
* `format` - Defines data structure for exercise tracking (possible parameters: `weight`, `reps`, `rpe`, `set_type`)
* `format_units` - Defines units for each parameter (possible formats: "kg", "count", "0-10", "warmup|normal|drop|failure")
* `equipment` - Equipment type (possible values: `barbell`, `dumbbell`, `bodyweight`, `machine`, `cardio`)
#### Optional Tags
* `difficulty` - Skill level (possible values: `beginner`, `intermediate`, `advanced`)
* `imeta` - Media metadata for form demonstrations following NIP-92 format
* `t` - Hashtags for categorization such as muscle group or body movement (possible values: `chest`, `legs`, `push`, `pull`)
### Workout Template (kind: 33402)
Defines a complete workout plan. The `content` field contains workout notes and instructions. Workout templates can prescribe specific parameters while leaving others configurable by the user performing the workout.
#### Required Tags
* `d` - UUID for template identification
* `title` - Workout name
* `type` - Type of workout (possible values: `strength`, `circuit`, `emom`, `amrap`)
* `exercise` - Exercise reference and prescription. Format: ["exercise", "kind:pubkey:d-tag", "relay-url", ...parameters matching exercise template format]
#### Optional Tags
* `rounds` - Number of rounds for repeating formats
* `duration` - Total workout duration in seconds
* `interval` - Duration of each exercise portion in seconds (for timed workouts)
* `rest_between_rounds` - Rest time between rounds in seconds
* `t` - Hashtags for categorization
### Workout Record (kind: 33403)
Records a completed workout session. The `content` field contains notes about the workout.
#### Required Tags
* `d` - UUID for record identification
* `title` - Workout name
* `type` - Type of workout (possible values: `strength`, `circuit`, `emom`, `amrap`)
* `exercise` - Exercise reference and completion data. Format: ["exercise", "kind:pubkey:d-tag", "relay-url", ...parameters matching exercise template format]
* `start` - Unix timestamp in seconds for workout start
* `end` - Unix timestamp in seconds for workout end
* `completed` - Boolean indicating if workout was completed as planned
#### Optional Tags
* `rounds_completed` - Number of rounds completed
* `interval` - Duration of each exercise portion in seconds (for timed workouts)
* `pr` - Personal Record achieved during workout. Format: "kind:pubkey:d-tag,metric,value". Used to track when a user achieves their best performance for a given exercise and metric (e.g., heaviest weight lifted, most reps completed, fastest time)
* `t` - Hashtags for categorization
## Exercise Parameters
### Standard Parameters and Units
* `weight` - Load in kilograms (kg). Empty string for bodyweight exercises, negative values for assisted exercises
* `reps` - Number of repetitions (count)
* `rpe` - Rate of Perceived Exertion (0-10):
- RPE 10: Could not do any more reps, technical failure
- RPE 9: Could maybe do 1 more rep
- RPE 8: Could definitely do 1 more rep, maybe 2
- RPE 7: Could do 2-3 more reps
* `duration` - Time in seconds
* `set_type` - Set classification (possible values: `warmup`, `normal`, `drop`, `failure`)
Additional parameters can be defined in exercise templates in the `format_units` tag as needed for specific activities (e.g., distance, heartrate, intensity).
## Workout Types and Terminology
This specification provides examples of common workout structures but is not limited to these types. The format is extensible to support various training methodologies while maintaining consistent data structure.
### Common Workout Types
#### Strength
Traditional strength training focusing on sets and reps with defined weights. Typically includes warm-up sets, working sets, and may include techniques like drop sets or failure sets.
#### Circuit
Multiple exercises performed in sequence with minimal rest between exercises and defined rest periods between rounds. Focuses on maintaining work rate through prescribed exercises.
#### EMOM (Every Minute On the Minute)
Time-based workout where specific exercises are performed at the start of each minute. Rest time is whatever remains in the minute after completing prescribed work.
#### AMRAP (As Many Rounds/Reps As Possible)
Time-capped workout where the goal is to complete as many rounds or repetitions as possible of prescribed exercises while maintaining proper form.
## Set Types
### Normal Sets
Standard working sets that count toward volume and progress tracking.
### Warm-up Sets
Preparatory sets using submaximal weights. These sets are not counted in metrics or progress tracking.
### Drop Sets
Sets performed immediately after a working set with reduced weight. These are counted in volume calculations but tracked separately for progress analysis.
### Failure Sets
Sets where technical failure was reached before completing prescribed reps. These sets are counted in metrics but marked to indicate intensity/failure was reached.
## Examples
### Exercise Template
```json
{
"kind": 33401,
"content": "Stand with feet hip-width apart, barbell over midfoot. Hinge at hips, grip bar outside knees. Flatten back, brace core. Drive through floor, keeping bar close to legs.\n\nForm demonstration: https://powr.me/exercises/deadlift-demo.mp4",
"tags": [
["d", "bb-deadlift-template"],
["title", "Barbell Deadlift"],
["format", "weight", "reps", "rpe", "set_type"],
["format_units", "kg", "count", "0-10", "warmup|normal|drop|failure"],
["equipment", "barbell"],
["difficulty", "intermediate"],
["imeta",
"url https://powr.me/exercises/deadlift-demo.mp4",
"m video/mp4",
"dim 1920x1080",
"alt Demonstration of proper barbell deadlift form"
],
["t", "compound"],
["t", "legs"],
["t", "posterior"]
]
}
```
### EMOM Workout Template
```json
{
"kind": 33402,
"content": "20 minute EMOM alternating between squats and deadlifts every 30 seconds. Scale weight as needed to complete all reps within each interval.",
"tags": [
["d", "lower-body-emom-template"],
["title", "20min Squat/Deadlift EMOM"],
["type", "emom"],
["duration", "1200"],
["rounds", "20"],
["interval", "30"],
["exercise", "33401:9947f9659dd80c3682402b612f5447e28249997fb3709500c32a585eb0977340:bb-back-squat-template", "wss://powr.me", "", "5", "7", "normal"],
["exercise", "33401:9947f9659dd80c3682402b612f5447e28249997fb3709500c32a585eb0977340:bb-deadlift-template", "wss://powr.me", "", "4", "7", "normal"],
["t", "conditioning"],
["t", "legs"]
]
}
```
### Circuit Workout Record
```json
{
"kind": 33403,
"content": "Completed first round as prescribed. Second round showed form deterioration on deadlifts.",
"tags": [
["d", "workout-20250128"],
["title", "Leg Circuit"],
["type", "circuit"],
["rounds_completed", "1.5"],
["start", "1706454000"],
["end", "1706455800"],
// Round 1 - Completed as prescribed
["exercise", "33401:9947f9659dd80c3682402b612f5447e28249997fb3709500c32a585eb0977340:bb-back-squat-template", "wss://powr.me", "80", "12", "7", "normal"],
["exercise", "33401:9947f9659dd80c3682402b612f5447e28249997fb3709500c32a585eb0977340:bb-deadlift-template", "wss://powr.me", "100", "10", "7", "normal"],
// Round 2 - Failed on deadlifts
["exercise", "33401:9947f9659dd80c3682402b612f5447e28249997fb3709500c32a585eb0977340:bb-back-squat-template", "wss://powr.me", "80", "12", "8", "normal"],
["exercise", "33401:9947f9659dd80c3682402b612f5447e28249997fb3709500c32a585eb0977340:bb-deadlift-template", "wss://powr.me", "100", "4", "10", "failure"],
["completed", "false"],
["t", "legs"]
]
}
```
## Implementation Guidelines
1. All workout records SHOULD include accurate start and end times
2. Templates MAY prescribe specific parameters while leaving others as empty strings for user input
3. Records MUST include actual values for all parameters defined in exercise format
4. Failed sets SHOULD be marked with `failure` set_type
5. Records SHOULD be marked as `false` for completed if prescribed work wasn't completed
6. PRs SHOULD only be tracked in workout records, not templates
7. Exercise references SHOULD use the format "kind:pubkey:d-tag" to ensure proper attribution and versioning
## References
This NIP draws inspiration from:
- [NIP-01: Basic Protocol Flow Description](https://github.com/nostr-protocol/nips/blob/master/01.md)
- [NIP-92: Media Attachments](https://github.com/nostr-protocol/nips/blob/master/92.md#nip-92)

View File

@ -0,0 +1,655 @@
# NDK Mobile Implementation References
## File Structure Overview
# NDK Mobile Implementation References
## Core Types and Exports
### Main Entry Point (`src/index.ts`)
```typescript
import '@bacons/text-decoder/install';
import 'react-native-get-random-values';
export * from './hooks';
export * from './cache-adapter/sqlite';
export * from './components';
export * from './components/relays';
export * from '@nostr-dev-kit/ndk';
import NDK from '@nostr-dev-kit/ndk';
export default NDK;
```
### Core Types (`src/types.ts`)
```typescript
export type SettingsStore = {
getSync: (key: string) => string | null;
get: (key: string) => Promise<string | null>;
set: (key: string, value: string) => Promise<void>;
delete: (key: string) => Promise<void>;
}
```
## File Structure Overview
### NDK Mobile Structure (`@nostr-dev-kit/ndk-mobile`)
```plaintext
src/
├── cache-adapter/
│ ├── migrations.ts [REVIEWED] - Database versioning system
│ └── sqlite.ts [REVIEWED] - SQLite implementation
├── hooks/
│ ├── ndk.ts [REVIEWED] - Core NDK hook
│ ├── session.ts [REVIEWED] - Session management
│ ├── subscribe.ts [REVIEWED] - Event subscription
│ ├── user-profile.ts [REVIEWED] - Profile handling
│ └── wallet.ts [REVIEWED] - Wallet integration
├── providers/
│ ├── ndk/
│ │ ├── signers/ [REVIEWED]
│ │ │ ├── nip07.ts - Browser extension auth
│ │ │ ├── nip46.ts - Remote signing
│ │ │ └── pk.ts - Private key handling
│ │ ├── wallet.tsx [REVIEWED] - Wallet provider impl
│ │ └── context.tsx
│ └── session/
│ └── NEED ACCESS
├── stores/
│ ├── ndk.ts [REVIEWED] - NDK state management
│ ├── session/ [REVIEWED] - Session management
│ │ ├── index.ts - Store definition
│ │ ├── types.ts - Type definitions
│ │ └── actions/ - Store actions
│ └── wallet.ts
└── types.ts
```
## Recent Component Analysis
### Authentication & Signing (`providers/ndk/signers/`)
#### NIP-07 Browser Extension (`nip07.ts`)
```typescript
export async function loginWithNip07() {
const signer = new NDKNip07Signer();
return signer.user().then(async (user: NDKUser) => {
if (user.npub) {
return { user, npub: user.npub, signer };
}
});
}
```
#### NIP-46 Remote Signing (`nip46.ts`)
```typescript
export async function withNip46(
ndk: NDK,
token: string,
sk?: string
): Promise<NDKSigner | null> {
let localSigner = sk ?
new NDKPrivateKeySigner(sk) :
NDKPrivateKeySigner.generate();
const signer = new NDKNip46Signer(ndk, token, localSigner);
return signer.blockUntilReady();
}
```
### Wallet Provider (`providers/ndk/wallet.tsx`)
- **Key Features**:
- Wallet state management
- Balance tracking
- Transaction monitoring
- Multi-wallet support (NWC, Cashu)
- **Notable Implementation**:
```typescript
function persistWalletConfiguration(
wallet: NDKWallet,
settingsStore: SettingsStore
) {
const payload = walletPayload(wallet);
const type = wallet.type;
settingsStore.set('wallet', JSON.stringify({ type, payload }));
}
```
## POWR Implementation Patterns
### 1. Authentication Strategy
```typescript
// Support multiple auth methods
export type WorkoutAuthMethod =
| { type: 'nip07' }
| { type: 'nip46'; token: string }
| { type: 'privateKey'; key: string };
// Auth provider wrapper
export const WorkoutAuthProvider = ({
children,
onAuth
}: PropsWithChildren<{
onAuth: (user: NDKUser) => void
}>) => {
// Implementation
};
```
### 2. Session Management
```typescript
export interface WorkoutSession {
currentUser: NDKUser;
activeWorkout?: NDKEvent;
templates: Map<string, NDKEvent>;
history: NDKEvent[];
}
export const WorkoutSessionProvider = ({
children,
session
}: PropsWithChildren<{
session: WorkoutSession
}>) => {
// Implementation
};
```
### Files Still Needed:
1. Session Provider Implementation:
```plaintext
src/providers/session/
├── context.tsx
└── index.tsx
```
2. Wallet Store Implementation:
```plaintext
src/stores/wallet.ts
```# POWR Implementation Resources
## File Structures
### NDK Mobile Structure (`@nostr-dev-kit/ndk-mobile`)
```plaintext
src/
├── cache-adapter/
│ ├── migrations.ts [REVIEWED] - Database versioning system
│ └── sqlite.ts [REVIEWED] - SQLite implementation
├── hooks/
│ ├── ndk.ts [REVIEWED] - Core NDK hook
│ ├── session.ts [REVIEWED] - Session management
│ ├── subscribe.ts [REVIEWED] - Event subscription
│ ├── user-profile.ts [REVIEWED] - Profile handling
│ └── wallet.ts [REVIEWED] - Wallet integration
├── providers/
│ ├── ndk/
│ │ ├── signers/
│ │ │ ├── nip07.ts [REVIEWED] - Browser extension auth
│ │ │ ├── nip46.ts [REVIEWED] - Remote signing
│ │ │ └── pk.ts [REVIEWED] - Private key handling
│ │ └── context.tsx
│ └── session/
├── stores/
│ ├── ndk.ts [REVIEWED] - NDK state management
│ ├── session/
│ │ ├── index.ts [REVIEWED] - Session store
│ │ ├── types.ts [REVIEWED] - Session types
│ │ ├── utils.ts [REVIEWED] - Helper functions
│ │ └── actions/ [REVIEWED]
│ │ ├── addEvent.ts - Event handling
│ │ ├── init.ts - Session initialization
│ │ ├── mutePubkey.ts - User muting
│ │ ├── setEvents.ts - Event management
│ │ ├── setMuteList.ts - Mute list management
│ │ └── wot.ts - Web of trust
│ └── wallet.ts
└── types.ts
```
### Files To Review Next
#### NDK Mobile
1. Provider Implementation:
```plaintext
src/providers/ndk/
├── context.tsx - NDK context setup
└── index.tsx - Provider exports
```
2. Session Provider:
```plaintext
src/providers/session/
├── context.tsx - Session context
└── index.tsx - Session exports
```
#### NDK Core Internals
Looking at specific NDK files would be helpful:
```plaintext
packages/ndk/
├── events/
│ ├── Event.ts - Event implementation
│ └── kinds.ts - Event kind definitions
└── relay/
├── Relay.ts - Relay implementation
└── Pool.ts - Relay pool management
```
```
## Detailed Component Analysis
### 0. Authentication & Signing (NDK)
#### Signer Implementation (`src/providers/ndk/signers/`)
- **Available Signers**:
- NIP-07 (Browser Extension)
- NIP-46 (Remote Signing)
- Private Key
- **Key Implementations**:
```typescript
// NIP-07 Browser Extension
export async function loginWithNip07() {
const signer = new NDKNip07Signer();
return signer.user().then(async (user: NDKUser) => {
if (user.npub) {
return { user, npub: user.npub, signer };
}
});
}
// NIP-46 Remote Signing
export async function withNip46(ndk: NDK, token: string, sk?: string): Promise<NDKSigner | null> {
let localSigner = sk ?
new NDKPrivateKeySigner(sk) :
NDKPrivateKeySigner.generate();
const signer = new NDKNip46Signer(ndk, token, localSigner);
return signer.blockUntilReady();
}
```
- **Payload Handling**:
```typescript
export async function withPayload(
ndk: NDK,
payload: string,
settingsStore: SettingsStore
): Promise<NDKSigner | null> {
if (payload.startsWith('nsec1')) return withPrivateKey(payload);
// NIP-46 handling with local key persistence
}
```
- **POWR Applications**:
- User authentication
- Workout signing
- Template authorization
- Private workout encryption
#### Wallet Integration (`hooks/wallet.ts`)
- **Core Functionality**:
```typescript
const useNDKWallet = () => {
const { ndk } = useNDK();
const activeWallet = useWalletStore(s => s.activeWallet);
const setActiveWallet = (wallet: NDKWallet) => {
storeSetActiveWallet(wallet);
ndk.wallet = wallet;
// Handle persistence
}
return { activeWallet, setActiveWallet, balances };
}
```
- **Features**:
- Wallet state management
- Balance tracking
- Settings persistence
- **POWR Applications**:
- Premium template purchases
- Trainer payments
- Achievement rewards
### 1. Session Store Implementation (NDK)
#### Store Architecture (`stores/index.ts`, `stores/types.ts`)
- **Core State Structure**:
```typescript
export interface SessionState {
follows: string[] | undefined;
muteListEvent: NDKList | undefined;
muteList: Set<Hexpubkey>;
events: Map<NDKKind, NDKEvent[]>;
wot: Map<Hexpubkey, number>;
ndk: NDK | undefined;
}
```
- **Initialization Options**:
```typescript
export interface SessionInitOpts {
follows?: boolean;
muteList?: boolean;
wot?: number | false;
kinds?: Map<NDKKind, { wrapper?: NDKEventWithFrom<any> }>;
filters?: (user: NDKUser) => NDKFilter[];
}
```
#### Event Management (`stores/actions/`)
##### Event Addition (`addEvent.ts`)
```typescript
export const addEvent = (event: NDKEvent, onAdded, set) => {
set((state: SessionState) => {
const kind = event.kind!;
const newEvents = new Map(state.events);
let existing = newEvents.get(kind) || [];
// Handle replaceable events
if (event.isParamReplaceable()) {
// Deduplication logic
}
newEvents.set(kind, [...existing, event]);
return { events: newEvents, ...changes };
});
};
```
- **Key Features**:
- Event deduplication
- Replaceable event handling
- State immutability
##### Session Initialization (`init.ts`)
```typescript
export const initSession = (
ndk: NDK,
user: NDKUser,
settingsStore: SettingsStore,
opts: SessionInitOpts,
on: SessionInitCallbacks,
set,
get
) => {
const filters = generateFilters(user, opts);
const sub = ndk.subscribe(filters, {
groupable: false,
closeOnEose: false
});
// Event handling setup
};
```
- **Features**:
- Filter generation
- Event subscription
- State initialization
#### Web of Trust Implementation (`wot.ts`)
```typescript
export function addWotEntries(
ndk: NDK,
follows: Hexpubkey[],
settingsStore: SettingsStore,
set: (state: Partial<SessionState>) => void,
cb: () => void
) {
// WoT computation implementation
}
```
- **Key Features**:
- Trust score computation
- Cache management
- Persistence strategy
### POWR Adaptation Strategy
#### 1. Workout Session Store
```typescript
export interface WorkoutSessionState {
activeWorkout?: NDKEvent;
templates: Map<NDKKind, NDKEvent[]>;
workoutHistory: NDKEvent[];
follows: Set<Hexpubkey>; // Following trainers/users
trust: Map<Hexpubkey, number>; // Trainer trust scores
}
export interface WorkoutInitOpts {
loadTemplates?: boolean;
loadHistory?: boolean;
watchFollows?: boolean;
trustScoring?: boolean;
}
```
#### 2. Event Management
```typescript
export const addWorkoutEvent = (event: NDKEvent, set) => {
set((state: WorkoutSessionState) => {
switch(event.kind) {
case 33401: // Exercise Template
return handleTemplateEvent(state, event);
case 33402: // Workout Template
return handleWorkoutTemplate(state, event);
case 33403: // Workout Record
return handleWorkoutRecord(state, event);
}
});
};
```
#### 3. Trust System
```typescript
export const computeTrainerTrust = (
trainerId: Hexpubkey,
state: WorkoutSessionState
) => {
// Factors to consider:
// - Number of users following
// - Template usage count
// - Verified credentials
// - User ratings
};
```
### 2. NDK Core Components
#### Cache System (`src/cache-adapter/`)
##### SQLite Adapter (`sqlite.ts`)
- **Key Implementation**:
```typescript
export class NDKCacheAdapterSqlite implements NDKCacheAdapter {
// Core caching functionality
async setEvent(event: NDKEvent, filters: NDKFilter[], relay?: NDKRelay)
async query(subscription: NDKSubscription)
async fetchProfile(pubkey: Hexpubkey)
}
```
- **Notable Features**:
- LRU caching for profiles
- Transaction support
- Batch operation handling
- Event deduplication
- **POWR Applications**:
- Workout template caching
- Performance optimization
- Offline data access
##### Migrations (`migrations.ts`)
- **Key Tables**:
```typescript
export const migrations = [{
version: 0,
up: async (db: SQLite.SQLiteDatabase) => {
// Events table
// Profiles table
// Event tags table
// Relay status table
}
}];
```
- **POWR Adaptation Needed**:
- Add workout-specific tables
- Template versioning
- Exercise history tracking
### 2. Event Handling System
#### Subscription Hook (`src/hooks/subscribe.ts`)
- **Core Interface**:
```typescript
interface UseSubscribeParams {
filters: NDKFilter[] | null;
opts?: {
wrap?: boolean;
bufferMs?: number;
includeMuted?: boolean;
};
}
```
- **Key Features**:
- Event buffering system
- Automatic deduplication
- Muted user filtering
- **POWR Applications**:
- Real-time workout updates
- Social feed implementation
- Template sharing
#### Session Management (`src/hooks/session.ts`)
- **Key Functionality**:
- User authentication
- Event caching
- Profile management
- **Notable Methods**:
```typescript
useNDKSession() {
// Session initialization
// Wallet management
// Event handling
}
```
### 3. Olas Implementation Patterns
#### State Management (`stores/session/`)
- **Core Store Structure**:
```typescript
export const useNDKSessionStore = create<SessionState>()((set, get) => ({
follows: undefined,
events: new Map(),
muteList: new Set(),
// Actions
init: (ndk, user, settingsStore, opts, cb),
addEvent: (event, onAdded),
setEvents: (kind, events)
}));
```
- **Key Features**:
- Immutable state updates
- Action composition
- Event buffering
- **POWR Applications**:
- Workout state management
- Progress tracking
- Social interactions
#### Event Actions (`stores/session/actions/`)
- **Key Implementations**:
- `addEvent.ts`: Event processing and deduplication
- `init.ts`: Session initialization and relay setup
- `setEvents.ts`: Batch event updates
- **Notable Patterns**:
```typescript
export const addEvent = (event: NDKEvent, onAdded, set) => {
set((state: SessionState) => {
// Event processing
// State updates
// Callback handling
});
};
```
#### UI Components (`components/relays/`)
- **Connectivity Indicator**:
```typescript
const CONNECTIVITY_STATUS_COLORS: Record<NDKRelayStatus, string> = {
[NDKRelayStatus.CONNECTED]: '#66cc66',
[NDKRelayStatus.DISCONNECTED]: '#aa4240',
// ...
};
```
- **Usage in POWR**:
- Connection status visualization
- Workout sync indicators
- Real-time feedback
## Files To Review Next
### Priority 1
1. NDK Provider Context:
- `src/providers/ndk/context.tsx`
- Implementation of NDK context
- Provider patterns
2. Database Utilities:
- `apps/mobile/utils/db.ts`
- Database operations
- Sync logic
### Priority 2
1. Form Management:
- `apps/mobile/components/NewPost/store.ts`
- State management patterns
- Form validation
## Implementation Strategy for POWR
### Phase 1: Core Data Layer
1. Adapt NDK's SQLite adapter:
```typescript
export class WorkoutCacheAdapter extends NDKCacheAdapterSqlite {
// Add workout-specific methods
async setWorkoutRecord(workout: NDKEvent): Promise<void>;
async getWorkoutHistory(): Promise<NDKEvent[]>;
async getTemplates(): Promise<NDKEvent[]>;
}
```
### Phase 2: State Management
1. Create workout store using Olas patterns:
```typescript
export const useWorkoutStore = create<WorkoutState>((set, get) => ({
activeWorkout: undefined,
templates: new Map(),
history: [],
// Actions
startWorkout: (template: NDKEvent) => {},
recordSet: (exercise: NDKEvent, weight: number, reps: number) => {},
completeWorkout: () => {}
}));
```
### Phase 3: Event Handling
1. Implement workout-specific subscriptions:
```typescript
export const useWorkoutSubscribe = ({filters, opts}) => {
return useSubscribe({
filters,
opts: {
buffer: true,
includeMuted: false
}
});
};
```
Would you like me to:
1. Add analysis for any specific component?
2. Create example implementations for POWR?
3. Review additional files?

435
docs/powr-restart-plan.md Normal file
View File

@ -0,0 +1,435 @@
# POWR Controlled Restart Plan
## Overview
POWR is transitioning from a traditional fitness tracking application to a decentralized platform utilizing the Nostr protocol. This document outlines the plan for a controlled restart that prioritizes local-first functionality while preparing for Nostr integration.
## Current State Assessment
### Core Functionality
- Workout tracking and management
- Exercise and template library
- Local data storage
- Early Nostr integration attempts
### Key Challenges
1. Data Structure Misalignment
- Complex type hierarchies
- Overlapping interfaces
- Future Nostr integration difficulties
2. State Management
- Growing complexity in WorkoutContext
- Mixed concerns between local and network
- Auto-save and persistence challenges
3. User Experience
- Unclear content ownership
- Complex template management
- Limited sharing capabilities
## Restart Goals
### Primary Objectives
1. Local-First Architecture
- All core features work offline
- Clean data structures
- Clear state management
2. User Experience
- Maintain familiar workout tracking
- Improve template management
- Clear content ownership
3. Nostr Readiness
- Aligned data structures
- Clear sharing model
- Metadata management
## Architecture Decisions
### Content Organization
```plaintext
Library/
├── Exercises/
│ ├── My Exercises
│ ├── Saved Exercises
│ └── Exercise History
├── Workouts/
│ ├── My Templates
│ ├── Saved Templates
│ └── Workout History
└── Programs/ (Coming Soon)
├── Active Programs
├── Saved Programs
└── Program History
```
### User Interface Patterns
1. Content Cards
- Quick preview information
- Source indicators (Local/Nostr)
- Action buttons
2. Preview Modal
- Detailed content view
- Primary actions (Start/Add/Edit)
- Access to technical details
3. Technical Panel
- Nostr metadata
- Event IDs
- Relay information
### Data Flow
```plaintext
Local Storage (Primary)
User Actions
Optional Nostr Sync
```
## Component Architecture
### Component Usage Map
```plaintext
Navigation/
├── Home Tab
│ ├── Quick Start Section
│ │ └── ContentCard (Recent/Favorite Workouts)
│ └── Current Progress
├── Library Tab
│ ├── Exercises Section
│ │ ├── ContentCard (Exercise Templates)
│ │ ├── PreviewModal (Exercise Details)
│ │ └── TechnicalPanel (if Nostr-sourced)
│ │
│ ├── Workouts Section
│ │ ├── ContentCard (Workout Templates)
│ │ ├── PreviewModal (Workout Details)
│ │ └── TechnicalPanel (if Nostr-sourced)
│ │
│ └── Programs Section (Coming Soon)
│ └── ContentCard (Placeholder)
├── Social Tab
│ ├── Discovery Feed
│ │ ├── ContentCard (Shared Templates)
│ │ ├── PreviewModal (Template Details)
│ │ └── TechnicalPanel (Nostr Details)
│ └── User Profiles
└── History Tab
└── ContentCard (Past Workouts)
Active Workout Screen
├── Exercise List
└── ContentCard (Current Exercise)
```
### Core Components
1. Content Display
```plaintext
ContentCard/
├── Preview Image/Icon
├── Basic Info
│ ├── Title
│ ├── Description
│ └── Source Indicator
├── Quick Actions
└── Click Handler → Preview Modal
PreviewModal/
├── Content Details
│ ├── Full Description
│ ├── Exercise List/Details
│ └── Usage Stats
├── Action Buttons
│ ├── Primary (Start/Add)
│ ├── Secondary (Edit/Share)
│ └── Info Button
└── Technical Panel (expandable)
TechnicalPanel/
├── Event Information
├── Relay Details
└── Sharing Status
```
2. Form Components
```plaintext
ExerciseForm/
├── Basic Info
├── Set Configuration
└── Equipment Selection
WorkoutForm/
├── Basic Info
├── Exercise Selection
└── Template Options
```
3. Workout Interface
```plaintext
ActiveWorkout/
├── Exercise List
├── Set Tracking
├── Rest Timer
└── Progress Indicators
```
### User Flows
1. Template Creation
```mermaid
sequenceDiagram
actor User
participant UI
participant Context
participant Storage
User->>UI: Click "Create Template"
UI->>UI: Show Template Form
User->>UI: Fill Details
UI->>Context: Submit Template
Context->>Storage: Save Local Copy
Storage-->>Context: Confirm Save
Context-->>UI: Update Success
UI->>UI: Show Preview Modal
User->>UI: Optional: Share to Nostr
```
### User Flows
1. Creating Template
```mermaid
sequenceDiagram
actor User
participant UI
participant Context
participant Storage
User->>UI: Click "Create Template"
UI->>UI: Show Template Form
User->>UI: Fill Details
UI->>Context: Submit Template
Context->>Storage: Save Local Copy
Storage-->>Context: Confirm Save
Context-->>UI: Update Success
UI->>UI: Show Preview Modal
User->>UI: Optional: Share to Nostr
```
2. Starting Workout from Template
```mermaid
sequenceDiagram
actor User
participant UI
participant Context
participant Storage
User->>UI: Select Template
UI->>UI: Show Preview Modal
User->>UI: Click "Start Workout"
UI->>Context: Initialize Workout
Context->>Storage: Create Session
Storage-->>Context: Session Ready
Context-->>UI: Show Active Workout
User->>UI: Track Exercise Sets
```
3. Saving Discovered Template
```mermaid
sequenceDiagram
actor User
participant UI
participant Context
participant Storage
participant NostrContext
User->>UI: Browse Social Feed
User->>UI: Click Template Card
UI->>NostrContext: Fetch Full Details
NostrContext-->>UI: Template Data
UI->>UI: Show Preview Modal
User->>UI: Click "Save to Library"
UI->>Context: Create Local Copy
Context->>Storage: Save Template
Storage-->>Context: Confirm Save
Context-->>UI: Update Library
UI->>UI: Show Success State
```
4. Sharing Workout
```mermaid
sequenceDiagram
actor User
participant UI
participant Context
participant NostrContext
User->>UI: View Workout Details
User->>UI: Click Share Button
UI->>UI: Show Share Options
User->>UI: Confirm Share
UI->>Context: Get Workout Data
Context->>NostrContext: Create Nostr Event
NostrContext->>NostrContext: Sign Event
NostrContext->>NostrContext: Publish to Relays
NostrContext-->>UI: Share Success
UI->>UI: Update Share Status
```
## Existing Solutions Integration
### NDK Mobile Resources
1. Core Components
- SQLite Cache Adapter for template/workout storage
- Session Management for user profiles and signing
- Event Subscription system for content discovery
- Built-in relay management
2. Implementation Benefits
- Pre-built SQLite schema and migrations
- Automatic event caching and deduplication
- Profile management system
- Connection handling
### Olas Patterns
1. State Management
- Session store patterns
- Event caching approach
- Profile management
2. UI Components
- Event card patterns
- Preview/detail views
- Action handling
3. Event Handling Patterns
- Content discovery
- Template sharing
- Social interactions
## Development Phases and Components
### Phase 1: Core Components Review
#### Components to Reuse
1. Active Workout Interface
- Exercise tracking ✓
- Set logging ✓
- Rest timer ✓
- Basic stats ✓
2. Form Components
- Exercise form ✓
- Basic input fields ✓
- Validation logic ✓
3. Navigation
- Tab structure ✓
- Basic routing ✓
#### Components to Rebuild
1. Content Display
- ContentCard (new design)
- PreviewModal (enhanced)
- TechnicalPanel (new)
2. Library Management
- Template organization
- Content filtering
- Search interface
3. Social Integration
- Discovery feed
- Share interface
- Profile views
### Phase 2: Feature Implementation
#### Week 1-2: Core UI
- [ ] Build ContentCard component
- [ ] Implement PreviewModal
- [ ] Create TechnicalPanel
- [ ] Update navigation flow
#### Week 3-4: Library System
- [ ] Implement new library structure
- [ ] Build template management
- [ ] Add search/filter capabilities
- [ ] Create exercise library
#### Week 5-6: Local Features
- [ ] Complete workout tracking
- [ ] Add template system
- [ ] Implement history
- [ ] Add basic stats
#### Week 7-8: Nostr Integration
- [ ] Add sharing capabilities
- [ ] Implement discovery
- [ ] Build social features
- [ ] Add technical details view
```mermaid
sequenceDiagram
actor User
participant UI
participant Context
participant Storage
User->>UI: Select Template
UI->>UI: Show Preview Modal
User->>UI: Click "Start Workout"
UI->>Context: Initialize Workout
Context->>Storage: Create Session
Storage-->>Context: Session Ready
Context-->>UI: Show Active Workout
User->>UI: Track Exercise Sets
```
## Implementation Strategy
### Phase 1: Core Infrastructure
1. Data Structures
- Define base types
- Implement storage models
- Create type validators
2. State Management
- Implement WorkoutContext
- Setup storage service
- Add auto-save logic
3. UI Components
- Build reusable components
- Implement content cards
- Create preview modals
### Phase 2: Template System
1. Exercise Management
- Exercise creation
- Template storage
- History tracking
2. Workout Templates
- Template creation
- Usage tracking
- Version management
### Phase 3: Nostr Integration
1. Basic Integration
- Event structure alignment
- Local/Nostr bridging
- Sharing mechanics
2. Social Features
- Content discovery
- Template sharing
- User interactions

View File

@ -1 +1,12 @@
export { useColorScheme } from 'react-native'; // hooks/useColorScheme.ts
import { useAppearance } from '@/contexts/AppearanceContext';
import { ThemeColors, ColorScheme, ThemeName } from '@/types/theme';
export function useColorScheme(): {
colorScheme: ColorScheme;
theme: ThemeName;
colors: ThemeColors;
} {
const context = useAppearance();
return context;
}

15
hooks/useFabPosition.ts Normal file
View File

@ -0,0 +1,15 @@
// hooks/useFabPosition.ts
import { useMemo } from 'react';
import { Platform } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
export function useFabPosition() {
const insets = useSafeAreaInsets();
return useMemo(() => ({
bottom: Platform.select({
ios: Math.max(20, insets.bottom) + 65,
android: 20,
}),
}), [insets.bottom]);
}

View File

@ -1,21 +1,17 @@
/** // hooks/useThemeColor.ts
* Learn more about light and dark modes:
* https://docs.expo.dev/guides/color-schemes/
*/
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
import { ColorScheme } from '@/types/theme';
export function useThemeColor( export function useThemeColor(
props: { light?: string; dark?: string }, props: { light?: string; dark?: string },
colorName: keyof typeof Colors.light & keyof typeof Colors.dark colorName: keyof typeof Colors.light & keyof typeof Colors.dark
) { ) {
const theme = useColorScheme() ?? 'light'; const { colorScheme } = useColorScheme();
const colorFromProps = props[theme]; const themeColor = colorScheme as ColorScheme;
if (colorFromProps) { if (props[themeColor]) {
return colorFromProps; return props[themeColor];
} else {
return Colors[theme][colorName];
} }
} return Colors[themeColor][colorName];
}

925
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -15,25 +15,33 @@
"preset": "jest-expo" "preset": "jest-expo"
}, },
"dependencies": { "dependencies": {
"@expo/vector-icons": "^14.0.2", "@expo/vector-icons": "^14.0.4",
"@react-native-async-storage/async-storage": "1.23.1",
"@react-navigation/bottom-tabs": "^7.2.0", "@react-navigation/bottom-tabs": "^7.2.0",
"@react-navigation/native": "^7.0.14", "@react-navigation/native": "^7.0.14",
"@react-navigation/native-stack": "^7.2.0",
"date-fns": "^4.1.0",
"expo": "~52.0.28", "expo": "~52.0.28",
"expo-av": "~15.0.2",
"expo-blur": "~14.0.3", "expo-blur": "~14.0.3",
"expo-constants": "~17.0.5", "expo-constants": "~17.0.5",
"expo-font": "~13.0.3", "expo-font": "~13.0.3",
"expo-haptics": "~14.0.1", "expo-haptics": "~14.0.1",
"expo-linking": "~7.0.5", "expo-linking": "~7.0.5",
"expo-router": "~4.0.17", "expo-router": "^3.5.24",
"expo-splash-screen": "~0.29.21", "expo-splash-screen": "~0.29.21",
"expo-sqlite": "~15.1.1",
"expo-status-bar": "~2.0.1", "expo-status-bar": "~2.0.1",
"expo-symbols": "~0.2.1", "expo-symbols": "~0.2.1",
"expo-system-ui": "~4.0.7", "expo-system-ui": "~4.0.7",
"expo-web-browser": "~14.0.2", "expo-web-browser": "~14.0.2",
"lucide-react-native": "^0.474.0",
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-native": "0.76.6", "react-native": "0.76.6",
"react-native-gesture-handler": "~2.20.2", "react-native-gesture-handler": "~2.20.2",
"react-native-modal": "^13.0.1",
"react-native-pager-view": "^6.7.0",
"react-native-reanimated": "~3.16.1", "react-native-reanimated": "~3.16.1",
"react-native-safe-area-context": "4.12.0", "react-native-safe-area-context": "4.12.0",
"react-native-screens": "~4.4.0", "react-native-screens": "~4.4.0",
@ -45,6 +53,7 @@
"@types/jest": "^29.5.12", "@types/jest": "^29.5.12",
"@types/react": "~18.3.12", "@types/react": "~18.3.12",
"@types/react-test-renderer": "^18.3.0", "@types/react-test-renderer": "^18.3.0",
"babel-plugin-module-resolver": "^5.0.2",
"jest": "^29.2.1", "jest": "^29.2.1",
"jest-expo": "~52.0.3", "jest-expo": "~52.0.3",
"react-test-renderer": "18.3.1", "react-test-renderer": "18.3.1",

314
services/LibraryService.ts Normal file
View File

@ -0,0 +1,314 @@
// services/LibraryService.ts
import { DbService } from '@/utils/db/db-service';
import { generateId } from '@/utils/ids';
import {
BaseExercise,
ExerciseCategory,
Equipment,
LibraryContent
} from '@/types/exercise';
import { WorkoutTemplate } from '@/types/workout';
class LibraryService {
private db: DbService;
constructor() {
this.db = new DbService('powr.db');
}
async addExercise(exercise: Omit<BaseExercise, 'id' | 'created_at' | 'availability'>): Promise<string> {
const id = generateId();
const timestamp = Date.now();
try {
await this.db.withTransaction(async () => {
// Insert main exercise data
await this.db.executeWrite(
`INSERT INTO exercises (
id, title, type, category, equipment, description, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[
id,
exercise.title,
exercise.type,
exercise.category,
exercise.equipment || null,
exercise.description || null,
timestamp,
timestamp
]
);
// Insert instructions if provided
if (exercise.instructions?.length) {
for (const [index, instruction] of exercise.instructions.entries()) {
await this.db.executeWrite(
`INSERT INTO exercise_instructions (
exercise_id, instruction, display_order
) VALUES (?, ?, ?)`,
[id, instruction, index]
);
}
}
// Insert tags if provided
if (exercise.tags?.length) {
for (const tag of exercise.tags) {
await this.db.executeWrite(
`INSERT INTO exercise_tags (exercise_id, tag) VALUES (?, ?)`,
[id, tag]
);
}
}
// Insert format settings if provided
if (exercise.format) {
await this.db.executeWrite(
`INSERT INTO exercise_format (
exercise_id, format_json, units_json
) VALUES (?, ?, ?)`,
[
id,
JSON.stringify(exercise.format),
JSON.stringify(exercise.format_units || {})
]
);
}
});
return id;
} catch (error) {
console.error('Error adding exercise:', error);
throw error;
}
}
async getExercise(id: string): Promise<BaseExercise | null> {
try {
const result = await this.db.executeSql(
`SELECT
e.*,
GROUP_CONCAT(DISTINCT i.instruction) as instructions,
GROUP_CONCAT(DISTINCT t.tag) as tags,
f.format_json,
f.units_json
FROM exercises e
LEFT JOIN exercise_instructions i ON e.id = i.exercise_id
LEFT JOIN exercise_tags t ON e.id = t.exercise_id
LEFT JOIN exercise_format f ON e.id = f.exercise_id
WHERE e.id = ?
GROUP BY e.id`,
[id]
);
if (result.rows.length === 0) return null;
const row = result.rows.item(0);
return {
id: row.id,
title: row.title,
type: row.type,
category: row.category as ExerciseCategory,
equipment: row.equipment as Equipment,
description: row.description,
instructions: row.instructions ? row.instructions.split(',') : [],
tags: row.tags ? row.tags.split(',') : [],
format: row.format_json ? JSON.parse(row.format_json) : undefined,
format_units: row.units_json ? JSON.parse(row.units_json) : undefined,
created_at: row.created_at,
updated_at: row.updated_at,
availability: {
source: ['local']
}
};
} catch (error) {
console.error('Error getting exercise:', error);
throw error;
}
}
async getExercises(category?: ExerciseCategory): Promise<BaseExercise[]> {
try {
const query = `
SELECT
e.*,
GROUP_CONCAT(DISTINCT i.instruction) as instructions,
GROUP_CONCAT(DISTINCT t.tag) as tags,
f.format_json,
f.units_json
FROM exercises e
LEFT JOIN exercise_instructions i ON e.id = i.exercise_id
LEFT JOIN exercise_tags t ON e.id = t.exercise_id
LEFT JOIN exercise_format f ON e.id = f.exercise_id
${category ? 'WHERE e.category = ?' : ''}
GROUP BY e.id
ORDER BY e.title
`;
const result = await this.db.executeSql(query, category ? [category] : []);
return Array.from({ length: result.rows.length }, (_, i) => {
const row = result.rows.item(i);
return {
id: row.id,
title: row.title,
type: row.type,
category: row.category as ExerciseCategory,
equipment: row.equipment as Equipment,
description: row.description,
instructions: row.instructions ? row.instructions.split(',') : [],
tags: row.tags ? row.tags.split(',') : [],
format: row.format_json ? JSON.parse(row.format_json) : undefined,
format_units: row.units_json ? JSON.parse(row.units_json) : undefined,
created_at: row.created_at,
updated_at: row.updated_at,
availability: {
source: ['local']
}
};
});
} catch (error) {
console.error('Error getting exercises:', error);
throw error;
}
}
async getTemplates(): Promise<LibraryContent[]> {
try {
// First get exercises
const exercises = await this.getExercises();
const exerciseContent = exercises.map(exercise => ({
id: exercise.id,
title: exercise.title,
type: 'exercise' as const,
description: exercise.description,
category: exercise.category,
equipment: exercise.equipment,
source: 'local' as const,
tags: exercise.tags,
created_at: exercise.created_at,
availability: {
source: ['local']
}
}));
// Get workout templates
const templatesQuery = `
SELECT
t.*,
GROUP_CONCAT(tt.tag) as tags
FROM templates t
LEFT JOIN template_tags tt ON t.id = tt.template_id
GROUP BY t.id
ORDER BY t.updated_at DESC
`;
const result = await this.db.executeSql(templatesQuery);
const templateContent: LibraryContent[] = Array.from({ length: result.rows.length }, (_, i) => {
const row = result.rows.item(i);
return {
id: row.id,
title: row.title,
type: 'workout' as const,
description: row.description,
author: row.author_name ? {
name: row.author_name,
pubkey: row.author_pubkey
} : undefined,
source: row.source as 'local' | 'pow' | 'nostr',
tags: row.tags ? row.tags.split(',') : [],
created_at: row.created_at,
availability: JSON.parse(row.availability_json)
};
});
return [...exerciseContent, ...templateContent];
} catch (error) {
console.error('Error getting templates:', error);
throw error;
}
}
async getTemplate(id: string): Promise<WorkoutTemplate | null> {
try {
// Get main template data
const templateResult = await this.db.executeSql(
`SELECT t.*, GROUP_CONCAT(tt.tag) as tags
FROM templates t
LEFT JOIN template_tags tt ON t.id = tt.template_id
WHERE t.id = ?
GROUP BY t.id`,
[id]
);
if (templateResult.rows.length === 0) return null;
const templateRow = templateResult.rows.item(0);
const exercises = await this.getTemplateExercises(id);
return {
id: templateRow.id,
title: templateRow.title,
type: templateRow.type,
category: templateRow.category,
description: templateRow.description,
author: templateRow.author_name ? {
name: templateRow.author_name,
pubkey: templateRow.author_pubkey
} : undefined,
exercises,
tags: templateRow.tags ? templateRow.tags.split(',') : [],
rounds: templateRow.rounds,
duration: templateRow.duration,
interval: templateRow.interval_time,
restBetweenRounds: templateRow.rest_between_rounds,
isPublic: Boolean(templateRow.is_public),
created_at: templateRow.created_at,
metadata: templateRow.metadata_json ? JSON.parse(templateRow.metadata_json) : undefined,
availability: JSON.parse(templateRow.availability_json)
};
} catch (error) {
console.error('Error getting template:', error);
throw error;
}
}
private async getTemplateExercises(templateId: string): Promise<Array<{
exercise: BaseExercise;
targetSets?: number;
targetReps?: number;
notes?: string;
}>> {
try {
const result = await this.db.executeSql(
`SELECT te.*, e.*
FROM template_exercises te
JOIN exercises e ON te.exercise_id = e.id
WHERE te.template_id = ?
ORDER BY te.display_order`,
[templateId]
);
return Promise.all(
Array.from({ length: result.rows.length }, async (_, i) => {
const row = result.rows.item(i);
const exercise = await this.getExercise(row.exercise_id);
if (!exercise) throw new Error(`Exercise ${row.exercise_id} not found`);
return {
exercise,
targetSets: row.target_sets,
targetReps: row.target_reps,
notes: row.notes
};
})
);
} catch (error) {
console.error('Error getting template exercises:', error);
throw error;
}
}
}
// Export a singleton instance
export const libraryService = new LibraryService();

19
styles/sharedStyles.ts Normal file
View File

@ -0,0 +1,19 @@
// styles/sharedStyles.ts
import { StyleSheet } from 'react-native';
import { Colors } from '@/constants/Colors';
export const spacing = {
xs: 4,
small: 8,
medium: 16,
large: 24,
xl: 32,
} as const;
export const createThemedStyles = (colors: typeof Colors.light) => StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
},
});

View File

@ -3,9 +3,7 @@
"compilerOptions": { "compilerOptions": {
"strict": true, "strict": true,
"paths": { "paths": {
"@/*": [ "@/*": ["./*"]
"./*"
]
} }
}, },
"include": [ "include": [
@ -14,4 +12,4 @@
".expo/types/**/*.ts", ".expo/types/**/*.ts",
"expo-env.d.ts" "expo-env.d.ts"
] ]
} }

74
types/events.ts Normal file
View File

@ -0,0 +1,74 @@
// types/events.ts
export enum NostrEventKind {
METADATA = 0,
TEXT = 1,
EXERCISE_TEMPLATE = 33401,
WORKOUT_TEMPLATE = 33402,
WORKOUT_RECORD = 33403
}
export interface NostrEvent {
kind: NostrEventKind;
content: string;
tags: string[][];
created_at: number;
pubkey?: string;
id?: string;
sig?: string;
}
export interface NostrMetadata {
id: string;
pubkey: string;
relayUrl: string;
kind?: number;
created_at: number;
}
export interface NostrReference {
id: string;
kind: NostrEventKind;
pubkey: string;
relay?: string;
}
// Utility types for tag handling
export type NostrTag = string[];
export interface NostrTagMap {
d?: string;
title?: string;
type?: string;
category?: string;
equipment?: string;
format?: string;
format_units?: string;
instructions?: string[];
exercise?: string[];
start?: string;
end?: string;
completed?: string;
total_weight?: string;
t?: string[];
[key: string]: string | string[] | undefined;
}
export function getTagValue(tags: NostrTag[], name: string): string | undefined {
return tags.find(tag => tag[0] === name)?.[1];
}
export function getTagValues(tags: NostrTag[], name: string): string[] {
return tags.filter(tag => tag[0] === name).map(tag => tag[1]);
}
export function tagsToMap(tags: NostrTag[]): NostrTagMap {
return tags.reduce((map, tag) => {
const [name, ...values] = tag;
if (values.length === 1) {
map[name] = values[0];
} else {
map[name] = values;
}
return map;
}, {} as NostrTagMap);
}

139
types/exercise.ts Normal file
View File

@ -0,0 +1,139 @@
// types/exercise.ts - handles everything about individual exercises
import { NostrEventKind } from './events';
import { SyncableContent } from './shared';
// Exercise classification types
export type ExerciseType = 'strength' | 'cardio' | 'bodyweight';
export type ExerciseCategory = 'Push' | 'Pull' | 'Legs' | 'Core';
export type Equipment =
| 'bodyweight'
| 'barbell'
| 'dumbbell'
| 'kettlebell'
| 'machine'
| 'cable'
| 'other';
// Base library content interface
export interface LibraryContent extends SyncableContent {
title: string;
type: 'exercise' | 'workout' | 'program';
description?: string;
author?: {
name: string;
pubkey?: string;
};
category?: ExerciseCategory;
equipment?: Equipment;
source: 'local' | 'pow' | 'nostr';
tags: string[];
isPublic?: boolean;
}
// Basic exercise definition
export interface BaseExercise extends SyncableContent {
title: string;
type: ExerciseType;
category: ExerciseCategory;
equipment?: Equipment;
description?: string;
instructions?: string[];
tags: string[];
format?: {
weight?: boolean;
reps?: boolean;
rpe?: boolean;
set_type?: boolean;
};
format_units?: {
weight?: 'kg' | 'lbs';
reps?: 'count';
rpe?: '0-10';
set_type?: 'warmup|normal|drop|failure';
};
}
// Set types and formats
export type SetType = 'warmup' | 'normal' | 'drop' | 'failure';
export interface WorkoutSet {
id: string;
weight?: number;
reps?: number;
rpe?: number;
type: SetType;
isCompleted: boolean;
notes?: string;
timestamp?: number;
}
// Exercise with workout-specific data
export interface WorkoutExercise extends BaseExercise {
sets: WorkoutSet[];
totalWeight?: number;
notes?: string;
restTime?: number; // Rest time in seconds
targetSets?: number;
targetReps?: number;
}
// Exercise template specific types
export interface ExerciseTemplate extends BaseExercise {
defaultSets?: {
type: SetType;
weight?: number;
reps?: number;
rpe?: number;
}[];
recommendations?: {
beginnerWeight?: number;
intermediateWeight?: number;
advancedWeight?: number;
restTime?: number;
tempo?: string;
};
variations?: string[];
progression?: {
type: 'linear' | 'percentage' | 'custom';
increment?: number;
rules?: string[];
};
}
// Exercise history and progress tracking
export interface ExerciseHistory {
exerciseId: string;
entries: Array<{
date: number;
workoutId: string;
sets: WorkoutSet[];
totalWeight: number;
notes?: string;
}>;
personalBests: {
weight?: {
value: number;
date: number;
workoutId: string;
};
reps?: {
value: number;
date: number;
workoutId: string;
};
volume?: {
value: number;
date: number;
workoutId: string;
};
};
}
// Type guards
export function isWorkoutExercise(exercise: any): exercise is WorkoutExercise {
return exercise && Array.isArray(exercise.sets);
}
export function isExerciseTemplate(exercise: any): exercise is ExerciseTemplate {
return exercise && 'recommendations' in exercise;
}

2
types/index.ts Normal file
View File

@ -0,0 +1,2 @@
export * from './events';
export * from './workout';

55
types/shared.ts Normal file
View File

@ -0,0 +1,55 @@
// types/shared.ts
/**
* Available storage sources for content
*/
export type StorageSource = 'local' | 'backup' | 'nostr';
/**
* Nostr sync metadata
*/
export interface NostrSyncMetadata {
timestamp: number;
metadata: {
id: string;
pubkey: string;
relayUrl: string;
created_at: number;
};
}
/**
* Last synced information for different storage sources
*/
export interface LastSyncedInfo {
backup?: number;
nostr?: NostrSyncMetadata;
}
/**
* Content availability information
* Tracks where content is stored and when it was last synced
*/
export interface ContentAvailability {
source: StorageSource[];
lastSynced?: LastSyncedInfo;
}
/**
* Generic content metadata interface
* Can be extended by specific content types
*/
export interface ContentMetadata {
created_at: number;
updated_at?: number;
deleted_at?: number;
version?: number;
}
/**
* Base interface for all syncable content
*/
export interface SyncableContent extends ContentMetadata {
id: string;
availability: ContentAvailability;
}

89
types/sqlite.ts Normal file
View File

@ -0,0 +1,89 @@
// types/sqlite.ts
// Database interfaces
export interface SQLite {
rows: {
_array: any[];
length: number;
item: (idx: number) => any;
};
rowsAffected: number;
insertId?: number;
}
// Transaction interfaces
export interface SQLiteCallback {
(transaction: SQLTransaction, resultSet: SQLite): void;
}
export interface SQLErrorCallback {
(transaction: SQLTransaction, error: Error): boolean;
}
export interface SQLTransaction {
executeSql: (
sqlStatement: string,
args?: (string | number | null)[],
callback?: SQLiteCallback,
errorCallback?: SQLErrorCallback
) => void;
}
// Database error interfaces
export interface SQLError extends Error {
code?: number;
}
// Database open options
export interface SQLiteOpenOptions {
enableChangeListener?: boolean;
useNewConnection?: boolean;
}
// Result interfaces
export interface SQLiteRunResult {
insertId: number;
rowsAffected: number;
}
export interface SQLiteRow {
[key: string]: any;
}
export interface SQLiteResultSet {
insertId?: number;
rowsAffected: number;
rows: {
length: number;
_array: SQLiteRow[];
item: (index: number) => SQLiteRow;
};
}
// Transaction callbacks
export interface TransactionCallback {
(tx: SQLTransaction): void;
}
export interface TransactionErrorCallback {
(error: SQLError): void;
}
export interface TransactionSuccessCallback {
(): void;
}
// Database static type
export interface Database {
transaction(
callback: TransactionCallback,
error?: TransactionErrorCallback,
success?: TransactionSuccessCallback
): void;
readTransaction(
callback: TransactionCallback,
error?: TransactionErrorCallback,
success?: TransactionSuccessCallback
): void;
closeAsync(): Promise<void>;
}

21
types/theme.ts Normal file
View File

@ -0,0 +1,21 @@
// types/theme.ts
export type ColorScheme = 'light' | 'dark';
export type ThemeName = 'default' | 'powr' | 'highContrast';
export interface ThemeColors {
primary: string;
background: string;
cardBg: string;
text: string;
textSecondary: string;
border: string;
tabIconDefault: string;
tabIconSelected: string;
error: string;
}
export interface AppThemeConfig {
theme: ThemeName;
useSystemTheme: boolean;
colorScheme: ColorScheme;
}

166
types/workout.ts Normal file
View File

@ -0,0 +1,166 @@
// types/workout.ts - handles how exercises are combined and tracked in workouts
import { BaseExercise, WorkoutExercise } from './exercise';
export type TemplateCategory =
| 'Full Body'
| 'Custom'
| 'Push/Pull/Legs'
| 'Upper/Lower'
| 'Cardio'
| 'CrossFit'
| 'Strength';
// Base state that all workout types extend
interface BaseWorkoutState {
id: string;
title: string;
description?: string;
notes: string;
created_at: number;
availability: {
source: ('local' | 'nostr' | 'backup')[];
lastSynced?: {
backup?: number;
nostr?: {
timestamp: number;
metadata: {
id: string;
pubkey: string;
relayUrl: string;
created_at: number;
};
};
};
};
}
// Active workout state used in WorkoutContext
export interface WorkoutState extends BaseWorkoutState {
startTime: Date | null;
endTime: Date | null;
isActive: boolean;
exercises: WorkoutExercise[];
totalTime: number;
isPaused: boolean;
totalWeight: number;
templateSource?: {
id: string;
title: string;
category: TemplateCategory;
};
}
// Workout template definition
export interface WorkoutTemplate extends BaseWorkoutState {
type: 'strength' | 'circuit' | 'emom' | 'amrap';
category: TemplateCategory;
exercises: Array<{
exercise: BaseExercise;
targetSets: number;
targetReps: number;
notes?: string;
}>;
author?: {
name: string;
pubkey?: string;
};
metadata?: {
lastUsed?: number;
useCount?: number;
averageDuration?: number;
};
isPublic: boolean;
tags: string[];
rounds?: number;
duration?: number; // in seconds
interval?: number; // in seconds
restBetweenRounds?: number; // in seconds
}
// Completed workout record
export interface WorkoutRecord extends BaseWorkoutState {
exercises: Array<{
exercise: BaseExercise;
sets: Array<{
weight: number;
reps: number;
type: 'warmup' | 'normal' | 'drop' | 'failure';
isCompleted: boolean;
}>;
totalWeight: number;
}>;
startTime: number;
endTime: number;
totalWeight: number;
templateId?: string;
metrics?: {
totalTime: number;
totalVolume: number;
exerciseCount: number;
setCount: number;
personalBests: Array<{
exerciseId: string;
metric: 'weight' | 'reps' | 'volume';
value: number;
}>;
};
}
// Exercise progress tracking
export interface ExerciseProgress {
exerciseId: string;
history: Array<{
date: number;
workoutId: string;
weight: number;
reps: number;
volume: number;
}>;
personalBests: {
weight?: {
value: number;
date: number;
workoutId: string;
};
reps?: {
value: number;
date: number;
workoutId: string;
};
volume?: {
value: number;
date: number;
workoutId: string;
};
};
trends: {
lastMonth: {
workouts: number;
volumeChange: number;
weightChange: number;
};
lastYear: {
workouts: number;
volumeChange: number;
weightChange: number;
};
};
}
// Type guard functions
export function isWorkoutTemplate(workout: any): workout is WorkoutTemplate {
return workout &&
'type' in workout &&
'category' in workout &&
'exercises' in workout &&
Array.isArray(workout.exercises);
}
export function isWorkoutRecord(workout: any): workout is WorkoutRecord {
return workout &&
'startTime' in workout &&
'endTime' in workout &&
'exercises' in workout &&
Array.isArray(workout.exercises);
}

120
utils/db/db-service.ts Normal file
View File

@ -0,0 +1,120 @@
// utils/db/db-service.ts
import * as SQLite from 'expo-sqlite';
import {
SQLite as SQLiteResult,
SQLTransaction,
SQLiteCallback,
SQLErrorCallback,
SQLError,
TransactionCallback,
TransactionErrorCallback,
TransactionSuccessCallback
} from '@/types/sqlite';
export class DbService {
private db: SQLite.SQLiteDatabase;
constructor(dbName: string) {
this.db = SQLite.openDatabaseSync(dbName);
}
async executeSql(sql: string, params: (string | number | null)[] = []): Promise<SQLiteResult> {
return new Promise((resolve, reject) => {
this.db.withTransactionAsync(async (tx) => {
tx.executeSql(
sql,
params,
(_, result) => resolve(result),
(_, error) => {
console.error('SQL Error:', error);
reject(error);
return false;
}
);
}).catch(error => {
console.error('Transaction Error:', error);
reject(error);
});
});
}
async executeWrite(sql: string, params: (string | number | null)[] = []): Promise<SQLiteResult> {
return this.executeSql(sql, params);
}
async executeWriteMany(queries: { sql: string; args?: (string | number | null)[] }[]): Promise<SQLiteResult[]> {
return new Promise((resolve, reject) => {
const results: SQLiteResult[] = [];
this.db.withTransactionAsync(async (tx) => {
try {
for (const query of queries) {
await new Promise<void>((resolveQuery, rejectQuery) => {
tx.executeSql(
query.sql,
query.args || [],
(_, result) => {
results.push(result);
resolveQuery();
},
(_, error) => {
console.error('SQL Error:', error);
rejectQuery(error);
return false;
}
);
});
}
resolve(results);
} catch (error) {
console.error('Transaction Error:', error);
reject(error);
}
}).catch(error => {
console.error('Transaction Error:', error);
reject(error);
});
});
}
async withTransaction<T>(
callback: (tx: SQLTransaction) => Promise<T>
): Promise<T> {
return new Promise((resolve, reject) => {
this.db.withTransactionAsync(async (tx) => {
try {
const result = await callback(tx);
resolve(result);
} catch (error) {
console.error('Transaction Error:', error);
reject(error);
}
}).catch(error => {
console.error('Transaction Error:', error);
reject(error);
});
});
}
async tableExists(tableName: string): Promise<boolean> {
try {
const result = await this.executeSql(
`SELECT name FROM sqlite_master WHERE type='table' AND name=?`,
[tableName]
);
return result.rows.length > 0;
} catch (error) {
console.error('Error checking table existence:', error);
return false;
}
}
async close(): Promise<void> {
try {
await this.db.closeAsync();
} catch (error) {
console.error('Error closing database:', error);
throw error;
}
}
}

39
utils/db/index.ts Normal file
View File

@ -0,0 +1,39 @@
// utils/db/index.ts
import * as SQLite from 'expo-sqlite';
import { Platform } from 'react-native';
let db: SQLite.SQLiteDatabase;
export const getDatabase = () => {
if (db) return db;
if (Platform.OS === 'web') {
// Return in-memory SQLite for web
db = SQLite.openDatabaseSync('powr.db');
} else {
// Use async database for native platforms
db = SQLite.openDatabaseSync('powr.db');
}
return db;
};
export const executeSql = async (
sql: string,
params: any[] = []
): Promise<SQLite.SQLResultSet> => {
return new Promise((resolve, reject) => {
const db = getDatabase();
db.transaction(tx => {
tx.executeSql(
sql,
params,
(_, result) => resolve(result),
(_, error) => {
reject(error);
return true;
}
);
});
});
};

213
utils/db/schema.ts Normal file
View File

@ -0,0 +1,213 @@
// utils/db/schema.ts
import { DbService } from './db-service';
export const SCHEMA_VERSION = 3;
class Schema {
private db: DbService;
constructor() {
this.db = new DbService('powr.db');
}
async createTables(): Promise<void> {
try {
await this.db.executeWriteMany([
// Version tracking
{
sql: `CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY,
updated_at INTEGER NOT NULL
);`
},
// Exercise table with updated fields
{
sql: `CREATE TABLE IF NOT EXISTS exercises (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
type TEXT NOT NULL CHECK(type IN ('strength', 'cardio', 'bodyweight')),
category TEXT NOT NULL CHECK(category IN ('Push', 'Pull', 'Legs', 'Core')),
equipment TEXT CHECK(equipment IN ('bodyweight', 'barbell', 'dumbbell', 'kettlebell', 'machine', 'cable', 'other')),
description TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
source TEXT NOT NULL DEFAULT 'local'
);`
},
// Exercise instructions
{
sql: `CREATE TABLE IF NOT EXISTS exercise_instructions (
exercise_id TEXT NOT NULL,
instruction TEXT NOT NULL,
display_order INTEGER NOT NULL,
FOREIGN KEY(exercise_id) REFERENCES exercises(id) ON DELETE CASCADE
);`
},
// Exercise tags
{
sql: `CREATE TABLE IF NOT EXISTS exercise_tags (
exercise_id TEXT NOT NULL,
tag TEXT NOT NULL,
FOREIGN KEY(exercise_id) REFERENCES exercises(id) ON DELETE CASCADE,
UNIQUE(exercise_id, tag)
);`
},
// Exercise format settings
{
sql: `CREATE TABLE IF NOT EXISTS exercise_format (
exercise_id TEXT PRIMARY KEY,
format_json TEXT NOT NULL,
units_json TEXT,
FOREIGN KEY(exercise_id) REFERENCES exercises(id) ON DELETE CASCADE
);`
},
// Workout templates
{
sql: `CREATE TABLE IF NOT EXISTS templates (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
type TEXT NOT NULL CHECK(type IN ('strength', 'circuit', 'emom', 'amrap')),
category TEXT NOT NULL CHECK(category IN ('Full Body', 'Custom', 'Push/Pull/Legs', 'Upper/Lower', 'Cardio', 'CrossFit', 'Strength')),
description TEXT,
author_name TEXT,
author_pubkey TEXT,
rounds INTEGER,
duration INTEGER,
interval_time INTEGER,
rest_between_rounds INTEGER,
is_public BOOLEAN NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
metadata_json TEXT,
availability_json TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'local'
);`
},
// Template exercises (junction table)
{
sql: `CREATE TABLE IF NOT EXISTS template_exercises (
template_id TEXT NOT NULL,
exercise_id TEXT NOT NULL,
target_sets INTEGER,
target_reps INTEGER,
target_weight REAL,
target_rpe INTEGER CHECK(target_rpe BETWEEN 0 AND 10),
notes TEXT,
display_order INTEGER NOT NULL,
FOREIGN KEY(template_id) REFERENCES templates(id) ON DELETE CASCADE,
FOREIGN KEY(exercise_id) REFERENCES exercises(id) ON DELETE CASCADE,
PRIMARY KEY(template_id, exercise_id, display_order)
);`
},
// Template tags
{
sql: `CREATE TABLE IF NOT EXISTS template_tags (
template_id TEXT NOT NULL,
tag TEXT NOT NULL,
FOREIGN KEY(template_id) REFERENCES templates(id) ON DELETE CASCADE,
UNIQUE(template_id, tag)
);`
}
]);
} catch (error) {
console.error('Error creating tables:', error);
throw error;
}
}
async migrate(): Promise<void> {
const currentVersion = await this.getCurrentVersion();
if (currentVersion < SCHEMA_VERSION) {
if (currentVersion < 1) {
await this.createTables();
await this.setVersion(1);
}
// Migration to version 2 - Add format table
if (currentVersion < 2) {
await this.db.executeWrite(`
CREATE TABLE IF NOT EXISTS exercise_format (
exercise_id TEXT PRIMARY KEY,
format_json TEXT NOT NULL,
units_json TEXT,
FOREIGN KEY(exercise_id) REFERENCES exercises(id) ON DELETE CASCADE
);
`);
await this.setVersion(2);
}
// Migration to version 3 - Add template tables
if (currentVersion < 3) {
await this.db.executeWriteMany([
{
sql: `CREATE TABLE IF NOT EXISTS templates (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
type TEXT NOT NULL CHECK(type IN ('strength', 'circuit', 'emom', 'amrap')),
category TEXT NOT NULL CHECK(category IN ('Full Body', 'Custom', 'Push/Pull/Legs', 'Upper/Lower', 'Cardio', 'CrossFit', 'Strength')),
description TEXT,
author_name TEXT,
author_pubkey TEXT,
rounds INTEGER,
duration INTEGER,
interval_time INTEGER,
rest_between_rounds INTEGER,
is_public BOOLEAN NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
metadata_json TEXT,
availability_json TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'local'
);`
},
{
sql: `CREATE TABLE IF NOT EXISTS template_exercises (
template_id TEXT NOT NULL,
exercise_id TEXT NOT NULL,
target_sets INTEGER,
target_reps INTEGER,
target_weight REAL,
target_rpe INTEGER CHECK(target_rpe BETWEEN 0 AND 10),
notes TEXT,
display_order INTEGER NOT NULL,
FOREIGN KEY(template_id) REFERENCES templates(id) ON DELETE CASCADE,
FOREIGN KEY(exercise_id) REFERENCES exercises(id) ON DELETE CASCADE,
PRIMARY KEY(template_id, exercise_id, display_order)
);`
},
{
sql: `CREATE TABLE IF NOT EXISTS template_tags (
template_id TEXT NOT NULL,
tag TEXT NOT NULL,
FOREIGN KEY(template_id) REFERENCES templates(id) ON DELETE CASCADE,
UNIQUE(template_id, tag)
);`
}
]);
await this.setVersion(3);
}
}
}
private async getCurrentVersion(): Promise<number> {
try {
const result = await this.db.executeSql(
'SELECT version FROM schema_version ORDER BY version DESC LIMIT 1'
);
return result.rows.length > 0 ? result.rows.item(0).version : 0;
} catch (error) {
console.error('Error getting schema version:', error);
return 0;
}
}
private async setVersion(version: number): Promise<void> {
await this.db.executeSql(
'INSERT INTO schema_version (version, updated_at) VALUES (?, ?)',
[version, Date.now()]
);
}
}
export const schema = new Schema();

48
utils/ids.ts Normal file
View File

@ -0,0 +1,48 @@
// utils/ids.ts
/**
* Generates a unique identifier with optional source prefix
* @param source - Optional source identifier ('local' or 'nostr')
* @returns A unique string identifier
*/
export function generateId(source: 'local' | 'nostr' = 'local'): string {
// Generate timestamp and random parts
const timestamp = Date.now().toString(36);
const randomPart = Math.random().toString(36).substring(2, 15);
// For local IDs, use the current format with a prefix
if (source === 'local') {
return `local:${timestamp}-${randomPart}`;
}
// For Nostr-compatible IDs (temporary until we integrate actual Nostr)
// This creates a similar format to Nostr but is clearly marked as temporary
return `nostr:temp:${timestamp}-${randomPart}`;
}
/**
* Checks if an ID is a Nostr event ID or temporary Nostr-format ID
*/
export function isNostrId(id: string): boolean {
return id.startsWith('note1') || id.startsWith('nostr:');
}
/**
* Checks if an ID is a local ID
*/
export function isLocalId(id: string): boolean {
return id.startsWith('local:');
}
/**
* Extracts the timestamp from an ID
*/
export function getTimestampFromId(id: string): number | null {
try {
const parts = id.split(':').pop()?.split('-');
if (!parts?.[0]) return null;
return parseInt(parts[0], 36);
} catch {
return null;
}
}

234
utils/nostr-transformers.ts Normal file
View File

@ -0,0 +1,234 @@
// utils/nostr-transformers.ts
import { generateId } from '@/utils/ids';
import {
NostrEvent,
NostrEventKind,
getTagValue,
getTagValues
} from '@/types/events';
import {
BaseExercise,
WorkoutExercise,
WorkoutSet
} from '@/types/exercise';
import {
WorkoutTemplate,
WorkoutRecord,
TemplateCategory
} from '@/types/workout';
import {
validateNostrExerciseEvent,
validateNostrTemplateEvent,
validateNostrWorkoutEvent
} from './validation';
export function exerciseToNostrEvent(exercise: BaseExercise): NostrEvent {
return {
kind: NostrEventKind.EXERCISE_TEMPLATE,
content: exercise.description || '',
tags: [
['d', exercise.id],
['name', exercise.name],
['type', exercise.type],
['category', exercise.category],
['equipment', exercise.equipment || ''],
...exercise.tags.map(tag => ['t', tag]),
['format', JSON.stringify(exercise.format)],
['format_units', JSON.stringify(exercise.format_units)],
exercise.instructions ? ['instructions', ...exercise.instructions] : []
].filter(tag => tag.length > 0),
created_at: Math.floor(exercise.created_at / 1000)
};
}
export function workoutToNostrEvent(workout: WorkoutRecord): NostrEvent {
const tags: string[][] = [
['d', generateId('nostr')],
['title', workout.title],
['type', 'strength'],
['start', Math.floor(workout.startTime / 1000).toString()],
['end', Math.floor(workout.endTime / 1000).toString()],
['completed', 'true']
];
workout.exercises.forEach(exercise => {
tags.push([
'exercise',
exercise.exercise.id,
exercise.exercise.type,
exercise.exercise.category,
JSON.stringify(exercise.sets.map(set => ({
weight: set.weight,
reps: set.reps,
completed: set.isCompleted
})))
]);
});
if (workout.totalWeight) {
tags.push(['total_weight', workout.totalWeight.toString()]);
}
if (workout.metrics) {
tags.push(['metrics', JSON.stringify(workout.metrics)]);
}
return {
kind: NostrEventKind.WORKOUT_RECORD,
content: workout.notes || '',
created_at: Math.floor(workout.created_at / 1000),
tags
};
}
export function templateToNostrEvent(template: WorkoutTemplate): NostrEvent {
const tags: string[][] = [
['d', generateId()],
['title', template.title],
['category', template.category],
['type', template.type]
];
// Add timing parameters if present
if (template.rounds) tags.push(['rounds', template.rounds.toString()]);
if (template.duration) tags.push(['duration', template.duration.toString()]);
if (template.interval) tags.push(['interval', template.interval.toString()]);
if (template.restBetweenRounds) {
tags.push(['rest_between_rounds', template.restBetweenRounds.toString()]);
}
// Add exercises
template.exercises.forEach(ex => {
tags.push([
'exercise',
ex.exercise.id,
JSON.stringify({
targetSets: ex.targetSets,
targetReps: ex.targetReps,
notes: ex.notes
})
]);
});
// Add template tags
template.tags.forEach(tag => tags.push(['t', tag]));
return {
kind: NostrEventKind.WORKOUT_TEMPLATE,
content: template.description || '',
created_at: Math.floor(template.created_at / 1000),
tags
};
}
export function nostrEventToWorkout(event: NostrEvent): WorkoutRecord {
if (!validateNostrWorkoutEvent(event)) {
throw new Error('Invalid Nostr workout event');
}
const title = getTagValue(event.tags, 'title') || 'Untitled Workout';
const start = parseInt(getTagValue(event.tags, 'start') || '0') * 1000;
const end = parseInt(getTagValue(event.tags, 'end') || '0') * 1000;
const totalWeight = parseInt(getTagValue(event.tags, 'total_weight') || '0');
const exercises = event.tags
.filter(tag => tag[0] === 'exercise')
.map(tag => {
const [_, id, type, category, setsData] = tag;
const sets = JSON.parse(setsData);
return {
exercise: { id } as BaseExercise, // Exercise details to be populated by caller
sets: sets.map((set: any) => ({
weight: set.weight,
reps: set.reps,
isCompleted: set.completed
})),
totalWeight: sets.reduce((total: number, set: any) =>
total + (set.weight || 0) * (set.reps || 0), 0)
};
});
return {
id: event.id || generateId(),
title,
exercises,
startTime: start,
endTime: end,
totalWeight,
notes: event.content,
created_at: event.created_at * 1000,
availability: {
source: ['nostr'],
lastSynced: {
nostr: {
timestamp: Date.now(),
metadata: {
id: event.id!,
pubkey: event.pubkey!,
relayUrl: '',
created_at: event.created_at
}
}
}
}
};
}
export function nostrEventToTemplate(event: NostrEvent): WorkoutTemplate {
if (!validateNostrTemplateEvent(event)) {
throw new Error('Invalid Nostr template event');
}
const title = getTagValue(event.tags, 'title') || 'Untitled Template';
const category = getTagValue(event.tags, 'category') as TemplateCategory;
const type = getTagValue(event.tags, 'type') as WorkoutTemplate['type'];
const exercises = event.tags
.filter(tag => tag[0] === 'exercise')
.map(tag => {
const [_, id, configData] = tag;
const config = JSON.parse(configData);
return {
exercise: { id } as BaseExercise, // Exercise details to be populated by caller
targetSets: config.targetSets,
targetReps: config.targetReps,
notes: config.notes
};
});
return {
id: event.id || generateId(),
title,
type,
description: event.content,
category: category || 'Custom',
exercises,
tags: getTagValues(event.tags, 't'),
rounds: parseInt(getTagValue(event.tags, 'rounds') || '0'),
duration: parseInt(getTagValue(event.tags, 'duration') || '0'),
interval: parseInt(getTagValue(event.tags, 'interval') || '0'),
restBetweenRounds: parseInt(getTagValue(event.tags, 'rest_between_rounds') || '0'),
isPublic: true,
created_at: event.created_at * 1000,
author: {
name: 'Unknown',
pubkey: event.pubkey
},
availability: {
source: ['nostr'],
lastSynced: {
nostr: {
timestamp: Date.now(),
metadata: {
id: event.id!,
pubkey: event.pubkey!,
relayUrl: '',
created_at: event.created_at
}
}
}
}
};
}

68
utils/storage-status.ts Normal file
View File

@ -0,0 +1,68 @@
// utils/storage-status.ts
import { ContentAvailability, StorageSource } from '@/types/shared';
import { formatDistanceToNow } from 'date-fns';
export interface StorageStatus {
icon: string; // Feather icon name
label: string;
color: string;
details?: string;
}
export function getStorageStatus(availability: ContentAvailability): StorageStatus {
const sources = availability.source;
// No sources - remote only content
if (!sources.length) {
return {
icon: 'cloud',
label: 'Remote',
color: 'gray',
details: 'Available online only'
};
}
// Check Nostr status
if (sources.includes('nostr')) {
const nostrData = availability.lastSynced?.nostr;
return {
icon: 'zap',
label: 'Published',
color: 'purple',
details: nostrData ?
`Published ${formatDistanceToNow(nostrData.timestamp)} ago` :
'Published to Nostr'
};
}
// Check backup status
if (sources.includes('backup')) {
const backupTime = availability.lastSynced?.backup;
return {
icon: 'cloud-check',
label: 'Backed Up',
color: 'green',
details: backupTime ?
`Backed up ${formatDistanceToNow(backupTime)} ago` :
'Backed up to cloud'
};
}
// Local only
return {
icon: 'hard-drive',
label: 'Local',
color: 'orange',
details: 'Stored on device only'
};
}
export function shouldPromptForBackup(availability: ContentAvailability): boolean {
// Prompt if content is only local and hasn't been backed up in last 24 hours
return (
availability.source.length === 1 &&
availability.source[0] === 'local' &&
(!availability.lastSynced?.backup ||
Date.now() - availability.lastSynced.backup > 24 * 60 * 60 * 1000)
);
}

84
utils/validation.ts Normal file
View File

@ -0,0 +1,84 @@
// utils/validation.ts
import { NostrEvent, NostrEventKind, NostrTag, getTagValue, getTagValues } from '@/types/events';
function validateBasicEventStructure(event: NostrEvent): boolean {
return !!(event.id &&
event.pubkey &&
event.kind &&
event.created_at &&
Array.isArray(event.tags));
}
function validateExerciseTag(tag: NostrTag): boolean {
// Exercise tag format: ['exercise', id, type, category, setsData]
if (tag.length < 5) return false;
try {
const setsData = JSON.parse(tag[4]);
return Array.isArray(setsData) && setsData.every(set =>
typeof set === 'object' &&
('weight' in set || 'reps' in set || 'type' in set)
);
} catch {
return false;
}
}
export function validateNostrExerciseEvent(event: NostrEvent): boolean {
if (!validateBasicEventStructure(event)) return false;
if (event.kind !== NostrEventKind.EXERCISE_TEMPLATE) return false;
const requiredTags = ['d', 'name', 'type', 'category'];
const tagMap = new Set(event.tags.map(tag => tag[0]));
if (!requiredTags.every(tag => tagMap.has(tag))) return false;
const format = getTagValue(event.tags, 'format');
if (format) {
try {
const formatObj = JSON.parse(format);
if (typeof formatObj !== 'object') return false;
} catch {
return false;
}
}
return true;
}
export function validateNostrTemplateEvent(event: NostrEvent): boolean {
if (!validateBasicEventStructure(event)) return false;
if (event.kind !== NostrEventKind.WORKOUT_TEMPLATE) return false;
const requiredTags = ['d', 'title', 'type', 'category'];
const tagMap = new Set(event.tags.map(tag => tag[0]));
if (!requiredTags.every(tag => tagMap.has(tag))) return false;
// Validate exercise tags
const exerciseTags = event.tags.filter(tag => tag[0] === 'exercise');
if (exerciseTags.length === 0) return false;
return exerciseTags.every(validateExerciseTag);
}
export function validateNostrWorkoutEvent(event: NostrEvent): boolean {
if (!validateBasicEventStructure(event)) return false;
if (event.kind !== NostrEventKind.WORKOUT_RECORD) return false;
const requiredTags = ['d', 'title', 'type', 'start', 'end', 'completed'];
const tagMap = new Set(event.tags.map(tag => tag[0]));
if (!requiredTags.every(tag => tagMap.has(tag))) return false;
// Validate timestamps
const start = parseInt(getTagValue(event.tags, 'start') || '');
const end = parseInt(getTagValue(event.tags, 'end') || '');
if (isNaN(start) || isNaN(end) || start > end) return false;
// Validate exercise tags
const exerciseTags = event.tags.filter(tag => tag[0] === 'exercise');
if (exerciseTags.length === 0) return false;
return exerciseTags.every(validateExerciseTag);
}