mirror of
https://github.com/DocNR/POWR.git
synced 2025-04-23 01:01:27 +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",
|
"version": "1.0.0",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "myapp",
|
"scheme": "powr",
|
||||||
"userInterfaceStyle": "automatic",
|
"userInterfaceStyle": "automatic",
|
||||||
"newArchEnabled": true,
|
"newArchEnabled": true,
|
||||||
|
"assets": [
|
||||||
|
"assets/fonts",
|
||||||
|
"assets/images",
|
||||||
|
"assets/videos"
|
||||||
|
],
|
||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": true
|
"supportsTablet": true
|
||||||
},
|
},
|
||||||
@ -22,6 +27,9 @@
|
|||||||
"output": "static",
|
"output": "static",
|
||||||
"favicon": "./assets/images/favicon.png"
|
"favicon": "./assets/images/favicon.png"
|
||||||
},
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react-native-pager-view": "6.2.0"
|
||||||
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"expo-router",
|
"expo-router",
|
||||||
[
|
[
|
||||||
@ -32,7 +40,8 @@
|
|||||||
"resizeMode": "contain",
|
"resizeMode": "contain",
|
||||||
"backgroundColor": "#ffffff"
|
"backgroundColor": "#ffffff"
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"expo-sqlite"
|
||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
"typedRoutes": true
|
"typedRoutes": true
|
||||||
|
@ -1,45 +1,77 @@
|
|||||||
import { Tabs } from 'expo-router';
|
// app/(tabs)/_layout.tsx
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Platform } from 'react-native';
|
import { Platform } from 'react-native';
|
||||||
|
import { Tabs } from 'expo-router';
|
||||||
import { HapticTab } from '@/components/HapticTab';
|
|
||||||
import { IconSymbol } from '@/components/ui/IconSymbol';
|
|
||||||
import TabBarBackground from '@/components/ui/TabBarBackground';
|
|
||||||
import { Colors } from '@/constants/Colors';
|
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import { Dumbbell, Library, Users, History, User } from 'lucide-react-native';
|
||||||
|
|
||||||
export default function TabLayout() {
|
export default function TabLayout() {
|
||||||
const colorScheme = useColorScheme();
|
const { colors } = useColorScheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs
|
<Tabs
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint,
|
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
tabBarButton: HapticTab,
|
tabBarStyle: {
|
||||||
tabBarBackground: TabBarBackground,
|
backgroundColor: colors.background,
|
||||||
tabBarStyle: Platform.select({
|
borderTopColor: colors.border,
|
||||||
ios: {
|
borderTopWidth: Platform.OS === 'ios' ? 0.5 : 1,
|
||||||
// Use a transparent background on iOS to show the blur effect
|
elevation: 0,
|
||||||
position: 'absolute',
|
shadowOpacity: 0,
|
||||||
},
|
},
|
||||||
default: {},
|
tabBarActiveTintColor: colors.primary,
|
||||||
}),
|
tabBarInactiveTintColor: colors.textSecondary,
|
||||||
|
tabBarShowLabel: true,
|
||||||
|
tabBarLabelStyle: {
|
||||||
|
fontSize: 12,
|
||||||
|
marginBottom: Platform.OS === 'ios' ? 0 : 4,
|
||||||
|
},
|
||||||
}}>
|
}}>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="index"
|
name="index"
|
||||||
options={{
|
options={{
|
||||||
title: 'Home',
|
title: 'Workout',
|
||||||
tabBarIcon: ({ color }) => <IconSymbol size={28} name="house.fill" color={color} />,
|
tabBarIcon: ({ color, size }) => (
|
||||||
|
<Dumbbell size={size} color={color} />
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="explore"
|
name="library"
|
||||||
options={{
|
options={{
|
||||||
title: 'Explore',
|
title: 'Library',
|
||||||
tabBarIcon: ({ color }) => <IconSymbol size={28} name="paperplane.fill" color={color} />,
|
tabBarIcon: ({ color, size }) => (
|
||||||
|
<Library size={size} color={color} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="social"
|
||||||
|
options={{
|
||||||
|
title: 'Social',
|
||||||
|
tabBarIcon: ({ color, size }) => (
|
||||||
|
<Users size={size} color={color} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="history"
|
||||||
|
options={{
|
||||||
|
title: 'History',
|
||||||
|
tabBarIcon: ({ color, size }) => (
|
||||||
|
<History size={size} color={color} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="profile"
|
||||||
|
options={{
|
||||||
|
title: 'Profile',
|
||||||
|
tabBarIcon: ({ color, size }) => (
|
||||||
|
<User size={size} color={color} />
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
);
|
);
|
||||||
}
|
}
|
@ -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 { View, Text } from 'react-native';
|
||||||
|
|
||||||
import { HelloWave } from '@/components/HelloWave';
|
|
||||||
import ParallaxScrollView from '@/components/ParallaxScrollView';
|
|
||||||
import { ThemedText } from '@/components/ThemedText';
|
|
||||||
import { ThemedView } from '@/components/ThemedView';
|
|
||||||
|
|
||||||
export default function HomeScreen() {
|
export default function HomeScreen() {
|
||||||
return (
|
return (
|
||||||
<ParallaxScrollView
|
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
||||||
headerBackgroundColor={{ light: '#A1CEDC', dark: '#1D3D47' }}
|
<Text>Home Screen</Text>
|
||||||
headerImage={
|
</View>
|
||||||
<Image
|
|
||||||
source={require('@/assets/images/partial-react-logo.png')}
|
|
||||||
style={styles.reactLogo}
|
|
||||||
/>
|
|
||||||
}>
|
|
||||||
<ThemedView style={styles.titleContainer}>
|
|
||||||
<ThemedText type="title">Welcome!</ThemedText>
|
|
||||||
<HelloWave />
|
|
||||||
</ThemedView>
|
|
||||||
<ThemedView style={styles.stepContainer}>
|
|
||||||
<ThemedText type="subtitle">Step 1: Try it</ThemedText>
|
|
||||||
<ThemedText>
|
|
||||||
Edit <ThemedText type="defaultSemiBold">app/(tabs)/index.tsx</ThemedText> to see changes.
|
|
||||||
Press{' '}
|
|
||||||
<ThemedText type="defaultSemiBold">
|
|
||||||
{Platform.select({
|
|
||||||
ios: 'cmd + d',
|
|
||||||
android: 'cmd + m',
|
|
||||||
web: 'F12'
|
|
||||||
})}
|
|
||||||
</ThemedText>{' '}
|
|
||||||
to open developer tools.
|
|
||||||
</ThemedText>
|
|
||||||
</ThemedView>
|
|
||||||
<ThemedView style={styles.stepContainer}>
|
|
||||||
<ThemedText type="subtitle">Step 2: Explore</ThemedText>
|
|
||||||
<ThemedText>
|
|
||||||
Tap the Explore tab to learn more about what's included in this starter app.
|
|
||||||
</ThemedText>
|
|
||||||
</ThemedView>
|
|
||||||
<ThemedView style={styles.stepContainer}>
|
|
||||||
<ThemedText type="subtitle">Step 3: Get a fresh start</ThemedText>
|
|
||||||
<ThemedText>
|
|
||||||
When you're ready, run{' '}
|
|
||||||
<ThemedText type="defaultSemiBold">npm run reset-project</ThemedText> to get a fresh{' '}
|
|
||||||
<ThemedText type="defaultSemiBold">app</ThemedText> directory. This will move the current{' '}
|
|
||||||
<ThemedText type="defaultSemiBold">app</ThemedText> to{' '}
|
|
||||||
<ThemedText type="defaultSemiBold">app-example</ThemedText>.
|
|
||||||
</ThemedText>
|
|
||||||
</ThemedView>
|
|
||||||
</ParallaxScrollView>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
titleContainer: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 8,
|
|
||||||
},
|
|
||||||
stepContainer: {
|
|
||||||
gap: 8,
|
|
||||||
marginBottom: 8,
|
|
||||||
},
|
|
||||||
reactLogo: {
|
|
||||||
height: 178,
|
|
||||||
width: 290,
|
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
position: 'absolute',
|
|
||||||
},
|
|
||||||
});
|
|
350
app/(tabs)/library.tsx
Normal file
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';
|
// app/_layout.tsx
|
||||||
import { useFonts } from 'expo-font';
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Platform } from 'react-native';
|
||||||
import { Stack } from 'expo-router';
|
import { Stack } from 'expo-router';
|
||||||
import * as SplashScreen from 'expo-splash-screen';
|
import { NavigationContainer } from '@react-navigation/native';
|
||||||
import { StatusBar } from 'expo-status-bar';
|
import { AppearanceProvider } from '@/contexts/AppearanceContext';
|
||||||
import { useEffect } from 'react';
|
import { WorkoutProvider } from '@/contexts/WorkoutContext';
|
||||||
import 'react-native-reanimated';
|
import { schema } from '@/utils/db/schema';
|
||||||
|
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import * as ExpoSplashScreen from 'expo-splash-screen';
|
||||||
|
import SplashScreen from '@/components/SplashScreen';
|
||||||
|
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
// Prevent the splash screen from auto-hiding before asset loading is complete.
|
// Prevent auto-hide of splash screen
|
||||||
SplashScreen.preventAutoHideAsync();
|
ExpoSplashScreen.preventAutoHideAsync();
|
||||||
|
|
||||||
export default function RootLayout() {
|
function RootLayoutNav() {
|
||||||
const colorScheme = useColorScheme();
|
const { colors } = useColorScheme();
|
||||||
const [loaded] = useFonts({
|
const [isReady, setIsReady] = React.useState(false);
|
||||||
SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'),
|
const [showSplash, setShowSplash] = useState(true);
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loaded) {
|
async function initializeApp() {
|
||||||
SplashScreen.hideAsync();
|
try {
|
||||||
|
await schema.createTables();
|
||||||
|
await schema.migrate();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error initializing database:', error);
|
||||||
|
} finally {
|
||||||
|
setIsReady(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [loaded]);
|
|
||||||
|
initializeApp();
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (!loaded) {
|
const onSplashAnimationComplete = async () => {
|
||||||
|
setShowSplash(false);
|
||||||
|
await ExpoSplashScreen.hideAsync();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isReady) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showSplash) {
|
||||||
|
return <SplashScreen onAnimationComplete={onSplashAnimationComplete} />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
<Stack
|
||||||
<Stack>
|
screenOptions={{
|
||||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
headerStyle: {
|
||||||
<Stack.Screen name="+not-found" />
|
backgroundColor: colors.background,
|
||||||
</Stack>
|
},
|
||||||
<StatusBar style="auto" />
|
headerTintColor: colors.text,
|
||||||
</ThemeProvider>
|
contentStyle: {
|
||||||
|
backgroundColor: colors.background,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack.Screen
|
||||||
|
name="(tabs)"
|
||||||
|
options={{ headerShown: false }}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="(workout)/new-exercise"
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: Platform.select({
|
||||||
|
ios: 'modal',
|
||||||
|
android: 'card',
|
||||||
|
default: 'transparentModal'
|
||||||
|
}),
|
||||||
|
animation: Platform.select({
|
||||||
|
ios: 'slide_from_bottom',
|
||||||
|
default: 'none'
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="(workout)/add-exercises"
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: Platform.select({
|
||||||
|
ios: 'modal',
|
||||||
|
android: 'card',
|
||||||
|
default: 'transparentModal'
|
||||||
|
}),
|
||||||
|
animation: Platform.select({
|
||||||
|
ios: 'slide_from_bottom',
|
||||||
|
default: 'none'
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="(workout)/create-template"
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: Platform.select({
|
||||||
|
ios: 'modal',
|
||||||
|
android: 'card',
|
||||||
|
default: 'transparentModal'
|
||||||
|
}),
|
||||||
|
animation: Platform.select({
|
||||||
|
ios: 'slide_from_bottom',
|
||||||
|
default: 'none'
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function RootLayout() {
|
||||||
|
return (
|
||||||
|
<SafeAreaProvider>
|
||||||
|
<NavigationContainer>
|
||||||
|
<AppearanceProvider>
|
||||||
|
<WorkoutProvider>
|
||||||
|
<RootLayoutNav />
|
||||||
|
</WorkoutProvider>
|
||||||
|
</AppearanceProvider>
|
||||||
|
</NavigationContainer>
|
||||||
|
</SafeAreaProvider>
|
||||||
|
);
|
||||||
|
}
|
BIN
assets/videos/splash.mov
Normal file
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 { PropsWithChildren, useState } from 'react';
|
||||||
import { StyleSheet, TouchableOpacity } from 'react-native';
|
import { StyleSheet, TouchableOpacity } from 'react-native';
|
||||||
|
|
||||||
import { ThemedText } from '@/components/ThemedText';
|
import { ThemedText } from '@/components/ThemedText';
|
||||||
import { ThemedView } from '@/components/ThemedView';
|
import { ThemedView } from '@/components/ThemedView';
|
||||||
import { IconSymbol } from '@/components/ui/IconSymbol';
|
import { IconSymbol } from '@/components/ui/IconSymbol';
|
||||||
import { Colors } from '@/constants/Colors';
|
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
|
||||||
export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
|
interface CollapsibleProps {
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Collapsible({ children, title }: PropsWithChildren<CollapsibleProps>) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const theme = useColorScheme() ?? 'light';
|
const { colors } = useColorScheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemedView>
|
<ThemedView>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.heading}
|
style={styles.heading}
|
||||||
onPress={() => setIsOpen((value) => !value)}
|
onPress={() => setIsOpen((value) => !value)}
|
||||||
activeOpacity={0.8}>
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
<IconSymbol
|
<IconSymbol
|
||||||
name="chevron.right"
|
name="chevron.right"
|
||||||
size={18}
|
size={18}
|
||||||
weight="medium"
|
weight="medium"
|
||||||
color={theme === 'light' ? Colors.light.icon : Colors.dark.icon}
|
color={colors.text}
|
||||||
style={{ transform: [{ rotate: isOpen ? '90deg' : '0deg' }] }}
|
style={{ transform: [{ rotate: isOpen ? '90deg' : '0deg' }] }}
|
||||||
/>
|
/>
|
||||||
|
<ThemedText style={styles.title}>{title}</ThemedText>
|
||||||
<ThemedText type="defaultSemiBold">{title}</ThemedText>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
{isOpen && <ThemedView style={styles.content}>{children}</ThemedView>}
|
|
||||||
|
{isOpen && (
|
||||||
|
<ThemedView style={styles.content}>
|
||||||
|
{children}
|
||||||
|
</ThemedView>
|
||||||
|
)}
|
||||||
</ThemedView>
|
</ThemedView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -42,4 +50,8 @@ const styles = StyleSheet.create({
|
|||||||
marginTop: 6,
|
marginTop: 6,
|
||||||
marginLeft: 24,
|
marginLeft: 24,
|
||||||
},
|
},
|
||||||
});
|
title: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
});
|
129
components/EditableText.tsx
Normal file
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';
|
// components/ExternalLink.tsx
|
||||||
import { openBrowserAsync } from 'expo-web-browser';
|
import { Link, Href } from 'expo-router';
|
||||||
|
import * as WebBrowser from 'expo-web-browser';
|
||||||
import { type ComponentProps } from 'react';
|
import { type ComponentProps } from 'react';
|
||||||
import { Platform } from 'react-native';
|
import { Platform } from 'react-native';
|
||||||
|
|
||||||
type Props = Omit<ComponentProps<typeof Link>, 'href'> & { href: string };
|
type Props = Omit<ComponentProps<typeof Link>, 'href'> & {
|
||||||
|
href: Href<string | object>;
|
||||||
|
};
|
||||||
|
|
||||||
export function ExternalLink({ href, ...rest }: Props) {
|
export function ExternalLink({ href, ...rest }: Props) {
|
||||||
|
const handlePress = async (event: any) => {
|
||||||
|
if (Platform.OS !== 'web') {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
// Convert href to string based on its type
|
||||||
|
let url: string;
|
||||||
|
if (typeof href === 'string') {
|
||||||
|
url = href;
|
||||||
|
} else if (typeof href === 'object' && href !== null) {
|
||||||
|
// Handle route objects from expo-router
|
||||||
|
if ('pathname' in href) {
|
||||||
|
url = href.pathname;
|
||||||
|
} else {
|
||||||
|
url = String(href);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
url = String(href);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await WebBrowser.openBrowserAsync(url);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error opening external link:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
target="_blank"
|
target="_blank"
|
||||||
{...rest}
|
{...rest}
|
||||||
href={href}
|
href={href}
|
||||||
onPress={async (event) => {
|
onPress={handlePress}
|
||||||
if (Platform.OS !== 'web') {
|
|
||||||
// Prevent the default behavior of linking to the default browser on native.
|
|
||||||
event.preventDefault();
|
|
||||||
// Open the link in an in-app browser.
|
|
||||||
await openBrowserAsync(href);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
@ -23,7 +23,7 @@ export default function ParallaxScrollView({
|
|||||||
headerImage,
|
headerImage,
|
||||||
headerBackgroundColor,
|
headerBackgroundColor,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const colorScheme = useColorScheme() ?? 'light';
|
const { colorScheme } = useColorScheme();
|
||||||
const scrollRef = useAnimatedRef<Animated.ScrollView>();
|
const scrollRef = useAnimatedRef<Animated.ScrollView>();
|
||||||
const scrollOffset = useScrollViewOffset(scrollRef);
|
const scrollOffset = useScrollViewOffset(scrollRef);
|
||||||
const bottom = useBottomTabOverflow();
|
const bottom = useBottomTabOverflow();
|
||||||
@ -54,7 +54,7 @@ export default function ParallaxScrollView({
|
|||||||
<Animated.View
|
<Animated.View
|
||||||
style={[
|
style={[
|
||||||
styles.header,
|
styles.header,
|
||||||
{ backgroundColor: headerBackgroundColor[colorScheme] },
|
{ backgroundColor: headerBackgroundColor[colorScheme as 'light' | 'dark'] },
|
||||||
headerAnimatedStyle,
|
headerAnimatedStyle,
|
||||||
]}>
|
]}>
|
||||||
{headerImage}
|
{headerImage}
|
||||||
|
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 & {
|
interface ThemedTextProps extends TextProps {
|
||||||
lightColor?: string;
|
type?: TextType;
|
||||||
darkColor?: string;
|
children: React.ReactNode;
|
||||||
type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link';
|
}
|
||||||
};
|
|
||||||
|
|
||||||
export function ThemedText({
|
export function ThemedText({
|
||||||
style,
|
style,
|
||||||
lightColor,
|
type = 'default',
|
||||||
darkColor,
|
children,
|
||||||
type = 'default',
|
...props
|
||||||
...rest
|
|
||||||
}: ThemedTextProps) {
|
}: ThemedTextProps) {
|
||||||
const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text');
|
const { colors } = useAppearance();
|
||||||
|
|
||||||
|
const baseStyle = { color: colors.text };
|
||||||
|
const typeStyle = styles[type] || {};
|
||||||
|
|
||||||
|
if (type === 'link') {
|
||||||
|
baseStyle.color = colors.primary;
|
||||||
|
} else if (type === 'error') {
|
||||||
|
baseStyle.color = 'red';
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[baseStyle, typeStyle, style]}
|
||||||
{ color },
|
{...props}
|
||||||
type === 'default' ? styles.default : undefined,
|
>
|
||||||
type === 'title' ? styles.title : undefined,
|
{children}
|
||||||
type === 'defaultSemiBold' ? styles.defaultSemiBold : undefined,
|
</Text>
|
||||||
type === 'subtitle' ? styles.subtitle : undefined,
|
|
||||||
type === 'link' ? styles.link : undefined,
|
|
||||||
style,
|
|
||||||
]}
|
|
||||||
{...rest}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
default: {
|
default: {
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
lineHeight: 24,
|
|
||||||
},
|
|
||||||
defaultSemiBold: {
|
|
||||||
fontSize: 16,
|
|
||||||
lineHeight: 24,
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
fontSize: 32,
|
fontSize: 20,
|
||||||
fontWeight: 'bold',
|
fontWeight: '600',
|
||||||
lineHeight: 32,
|
|
||||||
},
|
},
|
||||||
subtitle: {
|
subtitle: {
|
||||||
fontSize: 20,
|
fontSize: 16,
|
||||||
fontWeight: 'bold',
|
fontWeight: '500',
|
||||||
},
|
},
|
||||||
link: {
|
link: {
|
||||||
lineHeight: 30,
|
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
color: '#0a7ea4',
|
textDecorationLine: 'underline',
|
||||||
},
|
},
|
||||||
});
|
error: {
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
});
|
140
components/form/Button.tsx
Normal file
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 MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||||
import { SymbolWeight } from 'expo-symbols';
|
import { SymbolWeight } from 'expo-symbols';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { OpaqueColorValue, StyleProp, ViewStyle } from 'react-native';
|
import { OpaqueColorValue, StyleProp, ViewStyle, TextStyle } from 'react-native';
|
||||||
|
|
||||||
// Add your SFSymbol to MaterialIcons mappings here.
|
// Add your SFSymbol to MaterialIcons mappings here.
|
||||||
const MAPPING = {
|
const MAPPING = {
|
||||||
@ -36,7 +36,7 @@ export function IconSymbol({
|
|||||||
name: IconSymbolName;
|
name: IconSymbolName;
|
||||||
size?: number;
|
size?: number;
|
||||||
color: string | OpaqueColorValue;
|
color: string | OpaqueColorValue;
|
||||||
style?: StyleProp<ViewStyle>;
|
style?: StyleProp<TextStyle>;
|
||||||
weight?: SymbolWeight;
|
weight?: SymbolWeight;
|
||||||
}) {
|
}) {
|
||||||
return <MaterialIcons color={color} size={size} name={MAPPING[name]} style={style} />;
|
return <MaterialIcons color={color} size={size} name={MAPPING[name]} style={style} />;
|
||||||
|
@ -1,26 +1,30 @@
|
|||||||
/**
|
// constants/Colors.ts
|
||||||
* Below are the colors that are used in the app. The colors are defined in the light and dark mode.
|
import { ThemeColors } from '@/types/theme';
|
||||||
* There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const tintColorLight = '#0a7ea4';
|
const tintColorLight = '#2563eb';
|
||||||
const tintColorDark = '#fff';
|
const tintColorDark = '#60a5fa';
|
||||||
|
|
||||||
export const Colors = {
|
export const Colors: Record<string, ThemeColors> = {
|
||||||
light: {
|
light: {
|
||||||
text: '#11181C',
|
primary: tintColorLight,
|
||||||
background: '#fff',
|
background: '#ffffff',
|
||||||
tint: tintColorLight,
|
cardBg: '#f3f4f6',
|
||||||
icon: '#687076',
|
text: '#1f2937',
|
||||||
tabIconDefault: '#687076',
|
textSecondary: '#6b7280',
|
||||||
|
border: '#e5e7eb',
|
||||||
|
tabIconDefault: '#6b7280',
|
||||||
tabIconSelected: tintColorLight,
|
tabIconSelected: tintColorLight,
|
||||||
|
error: '#dc2626', // Added error color (red-600)
|
||||||
},
|
},
|
||||||
dark: {
|
dark: {
|
||||||
text: '#ECEDEE',
|
primary: tintColorDark,
|
||||||
background: '#151718',
|
background: '#1f2937',
|
||||||
tint: tintColorDark,
|
cardBg: '#374151',
|
||||||
icon: '#9BA1A6',
|
text: '#f3f4f6',
|
||||||
tabIconDefault: '#9BA1A6',
|
textSecondary: '#9ca3af',
|
||||||
|
border: '#4b5563',
|
||||||
|
tabIconDefault: '#9ca3af',
|
||||||
tabIconSelected: tintColorDark,
|
tabIconSelected: tintColorDark,
|
||||||
|
error: '#ef4444', // Added error color (red-500, slightly lighter for dark mode)
|
||||||
},
|
},
|
||||||
};
|
};
|
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 @@
|
|||||||
/**
|
// hooks/useThemeColor.ts
|
||||||
* Learn more about light and dark modes:
|
|
||||||
* https://docs.expo.dev/guides/color-schemes/
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import { ColorScheme } from '@/types/theme';
|
||||||
|
|
||||||
export function useThemeColor(
|
export function useThemeColor(
|
||||||
props: { light?: string; dark?: string },
|
props: { light?: string; dark?: string },
|
||||||
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
|
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
|
||||||
) {
|
) {
|
||||||
const theme = useColorScheme() ?? 'light';
|
const { colorScheme } = useColorScheme();
|
||||||
const colorFromProps = props[theme];
|
const themeColor = colorScheme as ColorScheme;
|
||||||
|
|
||||||
if (colorFromProps) {
|
if (props[themeColor]) {
|
||||||
return colorFromProps;
|
return props[themeColor];
|
||||||
} else {
|
|
||||||
return Colors[theme][colorName];
|
|
||||||
}
|
}
|
||||||
}
|
return Colors[themeColor][colorName];
|
||||||
|
}
|
925
package-lock.json
generated
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"
|
"preset": "jest-expo"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/vector-icons": "^14.0.2",
|
"@expo/vector-icons": "^14.0.4",
|
||||||
|
"@react-native-async-storage/async-storage": "1.23.1",
|
||||||
"@react-navigation/bottom-tabs": "^7.2.0",
|
"@react-navigation/bottom-tabs": "^7.2.0",
|
||||||
"@react-navigation/native": "^7.0.14",
|
"@react-navigation/native": "^7.0.14",
|
||||||
|
"@react-navigation/native-stack": "^7.2.0",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"expo": "~52.0.28",
|
"expo": "~52.0.28",
|
||||||
|
"expo-av": "~15.0.2",
|
||||||
"expo-blur": "~14.0.3",
|
"expo-blur": "~14.0.3",
|
||||||
"expo-constants": "~17.0.5",
|
"expo-constants": "~17.0.5",
|
||||||
"expo-font": "~13.0.3",
|
"expo-font": "~13.0.3",
|
||||||
"expo-haptics": "~14.0.1",
|
"expo-haptics": "~14.0.1",
|
||||||
"expo-linking": "~7.0.5",
|
"expo-linking": "~7.0.5",
|
||||||
"expo-router": "~4.0.17",
|
"expo-router": "^3.5.24",
|
||||||
"expo-splash-screen": "~0.29.21",
|
"expo-splash-screen": "~0.29.21",
|
||||||
|
"expo-sqlite": "~15.1.1",
|
||||||
"expo-status-bar": "~2.0.1",
|
"expo-status-bar": "~2.0.1",
|
||||||
"expo-symbols": "~0.2.1",
|
"expo-symbols": "~0.2.1",
|
||||||
"expo-system-ui": "~4.0.7",
|
"expo-system-ui": "~4.0.7",
|
||||||
"expo-web-browser": "~14.0.2",
|
"expo-web-browser": "~14.0.2",
|
||||||
|
"lucide-react-native": "^0.474.0",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"react-native": "0.76.6",
|
"react-native": "0.76.6",
|
||||||
"react-native-gesture-handler": "~2.20.2",
|
"react-native-gesture-handler": "~2.20.2",
|
||||||
|
"react-native-modal": "^13.0.1",
|
||||||
|
"react-native-pager-view": "^6.7.0",
|
||||||
"react-native-reanimated": "~3.16.1",
|
"react-native-reanimated": "~3.16.1",
|
||||||
"react-native-safe-area-context": "4.12.0",
|
"react-native-safe-area-context": "4.12.0",
|
||||||
"react-native-screens": "~4.4.0",
|
"react-native-screens": "~4.4.0",
|
||||||
@ -45,6 +53,7 @@
|
|||||||
"@types/jest": "^29.5.12",
|
"@types/jest": "^29.5.12",
|
||||||
"@types/react": "~18.3.12",
|
"@types/react": "~18.3.12",
|
||||||
"@types/react-test-renderer": "^18.3.0",
|
"@types/react-test-renderer": "^18.3.0",
|
||||||
|
"babel-plugin-module-resolver": "^5.0.2",
|
||||||
"jest": "^29.2.1",
|
"jest": "^29.2.1",
|
||||||
"jest-expo": "~52.0.3",
|
"jest-expo": "~52.0.3",
|
||||||
"react-test-renderer": "18.3.1",
|
"react-test-renderer": "18.3.1",
|
||||||
|
314
services/LibraryService.ts
Normal file
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": {
|
"compilerOptions": {
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": [
|
"@/*": ["./*"]
|
||||||
"./*"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
@ -14,4 +12,4 @@
|
|||||||
".expo/types/**/*.ts",
|
".expo/types/**/*.ts",
|
||||||
"expo-env.d.ts"
|
"expo-env.d.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
74
types/events.ts
Normal file
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