mirror of
https://github.com/DocNR/POWR.git
synced 2025-04-19 19:01:18 +00:00
Initial commit: POWR rebuild with React Native and Expo
This commit is contained in:
parent
d06c90d922
commit
e7ed5374c1
78
CHANGELOG.md
Normal file
78
CHANGELOG.md
Normal 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
|
13
app.json
13
app.json
@ -5,9 +5,14 @@
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "myapp",
|
||||
"scheme": "powr",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"newArchEnabled": true,
|
||||
"assets": [
|
||||
"assets/fonts",
|
||||
"assets/images",
|
||||
"assets/videos"
|
||||
],
|
||||
"ios": {
|
||||
"supportsTablet": true
|
||||
},
|
||||
@ -22,6 +27,9 @@
|
||||
"output": "static",
|
||||
"favicon": "./assets/images/favicon.png"
|
||||
},
|
||||
"dependencies": {
|
||||
"react-native-pager-view": "6.2.0"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
[
|
||||
@ -32,7 +40,8 @@
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
}
|
||||
]
|
||||
],
|
||||
"expo-sqlite"
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
|
@ -1,45 +1,77 @@
|
||||
import { Tabs } from 'expo-router';
|
||||
// app/(tabs)/_layout.tsx
|
||||
import React from 'react';
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
import { HapticTab } from '@/components/HapticTab';
|
||||
import { IconSymbol } from '@/components/ui/IconSymbol';
|
||||
import TabBarBackground from '@/components/ui/TabBarBackground';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { Tabs } from 'expo-router';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { Dumbbell, Library, Users, History, User } from 'lucide-react-native';
|
||||
|
||||
export default function TabLayout() {
|
||||
const colorScheme = useColorScheme();
|
||||
const { colors } = useColorScheme();
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint,
|
||||
headerShown: false,
|
||||
tabBarButton: HapticTab,
|
||||
tabBarBackground: TabBarBackground,
|
||||
tabBarStyle: Platform.select({
|
||||
ios: {
|
||||
// Use a transparent background on iOS to show the blur effect
|
||||
position: 'absolute',
|
||||
},
|
||||
default: {},
|
||||
}),
|
||||
tabBarStyle: {
|
||||
backgroundColor: colors.background,
|
||||
borderTopColor: colors.border,
|
||||
borderTopWidth: Platform.OS === 'ios' ? 0.5 : 1,
|
||||
elevation: 0,
|
||||
shadowOpacity: 0,
|
||||
},
|
||||
tabBarActiveTintColor: colors.primary,
|
||||
tabBarInactiveTintColor: colors.textSecondary,
|
||||
tabBarShowLabel: true,
|
||||
tabBarLabelStyle: {
|
||||
fontSize: 12,
|
||||
marginBottom: Platform.OS === 'ios' ? 0 : 4,
|
||||
},
|
||||
}}>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: 'Home',
|
||||
tabBarIcon: ({ color }) => <IconSymbol size={28} name="house.fill" color={color} />,
|
||||
title: 'Workout',
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Dumbbell size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="explore"
|
||||
name="library"
|
||||
options={{
|
||||
title: 'Explore',
|
||||
tabBarIcon: ({ color }) => <IconSymbol size={28} name="paperplane.fill" color={color} />,
|
||||
title: 'Library',
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
@ -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
9
app/(tabs)/history.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -1,74 +1,9 @@
|
||||
import { Image, StyleSheet, Platform } from 'react-native';
|
||||
|
||||
import { HelloWave } from '@/components/HelloWave';
|
||||
import ParallaxScrollView from '@/components/ParallaxScrollView';
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { ThemedView } from '@/components/ThemedView';
|
||||
import { View, Text } from 'react-native';
|
||||
|
||||
export default function HomeScreen() {
|
||||
return (
|
||||
<ParallaxScrollView
|
||||
headerBackgroundColor={{ light: '#A1CEDC', dark: '#1D3D47' }}
|
||||
headerImage={
|
||||
<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>
|
||||
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Text>Home Screen</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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
350
app/(tabs)/library.tsx
Normal 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
9
app/(tabs)/profile.tsx
Normal 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
9
app/(tabs)/social.tsx
Normal 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>
|
||||
);
|
||||
}
|
340
app/(workout)/add-exercises.tsx
Normal file
340
app/(workout)/add-exercises.tsx
Normal 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',
|
||||
},
|
||||
});
|
351
app/(workout)/create-template.tsx
Normal file
351
app/(workout)/create-template.tsx
Normal 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;
|
226
app/(workout)/new-exercise.tsx
Normal file
226
app/(workout)/new-exercise.tsx
Normal 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,
|
||||
},
|
||||
});
|
138
app/_layout.tsx
138
app/_layout.tsx
@ -1,39 +1,127 @@
|
||||
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
|
||||
import { useFonts } from 'expo-font';
|
||||
// app/_layout.tsx
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Platform } from 'react-native';
|
||||
import { Stack } from 'expo-router';
|
||||
import * as SplashScreen from 'expo-splash-screen';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { useEffect } from 'react';
|
||||
import 'react-native-reanimated';
|
||||
|
||||
import { NavigationContainer } from '@react-navigation/native';
|
||||
import { AppearanceProvider } from '@/contexts/AppearanceContext';
|
||||
import { WorkoutProvider } from '@/contexts/WorkoutContext';
|
||||
import { schema } from '@/utils/db/schema';
|
||||
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.
|
||||
SplashScreen.preventAutoHideAsync();
|
||||
// Prevent auto-hide of splash screen
|
||||
ExpoSplashScreen.preventAutoHideAsync();
|
||||
|
||||
export default function RootLayout() {
|
||||
const colorScheme = useColorScheme();
|
||||
const [loaded] = useFonts({
|
||||
SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'),
|
||||
});
|
||||
function RootLayoutNav() {
|
||||
const { colors } = useColorScheme();
|
||||
const [isReady, setIsReady] = React.useState(false);
|
||||
const [showSplash, setShowSplash] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (loaded) {
|
||||
SplashScreen.hideAsync();
|
||||
async function initializeApp() {
|
||||
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;
|
||||
}
|
||||
|
||||
if (showSplash) {
|
||||
return <SplashScreen onAnimationComplete={onSplashAnimationComplete} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||
<Stack>
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="+not-found" />
|
||||
</Stack>
|
||||
<StatusBar style="auto" />
|
||||
</ThemeProvider>
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerStyle: {
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
headerTintColor: colors.text,
|
||||
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
BIN
assets/videos/splash.mov
Normal file
Binary file not shown.
16
babel.config.js
Normal file
16
babel.config.js
Normal file
@ -0,0 +1,16 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ['babel-preset-expo'],
|
||||
plugins: [
|
||||
[
|
||||
'module-resolver',
|
||||
{
|
||||
alias: {
|
||||
'@': '.',
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
};
|
@ -1,33 +1,41 @@
|
||||
// components/Collapsible.tsx
|
||||
import { PropsWithChildren, useState } from 'react';
|
||||
import { StyleSheet, TouchableOpacity } from 'react-native';
|
||||
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { ThemedView } from '@/components/ThemedView';
|
||||
import { IconSymbol } from '@/components/ui/IconSymbol';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
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 theme = useColorScheme() ?? 'light';
|
||||
const { colors } = useColorScheme();
|
||||
|
||||
return (
|
||||
<ThemedView>
|
||||
<TouchableOpacity
|
||||
style={styles.heading}
|
||||
onPress={() => setIsOpen((value) => !value)}
|
||||
activeOpacity={0.8}>
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<IconSymbol
|
||||
name="chevron.right"
|
||||
size={18}
|
||||
weight="medium"
|
||||
color={theme === 'light' ? Colors.light.icon : Colors.dark.icon}
|
||||
color={colors.text}
|
||||
style={{ transform: [{ rotate: isOpen ? '90deg' : '0deg' }] }}
|
||||
/>
|
||||
|
||||
<ThemedText type="defaultSemiBold">{title}</ThemedText>
|
||||
<ThemedText style={styles.title}>{title}</ThemedText>
|
||||
</TouchableOpacity>
|
||||
{isOpen && <ThemedView style={styles.content}>{children}</ThemedView>}
|
||||
|
||||
{isOpen && (
|
||||
<ThemedView style={styles.content}>
|
||||
{children}
|
||||
</ThemedView>
|
||||
)}
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
@ -42,4 +50,8 @@ const styles = StyleSheet.create({
|
||||
marginTop: 6,
|
||||
marginLeft: 24,
|
||||
},
|
||||
});
|
||||
title: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
129
components/EditableText.tsx
Normal file
129
components/EditableText.tsx
Normal 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,
|
||||
},
|
||||
});
|
@ -1,24 +1,47 @@
|
||||
import { Link } from 'expo-router';
|
||||
import { openBrowserAsync } from 'expo-web-browser';
|
||||
// components/ExternalLink.tsx
|
||||
import { Link, Href } from 'expo-router';
|
||||
import * as WebBrowser from 'expo-web-browser';
|
||||
import { type ComponentProps } from 'react';
|
||||
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) {
|
||||
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 (
|
||||
<Link
|
||||
target="_blank"
|
||||
{...rest}
|
||||
href={href}
|
||||
onPress={async (event) => {
|
||||
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);
|
||||
}
|
||||
}}
|
||||
onPress={handlePress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
@ -23,7 +23,7 @@ export default function ParallaxScrollView({
|
||||
headerImage,
|
||||
headerBackgroundColor,
|
||||
}: Props) {
|
||||
const colorScheme = useColorScheme() ?? 'light';
|
||||
const { colorScheme } = useColorScheme();
|
||||
const scrollRef = useAnimatedRef<Animated.ScrollView>();
|
||||
const scrollOffset = useScrollViewOffset(scrollRef);
|
||||
const bottom = useBottomTabOverflow();
|
||||
@ -54,7 +54,7 @@ export default function ParallaxScrollView({
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.header,
|
||||
{ backgroundColor: headerBackgroundColor[colorScheme] },
|
||||
{ backgroundColor: headerBackgroundColor[colorScheme as 'light' | 'dark'] },
|
||||
headerAnimatedStyle,
|
||||
]}>
|
||||
{headerImage}
|
||||
|
77
components/SplashScreen.tsx
Normal file
77
components/SplashScreen.tsx
Normal 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
62
components/TabLayout.tsx
Normal 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,
|
||||
},
|
||||
});
|
@ -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 & {
|
||||
lightColor?: string;
|
||||
darkColor?: string;
|
||||
type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link';
|
||||
};
|
||||
interface ThemedTextProps extends TextProps {
|
||||
type?: TextType;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ThemedText({
|
||||
style,
|
||||
lightColor,
|
||||
darkColor,
|
||||
type = 'default',
|
||||
...rest
|
||||
export function ThemedText({
|
||||
style,
|
||||
type = 'default',
|
||||
children,
|
||||
...props
|
||||
}: 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 (
|
||||
<Text
|
||||
style={[
|
||||
{ color },
|
||||
type === 'default' ? styles.default : undefined,
|
||||
type === 'title' ? styles.title : undefined,
|
||||
type === 'defaultSemiBold' ? styles.defaultSemiBold : undefined,
|
||||
type === 'subtitle' ? styles.subtitle : undefined,
|
||||
type === 'link' ? styles.link : undefined,
|
||||
style,
|
||||
]}
|
||||
{...rest}
|
||||
/>
|
||||
<Text
|
||||
style={[baseStyle, typeStyle, style]}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
default: {
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
},
|
||||
defaultSemiBold: {
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
fontWeight: '600',
|
||||
},
|
||||
title: {
|
||||
fontSize: 32,
|
||||
fontWeight: 'bold',
|
||||
lineHeight: 32,
|
||||
fontSize: 20,
|
||||
fontWeight: '600',
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
link: {
|
||||
lineHeight: 30,
|
||||
fontSize: 16,
|
||||
color: '#0a7ea4',
|
||||
textDecorationLine: 'underline',
|
||||
},
|
||||
});
|
||||
error: {
|
||||
fontSize: 14,
|
||||
},
|
||||
});
|
140
components/form/Button.tsx
Normal file
140
components/form/Button.tsx
Normal 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
77
components/form/Input.tsx
Normal 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
207
components/form/Select.tsx
Normal 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,
|
||||
},
|
||||
});
|
114
components/library/AddContentModal.tsx
Normal file
114
components/library/AddContentModal.tsx
Normal 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,
|
||||
},
|
||||
});
|
188
components/library/ContentPreviewModal.tsx
Normal file
188
components/library/ContentPreviewModal.tsx
Normal 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',
|
||||
},
|
||||
});
|
118
components/library/Discover.tsx
Normal file
118
components/library/Discover.tsx
Normal 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',
|
||||
},
|
||||
});
|
193
components/library/FilterSheet.tsx
Normal file
193
components/library/FilterSheet.tsx
Normal 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',
|
||||
},
|
||||
});
|
139
components/library/LibraryContentCard.tsx
Normal file
139
components/library/LibraryContentCard.tsx
Normal 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',
|
||||
},
|
||||
});
|
120
components/library/MyLibrary.tsx
Normal file
120
components/library/MyLibrary.tsx
Normal 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',
|
||||
},
|
||||
});
|
119
components/library/Programs.tsx
Normal file
119
components/library/Programs.tsx
Normal 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',
|
||||
},
|
||||
});
|
64
components/library/SearchBar.tsx
Normal file
64
components/library/SearchBar.tsx
Normal 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
14
components/pager/index.ts
Normal 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;
|
8
components/pager/pager.native.tsx
Normal file
8
components/pager/pager.native.tsx
Normal 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;
|
58
components/pager/pager.web.tsx
Normal file
58
components/pager/pager.web.tsx
Normal 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
20
components/pager/types.ts
Normal 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;
|
||||
}
|
86
components/shared/FloatingActionButton.tsx
Normal file
86
components/shared/FloatingActionButton.tsx
Normal 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,
|
||||
},
|
||||
});
|
@ -3,7 +3,7 @@
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { SymbolWeight } from 'expo-symbols';
|
||||
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.
|
||||
const MAPPING = {
|
||||
@ -36,7 +36,7 @@ export function IconSymbol({
|
||||
name: IconSymbolName;
|
||||
size?: number;
|
||||
color: string | OpaqueColorValue;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
style?: StyleProp<TextStyle>;
|
||||
weight?: SymbolWeight;
|
||||
}) {
|
||||
return <MaterialIcons color={color} size={size} name={MAPPING[name]} style={style} />;
|
||||
|
@ -1,26 +1,30 @@
|
||||
/**
|
||||
* Below are the colors that are used in the app. The colors are defined in the light and dark mode.
|
||||
* 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.
|
||||
*/
|
||||
// constants/Colors.ts
|
||||
import { ThemeColors } from '@/types/theme';
|
||||
|
||||
const tintColorLight = '#0a7ea4';
|
||||
const tintColorDark = '#fff';
|
||||
const tintColorLight = '#2563eb';
|
||||
const tintColorDark = '#60a5fa';
|
||||
|
||||
export const Colors = {
|
||||
export const Colors: Record<string, ThemeColors> = {
|
||||
light: {
|
||||
text: '#11181C',
|
||||
background: '#fff',
|
||||
tint: tintColorLight,
|
||||
icon: '#687076',
|
||||
tabIconDefault: '#687076',
|
||||
primary: tintColorLight,
|
||||
background: '#ffffff',
|
||||
cardBg: '#f3f4f6',
|
||||
text: '#1f2937',
|
||||
textSecondary: '#6b7280',
|
||||
border: '#e5e7eb',
|
||||
tabIconDefault: '#6b7280',
|
||||
tabIconSelected: tintColorLight,
|
||||
error: '#dc2626', // Added error color (red-600)
|
||||
},
|
||||
dark: {
|
||||
text: '#ECEDEE',
|
||||
background: '#151718',
|
||||
tint: tintColorDark,
|
||||
icon: '#9BA1A6',
|
||||
tabIconDefault: '#9BA1A6',
|
||||
primary: tintColorDark,
|
||||
background: '#1f2937',
|
||||
cardBg: '#374151',
|
||||
text: '#f3f4f6',
|
||||
textSecondary: '#9ca3af',
|
||||
border: '#4b5563',
|
||||
tabIconDefault: '#9ca3af',
|
||||
tabIconSelected: tintColorDark,
|
||||
error: '#ef4444', // Added error color (red-500, slightly lighter for dark mode)
|
||||
},
|
||||
};
|
||||
};
|
132
contexts/AppearanceContext.tsx
Normal file
132
contexts/AppearanceContext.tsx
Normal 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
285
contexts/WorkoutContext.tsx
Normal 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
195
docs/nostr-exercise-nip.md
Normal 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)
|
655
docs/olas-ndk-mobile-resources.md
Normal file
655
docs/olas-ndk-mobile-resources.md
Normal 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
435
docs/powr-restart-plan.md
Normal 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
|
@ -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
15
hooks/useFabPosition.ts
Normal 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]);
|
||||
}
|
@ -1,21 +1,17 @@
|
||||
/**
|
||||
* Learn more about light and dark modes:
|
||||
* https://docs.expo.dev/guides/color-schemes/
|
||||
*/
|
||||
|
||||
// hooks/useThemeColor.ts
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { ColorScheme } from '@/types/theme';
|
||||
|
||||
export function useThemeColor(
|
||||
props: { light?: string; dark?: string },
|
||||
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
|
||||
) {
|
||||
const theme = useColorScheme() ?? 'light';
|
||||
const colorFromProps = props[theme];
|
||||
|
||||
if (colorFromProps) {
|
||||
return colorFromProps;
|
||||
} else {
|
||||
return Colors[theme][colorName];
|
||||
const { colorScheme } = useColorScheme();
|
||||
const themeColor = colorScheme as ColorScheme;
|
||||
|
||||
if (props[themeColor]) {
|
||||
return props[themeColor];
|
||||
}
|
||||
}
|
||||
return Colors[themeColor][colorName];
|
||||
}
|
925
package-lock.json
generated
925
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@ -15,25 +15,33 @@
|
||||
"preset": "jest-expo"
|
||||
},
|
||||
"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/native": "^7.0.14",
|
||||
"@react-navigation/native-stack": "^7.2.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"expo": "~52.0.28",
|
||||
"expo-av": "~15.0.2",
|
||||
"expo-blur": "~14.0.3",
|
||||
"expo-constants": "~17.0.5",
|
||||
"expo-font": "~13.0.3",
|
||||
"expo-haptics": "~14.0.1",
|
||||
"expo-linking": "~7.0.5",
|
||||
"expo-router": "~4.0.17",
|
||||
"expo-router": "^3.5.24",
|
||||
"expo-splash-screen": "~0.29.21",
|
||||
"expo-sqlite": "~15.1.1",
|
||||
"expo-status-bar": "~2.0.1",
|
||||
"expo-symbols": "~0.2.1",
|
||||
"expo-system-ui": "~4.0.7",
|
||||
"expo-web-browser": "~14.0.2",
|
||||
"lucide-react-native": "^0.474.0",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-native": "0.76.6",
|
||||
"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-safe-area-context": "4.12.0",
|
||||
"react-native-screens": "~4.4.0",
|
||||
@ -45,6 +53,7 @@
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/react": "~18.3.12",
|
||||
"@types/react-test-renderer": "^18.3.0",
|
||||
"babel-plugin-module-resolver": "^5.0.2",
|
||||
"jest": "^29.2.1",
|
||||
"jest-expo": "~52.0.3",
|
||||
"react-test-renderer": "18.3.1",
|
||||
|
314
services/LibraryService.ts
Normal file
314
services/LibraryService.ts
Normal 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
19
styles/sharedStyles.ts
Normal 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,
|
||||
},
|
||||
|
||||
});
|
@ -3,9 +3,7 @@
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
@ -14,4 +12,4 @@
|
||||
".expo/types/**/*.ts",
|
||||
"expo-env.d.ts"
|
||||
]
|
||||
}
|
||||
}
|
74
types/events.ts
Normal file
74
types/events.ts
Normal 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
139
types/exercise.ts
Normal 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
2
types/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './events';
|
||||
export * from './workout';
|
55
types/shared.ts
Normal file
55
types/shared.ts
Normal 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
89
types/sqlite.ts
Normal 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
21
types/theme.ts
Normal 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
166
types/workout.ts
Normal 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
120
utils/db/db-service.ts
Normal 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
39
utils/db/index.ts
Normal 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
213
utils/db/schema.ts
Normal 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
48
utils/ids.ts
Normal 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
234
utils/nostr-transformers.ts
Normal 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
68
utils/storage-status.ts
Normal 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
84
utils/validation.ts
Normal 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);
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user