1
0
mirror of https://github.com/DocNR/POWR.git synced 2025-05-25 11:22:05 +00:00

updated exercise/template UI, publication queue, forking of exercises (templates next)

This commit is contained in:
DocNR 2025-03-06 09:19:16 -05:00
parent 98a5b9ed09
commit 4eb9d428a2
20 changed files with 3010 additions and 780 deletions

@ -8,6 +8,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Added ### Added
- Comprehensive exercise management features
- Added exercise editing functionality
- Implemented exercise forking for Nostr exercises
- Created local-first editing with offline support
- Added publication queue for deferred Nostr publishing
- Built robust exercise update workflow
- Implemented source-aware editing permissions
- Connectivity service for network state management
- Added real-time connectivity monitoring
- Implemented persistence for offline state
- Built automatic retry system for failed requests
- Created hook-based connectivity API for components
- Extended database schema for publication queuing
- Added publication_queue table
- Implemented attempt tracking and rate limiting
- Added app_status table for system-wide states
- Successful Nostr protocol integration - Successful Nostr protocol integration
- Implemented NDK-mobile for React Native compatibility - Implemented NDK-mobile for React Native compatibility
- Added secure key management with Expo SecureStore - Added secure key management with Expo SecureStore
@ -33,12 +49,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added workout persistence and recovery - Added workout persistence and recovery
- Built automatic timer management with background support - Built automatic timer management with background support
- Developed minimization and maximization functionality - Developed minimization and maximization functionality
- Zustand workout store for state management
- Created comprehensive workout state store with Zustand
- Implemented selectors for efficient state access
- Added workout persistence and recovery
- Built automatic timer management with background support
- Developed minimization and maximization functionality
- Workout tracking implementation with real-time tracking - Workout tracking implementation with real-time tracking
- Added workout timer with proper background handling - Added workout timer with proper background handling
- Implemented rest timer functionality - Implemented rest timer functionality
@ -85,6 +95,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Improved workout history visualization - Improved workout history visualization
### Changed ### Changed
- Enhanced exercise detail viewer
- Replaced bottom sheet with full-screen modal
- Added tabbed interface for information organization
- Implemented edit capability with ownership detection
- Added fork functionality for Nostr exercises from other users
- Improved progress visualization with charts
- Redesigned exercise editor
- Created multi-purpose editor for create/edit/fork workflows
- Added context-aware UI based on exercise source
- Implemented specialized buttons based on workflow type
- Added better form validation and feedback
- Improved keyboard handling across platforms
- Improved workflow architecture for model context protocol
- Implemented offline-first editing paradigm
- Added cryptographic signing before submission
- Built local caching with deferred publishing
- Created connectivity-aware operation queueing
- Added proper error recovery and retry mechanisms
- Improved workout screen navigation consistency - Improved workout screen navigation consistency
- Standardized screen transitions and gestures - Standardized screen transitions and gestures
- Added back buttons for clearer navigation - Added back buttons for clearer navigation
@ -128,6 +156,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Enhanced visual separation between template metadata and content - Enhanced visual separation between template metadata and content
### Fixed ### Fixed
- Exercise update functionality using delete-recreate pattern
- Exercise data type handling in forking operation
- TypeScript errors in exercise component interfaces
- Nostr event queuing and retry mechanism
- Exercise ownership detection for edit vs fork workflows
- Connectivity monitoring edge cases
- Workout navigation gesture handling issues - Workout navigation gesture handling issues
- Workout timer inconsistency during app background state - Workout timer inconsistency during app background state
- Exercise deletion functionality - Exercise deletion functionality
@ -144,7 +178,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Content rendering issues in bottom sheet components - Content rendering issues in bottom sheet components
### Technical Details ### Technical Details
1. Nostr Integration: 1. Exercise Management:
- Implemented edit/fork/create workflows with unified interface
- Built local-first editing pattern with offline support
- Added publication queue for deferred Nostr submissions
- Created robust update mechanism in useExercises hook
- Implemented source-aware editing permissions
- Added ownership detection for exercise operations
2. Connectivity Management:
- Implemented singleton ConnectivityService for app-wide monitoring
- Added NetInfo integration for real-time status detection
- Built React hook for component-level connectivity awareness
- Created database persistence for connectivity state
- Implemented event-based notification system
3. Nostr Integration:
- Implemented @nostr-dev-kit/ndk-mobile package for React Native compatibility - Implemented @nostr-dev-kit/ndk-mobile package for React Native compatibility
- Created dedicated NDK store using Zustand for state management - Created dedicated NDK store using Zustand for state management
- Built secure key storage and retrieval using Expo SecureStore - Built secure key storage and retrieval using Expo SecureStore
@ -152,55 +201,65 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added relay connection management with status tracking - Added relay connection management with status tracking
- Developed proper error handling for network operations - Developed proper error handling for network operations
2. Cryptographic Implementation: 4. Cryptographic Implementation:
- Integrated react-native-get-random-values for crypto API polyfill - Integrated react-native-get-random-values for crypto API polyfill
- Implemented NDKMobilePrivateKeySigner for key operations - Implemented NDKMobilePrivateKeySigner for key operations
- Added proper key format handling (hex, nsec) - Added proper key format handling (hex, nsec)
- Created secure key generation functionality - Created secure key generation functionality
- Built robust error handling for cryptographic operations - Built robust error handling for cryptographic operations
3. Programs Testing Component: 5. Programs Testing Component:
- Developed dual-purpose interface for Database and Nostr testing - Developed dual-purpose interface for Database and Nostr testing
- Implemented login system with key generation and secure storage - Implemented login system with key generation and secure storage
- Built event creation interface with multiple event kinds - Built event creation interface with multiple event kinds
- Added event querying and display functionality - Added event querying and display functionality
- Created detailed event inspection with tag visualization - Created detailed event inspection with tag visualization
- Added relay status monitoring - Added relay status monitoring
4. Database Schema Enforcement:
6. Database Schema Enforcement:
- Added CHECK constraints for equipment types - Added CHECK constraints for equipment types
- Added CHECK constraints for exercise types - Added CHECK constraints for exercise types
- Added CHECK constraints for categories - Added CHECK constraints for categories
- Proper handling of foreign key constraints - Proper handling of foreign key constraints
5. Input Validation:
7. Input Validation:
- Equipment options: bodyweight, barbell, dumbbell, kettlebell, machine, cable, other - Equipment options: bodyweight, barbell, dumbbell, kettlebell, machine, cable, other
- Exercise types: strength, cardio, bodyweight - Exercise types: strength, cardio, bodyweight
- Categories: Push, Pull, Legs, Core - Categories: Push, Pull, Legs, Core
- Difficulty levels: beginner, intermediate, advanced - Difficulty levels: beginner, intermediate, advanced
- Movement patterns: push, pull, squat, hinge, carry, rotation - Movement patterns: push, pull, squat, hinge, carry, rotation
6. Error Handling:
8. Error Handling:
- Added SQLite error type definitions - Added SQLite error type definitions
- Improved error propagation in LibraryService - Improved error propagation in LibraryService
- Added transaction rollback on constraint violations - Added transaction rollback on constraint violations
7. Database Services:
9. Database Services:
- Added EventCache service for Nostr events - Added EventCache service for Nostr events
- Improved ExerciseService with transaction awareness - Improved ExerciseService with transaction awareness
- Added DevSeederService for development data - Added DevSeederService for development data
- Enhanced error handling and logging - Enhanced error handling and logging
8. Workout State Management with Zustand:
- Implemented selector pattern for performance optimization 10. Workout State Management with Zustand:
- Added module-level timer references for background operation - Implemented selector pattern for performance optimization
- Created workout persistence with auto-save functionality - Added module-level timer references for background operation
- Developed state recovery for crash protection - Created workout persistence with auto-save functionality
- Added support for future Nostr integration - Developed state recovery for crash protection
- Implemented workout minimization for multi-tasking - Added support for future Nostr integration
9. Template Details UI Architecture: - Implemented workout minimization for multi-tasking
- Implemented MaterialTopTabNavigator for content organization
- Created screen-specific components for each tab 11. Template Details UI Architecture:
- Developed conditional rendering based on template source - Implemented MaterialTopTabNavigator for content organization
- Implemented context-aware action buttons - Created screen-specific components for each tab
- Added proper navigation state handling - Developed conditional rendering based on template source
- Implemented context-aware action buttons
- Added proper navigation state handling
### Migration Notes ### Migration Notes
- Exercise editing now follows an offline-first approach with Nostr awareness
- ExerciseSheet component replaces separate create/edit components
- Exercise updates require proper source and metadata handling
- Publication queue provides automatic retry for Nostr events
- Exercise creation now enforces schema constraints - Exercise creation now enforces schema constraints
- Input validation prevents invalid data entry - Input validation prevents invalid data entry
- Enhanced error messages provide better debugging information - Enhanced error messages provide better debugging information

129
README.md

@ -1,29 +1,30 @@
# POWR - Cross-Platform Fitness Tracking App # POWR - Cross-Platform Fitness Tracking App
POWR is a local-first fitness tracking application built with React Native and Expo, featuring planned Nostr protocol integration for decentralized social features. POWR is a local-first fitness tracking application built with React Native and Expo, featuring integration with the Nostr protocol for decentralized social features and improved control of your fitness data.
## Features ## Features
### Current ### Current
- Exercise library management - Exercise library management with local SQLite database
- Workout template creation - Workout template creation
- Local-first data architecture - Local-first data architecture with Nostr sync capability
- Cross-platform support (iOS, Android) - Cross-platform support (iOS, Android)
- Dark mode support - Dark/light mode support
- Nostr authentication and event publishing
### Planned ### Planned
- Workout record and template sharing - Workout record and template sharing
- Nostr integration - Enhanced social features
- Social features
- Training programs - Training programs
- Performance analytics - Performance analytics
- Public/private workout sharing options
## Getting Started ## Getting Started
### Prerequisites ### Prerequisites
- Node.js (v18 or later) - Node.js (v18 or later)
- npm or yarn - npm or yarn
- Expo CLI - EAS CLI (`npm install -g eas-cli`)
- iOS Simulator (for iOS development) - iOS Simulator (for iOS development)
- Android Studio (for Android development) - Android Studio (for Android development)
@ -40,78 +41,96 @@ cd powr
npm install npm install
``` ```
3. Start the development server 3. Install development client modules
```bash ```bash
npx expo start npx expo install expo-dev-client expo-crypto expo-nip55
``` ```
### Development Options ### Development Using Expo Dev Client
- Press 'i' for iOS simulator
- Press 'a' for Android simulator POWR now uses Expo Dev Client for development instead of Expo Go. This allows us to use native modules required for Nostr integration.
- Scan QR code with Expo Go app for physical device
1. Configure EAS (if not already done)
```bash
eas build:configure
```
2. Create a development build
```bash
# For Android
eas build --profile development --platform android
# For iOS
eas build --profile development --platform ios
```
3. Start the development server with dev client
```bash
npx expo start --dev-client
```
4. Install the build on your device and scan the QR code to connect
## Project Structure ## Project Structure
```plaintext ```plaintext
powr/ powr/
├── app/ # Main application code ├── app/ # Main application code
│ ├── (tabs)/ # Tab-based navigation │ ├── (tabs)/ # Tab-based navigation
│ └── components/ # Shared components │ ├── (workout)/ # Workout screens
├── assets/ # Static assets │ └── _layout.tsx # Root layout
├── docs/ # Documentation ├── components/ # Shared components
│ └── design/ # Design documents │ ├── ui/ # UI components
├── lib/ # Shared utilities │ ├── sheets/ # Bottom sheets
└── types/ # TypeScript definitions │ └── library/ # Library components
├── lib/ # Shared utilities
│ ├── db/ # Database services
│ ├── hooks/ # Custom React hooks
│ ├── stores/ # Zustand stores
│ └── mobile-signer.ts # Nostr signer implementation
├── types/ # TypeScript definitions
└── utils/ # Utility functions
``` ```
## Technology Stack ## Technology Stack
### Core ### Core
- React Native - React Native
- Expo - Expo (with Dev Client)
- TypeScript - TypeScript
- SQLite (via expo-sqlite) - SQLite (via expo-sqlite)
- Zustand (state management)
### UI Components ### UI Components
- NativeWind - NativeWind/Tailwind
- React Navigation - React Navigation
- Lucide Icons - Lucide Icons
### Testing ### Nostr Integration
- Jest - NDK (Nostr Development Kit)
- React Native Testing Library - Custom mobile signer implementation
- Local event caching
## Development ## Database Architecture
### Environment Setup POWR uses a SQLite database with a service-oriented architecture:
1. Install development tools - Exercise data
```bash - Workout templates
npm install -g expo-cli - Nostr event caching
``` - User profiles
2. Configure environment Each domain has dedicated service classes for data operations.
```bash
cp .env.example .env
```
3. Configure development settings ## Nostr Integration
```bash
npm run setup-dev
```
### Running Tests POWR implements the Nostr protocol via NDK with:
```bash - Secure key management using expo-secure-store
# Run all tests - Event publishing for exercises, templates, and workouts
npm test - Profile discovery and following
- Custom event kinds for fitness data
# Run with coverage ## Building for Production
npm test -- --coverage
# Run in watch mode
npm test -- --watch
```
### Building for Production
```bash ```bash
# Build for iOS # Build for iOS
eas build -p ios eas build -p ios
@ -128,15 +147,6 @@ eas build -p android
4. Push to the branch 4. Push to the branch
5. Open a Pull Request 5. Open a Pull Request
Please read [CONTRIBUTING.md](CONTRIBUTING.md) for details on our code of conduct and development process.
## Documentation
- [Project Overview](docs/project-overview.md)
- [Architecture Guide](docs/architecture.md)
- [API Documentation](docs/api.md)
- [Testing Guide](docs/testing.md)
## License ## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
@ -145,4 +155,5 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
- [Expo](https://expo.dev/) - [Expo](https://expo.dev/)
- [React Native](https://reactnative.dev/) - [React Native](https://reactnative.dev/)
- [NDK](https://github.com/nostr-dev-kit/ndk)
- [Nostr Protocol](https://github.com/nostr-protocol/nostr) - [Nostr Protocol](https://github.com/nostr-protocol/nostr)

@ -5,13 +5,15 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Search, Dumbbell, ListFilter } from 'lucide-react-native'; import { Search, Dumbbell, ListFilter } from 'lucide-react-native';
import { FloatingActionButton } from '@/components/shared/FloatingActionButton'; import { FloatingActionButton } from '@/components/shared/FloatingActionButton';
import { NewExerciseSheet } from '@/components/library/NewExerciseSheet'; import { ExerciseSheet } from '@/components/library/ExerciseSheet';
import { SimplifiedExerciseList } from '@/components/exercises/SimplifiedExerciseList'; import { SimplifiedExerciseList } from '@/components/exercises/SimplifiedExerciseList';
import { ExerciseDetails } from '@/components/exercises/ExerciseDetails'; import { ModalExerciseDetails } from '@/components/exercises/ModalExerciseDetails';
import { ExerciseDisplay, ExerciseType, BaseExercise, Equipment } from '@/types/exercise'; import { ExerciseDisplay, ExerciseType, BaseExercise, Equipment } from '@/types/exercise';
import { useExercises } from '@/lib/hooks/useExercises'; import { useExercises } from '@/lib/hooks/useExercises';
import { FilterSheet, type FilterOptions, type SourceType } from '@/components/library/FilterSheet'; import { FilterSheet, type FilterOptions, type SourceType } from '@/components/library/FilterSheet';
import { useWorkoutStore } from '@/stores/workoutStore'; import { useWorkoutStore } from '@/stores/workoutStore';
import { generateId } from '@/utils/ids';
import { useNDKStore } from '@/lib/stores/ndk';
// Default available filters // Default available filters
const availableFilters = { const availableFilters = {
@ -28,13 +30,23 @@ const initialFilters: FilterOptions = {
}; };
export default function ExercisesScreen() { export default function ExercisesScreen() {
const [showNewExercise, setShowNewExercise] = useState(false); // Basic state
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [selectedExercise, setSelectedExercise] = useState<ExerciseDisplay | null>(null);
const [filterSheetOpen, setFilterSheetOpen] = useState(false); const [filterSheetOpen, setFilterSheetOpen] = useState(false);
const [currentFilters, setCurrentFilters] = useState<FilterOptions>(initialFilters); const [currentFilters, setCurrentFilters] = useState<FilterOptions>(initialFilters);
const [activeFilters, setActiveFilters] = useState(0); const [activeFilters, setActiveFilters] = useState(0);
// Exercise sheet state
const [showExerciseSheet, setShowExerciseSheet] = useState(false);
const [exerciseToEdit, setExerciseToEdit] = useState<ExerciseDisplay | undefined>(undefined);
const [editMode, setEditMode] = useState<'create' | 'edit' | 'fork'>('create');
// Exercise details state
const [selectedExercise, setSelectedExercise] = useState<ExerciseDisplay | null>(null);
// Other hooks
const { isActive, isMinimized } = useWorkoutStore(); const { isActive, isMinimized } = useWorkoutStore();
const { currentUser } = useNDKStore();
const shouldShowFAB = !isActive || !isMinimized; const shouldShowFAB = !isActive || !isMinimized;
const { const {
@ -43,6 +55,7 @@ export default function ExercisesScreen() {
error, error,
createExercise, createExercise,
deleteExercise, deleteExercise,
updateExercise,
refreshExercises, refreshExercises,
updateFilters, updateFilters,
clearFilters clearFilters
@ -61,22 +74,98 @@ export default function ExercisesScreen() {
setSelectedExercise(exercise); setSelectedExercise(exercise);
}; };
const handleEdit = async () => { // Mock exercise update function
// TODO: Implement edit functionality const handleUpdateExercise = async (id: string, updatedData: Partial<BaseExercise>): Promise<void> => {
setSelectedExercise(null); try {
// Since we don't have a real update function, we'll fake it with delete + create
// In a real app, this would be replaced with an actual update API call
console.log(`Updating exercise ${id} with data:`, updatedData);
// Delete the old exercise
await deleteExercise(id);
// Create a new exercise with the same ID and updated data
await createExercise({
...updatedData,
availability: updatedData.availability || { source: ['local'] }
} as Omit<BaseExercise, 'id'>);
// Refresh the exercise list
refreshExercises();
} catch (error) {
console.error('Error updating exercise:', error);
}
}; };
const handleCreateExercise = async (exerciseData: BaseExercise) => { // Handle editing an exercise
// Convert BaseExercise to include required source information const handleEdit = () => {
const exerciseWithSource: Omit<BaseExercise, 'id'> = { if (!selectedExercise) return;
...exerciseData,
availability: {
source: ['local']
}
};
await createExercise(exerciseWithSource); // Close the details modal
setShowNewExercise(false); setSelectedExercise(null);
// Determine if we should edit or fork based on Nostr ownership
const isNostrExercise = selectedExercise.source === 'nostr';
const isCurrentUserAuthor = isNostrExercise &&
selectedExercise.availability?.lastSynced?.nostr?.metadata?.pubkey === currentUser?.pubkey;
const mode = isNostrExercise && !isCurrentUserAuthor ? 'fork' : 'edit';
// Set up edit state
setEditMode(mode);
setExerciseToEdit(selectedExercise);
// Open the exercise sheet
setShowExerciseSheet(true);
};
// Handle creating a new exercise
const handleCreateExercise = () => {
setEditMode('create');
setExerciseToEdit(undefined);
setShowExerciseSheet(true);
};
// Handle submitting exercise form (create, edit, or fork)
const handleSubmitExercise = async (exerciseData: BaseExercise) => {
try {
if (editMode === 'create') {
// For new exercises, ensure the availability is set
const exerciseWithSource: Omit<BaseExercise, 'id'> = {
...exerciseData,
availability: {
source: ['local']
}
};
// Remove the ID from the data for new creation
delete (exerciseWithSource as any).id;
await createExercise(exerciseWithSource);
}
else if (editMode === 'edit') {
// Use the new updateExercise function directly
await updateExercise(exerciseData.id, exerciseData);
}
else if (editMode === 'fork') {
// For forking, create a new exercise but keep the original data
const { id: _, ...forkedExerciseData } = exerciseData;
const forkedExercise: Omit<BaseExercise, 'id'> = {
...forkedExerciseData,
availability: {
source: ['local'] // Start as a local exercise
}
};
await createExercise(forkedExercise);
}
// Refresh the exercise list after changes
refreshExercises();
} catch (error) {
console.error('Error handling exercise submission:', error);
}
// Close the sheet regardless of success/failure
setShowExerciseSheet(false);
}; };
const handleApplyFilters = (filters: FilterOptions) => { const handleApplyFilters = (filters: FilterOptions) => {
@ -158,30 +247,28 @@ export default function ExercisesScreen() {
/> />
{/* Exercise details sheet */} {/* Exercise details sheet */}
{selectedExercise && ( <ModalExerciseDetails
<ExerciseDetails exercise={selectedExercise} // This can now be null
exercise={selectedExercise} open={!!selectedExercise}
open={!!selectedExercise} onClose={() => setSelectedExercise(null)}
onOpenChange={(open) => { onEdit={handleEdit}
if (!open) setSelectedExercise(null); />
}}
onEdit={handleEdit}
/>
)}
{/* FAB for adding new exercise */} {/* FAB for adding new exercise */}
{shouldShowFAB && ( {shouldShowFAB && (
<FloatingActionButton <FloatingActionButton
icon={Dumbbell} icon={Dumbbell}
onPress={() => setShowNewExercise(true)} onPress={handleCreateExercise}
/> />
)} )}
{/* New exercise sheet */} {/* Exercise sheet for create/edit/fork */}
<NewExerciseSheet <ExerciseSheet
isOpen={showNewExercise} isOpen={showExerciseSheet}
onClose={() => setShowNewExercise(false)} onClose={() => setShowExerciseSheet(false)}
onSubmit={handleCreateExercise} onSubmit={handleSubmitExercise}
exerciseToEdit={exerciseToEdit}
mode={editMode}
/> />
</View> </View>
); );

@ -10,6 +10,7 @@ import { FloatingActionButton } from '@/components/shared/FloatingActionButton';
import { NewTemplateSheet } from '@/components/library/NewTemplateSheet'; import { NewTemplateSheet } from '@/components/library/NewTemplateSheet';
import { FilterSheet, type FilterOptions, type SourceType } from '@/components/library/FilterSheet'; import { FilterSheet, type FilterOptions, type SourceType } from '@/components/library/FilterSheet';
import { TemplateCard } from '@/components/templates/TemplateCard'; import { TemplateCard } from '@/components/templates/TemplateCard';
import { ModalTemplateDetails } from '@/components/templates/ModalTemplateDetails';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Template, Template,
@ -74,12 +75,21 @@ export default function TemplatesScreen() {
const { isActive, isMinimized } = useWorkoutStore(); const { isActive, isMinimized } = useWorkoutStore();
const shouldShowFAB = !isActive || !isMinimized; const shouldShowFAB = !isActive || !isMinimized;
// State for the modal template details
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null);
const [showTemplateModal, setShowTemplateModal] = useState(false);
const handleDelete = (id: string) => { const handleDelete = (id: string) => {
setTemplates(current => current.filter(t => t.id !== id)); setTemplates(current => current.filter(t => t.id !== id));
}; };
const handleTemplatePress = (template: Template) => { const handleTemplatePress = (template: Template) => {
router.push(`/template/${template.id}`); // Just open the modal without navigating to a route
setSelectedTemplateId(template.id);
setShowTemplateModal(true);
// We're no longer using this:
// router.push(`/template/${template.id}`);
}; };
const handleStartWorkout = async (template: Template) => { const handleStartWorkout = async (template: Template) => {
@ -127,6 +137,23 @@ export default function TemplatesScreen() {
setActiveFilters(totalFilters); setActiveFilters(totalFilters);
}; };
// Handle modal close
const handleModalClose = () => {
setShowTemplateModal(false);
};
// Handle favorite change from modal
const handleModalFavoriteChange = (templateId: string, isFavorite: boolean) => {
// Update local state to reflect change
setTemplates(current =>
current.map(t =>
t.id === templateId
? { ...t, isFavorite }
: t
)
);
};
useFocusEffect( useFocusEffect(
React.useCallback(() => { React.useCallback(() => {
// Refresh template favorite status when tab gains focus // Refresh template favorite status when tab gains focus
@ -275,6 +302,15 @@ export default function TemplatesScreen() {
/> />
)} )}
{/* Template Details Modal */}
<ModalTemplateDetails
templateId={selectedTemplateId || ''}
open={showTemplateModal}
onClose={handleModalClose}
onFavoriteChange={handleModalFavoriteChange}
/>
{/* New Template Sheet */}
<NewTemplateSheet <NewTemplateSheet
isOpen={showNewTemplate} isOpen={showNewTemplate}
onClose={() => setShowNewTemplate(false)} onClose={() => setShowNewTemplate(false)}

@ -13,7 +13,7 @@ import { TabScreen } from '@/components/layout/TabScreen';
import { ChevronLeft, Search, Plus } from 'lucide-react-native'; import { ChevronLeft, Search, Plus } from 'lucide-react-native';
import { BaseExercise } from '@/types/exercise'; import { BaseExercise } from '@/types/exercise';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { NewExerciseSheet } from '@/components/library/NewExerciseSheet'; import { ExerciseSheet } from '@/components/library/ExerciseSheet';
export default function AddExercisesScreen() { export default function AddExercisesScreen() {
const db = useSQLiteContext(); const db = useSQLiteContext();
@ -178,7 +178,7 @@ export default function AddExercisesScreen() {
</View> </View>
{/* New Exercise Sheet */} {/* New Exercise Sheet */}
<NewExerciseSheet <ExerciseSheet
isOpen={isNewExerciseSheetOpen} isOpen={isNewExerciseSheetOpen}
onClose={() => setIsNewExerciseSheetOpen(false)} onClose={() => setIsNewExerciseSheetOpen(false)}
onSubmit={handleNewExerciseSubmit} onSubmit={handleNewExerciseSubmit}

@ -1,351 +0,0 @@
// components/exercises/ExerciseDetails.tsx
import React from 'react';
import { View, ScrollView } from 'react-native';
import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs';
import { Text } from '@/components/ui/text';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import {
Edit2,
Dumbbell,
Target,
Calendar,
Hash,
AlertCircle,
LineChart,
Settings
} from 'lucide-react-native';
import { ExerciseDisplay } from '@/types/exercise';
import { useTheme } from '@react-navigation/native';
import type { CustomTheme } from '@/lib/theme';
const Tab = createMaterialTopTabNavigator();
interface ExerciseDetailsProps {
exercise: ExerciseDisplay;
open: boolean;
onOpenChange: (open: boolean) => void;
onEdit?: () => void;
}
// Info Tab Component
function InfoTab({ exercise, onEdit }: { exercise: ExerciseDisplay; onEdit?: () => void }) {
const {
title,
type,
category,
equipment,
description,
instructions = [],
tags = [],
source = 'local',
usageCount,
lastUsed
} = exercise;
return (
<ScrollView
className="flex-1 px-4"
contentContainerStyle={{ paddingBottom: 20 }}
>
<View className="gap-6 py-4">
{/* Basic Info Section */}
<View className="flex-row items-center gap-2">
<Badge
variant={source === 'local' ? 'outline' : 'secondary'}
className="capitalize"
>
<Text>{source}</Text>
</Badge>
<Badge
variant="outline"
className="capitalize bg-muted"
>
<Text>{type}</Text>
</Badge>
</View>
<Separator className="bg-border" />
{/* Category & Equipment Section */}
<View className="space-y-4">
<View className="flex-row items-center gap-2">
<View className="w-8 h-8 items-center justify-center rounded-md bg-muted">
<Target size={18} className="text-muted-foreground" />
</View>
<View>
<Text className="text-sm text-muted-foreground">Category</Text>
<Text className="text-base font-medium text-foreground">{category}</Text>
</View>
</View>
{equipment && (
<View className="flex-row items-center gap-2">
<View className="w-8 h-8 items-center justify-center rounded-md bg-muted">
<Dumbbell size={18} className="text-muted-foreground" />
</View>
<View>
<Text className="text-sm text-muted-foreground">Equipment</Text>
<Text className="text-base font-medium text-foreground capitalize">{equipment}</Text>
</View>
</View>
)}
</View>
{/* Description Section */}
{description && (
<View>
<Text className="text-base font-semibold text-foreground mb-2">Description</Text>
<Text className="text-base text-muted-foreground leading-relaxed">{description}</Text>
</View>
)}
{/* Tags Section */}
{tags.length > 0 && (
<View>
<View className="flex-row items-center gap-2 mb-2">
<Hash size={16} className="text-muted-foreground" />
<Text className="text-base font-semibold text-foreground">Tags</Text>
</View>
<View className="flex-row flex-wrap gap-2">
{tags.map((tag: string) => (
<Badge key={tag} variant="secondary">
<Text>{tag}</Text>
</Badge>
))}
</View>
</View>
)}
{/* Usage Stats Section */}
{(usageCount || lastUsed) && (
<View>
<View className="flex-row items-center gap-2 mb-2">
<Calendar size={16} className="text-muted-foreground" />
<Text className="text-base font-semibold text-foreground">Usage</Text>
</View>
<View className="gap-2">
{usageCount && (
<Text className="text-base text-muted-foreground">
Used {usageCount} times
</Text>
)}
{lastUsed && (
<Text className="text-base text-muted-foreground">
Last used: {lastUsed.toLocaleDateString()}
</Text>
)}
</View>
</View>
)}
{/* Edit Button */}
{onEdit && (
<Button
onPress={onEdit}
className="w-full mt-2"
>
<Edit2 size={18} className="mr-2 text-primary-foreground" />
<Text className="text-primary-foreground font-semibold">
Edit Exercise
</Text>
</Button>
)}
</View>
</ScrollView>
);
}
// Progress Tab Component
function ProgressTab({ exercise }: { exercise: ExerciseDisplay }) {
return (
<ScrollView
className="flex-1 px-4"
contentContainerStyle={{ paddingBottom: 20 }}
>
<View className="gap-6 py-4">
{/* Placeholder for Charts */}
<View className="h-48 bg-muted rounded-lg items-center justify-center">
<LineChart size={24} className="text-muted-foreground mb-2" />
<Text className="text-muted-foreground">Progress charts coming soon</Text>
</View>
{/* Personal Records Section */}
<View>
<Text className="text-base font-semibold text-foreground mb-4">Personal Records</Text>
<View className="gap-4">
<View className="bg-card p-4 rounded-lg">
<Text className="text-sm text-muted-foreground">Max Weight</Text>
<Text className="text-lg font-semibold text-foreground">-- kg</Text>
</View>
<View className="bg-card p-4 rounded-lg">
<Text className="text-sm text-muted-foreground">Max Reps</Text>
<Text className="text-lg font-semibold text-foreground">--</Text>
</View>
<View className="bg-card p-4 rounded-lg">
<Text className="text-sm text-muted-foreground">Best Volume</Text>
<Text className="text-lg font-semibold text-foreground">-- kg</Text>
</View>
</View>
</View>
</View>
</ScrollView>
);
}
// Form Tab Component
function FormTab({ exercise }: { exercise: ExerciseDisplay }) {
const { instructions = [] } = exercise;
return (
<ScrollView
className="flex-1 px-4"
contentContainerStyle={{ paddingBottom: 20 }}
>
<View className="gap-6 py-4">
{/* Instructions Section */}
{instructions.length > 0 ? (
<View>
<Text className="text-base font-semibold text-foreground mb-4">Instructions</Text>
<View className="gap-4">
{instructions.map((instruction: string, index: number) => (
<View key={index} className="flex-row gap-3">
<Text className="text-sm font-medium text-muted-foreground min-w-[24px]">
{index + 1}.
</Text>
<Text className="text-base text-foreground flex-1">{instruction}</Text>
</View>
))}
</View>
</View>
) : (
<View className="items-center justify-center py-8">
<AlertCircle size={24} className="text-muted-foreground mb-2" />
<Text className="text-muted-foreground">No form instructions available</Text>
</View>
)}
{/* Placeholder for Media */}
<View className="h-48 bg-muted rounded-lg items-center justify-center">
<Text className="text-muted-foreground">Video demos coming soon</Text>
</View>
</View>
</ScrollView>
);
}
// Settings Tab Component
function SettingsTab({ exercise }: { exercise: ExerciseDisplay }) {
return (
<ScrollView
className="flex-1 px-4"
contentContainerStyle={{ paddingBottom: 20 }}
>
<View className="gap-6 py-4">
{/* Format Settings */}
<View>
<Text className="text-base font-semibold text-foreground mb-4">Exercise Settings</Text>
<View className="gap-4">
<View className="bg-card p-4 rounded-lg">
<Text className="text-sm text-muted-foreground mb-1">Format</Text>
<View className="flex-row flex-wrap gap-2">
{exercise.format && Object.entries(exercise.format).map(([key, enabled]) => (
enabled && (
<Badge key={key} variant="secondary">
<Text>{key}</Text>
</Badge>
)
))}
</View>
</View>
<View className="bg-card p-4 rounded-lg">
<Text className="text-sm text-muted-foreground mb-1">Units</Text>
<View className="flex-row flex-wrap gap-2">
{exercise.format_units && Object.entries(exercise.format_units).map(([key, unit]) => (
<Badge key={key} variant="secondary">
<Text>{key}: {String(unit)}</Text>
</Badge>
))}
</View>
</View>
</View>
</View>
</View>
</ScrollView>
);
}
export function ExerciseDetails({
exercise,
open,
onOpenChange,
onEdit
}: ExerciseDetailsProps) {
const theme = useTheme() as CustomTheme;
return (
<Sheet isOpen={open} onClose={() => onOpenChange(false)}>
<SheetHeader>
<SheetTitle>
<Text className="text-xl font-bold text-foreground">{exercise.title}</Text>
</SheetTitle>
</SheetHeader>
<SheetContent>
<View style={{ flex: 1, minHeight: 400 }} className="rounded-t-[10px]">
<Tab.Navigator
style={{ flex: 1 }}
screenOptions={{
tabBarActiveTintColor: theme.colors.tabIndicator,
tabBarInactiveTintColor: theme.colors.tabInactive,
tabBarLabelStyle: {
fontSize: 13,
textTransform: 'capitalize',
fontWeight: 'bold',
marginHorizontal: -4,
},
tabBarIndicatorStyle: {
backgroundColor: theme.colors.tabIndicator,
height: 2,
},
tabBarStyle: {
backgroundColor: theme.colors.background,
elevation: 0,
shadowOpacity: 0,
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
tabBarPressColor: theme.colors.primary,
}}
>
<Tab.Screen
name="info"
options={{ title: 'Info' }}
>
{() => <InfoTab exercise={exercise} onEdit={onEdit} />}
</Tab.Screen>
<Tab.Screen
name="progress"
options={{ title: 'Progress' }}
>
{() => <ProgressTab exercise={exercise} />}
</Tab.Screen>
<Tab.Screen
name="form"
options={{ title: 'Form' }}
>
{() => <FormTab exercise={exercise} />}
</Tab.Screen>
<Tab.Screen
name="settings"
options={{ title: 'Settings' }}
>
{() => <SettingsTab exercise={exercise} />}
</Tab.Screen>
</Tab.Navigator>
</View>
</SheetContent>
</Sheet>
);
}

@ -0,0 +1,556 @@
// components/exercises/ModalExerciseDetails.tsx
import React from 'react';
import { View, ScrollView, Modal, TouchableOpacity } from 'react-native';
import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs';
import { NavigationContainer } from '@react-navigation/native';
import { Text } from '@/components/ui/text';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import {
Dumbbell,
Target,
Calendar,
Hash,
AlertCircle,
LineChart,
Settings,
X
} from 'lucide-react-native';
import { ExerciseDisplay } from '@/types/exercise';
import { useTheme } from '@react-navigation/native';
import { useColorScheme } from '@/lib/useColorScheme';
import type { CustomTheme } from '@/lib/theme';
const Tab = createMaterialTopTabNavigator();
interface ModalExerciseDetailsProps {
exercise: ExerciseDisplay | null;
open: boolean;
onClose: () => void;
onEdit?: () => void;
}
// Info Tab Component
function InfoTab({ exercise, onEdit }: { exercise: ExerciseDisplay; onEdit?: () => void }) {
const {
title,
type,
category,
equipment,
description,
instructions = [],
tags = [],
source = 'local',
usageCount,
lastUsed
} = exercise;
return (
<ScrollView
className="flex-1 px-4"
contentContainerStyle={{ paddingBottom: 20 }}
>
<View className="gap-6 py-4">
{/* Basic Info Section */}
<View className="flex-row items-center gap-2">
<Badge
variant={source === 'local' ? 'outline' : 'secondary'}
className="capitalize"
>
<Text>{source}</Text>
</Badge>
<Badge
variant="outline"
className="capitalize bg-muted"
>
<Text>{type}</Text>
</Badge>
</View>
<Separator className="bg-border" />
{/* Category & Equipment Section */}
<View className="space-y-4">
<View className="flex-row items-center gap-2">
<View className="w-8 h-8 items-center justify-center rounded-md bg-muted">
<Target size={18} className="text-muted-foreground" />
</View>
<View>
<Text className="text-sm text-muted-foreground">Category</Text>
<Text className="text-base font-medium text-foreground">{category}</Text>
</View>
</View>
{equipment && (
<View className="flex-row items-center gap-2">
<View className="w-8 h-8 items-center justify-center rounded-md bg-muted">
<Dumbbell size={18} className="text-muted-foreground" />
</View>
<View>
<Text className="text-sm text-muted-foreground">Equipment</Text>
<Text className="text-base font-medium text-foreground capitalize">{equipment}</Text>
</View>
</View>
)}
</View>
{/* Description Section */}
{description && (
<View>
<Text className="text-base font-semibold text-foreground mb-2">Description</Text>
<Text className="text-base text-muted-foreground leading-relaxed">{description}</Text>
</View>
)}
{/* Tags Section */}
{tags.length > 0 && (
<View>
<View className="flex-row items-center gap-2 mb-2">
<Hash size={16} className="text-muted-foreground" />
<Text className="text-base font-semibold text-foreground">Tags</Text>
</View>
<View className="flex-row flex-wrap gap-2">
{tags.map((tag: string) => (
<Badge key={tag} variant="secondary">
<Text>{tag}</Text>
</Badge>
))}
</View>
</View>
)}
{/* Usage Stats Section */}
{(usageCount || lastUsed) && (
<View>
<View className="flex-row items-center gap-2 mb-2">
<Calendar size={16} className="text-muted-foreground" />
<Text className="text-base font-semibold text-foreground">Usage</Text>
</View>
<View className="gap-2">
{usageCount && (
<Text className="text-base text-muted-foreground">
Used {usageCount} times
</Text>
)}
{lastUsed && (
<Text className="text-base text-muted-foreground">
Last used: {lastUsed.toLocaleDateString()}
</Text>
)}
</View>
</View>
)}
{/* Edit Button */}
{onEdit && (
<Button
onPress={onEdit}
className="w-full mt-2"
style={{ backgroundColor: 'hsl(261, 90%, 66%)' }}
>
<Text className="text-white font-semibold">
Edit Exercise
</Text>
</Button>
)}
</View>
</ScrollView>
);
}
// Progress Tab Component
function ProgressTab({ exercise }: { exercise: ExerciseDisplay }) {
// Mock data for the progress chart
const weightData = [
{ date: 'Jan 10', weight: 135 },
{ date: 'Jan 17', weight: 140 },
{ date: 'Jan 24', weight: 145 },
{ date: 'Jan 31', weight: 150 },
{ date: 'Feb 7', weight: 155 },
{ date: 'Feb 14', weight: 155 },
{ date: 'Feb 21', weight: 160 },
{ date: 'Feb 28', weight: 165 },
];
const volumeData = [
{ date: 'Jan 10', volume: 1350 },
{ date: 'Jan 17', volume: 1400 },
{ date: 'Jan 24', volume: 1450 },
{ date: 'Jan 31', volume: 1500 },
{ date: 'Feb 7', volume: 1550 },
{ date: 'Feb 14', volume: 1550 },
{ date: 'Feb 21', volume: 1600 },
{ date: 'Feb 28', volume: 1650 },
];
return (
<ScrollView
className="flex-1 px-4"
contentContainerStyle={{ paddingBottom: 20 }}
>
<View className="gap-6 py-4">
{/* Progress Chart */}
<View>
<Text className="text-base font-semibold text-foreground mb-2">Weight Progress</Text>
<View className="h-56 bg-card rounded-lg p-4 overflow-hidden">
<View className="flex-row justify-between mb-2">
<Text className="text-sm text-muted-foreground">Max: 165 kg</Text>
<Text className="text-sm text-muted-foreground">Last 8 weeks</Text>
</View>
{/* Chart Container */}
<View className="flex-1 mt-2">
{/* Y-axis labels */}
<View className="absolute left-0 h-full justify-between py-2">
<Text className="text-xs text-muted-foreground">165kg</Text>
<Text className="text-xs text-muted-foreground">150kg</Text>
<Text className="text-xs text-muted-foreground">135kg</Text>
</View>
{/* Chart visualization */}
<View className="flex-1 ml-8 flex-row items-end justify-between">
{weightData.map((item, index) => (
<View key={index} className="items-center">
<View
className="bg-primary w-5 rounded-t-sm"
style={{
height: ((item.weight - 130) / (170 - 130)) * 120,
opacity: 0.7 + ((item.weight - 135) / (165 - 135)) * 0.3
}}
/>
<Text className="text-xs text-muted-foreground rotate-45 mt-2 origin-left">
{item.date.split(' ')[1]}
</Text>
</View>
))}
</View>
{/* Grid lines */}
<View className="absolute left-8 right-0 top-0 bottom-0">
<View className="border-t border-border absolute top-0 left-0 right-0" />
<View className="border-t border-border absolute top-1/3 left-0 right-0" />
<View className="border-t border-border absolute top-2/3 left-0 right-0" />
<View className="border-t border-border absolute bottom-0 left-0 right-0" />
</View>
</View>
</View>
</View>
{/* Volume Chart */}
<View>
<Text className="text-base font-semibold text-foreground mb-2">Volume Progress</Text>
<View className="h-56 bg-card rounded-lg p-4 overflow-hidden">
{/* Chart Content */}
<View className="flex-row justify-between mb-2">
<Text className="text-sm text-muted-foreground">Total: 10,500 kg</Text>
<Text className="text-sm text-muted-foreground">Last 8 weeks</Text>
</View>
{/* Line chart for volume */}
<View className="flex-1 mt-2">
{/* Y-axis labels */}
<View className="absolute left-0 h-full justify-between py-2">
<Text className="text-xs text-muted-foreground">1650</Text>
<Text className="text-xs text-muted-foreground">1500</Text>
<Text className="text-xs text-muted-foreground">1350</Text>
</View>
{/* Line chart */}
<View className="flex-1 ml-8 relative">
{/* Draw the line */}
<View className="absolute left-0 right-0 top-0 bottom-0">
<View
className="absolute bg-green-500 h-1 rounded-full"
style={{
top: '70%',
left: '0%',
width: '12%',
transform: [{ rotate: '-15deg' }]
}}
/>
<View
className="absolute bg-green-500 h-1 rounded-full"
style={{
top: '65%',
left: '12%',
width: '12%',
transform: [{ rotate: '-10deg' }]
}}
/>
<View
className="absolute bg-green-500 h-1 rounded-full"
style={{
top: '60%',
left: '24%',
width: '12%',
transform: [{ rotate: '-5deg' }]
}}
/>
<View
className="absolute bg-green-500 h-1 rounded-full"
style={{
top: '58%',
left: '36%',
width: '12%',
transform: [{ rotate: '0deg' }]
}}
/>
<View
className="absolute bg-green-500 h-1 rounded-full"
style={{
top: '55%',
left: '48%',
width: '12%',
transform: [{ rotate: '-5deg' }]
}}
/>
<View
className="absolute bg-green-500 h-1 rounded-full"
style={{
top: '52%',
left: '60%',
width: '12%',
transform: [{ rotate: '-5deg' }]
}}
/>
<View
className="absolute bg-green-500 h-1 rounded-full"
style={{
top: '47%',
left: '72%',
width: '12%',
transform: [{ rotate: '-10deg' }]
}}
/>
<View
className="absolute bg-green-500 h-1 rounded-full"
style={{
top: '40%',
left: '84%',
width: '12%',
transform: [{ rotate: '-15deg' }]
}}
/>
{/* Data points */}
{volumeData.map((item, index) => (
<View
key={index}
className="absolute h-3 w-3 rounded-full bg-white border-2 border-green-500"
style={{
left: `${index * 12.5 + 6}%`,
top: `${70 - ((item.volume - 1350) / 300) * 30}%`
}}
/>
))}
</View>
{/* X-axis labels */}
<View className="absolute bottom-0 left-0 right-0 flex-row justify-between">
{volumeData.map((item, index) => (
<Text key={index} className="text-xs text-muted-foreground">
{item.date.split(' ')[1]}
</Text>
))}
</View>
</View>
{/* Grid lines */}
<View className="absolute left-8 right-0 top-0 bottom-0">
<View className="border-t border-border absolute top-0 left-0 right-0" />
<View className="border-t border-border absolute top-1/3 left-0 right-0" />
<View className="border-t border-border absolute top-2/3 left-0 right-0" />
<View className="border-t border-border absolute bottom-0 left-0 right-0" />
</View>
</View>
</View>
</View>
{/* Personal Records Section */}
<View>
<Text className="text-base font-semibold text-foreground mb-4">Personal Records</Text>
<View className="gap-4">
<View className="bg-card p-4 rounded-lg">
<Text className="text-sm text-muted-foreground">Max Weight</Text>
<Text className="text-lg font-semibold text-foreground">165 kg</Text>
<Text className="text-xs text-muted-foreground mt-1">Achieved on Feb 28, 2025</Text>
</View>
<View className="bg-card p-4 rounded-lg">
<Text className="text-sm text-muted-foreground">Max Reps</Text>
<Text className="text-lg font-semibold text-foreground">12 reps at 135 kg</Text>
<Text className="text-xs text-muted-foreground mt-1">Achieved on Jan 10, 2025</Text>
</View>
<View className="bg-card p-4 rounded-lg">
<Text className="text-sm text-muted-foreground">Best Volume</Text>
<Text className="text-lg font-semibold text-foreground">1650 kg</Text>
<Text className="text-xs text-muted-foreground mt-1">Achieved on Feb 28, 2025</Text>
</View>
</View>
</View>
</View>
</ScrollView>
);
}
// Form Tab Component
function FormTab({ exercise }: { exercise: ExerciseDisplay }) {
const { instructions = [] } = exercise;
return (
<ScrollView
className="flex-1 px-4"
contentContainerStyle={{ paddingBottom: 20 }}
>
<View className="gap-6 py-4">
{/* Instructions Section */}
{instructions.length > 0 ? (
<View>
<Text className="text-base font-semibold text-foreground mb-4">Instructions</Text>
<View className="gap-4">
{instructions.map((instruction: string, index: number) => (
<View key={index} className="flex-row gap-3">
<Text className="text-sm font-medium text-muted-foreground min-w-[24px]">
{index + 1}.
</Text>
<Text className="text-base text-foreground flex-1">{instruction}</Text>
</View>
))}
</View>
</View>
) : (
<View className="items-center justify-center py-8">
<AlertCircle size={24} className="text-muted-foreground mb-2" />
<Text className="text-muted-foreground">No form instructions available</Text>
</View>
)}
{/* Placeholder for Media */}
<View className="h-48 bg-muted rounded-lg items-center justify-center">
<Text className="text-muted-foreground">Video demos coming soon</Text>
</View>
</View>
</ScrollView>
);
}
// Settings Tab Component
function SettingsTab({ exercise }: { exercise: ExerciseDisplay }) {
return (
<ScrollView
className="flex-1 px-4"
contentContainerStyle={{ paddingBottom: 20 }}
>
<View className="gap-6 py-4">
{/* Format Settings */}
<View>
<Text className="text-base font-semibold text-foreground mb-4">Exercise Settings</Text>
<View className="gap-4">
<View className="bg-card p-4 rounded-lg">
<Text className="text-sm text-muted-foreground mb-1">Format</Text>
<View className="flex-row flex-wrap gap-2">
{exercise.format && Object.entries(exercise.format).map(([key, enabled]) => (
enabled && (
<Badge key={key} variant="secondary">
<Text>{key}</Text>
</Badge>
)
))}
</View>
</View>
<View className="bg-card p-4 rounded-lg">
<Text className="text-sm text-muted-foreground mb-1">Units</Text>
<View className="flex-row flex-wrap gap-2">
{exercise.format_units && Object.entries(exercise.format_units).map(([key, unit]) => (
<Badge key={key} variant="secondary">
<Text>{key}: {String(unit)}</Text>
</Badge>
))}
</View>
</View>
</View>
</View>
</View>
</ScrollView>
);
}
export function ModalExerciseDetails({
exercise,
open,
onClose,
onEdit
}: ModalExerciseDetailsProps) {
const theme = useTheme() as CustomTheme;
const { isDarkColorScheme } = useColorScheme();
// Return null if not open or if exercise is null
if (!open || !exercise) return null;
return (
<Modal
visible={open}
transparent={true}
animationType="slide"
onRequestClose={onClose}
>
<View className="flex-1 justify-center items-center bg-black/70">
<View
className={`bg-background ${isDarkColorScheme ? 'bg-card border border-border' : ''} rounded-lg w-[95%] h-[85%] max-w-xl shadow-xl overflow-hidden`}
style={{ maxHeight: 700 }}
>
{/* Header */}
<View className="flex-row justify-between items-center p-4 border-b border-border">
<Text className="text-xl font-bold text-foreground">{exercise.title}</Text>
<TouchableOpacity onPress={onClose} className="p-1">
<X size={24} />
</TouchableOpacity>
</View>
{/* Tab Navigator */}
<View style={{ flex: 1 }}>
{/* Using NavigationContainer without the independent prop */}
{/* Let's use a more compatible approach without NavigationContainer */}
<Tab.Navigator
screenOptions={{
tabBarActiveTintColor: theme.colors.tabIndicator,
tabBarInactiveTintColor: theme.colors.tabInactive,
tabBarLabelStyle: {
fontSize: 13,
textTransform: 'capitalize',
fontWeight: 'bold',
marginHorizontal: -4,
},
tabBarIndicatorStyle: {
backgroundColor: theme.colors.tabIndicator,
height: 2,
},
tabBarStyle: {
backgroundColor: theme.colors.background,
elevation: 0,
shadowOpacity: 0,
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
tabBarPressColor: theme.colors.primary,
}}
>
<Tab.Screen name="Info">
{() => <InfoTab exercise={exercise} onEdit={onEdit} />}
</Tab.Screen>
<Tab.Screen name="Progress">
{() => <ProgressTab exercise={exercise} />}
</Tab.Screen>
<Tab.Screen name="Form">
{() => <FormTab exercise={exercise} />}
</Tab.Screen>
<Tab.Screen name="Settings">
{() => <SettingsTab exercise={exercise} />}
</Tab.Screen>
</Tab.Navigator>
</View>
</View>
</View>
</Modal>
);
}

@ -0,0 +1,475 @@
// components/library/ExerciseSheet.tsx
import React, { useState, useEffect } from 'react';
import { View, ScrollView, KeyboardAvoidingView, Platform, TouchableWithoutFeedback,
Keyboard, Modal, TouchableOpacity } from 'react-native';
import { Text } from '@/components/ui/text';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { generateId } from '@/utils/ids';
import { X } from 'lucide-react-native';
import { useColorScheme } from '@/lib/useColorScheme';
import {
BaseExercise,
ExerciseType,
ExerciseCategory,
Equipment,
ExerciseFormat,
ExerciseFormatUnits,
ExerciseDisplay
} from '@/types/exercise';
import { StorageSource } from '@/types/shared';
import { useNDKStore } from '@/lib/stores/ndk';
import { useEventCache } from '@/components/DatabaseProvider';
interface ExerciseSheetProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (exercise: BaseExercise) => void;
exerciseToEdit?: ExerciseDisplay; // Optional - if provided, we're in edit mode
mode?: 'create' | 'edit' | 'fork'; // Optional - defaults to 'create' or 'edit' based on exerciseToEdit
}
const EXERCISE_TYPES: ExerciseType[] = ['strength', 'cardio', 'bodyweight'];
const CATEGORIES: ExerciseCategory[] = ['Push', 'Pull', 'Legs', 'Core'];
const EQUIPMENT_OPTIONS: Equipment[] = [
'bodyweight',
'barbell',
'dumbbell',
'kettlebell',
'machine',
'cable',
'other'
];
// Default empty form data
const DEFAULT_FORM_DATA = {
title: '',
type: 'strength' as ExerciseType,
category: 'Push' as ExerciseCategory,
equipment: undefined as Equipment | undefined,
description: '',
tags: [] as string[],
format: {
weight: true,
reps: true,
rpe: true,
set_type: true
} as ExerciseFormat,
format_units: {
weight: 'kg',
reps: 'count',
rpe: '0-10',
set_type: 'warmup|normal|drop|failure'
} as ExerciseFormatUnits
};
export function ExerciseSheet({ isOpen, onClose, onSubmit, exerciseToEdit, mode: explicitMode }: ExerciseSheetProps) {
const { isDarkColorScheme } = useColorScheme();
const [formData, setFormData] = useState(DEFAULT_FORM_DATA);
const ndkStore = useNDKStore();
const eventCache = useEventCache();
// Determine if we're in edit, create, or fork mode
const hasExercise = !!exerciseToEdit;
const isNostrExercise = exerciseToEdit?.source === 'nostr';
const isCurrentUserAuthor = isNostrExercise &&
exerciseToEdit?.availability?.lastSynced?.nostr?.metadata?.pubkey === ndkStore.currentUser?.pubkey;
// Use explicit mode if provided, otherwise determine based on context
const mode = explicitMode || (hasExercise ? (isNostrExercise && !isCurrentUserAuthor ? 'fork' : 'edit') : 'create');
const isEditMode = mode === 'edit';
const isForkMode = mode === 'fork';
// Load data from exerciseToEdit when in edit mode
useEffect(() => {
if (isOpen && exerciseToEdit) {
setFormData({
title: exerciseToEdit.title,
type: exerciseToEdit.type,
category: exerciseToEdit.category,
equipment: exerciseToEdit.equipment,
description: exerciseToEdit.description || '',
tags: exerciseToEdit.tags || [],
format: exerciseToEdit.format || DEFAULT_FORM_DATA.format,
format_units: exerciseToEdit.format_units || DEFAULT_FORM_DATA.format_units
});
} else if (isOpen && !exerciseToEdit) {
// Reset form when opening in create mode
setFormData(DEFAULT_FORM_DATA);
}
}, [isOpen, exerciseToEdit]);
// Reset form data when modal closes
useEffect(() => {
if (!isOpen) {
// Add a delay to ensure the closing animation completes first
const timer = setTimeout(() => {
setFormData(DEFAULT_FORM_DATA);
}, 300);
return () => clearTimeout(timer);
}
}, [isOpen]);
const handleSubmit = async () => {
if (!formData.title || !formData.equipment) return;
const timestamp = Date.now();
const isNostrExercise = exerciseToEdit?.source === 'nostr';
const canEditNostr = isNostrExercise && isCurrentUserAuthor;
// Create BaseExercise
const exercise: BaseExercise = {
// Generate new ID when forking, otherwise use existing or generate new
id: isForkMode ? generateId() : (exerciseToEdit?.id || generateId()),
title: formData.title,
type: formData.type,
category: formData.category,
equipment: formData.equipment,
description: formData.description,
tags: formData.tags.length ? formData.tags : [formData.category.toLowerCase()],
format: formData.format,
format_units: formData.format_units,
// Use current timestamp for fork, otherwise preserve original or use current
created_at: isForkMode ? timestamp : (exerciseToEdit?.created_at || timestamp),
// For forked exercises, create new local availability
availability: isForkMode ? {
source: ['local' as StorageSource],
lastSynced: undefined
} : (exerciseToEdit?.availability || {
source: ['local' as StorageSource],
lastSynced: undefined
})
};
// If this is a Nostr exercise we can edit OR a new exercise while authenticated,
// we should create and possibly publish the Nostr event
if ((canEditNostr || (!exerciseToEdit && ndkStore.isAuthenticated)) && !isForkMode) {
try {
// Create tags for the exercise
const nostrTags = [
['d', exercise.id], // Use the same 'd' tag to make it replaceable
['title', exercise.title],
['type', exercise.type],
['category', exercise.category],
['equipment', exercise.equipment || ''],
...(exercise.tags.map(tag => ['t', tag])),
// Format tags - handle possible undefined with null coalescing operator
['format', ...Object.keys(exercise.format || {}).filter(k =>
exercise.format && exercise.format[k as keyof ExerciseFormat]
)]
];
// Add format units if they exist
if (exercise.format_units) {
const unitEntries = Object.entries(exercise.format_units);
if (unitEntries.length > 0) {
nostrTags.push(['format_units', ...unitEntries.flat()]);
}
}
// Create and attempt to publish the event
const event = await ndkStore.createEvent(33401, exercise.description || '', nostrTags);
if (event) {
// Queue for publication (this will publish immediately if online)
await ndkStore.queueEventForPublishing(event);
// If this is a new exercise, add nostr to sources
if (!exerciseToEdit) {
exercise.availability.source.push('nostr');
// Add nostr metadata
exercise.availability.lastSynced = {
...exercise.availability.lastSynced,
nostr: {
timestamp: Date.now(),
metadata: {
id: event.id || exercise.id,
pubkey: ndkStore.currentUser?.pubkey || '',
relayUrl: 'wss://relay.damus.io', // Default relay
created_at: event.created_at || Math.floor(Date.now() / 1000)
}
}
};
}
console.log(isEditMode ? 'Exercise updated on Nostr' : 'Exercise published to Nostr');
}
} catch (error) {
console.error('Error with Nostr event:', error);
// Continue with local update even if Nostr fails
}
}
// Close first, then submit with a small delay
onClose();
setTimeout(() => {
onSubmit(exercise);
}, 50);
};
// Purple color used throughout the app
const purpleColor = 'hsl(261, 90%, 66%)';
// Get title and button text based on mode
const getTitle = () => {
if (isEditMode) return "Edit Exercise";
if (isForkMode) return "Fork Exercise";
return "Create New Exercise";
};
const getButtonText = () => {
if (isEditMode) return "Update Exercise";
if (isForkMode) return "Save as My Exercise";
return "Create Exercise";
};
// Return null if not open
if (!isOpen) return null;
return (
<Modal
visible={isOpen}
transparent={true}
animationType="slide"
onRequestClose={onClose}
>
<View className="flex-1 justify-center items-center bg-black/70">
<View
className={`bg-background ${isDarkColorScheme ? 'bg-card border border-border' : ''} rounded-lg w-[95%] h-[85%] max-w-xl shadow-xl overflow-hidden`}
style={{ maxHeight: 700 }}
>
{/* Header */}
<View className="flex-row justify-between items-center p-4 border-b border-border">
<Text className="text-xl font-bold text-foreground">{getTitle()}</Text>
<TouchableOpacity onPress={onClose} className="p-1">
<X size={24} />
</TouchableOpacity>
</View>
{/* Content */}
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={{ flex: 1 }}
>
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
<View style={{ flex: 1 }}>
<ScrollView className="flex-1" showsVerticalScrollIndicator={false}>
<View className="gap-5 py-5 px-4">
{/* Source badge for edit/fork mode */}
{(isEditMode || isForkMode) && (
<View className="flex-row mb-2 items-center gap-2">
<View className={`px-2 py-1 rounded-md ${exerciseToEdit?.source === 'nostr' ? 'bg-purple-100 dark:bg-purple-900' : 'bg-blue-100 dark:bg-blue-900'}`}>
<Text className={`text-xs ${exerciseToEdit?.source === 'nostr' ? 'text-purple-800 dark:text-purple-200' : 'text-blue-800 dark:text-blue-200'}`}>
{exerciseToEdit?.source === 'nostr' ? 'Nostr' : exerciseToEdit?.source}
</Text>
</View>
{/* Show forked badge when in fork mode */}
{isForkMode && (
<View className="px-2 py-1 rounded-md bg-amber-100 dark:bg-amber-900">
<Text className="text-xs text-amber-800 dark:text-amber-200">
Creating Local Copy
</Text>
</View>
)}
</View>
)}
<View>
<Text className="text-base font-medium mb-2">Exercise Name</Text>
<Input
value={formData.title}
onChangeText={(text) => setFormData(prev => ({ ...prev, title: text }))}
placeholder="e.g., Barbell Back Squat"
className="text-foreground"
/>
{!formData.title && (
<Text className="text-xs text-muted-foreground mt-1 ml-1">
* Required field
</Text>
)}
</View>
<View>
<Text className="text-base font-medium mb-2">Type</Text>
<View className="flex-row flex-wrap gap-2">
{EXERCISE_TYPES.map((type) => (
<Button
key={type}
variant={formData.type === type ? 'default' : 'outline'}
onPress={() => setFormData(prev => ({ ...prev, type }))}
style={formData.type === type ? { backgroundColor: purpleColor } : {}}
>
<Text className={formData.type === type ? 'text-white' : ''}>
{type.charAt(0).toUpperCase() + type.slice(1)}
</Text>
</Button>
))}
</View>
</View>
<View>
<Text className="text-base font-medium mb-2">Category</Text>
<View className="flex-row flex-wrap gap-2">
{CATEGORIES.map((category) => (
<Button
key={category}
variant={formData.category === category ? 'default' : 'outline'}
onPress={() => setFormData(prev => ({ ...prev, category }))}
style={formData.category === category ? { backgroundColor: purpleColor } : {}}
>
<Text className={formData.category === category ? 'text-white' : ''}>
{category}
</Text>
</Button>
))}
</View>
</View>
<View>
<Text className="text-base font-medium mb-2">Equipment</Text>
<View className="flex-row flex-wrap gap-2">
{EQUIPMENT_OPTIONS.map((eq) => (
<Button
key={eq}
variant={formData.equipment === eq ? 'default' : 'outline'}
onPress={() => setFormData(prev => ({ ...prev, equipment: eq }))}
style={formData.equipment === eq ? { backgroundColor: purpleColor } : {}}
>
<Text className={formData.equipment === eq ? 'text-white' : ''}>
{eq.charAt(0).toUpperCase() + eq.slice(1)}
</Text>
</Button>
))}
</View>
{!formData.equipment && (
<Text className="text-xs text-muted-foreground mt-1 ml-1">
* Required field
</Text>
)}
</View>
<View>
<Text className="text-base font-medium mb-2">Description</Text>
<Input
value={formData.description}
onChangeText={(text) => setFormData(prev => ({ ...prev, description: text }))}
placeholder="Exercise description..."
multiline
numberOfLines={4}
textAlignVertical="top"
className="min-h-24 py-2"
/>
</View>
<View>
<Text className="text-base font-medium mb-2">Tags</Text>
<Input
value={formData.tags.join(', ')}
onChangeText={(text) => {
const tags = text.split(',')
.map(tag => tag.trim())
.filter(tag => tag.length > 0);
setFormData(prev => ({ ...prev, tags }));
}}
placeholder="strength, compound, legs..."
className="text-foreground"
/>
<Text className="text-xs text-muted-foreground mt-1 ml-1">
Separate tags with commas
</Text>
</View>
{/* Additional Nostr information */}
{exerciseToEdit?.source === 'nostr' && exerciseToEdit?.availability?.lastSynced?.nostr && (
<View className="mt-2 p-3 bg-muted rounded-md">
<Text className="text-sm text-muted-foreground">
Last synced with Nostr: {new Date(exerciseToEdit.availability.lastSynced.nostr.timestamp).toLocaleString()}
</Text>
{isEditMode && !isCurrentUserAuthor && (
<Text className="text-xs text-amber-500 mt-1">
You're not the original author. Use the "Fork" option to create your own copy.
</Text>
)}
{isEditMode && isCurrentUserAuthor && !ndkStore.isAuthenticated && (
<Text className="text-xs">
Changes will be saved locally and synced to Nostr when you're online and logged in.
</Text>
)}
{isForkMode && (
<Text className="text-xs text-green-500 mt-1">
Creating a local copy of this exercise that you can customize
</Text>
)}
{isNostrExercise && exerciseToEdit.availability.lastSynced.nostr.metadata.pubkey && (
<Text className="text-xs text-muted-foreground mt-1">
Author: {exerciseToEdit.availability.lastSynced.nostr.metadata.pubkey.substring(0, 8)}...
</Text>
)}
</View>
)}
</View>
</ScrollView>
{/* Create/Update button at bottom */}
<View className="p-4 border-t border-border">
{/* Show fork button when editing Nostr content we don't own */}
{isEditMode && isNostrExercise && !isCurrentUserAuthor ? (
<View className="flex-row gap-2">
<Button
className="flex-1 py-5"
variant='outline'
onPress={onClose}
>
<Text>Cancel</Text>
</Button>
<Button
className="flex-1 py-5"
variant='default'
onPress={() => {
// Close this modal and reopen in fork mode
onClose();
// This would be implemented in the parent component
// by reopening the modal with mode="fork"
}}
style={{ backgroundColor: purpleColor }}
>
<Text className="text-white font-semibold">
Fork Exercise
</Text>
</Button>
</View>
) : (
// Regular submit button for create/edit/fork
<Button
className="w-full py-5"
variant='default'
onPress={handleSubmit}
disabled={!formData.title || !formData.equipment}
style={(!formData.title || !formData.equipment)
? {}
: { backgroundColor: purpleColor }}
>
<Text className={(!formData.title || !formData.equipment)
? 'text-white opacity-50'
: 'text-white font-semibold'}>
{getButtonText()}
</Text>
</Button>
)}
</View>
</View>
</TouchableWithoutFeedback>
</KeyboardAvoidingView>
</View>
</View>
</Modal>
);
}

@ -1,230 +0,0 @@
// components/library/NewExerciseSheet.tsx
import React, { useState } from 'react';
import { View, ScrollView, KeyboardAvoidingView, Platform, TouchableWithoutFeedback, Keyboard } from 'react-native';
import { Text } from '@/components/ui/text';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { generateId } from '@/utils/ids';
import {
BaseExercise,
ExerciseType,
ExerciseCategory,
Equipment,
ExerciseFormat,
ExerciseFormatUnits
} from '@/types/exercise';
import { StorageSource } from '@/types/shared';
interface NewExerciseSheetProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (exercise: BaseExercise) => void;
}
const EXERCISE_TYPES: ExerciseType[] = ['strength', 'cardio', 'bodyweight'];
const CATEGORIES: ExerciseCategory[] = ['Push', 'Pull', 'Legs', 'Core'];
const EQUIPMENT_OPTIONS: Equipment[] = [
'bodyweight',
'barbell',
'dumbbell',
'kettlebell',
'machine',
'cable',
'other'
];
export function NewExerciseSheet({ isOpen, onClose, onSubmit }: NewExerciseSheetProps) {
const [formData, setFormData] = useState({
title: '',
type: 'strength' as ExerciseType,
category: 'Push' as ExerciseCategory,
equipment: undefined as Equipment | undefined,
description: '',
tags: [] as string[],
format: {
weight: true,
reps: true,
rpe: true,
set_type: true
} as ExerciseFormat,
format_units: {
weight: 'kg',
reps: 'count',
rpe: '0-10',
set_type: 'warmup|normal|drop|failure'
} as ExerciseFormatUnits
});
const handleSubmit = () => {
if (!formData.title || !formData.equipment) return;
const timestamp = Date.now();
// Create BaseExercise
const exercise: BaseExercise = {
id: generateId(),
title: formData.title,
type: formData.type,
category: formData.category,
equipment: formData.equipment,
description: formData.description,
tags: formData.tags.length ? formData.tags : [formData.category.toLowerCase()],
format: formData.format,
format_units: formData.format_units,
created_at: timestamp,
availability: {
source: ['local' as StorageSource],
lastSynced: undefined
}
};
onSubmit(exercise);
// Reset form
setFormData({
title: '',
type: 'strength',
category: 'Push',
equipment: undefined,
description: '',
tags: [],
format: {
weight: true,
reps: true,
rpe: true,
set_type: true
},
format_units: {
weight: 'kg',
reps: 'count',
rpe: '0-10',
set_type: 'warmup|normal|drop|failure'
}
});
onClose();
};
// Purple color used throughout the app
const purpleColor = 'hsl(261, 90%, 66%)';
return (
<Sheet isOpen={isOpen} onClose={onClose}>
<SheetContent>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={{ flex: 1 }}
>
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
<View style={{ flex: 1 }}>
<SheetHeader>
<SheetTitle>Create New Exercise</SheetTitle>
</SheetHeader>
<ScrollView className="flex-1" showsVerticalScrollIndicator={false}>
<View className="gap-5 py-5">
<View>
<Text className="text-base font-medium mb-2">Exercise Name</Text>
<Input
value={formData.title}
onChangeText={(text) => setFormData(prev => ({ ...prev, title: text }))}
placeholder="e.g., Barbell Back Squat"
className="text-foreground"
/>
{!formData.title && (
<Text className="text-xs text-muted-foreground mt-1 ml-1">
* Required field
</Text>
)}
</View>
<View>
<Text className="text-base font-medium mb-2">Type</Text>
<View className="flex-row flex-wrap gap-2">
{EXERCISE_TYPES.map((type) => (
<Button
key={type}
variant={formData.type === type ? 'default' : 'outline'}
onPress={() => setFormData(prev => ({ ...prev, type }))}
style={formData.type === type ? { backgroundColor: purpleColor } : {}}
>
<Text className={formData.type === type ? 'text-white' : ''}>
{type.charAt(0).toUpperCase() + type.slice(1)}
</Text>
</Button>
))}
</View>
</View>
<View>
<Text className="text-base font-medium mb-2">Category</Text>
<View className="flex-row flex-wrap gap-2">
{CATEGORIES.map((category) => (
<Button
key={category}
variant={formData.category === category ? 'default' : 'outline'}
onPress={() => setFormData(prev => ({ ...prev, category }))}
style={formData.category === category ? { backgroundColor: purpleColor } : {}}
>
<Text className={formData.category === category ? 'text-white' : ''}>
{category}
</Text>
</Button>
))}
</View>
</View>
<View>
<Text className="text-base font-medium mb-2">Equipment</Text>
<View className="flex-row flex-wrap gap-2">
{EQUIPMENT_OPTIONS.map((eq) => (
<Button
key={eq}
variant={formData.equipment === eq ? 'default' : 'outline'}
onPress={() => setFormData(prev => ({ ...prev, equipment: eq }))}
style={formData.equipment === eq ? { backgroundColor: purpleColor } : {}}
>
<Text className={formData.equipment === eq ? 'text-white' : ''}>
{eq.charAt(0).toUpperCase() + eq.slice(1)}
</Text>
</Button>
))}
</View>
{!formData.equipment && (
<Text className="text-xs text-muted-foreground mt-1 ml-1">
* Required field
</Text>
)}
</View>
<View>
<Text className="text-base font-medium mb-2">Description</Text>
<Input
value={formData.description}
onChangeText={(text) => setFormData(prev => ({ ...prev, description: text }))}
placeholder="Exercise description..."
multiline
numberOfLines={4}
textAlignVertical="top"
className="min-h-24 py-2"
/>
</View>
<Button
className="mt-6 py-5"
variant='default'
onPress={handleSubmit}
disabled={!formData.title || !formData.equipment}
style={{ backgroundColor: purpleColor }}
>
<Text className="text-white font-semibold">Create Exercise</Text>
</Button>
</View>
</ScrollView>
</View>
</TouchableWithoutFeedback>
</KeyboardAvoidingView>
</SheetContent>
</Sheet>
);
}

@ -1,11 +1,10 @@
// components/library/NewTemplateSheet.tsx // components/library/NewTemplateSheet.tsx
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { View, ScrollView, TouchableOpacity } from 'react-native'; import { View, ScrollView, TouchableOpacity, Modal } from 'react-native';
import { Text } from '@/components/ui/text'; import { Text } from '@/components/ui/text';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { import {
Template, Template,
@ -17,7 +16,8 @@ import { ExerciseDisplay } from '@/types/exercise';
import { generateId } from '@/utils/ids'; import { generateId } from '@/utils/ids';
import { useSQLiteContext } from 'expo-sqlite'; import { useSQLiteContext } from 'expo-sqlite';
import { LibraryService } from '@/lib/db/services/LibraryService'; import { LibraryService } from '@/lib/db/services/LibraryService';
import { ChevronLeft, ChevronRight, Dumbbell, Clock, RotateCw, List, Search } from 'lucide-react-native'; import { ChevronLeft, Dumbbell, Clock, RotateCw, List, Search, X } from 'lucide-react-native';
import { useColorScheme } from '@/lib/useColorScheme';
interface NewTemplateSheetProps { interface NewTemplateSheetProps {
isOpen: boolean; isOpen: boolean;
@ -71,7 +71,7 @@ function WorkoutTypeStep({ onSelectType, onCancel }: WorkoutTypeStepProps) {
return ( return (
<ScrollView className="flex-1"> <ScrollView className="flex-1">
<View className="gap-4 py-4"> <View className="gap-4 py-4 px-4">
<Text className="text-base mb-4">Select the type of workout template you want to create:</Text> <Text className="text-base mb-4">Select the type of workout template you want to create:</Text>
<View className="gap-3"> <View className="gap-3">
@ -92,9 +92,6 @@ function WorkoutTypeStep({ onSelectType, onCancel }: WorkoutTypeStepProps) {
</Text> </Text>
</View> </View>
</View> </View>
<View className="pl-2 pr-1">
<ChevronRight color={purpleColor} size={20} />
</View>
</TouchableOpacity> </TouchableOpacity>
) : ( ) : (
<View> <View>
@ -117,10 +114,6 @@ function WorkoutTypeStep({ onSelectType, onCancel }: WorkoutTypeStepProps) {
</View> </View>
))} ))}
</View> </View>
<Button variant="outline" onPress={onCancel} className="mt-4 py-4">
<Text>Cancel</Text>
</Button>
</View> </View>
</ScrollView> </ScrollView>
); );
@ -152,7 +145,7 @@ function BasicInfoStep({
return ( return (
<ScrollView className="flex-1"> <ScrollView className="flex-1">
<View className="gap-4 py-4"> <View className="gap-4 py-4 px-4">
<View> <View>
<Text className="text-base font-medium mb-2">Workout Name</Text> <Text className="text-base font-medium mb-2">Workout Name</Text>
<Input <Input
@ -199,9 +192,6 @@ function BasicInfoStep({
</View> </View>
<View className="flex-row justify-end gap-3 mt-4"> <View className="flex-row justify-end gap-3 mt-4">
<Button variant="outline" onPress={onCancel}>
<Text>Cancel</Text>
</Button>
<Button <Button
onPress={onNext} onPress={onNext}
disabled={!title} disabled={!title}
@ -314,13 +304,11 @@ function ExerciseSelectionStep({
</ScrollView> </ScrollView>
<View className="p-4 flex-row justify-between border-t border-border"> <View className="p-4 flex-row justify-between border-t border-border">
<Button variant="outline" onPress={onBack}>
<Text>Back</Text>
</Button>
<Button <Button
onPress={handleContinue} onPress={handleContinue}
disabled={selectedIds.length === 0} disabled={selectedIds.length === 0}
style={selectedIds.length === 0 ? {} : { backgroundColor: purpleColor }} style={selectedIds.length === 0 ? {} : { backgroundColor: purpleColor }}
className="w-full"
> >
<Text className={selectedIds.length === 0 ? '' : 'text-white'}> <Text className={selectedIds.length === 0 ? '' : 'text-white'}>
Continue with {selectedIds.length} Exercise{selectedIds.length !== 1 ? 's' : ''} Continue with {selectedIds.length} Exercise{selectedIds.length !== 1 ? 's' : ''}
@ -390,13 +378,11 @@ function ExerciseConfigStep({
</View> </View>
</ScrollView> </ScrollView>
<View className="p-4 flex-row justify-between border-t border-border"> <View className="p-4 border-t border-border">
<Button variant="outline" onPress={onBack}>
<Text>Back</Text>
</Button>
<Button <Button
onPress={onNext} onPress={onNext}
style={{ backgroundColor: purpleColor }} style={{ backgroundColor: purpleColor }}
className="w-full"
> >
<Text className="text-white">Review Template</Text> <Text className="text-white">Review Template</Text>
</Button> </Button>
@ -463,13 +449,11 @@ function ReviewStep({
</View> </View>
</ScrollView> </ScrollView>
<View className="p-4 flex-row justify-between border-t border-border"> <View className="p-4 border-t border-border">
<Button variant="outline" onPress={onBack}>
<Text>Back</Text>
</Button>
<Button <Button
onPress={onSubmit} onPress={onSubmit}
style={{ backgroundColor: purpleColor }} style={{ backgroundColor: purpleColor }}
className="w-full"
> >
<Text className="text-white">Create Template</Text> <Text className="text-white">Create Template</Text>
</Button> </Button>
@ -486,6 +470,7 @@ export function NewTemplateSheet({ isOpen, onClose, onSubmit }: NewTemplateSheet
const [exercises, setExercises] = useState<ExerciseDisplay[]>([]); const [exercises, setExercises] = useState<ExerciseDisplay[]>([]);
const [selectedExercises, setSelectedExercises] = useState<ExerciseDisplay[]>([]); const [selectedExercises, setSelectedExercises] = useState<ExerciseDisplay[]>([]);
const [configuredExercises, setConfiguredExercises] = useState<Template['exercises']>([]); const [configuredExercises, setConfiguredExercises] = useState<Template['exercises']>([]);
const { isDarkColorScheme } = useColorScheme();
// Template info // Template info
const [templateInfo, setTemplateInfo] = useState<{ const [templateInfo, setTemplateInfo] = useState<{
@ -519,16 +504,21 @@ export function NewTemplateSheet({ isOpen, onClose, onSubmit }: NewTemplateSheet
// Reset state when sheet closes // Reset state when sheet closes
useEffect(() => { useEffect(() => {
if (!isOpen) { if (!isOpen) {
setStep('type'); // Add a delay to ensure the closing animation completes first
setWorkoutType('strength'); const timer = setTimeout(() => {
setSelectedExercises([]); setStep('type');
setConfiguredExercises([]); setWorkoutType('strength');
setTemplateInfo({ setSelectedExercises([]);
title: '', setConfiguredExercises([]);
description: '', setTemplateInfo({
category: 'Full Body', title: '',
tags: ['strength'] description: '',
}); category: 'Full Body',
tags: ['strength']
});
}, 300);
return () => clearTimeout(timer);
} }
}, [isOpen]); }, [isOpen]);
@ -594,11 +584,28 @@ export function NewTemplateSheet({ isOpen, onClose, onSubmit }: NewTemplateSheet
isFavorite: false isFavorite: false
}; };
onSubmit(newTemplate); // Close first, then submit with a small delay
onClose(); onClose();
setTimeout(() => {
onSubmit(newTemplate);
}, 50);
}; };
// Render different content based on current step // Get title based on current step
const getStepTitle = () => {
switch (step) {
case 'type': return 'Select Workout Type';
case 'info': return `New ${workoutType.charAt(0).toUpperCase() + workoutType.slice(1)} Workout`;
case 'exercises': return 'Select Exercises';
case 'config': return 'Configure Exercises';
case 'review': return 'Review Template';
}
};
// Show back button for all steps except the first
const showBackButton = step !== 'type';
// Render content based on current step
const renderContent = () => { const renderContent = () => {
switch (step) { switch (step) {
case 'type': case 'type':
@ -658,40 +665,45 @@ export function NewTemplateSheet({ isOpen, onClose, onSubmit }: NewTemplateSheet
} }
}; };
// Get title based on current step // Return null if not open
const getStepTitle = () => { if (!isOpen) return null;
switch (step) {
case 'type': return 'Select Workout Type';
case 'info': return `New ${workoutType.charAt(0).toUpperCase() + workoutType.slice(1)} Workout`;
case 'exercises': return 'Select Exercises';
case 'config': return 'Configure Exercises';
case 'review': return 'Review Template';
}
};
// Show back button for all steps except the first
const showBackButton = step !== 'type';
return ( return (
<Sheet isOpen={isOpen} onClose={onClose}> <Modal
<SheetHeader> visible={isOpen}
<View className="flex-row items-center"> transparent={true}
{showBackButton && ( animationType="slide"
<Button onRequestClose={onClose}
variant="ghost" >
size="icon" <View className="flex-1 justify-center items-center bg-black/70">
className="mr-2" <View
onPress={handleGoBack} className={`bg-background ${isDarkColorScheme ? 'bg-card border border-border' : ''} rounded-lg w-[95%] h-[85%] max-w-xl shadow-xl overflow-hidden`}
> style={{ maxHeight: 700 }}
<ChevronLeft className="text-foreground" size={20} /> >
</Button> {/* Header */}
)} <View className="flex-row justify-between items-center p-4 border-b border-border">
<SheetTitle>{getStepTitle()}</SheetTitle> <View className="flex-row items-center">
{showBackButton && (
<TouchableOpacity
onPress={handleGoBack}
className="mr-2 p-1"
>
<ChevronLeft size={24} />
</TouchableOpacity>
)}
<Text className="text-xl font-bold text-foreground">{getStepTitle()}</Text>
</View>
<TouchableOpacity onPress={onClose} className="p-1">
<X size={24} />
</TouchableOpacity>
</View>
{/* Content */}
<View className="flex-1">
{renderContent()}
</View>
</View> </View>
</SheetHeader> </View>
<SheetContent> </Modal>
{renderContent()}
</SheetContent>
</Sheet>
); );
} }

@ -0,0 +1,947 @@
// components/templates/ModalTemplateDetails.tsx
import React, { useState, useEffect, createContext, useContext } from 'react';
import { View, ScrollView, Modal, TouchableOpacity, ActivityIndicator } from 'react-native';
import { Text } from '@/components/ui/text';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { Card, CardContent } from '@/components/ui/card';
import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs';
import { NavigationContainer } from '@react-navigation/native';
import { useTheme } from '@react-navigation/native';
import { useColorScheme } from '@/lib/useColorScheme';
import { WorkoutTemplate } from '@/types/templates';
import { useWorkoutStore } from '@/stores/workoutStore';
import { formatTime } from '@/utils/formatTime';
import { Calendar } from 'lucide-react-native';
import {
X,
ChevronLeft,
Star,
Copy,
Heart,
Dumbbell,
Target,
Hash,
Clock,
MessageSquare,
Zap,
Repeat,
Bookmark
} from 'lucide-react-native';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { cn } from '@/lib/utils';
import type { CustomTheme } from '@/lib/theme';
// Create the tab navigator
const Tab = createMaterialTopTabNavigator();
// Create a context to share the template with the tabs
interface TemplateContextType {
template: WorkoutTemplate | null;
onStartWorkout?: () => void;
}
const TemplateContext = createContext<TemplateContextType>({
template: null
});
// Custom hook to access the template
function useTemplate() {
const context = useContext(TemplateContext);
if (!context.template) {
throw new Error('useTemplate must be used within a TemplateContextProvider');
}
return { template: context.template, onStartWorkout: context.onStartWorkout };
}
interface ModalTemplateDetailsProps {
templateId: string;
open: boolean;
onClose: () => void;
onFavoriteChange?: (templateId: string, isFavorite: boolean) => void;
}
// Overview Tab Component
function OverviewTab() {
const { template } = useTemplate();
const {
title,
type,
category,
description,
exercises = [],
tags = [],
metadata,
availability
} = template;
// Calculate source type from availability
const sourceType = availability.source.includes('nostr')
? 'nostr'
: availability.source.includes('powr')
? 'powr'
: 'local';
const isEditable = sourceType === 'local';
return (
<ScrollView
className="flex-1"
contentContainerStyle={{ paddingBottom: 20 }}
>
<View className="gap-6 p-4">
{/* Basic Info Section */}
<View className="flex-row items-center gap-2">
<Badge
variant={sourceType === 'local' ? 'outline' : 'secondary'}
className="capitalize"
>
<Text>{sourceType === 'local' ? 'My Template' : sourceType === 'powr' ? 'POWR Template' : 'Nostr Template'}</Text>
</Badge>
<Badge
variant="outline"
className="capitalize bg-muted"
>
<Text>{type}</Text>
</Badge>
</View>
<Separator className="bg-border" />
{/* Category Section */}
<View className="flex-row items-center gap-2">
<View className="w-8 h-8 items-center justify-center rounded-md bg-muted">
<Target size={18} className="text-muted-foreground" />
</View>
<View>
<Text className="text-sm text-muted-foreground">Category</Text>
<Text className="text-base font-medium text-foreground">{category}</Text>
</View>
</View>
{/* Description Section */}
{description && (
<View>
<Text className="text-base font-semibold text-foreground mb-2">Description</Text>
<Text className="text-base text-muted-foreground leading-relaxed">{description}</Text>
</View>
)}
{/* Exercises Section */}
<View>
<View className="flex-row items-center gap-2 mb-2">
<Dumbbell size={16} className="text-muted-foreground" />
<Text className="text-base font-semibold text-foreground">Exercises</Text>
</View>
<View className="gap-2">
{exercises.map((exerciseConfig, index) => (
<View key={index} className="bg-card p-3 rounded-lg">
<Text className="text-base font-medium text-foreground">
{exerciseConfig.exercise.title}
</Text>
<Text className="text-sm text-muted-foreground">
{exerciseConfig.targetSets} sets × {exerciseConfig.targetReps} reps
</Text>
{exerciseConfig.notes && (
<Text className="text-sm text-muted-foreground mt-1">
{exerciseConfig.notes}
</Text>
)}
</View>
))}
</View>
</View>
{/* Tags Section */}
{tags.length > 0 && (
<View>
<View className="flex-row items-center gap-2 mb-2">
<Hash size={16} className="text-muted-foreground" />
<Text className="text-base font-semibold text-foreground">Tags</Text>
</View>
<View className="flex-row flex-wrap gap-2">
{tags.map(tag => (
<Badge key={tag} variant="secondary">
<Text>{tag}</Text>
</Badge>
))}
</View>
</View>
)}
{/* Workout Parameters Section */}
<View>
<View className="flex-row items-center gap-2 mb-2">
<Clock size={16} className="text-muted-foreground" />
<Text className="text-base font-semibold text-foreground">Workout Parameters</Text>
</View>
<View className="gap-2">
{template.rounds && (
<View className="flex-row">
<Text className="text-sm text-muted-foreground w-40">Rounds:</Text>
<Text className="text-sm text-foreground">{template.rounds}</Text>
</View>
)}
{template.duration && (
<View className="flex-row">
<Text className="text-sm text-muted-foreground w-40">Duration:</Text>
<Text className="text-sm text-foreground">{formatTime(template.duration * 1000)}</Text>
</View>
)}
{template.interval && (
<View className="flex-row">
<Text className="text-sm text-muted-foreground w-40">Interval:</Text>
<Text className="text-sm text-foreground">{formatTime(template.interval * 1000)}</Text>
</View>
)}
{template.restBetweenRounds && (
<View className="flex-row">
<Text className="text-sm text-muted-foreground w-40">Rest Between Rounds:</Text>
<Text className="text-sm text-foreground">
{formatTime(template.restBetweenRounds * 1000)}
</Text>
</View>
)}
{metadata?.averageDuration && (
<View className="flex-row">
<Text className="text-sm text-muted-foreground w-40">Average Completion Time:</Text>
<Text className="text-sm text-foreground">
{Math.round(metadata.averageDuration / 60)} minutes
</Text>
</View>
)}
</View>
</View>
{/* Usage Stats Section */}
{metadata && (
<View>
<View className="flex-row items-center gap-2 mb-2">
<Calendar size={16} className="text-muted-foreground" />
<Text className="text-base font-semibold text-foreground">Usage</Text>
</View>
<View className="gap-2">
{metadata.useCount && (
<Text className="text-base text-muted-foreground">
Used {metadata.useCount} times
</Text>
)}
{metadata.lastUsed && (
<Text className="text-base text-muted-foreground">
Last used: {new Date(metadata.lastUsed).toLocaleDateString()}
</Text>
)}
</View>
</View>
)}
{/* Action Buttons */}
<View className="gap-3 mt-2">
{isEditable ? (
<Button variant="outline" className="w-full" onPress={() => console.log('Edit template')}>
<Text>Edit Template</Text>
</Button>
) : (
<Button variant="outline" className="w-full" onPress={() => console.log('Fork template')}>
<Copy size={18} className="mr-2" />
<Text>Save as My Template</Text>
</Button>
)}
<Button variant="outline" className="w-full" onPress={() => console.log('Share template')}>
<Text>Share Template</Text>
</Button>
</View>
</View>
</ScrollView>
);
}
// History Tab Component
function HistoryTab() {
const { template } = useTemplate();
const [isLoading, setIsLoading] = useState(false);
// Format date helper
const formatDate = (date: Date) => {
return date.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric'
});
};
// Mock workout history - this would come from your database in a real app
const mockWorkoutHistory = [
{
id: 'hist1',
date: new Date(2024, 1, 25),
duration: 62, // minutes
completed: true,
notes: "Increased weight on squats by 10lbs",
exercises: [
{ name: "Barbell Squat", sets: 3, reps: 8, weight: 215 },
{ name: "Bench Press", sets: 3, reps: 8, weight: 175 },
{ name: "Bent Over Row", sets: 3, reps: 8, weight: 155 }
]
},
{
id: 'hist2',
date: new Date(2024, 1, 18),
duration: 58, // minutes
completed: true,
exercises: [
{ name: "Barbell Squat", sets: 3, reps: 8, weight: 205 },
{ name: "Bench Press", sets: 3, reps: 8, weight: 175 },
{ name: "Bent Over Row", sets: 3, reps: 8, weight: 155 }
]
},
{
id: 'hist3',
date: new Date(2024, 1, 11),
duration: 65, // minutes
completed: false,
notes: "Stopped early due to time constraints",
exercises: [
{ name: "Barbell Squat", sets: 3, reps: 8, weight: 205 },
{ name: "Bench Press", sets: 3, reps: 8, weight: 170 },
{ name: "Bent Over Row", sets: 2, reps: 8, weight: 150 }
]
}
];
// Simulate loading history data
useEffect(() => {
const loadHistory = async () => {
setIsLoading(true);
// Simulate loading delay
await new Promise(resolve => setTimeout(resolve, 500));
setIsLoading(false);
};
loadHistory();
}, [template.id]);
return (
<ScrollView
className="flex-1"
contentContainerStyle={{ paddingBottom: 20 }}
>
<View className="gap-6 p-4">
{/* Performance Summary */}
<View>
<Text className="text-base font-semibold text-foreground mb-4">Performance Summary</Text>
<Card className="bg-card">
<CardContent className="p-4">
<View className="flex-row justify-between">
<View className="items-center">
<Text className="text-xs text-muted-foreground">Total Workouts</Text>
<Text className="text-xl font-semibold">{mockWorkoutHistory.length}</Text>
</View>
<View className="items-center">
<Text className="text-xs text-muted-foreground">Avg Duration</Text>
<Text className="text-xl font-semibold">
{Math.round(mockWorkoutHistory.reduce((acc, w) => acc + w.duration, 0) / mockWorkoutHistory.length)} min
</Text>
</View>
<View className="items-center">
<Text className="text-xs text-muted-foreground">Completion</Text>
<Text className="text-xl font-semibold">
{Math.round(mockWorkoutHistory.filter(w => w.completed).length / mockWorkoutHistory.length * 100)}%
</Text>
</View>
</View>
</CardContent>
</Card>
</View>
{/* History List */}
<View>
<Text className="text-base font-semibold text-foreground mb-4">Workout History</Text>
{isLoading ? (
<View className="items-center justify-center py-8">
<ActivityIndicator size="small" className="mb-2" />
<Text className="text-muted-foreground">Loading history...</Text>
</View>
) : mockWorkoutHistory.length > 0 ? (
<View className="gap-4">
{mockWorkoutHistory.map((workout) => (
<Card key={workout.id} className="overflow-hidden">
<CardContent className="p-4">
<View className="flex-row justify-between mb-2">
<Text className="font-semibold">{formatDate(workout.date)}</Text>
<Badge variant={workout.completed ? "default" : "outline"}>
<Text>{workout.completed ? "Completed" : "Incomplete"}</Text>
</Badge>
</View>
<View className="flex-row justify-between mb-3">
<View>
<Text className="text-xs text-muted-foreground">Duration</Text>
<Text className="text-sm">{workout.duration} min</Text>
</View>
<View>
<Text className="text-xs text-muted-foreground">Sets</Text>
<Text className="text-sm">
{workout.exercises.reduce((acc, ex) => acc + ex.sets, 0)}
</Text>
</View>
<View>
<Text className="text-xs text-muted-foreground">Volume</Text>
<Text className="text-sm">
{workout.exercises.reduce((acc, ex) => acc + (ex.sets * ex.reps * ex.weight), 0)} lbs
</Text>
</View>
</View>
{workout.notes && (
<Text className="text-sm text-muted-foreground mb-3">
{workout.notes}
</Text>
)}
<Button variant="outline" size="sm" className="w-full">
<Text>View Details</Text>
</Button>
</CardContent>
</Card>
))}
</View>
) : (
<View className="bg-muted p-8 rounded-lg items-center justify-center">
<Calendar size={24} className="text-muted-foreground mb-2" />
<Text className="text-muted-foreground text-center">
No workout history available yet
</Text>
<Text className="text-sm text-muted-foreground text-center mt-1">
Complete a workout using this template to see your history
</Text>
</View>
)}
</View>
</View>
</ScrollView>
);
}
// Social Tab Component
function SocialTab() {
const { template } = useTemplate();
const [isLoading, setIsLoading] = useState(false);
// Mock social feed data
const mockSocialFeed = [
{
id: 'social1',
userName: 'FitnessFanatic',
userAvatar: 'https://randomuser.me/api/portraits/men/32.jpg',
pubkey: 'npub1q8s7vw...',
timestamp: new Date(Date.now() - 3600000 * 2), // 2 hours ago
content: 'Just crushed this Full Body workout! New PR on bench press 🎉',
metrics: {
duration: 58, // in minutes
volume: 4500, // total weight
exercises: 5
},
likes: 12,
comments: 3,
zaps: 5,
reposts: 2,
bookmarked: false
},
{
id: 'social2',
userName: 'StrengthJourney',
userAvatar: 'https://randomuser.me/api/portraits/women/44.jpg',
pubkey: 'npub1z92r3...',
timestamp: new Date(Date.now() - 3600000 * 24), // 1 day ago
content: 'Modified this workout with extra leg exercises. Feeling the burn!',
metrics: {
duration: 65,
volume: 5200,
exercises: "5+2"
},
likes: 8,
comments: 1,
zaps: 3,
reposts: 0,
bookmarked: false
},
{
id: 'social3',
userName: 'GymCoach',
userAvatar: 'https://randomuser.me/api/portraits/men/62.jpg',
pubkey: 'npub1xne8q...',
timestamp: new Date(Date.now() - 3600000 * 48), // 2 days ago
content: 'Great template for beginners! I recommend starting with lighter weights.',
metrics: {
duration: 45,
volume: 3600,
exercises: 5
},
likes: 24,
comments: 7,
zaps: 15,
reposts: 6,
bookmarked: true
}
];
// Social Feed Item Component
function SocialFeedItem({ item }: { item: typeof mockSocialFeed[0] }) {
const [liked, setLiked] = useState(false);
const [zapCount, setZapCount] = useState(item.zaps);
const [bookmarked, setBookmarked] = useState(item.bookmarked);
const [reposted, setReposted] = useState(false);
const [commentCount, setCommentCount] = useState(item.comments);
const formatDate = (date: Date) => {
const now = new Date();
const diffInHours = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60));
if (diffInHours < 1) {
return 'now';
} else if (diffInHours < 24) {
return `${diffInHours}h`;
} else {
const diffInDays = Math.floor(diffInHours / 24);
return `${diffInDays}d`;
}
};
return (
<View className="p-4 border-b border-border">
{/* User info and timestamp */}
<View className="flex-row mb-3">
<Avatar className="h-10 w-10 mr-3" alt={`${item.userName}'s profile picture`}>
<AvatarImage source={{ uri: item.userAvatar }} />
<AvatarFallback>
<Text className="text-sm">{item.userName.substring(0, 2)}</Text>
</AvatarFallback>
</Avatar>
<View className="flex-1">
<View className="flex-row justify-between">
<Text className="font-semibold">{item.userName}</Text>
<Text className="text-xs text-muted-foreground">
{formatDate(item.timestamp)}
</Text>
</View>
<Text className="text-xs text-muted-foreground">
@{item.pubkey.substring(0, 10)}...
</Text>
</View>
</View>
{/* Post content */}
<Text className="mb-3">{item.content}</Text>
{/* Workout metrics */}
<View className="flex-row justify-between mb-3 p-3 bg-muted/50 rounded-lg">
<View className="items-center">
<Text className="text-xs text-muted-foreground">Duration</Text>
<Text className="font-semibold">{item.metrics.duration} min</Text>
</View>
<View className="items-center">
<Text className="text-xs text-muted-foreground">Volume</Text>
<Text className="font-semibold">{item.metrics.volume} lbs</Text>
</View>
<View className="items-center">
<Text className="text-xs text-muted-foreground">Exercises</Text>
<Text className="font-semibold">{item.metrics.exercises}</Text>
</View>
</View>
{/* Twitter-like action buttons */}
<View className="flex-row justify-between items-center mt-2">
{/* Comment button */}
<TouchableOpacity
activeOpacity={0.7}
className="flex-row items-center"
onPress={() => setCommentCount(prev => prev + 1)}
>
<MessageSquare size={18} className="text-muted-foreground" />
{commentCount > 0 && (
<Text className="text-xs text-muted-foreground ml-1">{commentCount}</Text>
)}
</TouchableOpacity>
{/* Repost button */}
<TouchableOpacity
activeOpacity={0.7}
className="flex-row items-center"
onPress={() => setReposted(!reposted)}
>
<Repeat
size={18}
className={cn(
reposted ? "text-green-500" : "text-muted-foreground"
)}
/>
{(reposted || item.reposts > 0) && (
<Text
className={cn(
"text-xs ml-1",
reposted ? "text-green-500" : "text-muted-foreground"
)}
>
{reposted ? item.reposts + 1 : item.reposts}
</Text>
)}
</TouchableOpacity>
{/* Like button */}
<TouchableOpacity
activeOpacity={0.7}
className="flex-row items-center"
onPress={() => setLiked(!liked)}
>
<Heart
size={18}
className={cn(
liked ? "text-red-500 fill-red-500" : "text-muted-foreground"
)}
/>
{(liked || item.likes > 0) && (
<Text
className={cn(
"text-xs ml-1",
liked ? "text-red-500" : "text-muted-foreground"
)}
>
{liked ? item.likes + 1 : item.likes}
</Text>
)}
</TouchableOpacity>
{/* Zap button */}
<TouchableOpacity
activeOpacity={0.7}
className="flex-row items-center"
onPress={() => setZapCount(prev => prev + 1)}
>
<Zap
size={18}
className="text-amber-500"
/>
{zapCount > 0 && (
<Text className="text-xs text-muted-foreground ml-1">{zapCount}</Text>
)}
</TouchableOpacity>
{/* Bookmark button */}
<TouchableOpacity
activeOpacity={0.7}
onPress={() => setBookmarked(!bookmarked)}
>
<Bookmark
size={18}
className={cn(
bookmarked ? "text-blue-500 fill-blue-500" : "text-muted-foreground"
)}
/>
</TouchableOpacity>
</View>
</View>
);
}
return (
<ScrollView
className="flex-1 bg-background"
contentContainerStyle={{ paddingBottom: 20 }}
>
<View className="px-4 py-2 flex-row justify-between items-center border-b border-border">
<Text className="text-base font-semibold text-foreground">
Recent Activity
</Text>
<Badge variant="outline">
<Text>Nostr</Text>
</Badge>
</View>
{isLoading ? (
<View className="items-center justify-center py-8">
<ActivityIndicator size="small" className="mb-2" />
<Text className="text-muted-foreground">Loading activity...</Text>
</View>
) : mockSocialFeed.length > 0 ? (
<View>
{mockSocialFeed.map((item) => (
<SocialFeedItem key={item.id} item={item} />
))}
</View>
) : (
<View className="items-center justify-center py-8 mx-4 mt-4 bg-muted rounded-lg">
<MessageSquare size={24} className="text-muted-foreground mb-2" />
<Text className="text-muted-foreground text-center">No social activity found</Text>
<Text className="text-xs text-muted-foreground text-center mt-1">
This workout hasn't been shared on Nostr yet
</Text>
</View>
)}
</ScrollView>
);
}
export function ModalTemplateDetails({
templateId,
open,
onClose,
onFavoriteChange
}: ModalTemplateDetailsProps) {
const [isLoading, setIsLoading] = useState(true);
const [workoutTemplate, setWorkoutTemplate] = useState<WorkoutTemplate | null>(null);
const [isFavorite, setIsFavorite] = useState(false);
const [toggleCounter, setToggleCounter] = useState(0);
const theme = useTheme() as CustomTheme;
const { isDarkColorScheme } = useColorScheme();
// Use the workoutStore
const {
startWorkoutFromTemplate,
checkFavoriteStatus,
addFavorite,
removeFavorite
} = useWorkoutStore();
// Load template data when the modal opens or templateId changes
useEffect(() => {
async function loadTemplate() {
// Don't load data if the modal is closed
if (!open) return;
try {
setIsLoading(true);
if (!templateId) {
setIsLoading(false);
return;
}
// Always fetch the current favorite status from the store
const currentIsFavorite = checkFavoriteStatus(templateId);
setIsFavorite(currentIsFavorite);
console.log(`Initial load: Template ${templateId} isFavorite: ${currentIsFavorite}`);
// TODO: Implement fetching from database if needed
// For now, create a mock template if we couldn't find it
const mockTemplate: WorkoutTemplate = {
id: templateId,
title: "Sample Workout",
type: "strength",
category: "Full Body",
exercises: [{
exercise: {
id: "ex1",
title: "Barbell Squat",
type: "strength",
category: "Legs",
tags: ["compound", "legs"],
availability: { source: ["local"] },
created_at: Date.now()
},
targetSets: 3,
targetReps: 8
}],
isPublic: false,
tags: ["strength", "beginner"],
version: 1,
created_at: Date.now(),
availability: { source: ["local"] }
};
setWorkoutTemplate(mockTemplate);
setIsLoading(false);
} catch (error) {
console.error("Error loading template:", error);
setIsLoading(false);
}
}
loadTemplate();
}, [templateId, open, checkFavoriteStatus]);
const handleStartWorkout = async () => {
if (!workoutTemplate) return;
try {
// Use the workoutStore action to start a workout from template
await startWorkoutFromTemplate(workoutTemplate.id);
// Close the modal
onClose();
// Navigate to the active workout screen
// We'll leave navigation to the parent component
} catch (error) {
console.error("Error starting workout:", error);
}
};
// Inside your handleToggleFavorite function, update it to:
const handleToggleFavorite = async () => {
if (!workoutTemplate) return;
try {
// Get the current state directly from the store
const currentIsFavorite = checkFavoriteStatus(workoutTemplate.id);
console.log(`Current store state: Template ${workoutTemplate.id} isFavorite: ${currentIsFavorite}`);
// The new state will be the opposite
const newFavoriteStatus = !currentIsFavorite;
// Update the store first
if (currentIsFavorite) {
await removeFavorite(workoutTemplate.id);
console.log(`Removed template with ID "${workoutTemplate.id}" from favorites (store action)`);
} else {
await addFavorite(workoutTemplate);
console.log(`Added template "${workoutTemplate.title}" to favorites (store action)`);
}
// Verify the change took effect in the store
const updatedIsFavorite = checkFavoriteStatus(workoutTemplate.id);
console.log(`After store update: Template ${workoutTemplate.id} isFavorite: ${updatedIsFavorite}`);
// Now update our local state
setIsFavorite(updatedIsFavorite);
// Force re-render if needed
setToggleCounter(prev => prev + 1);
// Notify parent component
if (onFavoriteChange) {
onFavoriteChange(workoutTemplate.id, updatedIsFavorite);
}
console.log(`Toggled favorite UI: ${workoutTemplate.id} is now ${updatedIsFavorite ? 'favorited' : 'unfavorited'}`);
} catch (error) {
console.error("Error toggling favorite:", error);
}
};
console.log(`Template ${workoutTemplate?.id} isFavorite: ${isFavorite}`);
// Don't render anything if the modal is closed
if (!open) return null;
return (
<Modal
visible={open}
transparent={true}
animationType="slide"
onRequestClose={onClose}
>
<View className="flex-1 justify-center items-center bg-black/70">
<View
className={`bg-background ${isDarkColorScheme ? 'bg-card border border-border' : ''} rounded-lg w-[95%] h-[85%] max-w-xl shadow-xl overflow-hidden`}
style={{ maxHeight: 700 }}
>
{/* Loading State */}
{isLoading || !workoutTemplate ? (
<View className="flex-1 items-center justify-center p-6">
<ActivityIndicator size="large" className="mb-4" />
<Text className="text-muted-foreground">Loading template...</Text>
</View>
) : (
<>
{/* Header */}
<View className="flex-row justify-between items-center p-4 border-b border-border">
<View className="flex-row items-center flex-1">
<Text className="text-xl font-bold text-foreground" numberOfLines={1}>
{workoutTemplate.title}
</Text>
</View>
<View className="flex-row items-center">
<Button
variant="ghost"
size="icon"
onPress={handleToggleFavorite}
className="mr-2"
>
{/* Force re-render by using key with the current favorite state */}
<Star
key={`star-${workoutTemplate.id}-${isFavorite}-${toggleCounter}`}
className={isFavorite ? "text-primary" : "text-muted-foreground"}
fill={isFavorite ? "currentColor" : "none"}
size={22}
/>
</Button>
<TouchableOpacity onPress={onClose} className="ml-2 p-1">
<X size={24} />
</TouchableOpacity>
</View>
</View>
{/* Tab Navigator */}
<View style={{ flex: 1 }}>
<TemplateContext.Provider value={{
template: workoutTemplate,
onStartWorkout: handleStartWorkout
}}>
<Tab.Navigator
screenOptions={{
tabBarActiveTintColor: theme.colors.tabIndicator,
tabBarInactiveTintColor: theme.colors.tabInactive,
tabBarLabelStyle: {
fontSize: 13,
textTransform: 'capitalize',
fontWeight: 'bold',
marginHorizontal: -4,
},
tabBarIndicatorStyle: {
backgroundColor: theme.colors.tabIndicator,
height: 2,
},
tabBarStyle: {
backgroundColor: theme.colors.background,
elevation: 0,
shadowOpacity: 0,
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
tabBarPressColor: theme.colors.primary,
}}
>
<Tab.Screen name="Overview">
{() => <OverviewTab />}
</Tab.Screen>
<Tab.Screen name="Social">
{() => <SocialTab />}
</Tab.Screen>
<Tab.Screen name="History">
{() => <HistoryTab />}
</Tab.Screen>
</Tab.Navigator>
</TemplateContext.Provider>
</View>
{/* Footer with Start button */}
<View className="p-4 border-t border-border">
<Button
style={{ backgroundColor: 'hsl(261, 90%, 66%)' }}
className="w-full"
onPress={handleStartWorkout}
>
<Text className="text-white font-medium">Start Workout</Text>
</Button>
</View>
</>
)}
</View>
</View>
</Modal>
)};

@ -1,7 +1,6 @@
// components/templates/TemplateCard.tsx // components/templates/TemplateCard.tsx
import React from 'react'; import React from 'react';
import { View, TouchableOpacity, Platform } from 'react-native'; import { View, TouchableOpacity, Platform } from 'react-native';
import { router } from 'expo-router'; // Add this import
import { Text } from '@/components/ui/text'; import { Text } from '@/components/ui/text';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@ -55,13 +54,23 @@ export function TemplateCard({
setShowDeleteAlert(false); setShowDeleteAlert(false);
}; };
// Handle navigation to template details // Prevent event propagation when clicking on action buttons
const handleTemplatePress = () => { const handleStartWorkout = (e: any) => {
router.push(`/template/${id}`); if (e && e.stopPropagation) {
e.stopPropagation();
}
onStartWorkout();
};
const handleFavorite = (e: any) => {
if (e && e.stopPropagation) {
e.stopPropagation();
}
onFavorite();
}; };
return ( return (
<TouchableOpacity onPress={handleTemplatePress} activeOpacity={0.7}> <TouchableOpacity onPress={onPress} activeOpacity={0.7}>
<Card className="mx-4"> <Card className="mx-4">
<CardContent className="p-4"> <CardContent className="p-4">
<View className="flex-row justify-between items-start"> <View className="flex-row justify-between items-start">
@ -134,7 +143,7 @@ export function TemplateCard({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onPress={onStartWorkout} onPress={handleStartWorkout}
className="native:h-10 native:w-10" className="native:h-10 native:w-10"
accessibilityLabel="Start workout" accessibilityLabel="Start workout"
> >
@ -143,7 +152,7 @@ export function TemplateCard({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onPress={onFavorite} onPress={handleFavorite}
className="native:h-10 native:w-10" className="native:h-10 native:w-10"
accessibilityLabel={isFavorite ? "Remove from favorites" : "Add to favorites"} accessibilityLabel={isFavorite ? "Remove from favorites" : "Add to favorites"}
> >

@ -2,7 +2,7 @@
import { SQLiteDatabase } from 'expo-sqlite'; import { SQLiteDatabase } from 'expo-sqlite';
import { Platform } from 'react-native'; import { Platform } from 'react-native';
export const SCHEMA_VERSION = 4; // Updated to version 4 for user_profiles table export const SCHEMA_VERSION = 5; // Updated to version 5 for publication queue table
class Schema { class Schema {
private async getCurrentVersion(db: SQLiteDatabase): Promise<number> { private async getCurrentVersion(db: SQLiteDatabase): Promise<number> {
@ -210,6 +210,41 @@ class Schema {
console.log('[Schema] Version 4 upgrade completed'); console.log('[Schema] Version 4 upgrade completed');
} }
// Update to version 5 if needed - Publication Queue
if (currentVersion < 5) {
console.log('[Schema] Upgrading to version 5');
// Create publication queue table
await db.execAsync(`
CREATE TABLE IF NOT EXISTS publication_queue (
event_id TEXT PRIMARY KEY,
attempts INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
last_attempt INTEGER,
payload TEXT NOT NULL,
FOREIGN KEY(event_id) REFERENCES nostr_events(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_publication_queue_created
ON publication_queue(created_at ASC);
`);
// Create app status table for tracking connectivity
await db.execAsync(`
CREATE TABLE IF NOT EXISTS app_status (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
`);
await db.runAsync(
'INSERT INTO schema_version (version, updated_at) VALUES (?, ?)',
[5, Date.now()]
);
console.log('[Schema] Version 5 upgrade completed');
}
// Verify final schema // Verify final schema
const tables = await db.getAllAsync<{ name: string }>( const tables = await db.getAllAsync<{ name: string }>(
"SELECT name FROM sqlite_master WHERE type='table'" "SELECT name FROM sqlite_master WHERE type='table'"

@ -0,0 +1,133 @@
// lib/services/ConnectivityService.ts
import { useEffect, useState } from 'react';
import NetInfo, { NetInfoState } from '@react-native-community/netinfo';
import { openDatabaseSync } from 'expo-sqlite';
/**
* Service to monitor network connectivity and provide status information
*/
export class ConnectivityService {
private static instance: ConnectivityService;
private isOnline: boolean = false;
private listeners: Set<(isOnline: boolean) => void> = new Set();
// Singleton pattern
static getInstance(): ConnectivityService {
if (!ConnectivityService.instance) {
ConnectivityService.instance = new ConnectivityService();
}
return ConnectivityService.instance;
}
private constructor() {
// Initialize network monitoring
this.setupNetworkMonitoring();
}
/**
* Setup network state change monitoring
*/
private setupNetworkMonitoring(): void {
// Subscribe to network state updates
NetInfo.addEventListener(this.handleNetworkChange);
// Initial network check
this.checkNetworkStatus();
}
/**
* Handle network state changes
*/
private handleNetworkChange = (state: NetInfoState): void => {
const newOnlineStatus = state.isConnected === true && state.isInternetReachable !== false;
// Only trigger updates if status actually changed
if (this.isOnline !== newOnlineStatus) {
this.isOnline = newOnlineStatus;
this.updateStatusInDatabase(newOnlineStatus);
this.notifyListeners();
}
}
/**
* Perform an initial network status check
*/
private async checkNetworkStatus(): Promise<void> {
try {
const state = await NetInfo.fetch();
this.isOnline = state.isConnected === true && state.isInternetReachable !== false;
this.updateStatusInDatabase(this.isOnline);
} catch (error) {
console.error('[ConnectivityService] Error checking network status:', error);
this.isOnline = false;
}
}
/**
* Update online status in the database
*/
private async updateStatusInDatabase(isOnline: boolean): Promise<void> {
try {
const db = openDatabaseSync('powr.db');
await db.runAsync(
`INSERT OR REPLACE INTO app_status (key, value, updated_at)
VALUES (?, ?, ?)`,
['online_status', isOnline ? 'online' : 'offline', Date.now()]
);
} catch (error) {
console.error('[ConnectivityService] Error updating status in database:', error);
}
}
/**
* Notify all registered listeners of connectivity change
*/
private notifyListeners(): void {
this.listeners.forEach(listener => {
try {
listener(this.isOnline);
} catch (error) {
console.error('[ConnectivityService] Error in listener:', error);
}
});
}
/**
* Get current network connectivity status
*/
getConnectionStatus(): boolean {
return this.isOnline;
}
/**
* Register a listener for connectivity changes
*/
addListener(listener: (isOnline: boolean) => void): () => void {
this.listeners.add(listener);
// Return function to remove the listener
return () => {
this.listeners.delete(listener);
};
}
}
/**
* React hook for using connectivity status in components
*/
export function useConnectivity() {
const [isOnline, setIsOnline] = useState<boolean>(() => {
// Initialize with current status
return ConnectivityService.getInstance().getConnectionStatus();
});
useEffect(() => {
// Register listener for updates
const removeListener = ConnectivityService.getInstance().addListener(setIsOnline);
// Clean up on unmount
return removeListener;
}, []);
return { isOnline };
}

@ -0,0 +1,172 @@
// lib/db/services/PublicationQueueService.ts
import { SQLiteDatabase } from 'expo-sqlite';
import { NostrEvent } from '@/types/nostr';
import { EventCache } from './EventCache';
export class PublicationQueueService {
private db: SQLiteDatabase;
private eventCache: EventCache;
constructor(db: SQLiteDatabase, eventCache: EventCache) {
this.db = db;
this.eventCache = eventCache;
}
/**
* Queue an event for publishing
* @param event The Nostr event to queue
* @returns Promise that resolves when the event is queued
*/
async queueEvent(event: NostrEvent): Promise<void> {
try {
// First, ensure the event is cached
await this.eventCache.setEvent(event);
// Then add to publication queue
const payload = JSON.stringify(event);
await this.db.runAsync(
`INSERT OR REPLACE INTO publication_queue
(event_id, attempts, created_at, payload)
VALUES (?, ?, ?, ?)`,
[
event.id || '', // Add default empty string if undefined
0,
Date.now(),
JSON.stringify(event)
]
);
console.log(`[Queue] Event ${event.id} queued for publishing`);
} catch (error) {
console.error('[Queue] Error queueing event:', error);
throw error;
}
}
/**
* Get all pending events in the queue
* @param limit Maximum number of events to return
* @returns Array of queued events
*/
async getPendingEvents(limit: number = 10): Promise<{
id: string;
attempts: number;
created_at: number;
payload: NostrEvent;
}[]> {
try {
const rows = await this.db.getAllAsync<{
event_id: string;
attempts: number;
created_at: number;
payload: string;
}>(
`SELECT event_id, attempts, created_at, payload
FROM publication_queue
WHERE attempts < 5
ORDER BY attempts ASC, created_at ASC
LIMIT ?`,
[limit]
);
return rows.map(row => ({
id: row.event_id,
attempts: row.attempts,
created_at: row.created_at,
payload: JSON.parse(row.payload) as NostrEvent
}));
} catch (error) {
console.error('[Queue] Error getting pending events:', error);
return [];
}
}
/**
* Update the attempt count for an event
* @param eventId ID of the event
* @returns Promise that resolves when the event is updated
*/
async incrementAttempt(eventId: string): Promise<void> {
try {
await this.db.runAsync(
`UPDATE publication_queue
SET attempts = attempts + 1, last_attempt = ?
WHERE event_id = ?`,
[Date.now(), eventId]
);
} catch (error) {
console.error('[Queue] Error incrementing attempt:', error);
}
}
/**
* Remove an event from the queue (when successfully published)
* @param eventId ID of the event to remove
* @returns Promise that resolves when the event is removed
*/
async removeEvent(eventId: string): Promise<void> {
try {
await this.db.runAsync(
`DELETE FROM publication_queue WHERE event_id = ?`,
[eventId]
);
console.log(`[Queue] Event ${eventId} removed from queue`);
} catch (error) {
console.error('[Queue] Error removing event:', error);
}
}
/**
* Get the number of pending events in the queue
* @returns Promise that resolves with the count
*/
async getPendingCount(): Promise<number> {
try {
const result = await this.db.getFirstAsync<{ count: number }>(
`SELECT COUNT(*) as count FROM publication_queue WHERE attempts < 5`
);
return result?.count || 0;
} catch (error) {
console.error('[Queue] Error getting pending count:', error);
return 0;
}
}
/**
* Set the online status in the app_status table
* @param isOnline Whether the app is online
*/
async setOnlineStatus(isOnline: boolean): Promise<void> {
try {
await this.db.runAsync(
`INSERT OR REPLACE INTO app_status (key, value, updated_at)
VALUES (?, ?, ?)`,
['online_status', isOnline ? 'online' : 'offline', Date.now()]
);
} catch (error) {
console.error('[Queue] Error setting online status:', error);
}
}
/**
* Get the current online status
* @returns Promise that resolves with the online status
*/
async getOnlineStatus(): Promise<boolean | null> {
try {
const result = await this.db.getFirstAsync<{ value: string }>(
`SELECT value FROM app_status WHERE key = ?`,
['online_status']
);
if (result) {
return result.value === 'online';
}
return null;
} catch (error) {
console.error('[Queue] Error getting online status:', error);
return null;
}
}
}

@ -161,6 +161,44 @@ export function useExercises() {
} }
}, [libraryService, loadExercises]); }, [libraryService, loadExercises]);
// Update an exercise
const updateExercise = useCallback(async (id: string, updateData: Partial<BaseExercise>) => {
try {
// Get the existing exercise first
const existingExercises = await libraryService.getExercises();
const existingExercise = existingExercises.find(ex => ex.id === id);
if (!existingExercise) {
throw new Error(`Exercise with ID ${id} not found`);
}
// Delete the old exercise
await libraryService.deleteExercise(id);
// Prepare the updated exercise data (without id since it's Omit<ExerciseDisplay, "id">)
const updatedExercise: Omit<ExerciseDisplay, 'id'> = {
...existingExercise,
...updateData,
source: existingExercise.source || 'local',
isFavorite: existingExercise.isFavorite || false
};
// Remove id property since it's not allowed in this type
const { id: _, ...exerciseWithoutId } = updatedExercise as any;
// Add the updated exercise with the same ID
await libraryService.addExercise(exerciseWithoutId);
// Reload exercises to get the updated list
await loadExercises();
return id;
} catch (err) {
setError(err instanceof Error ? err : new Error('Failed to update exercise'));
throw err;
}
}, [libraryService, loadExercises]);
// Update filters // Update filters
const updateFilters = useCallback((newFilters: Partial<ExerciseFilters>) => { const updateFilters = useCallback((newFilters: Partial<ExerciseFilters>) => {
setFilters(current => ({ setFilters(current => ({
@ -189,6 +227,7 @@ export function useExercises() {
clearFilters, clearFilters,
createExercise, createExercise,
deleteExercise, deleteExercise,
updateExercise,
refreshExercises: loadExercises refreshExercises: loadExercises
}; };
} }

@ -9,6 +9,7 @@ import NDK, { NDKFilter } from '@nostr-dev-kit/ndk';
import { NDKEvent, NDKUser } from '@nostr-dev-kit/ndk-mobile'; import { NDKEvent, NDKUser } from '@nostr-dev-kit/ndk-mobile';
import * as SecureStore from 'expo-secure-store'; import * as SecureStore from 'expo-secure-store';
import * as Crypto from 'expo-crypto'; import * as Crypto from 'expo-crypto';
import { openDatabaseSync } from 'expo-sqlite';
import { NDKMobilePrivateKeySigner, generateKeyPair } from '@/lib/mobile-signer'; import { NDKMobilePrivateKeySigner, generateKeyPair } from '@/lib/mobile-signer';
// Constants for SecureStore // Constants for SecureStore
@ -37,6 +38,9 @@ type NDKStoreActions = {
logout: () => Promise<void>; logout: () => Promise<void>;
generateKeys: () => { privateKey: string; publicKey: string; nsec: string; npub: string }; generateKeys: () => { privateKey: string; publicKey: string; nsec: string; npub: string };
publishEvent: (kind: number, content: string, tags: string[][]) => Promise<NDKEvent | null>; publishEvent: (kind: number, content: string, tags: string[][]) => Promise<NDKEvent | null>;
createEvent: (kind: number, content: string, tags: string[][]) => Promise<NDKEvent | null>;
queueEventForPublishing: (event: NDKEvent) => Promise<boolean>;
processPublicationQueue: () => Promise<void>;
fetchEventsByFilter: (filter: NDKFilter) => Promise<NDKEvent[]>; fetchEventsByFilter: (filter: NDKFilter) => Promise<NDKEvent[]>;
}; };
@ -152,6 +156,26 @@ export const useNDKStore = create<NDKStoreState & NDKStoreActions>((set, get) =>
} }
} }
// Set up connectivity monitoring to process publication queue
try {
const { ConnectivityService } = await import('@/lib/db/services/ConnectivityService');
// Process queue on initial connection
if (ConnectivityService.getInstance().getConnectionStatus()) {
get().processPublicationQueue();
}
// Add listener to process queue when coming online
ConnectivityService.getInstance().addListener((isOnline) => {
if (isOnline) {
console.log('[NDK] Connection restored, processing publication queue');
get().processPublicationQueue();
}
});
} catch (error) {
console.error('[NDK] Error setting up connectivity monitoring:', error);
}
set({ isLoading: false }); set({ isLoading: false });
} catch (error) { } catch (error) {
console.error('[NDK] Initialization error:', error); console.error('[NDK] Initialization error:', error);
@ -330,6 +354,210 @@ export const useNDKStore = create<NDKStoreState & NDKStoreActions>((set, get) =>
} }
}, },
// Create and sign a Nostr event without publishing it
createEvent: async (kind: number, content: string, tags: string[][]): Promise<NDKEvent | null> => {
try {
const { ndk, isAuthenticated, currentUser } = get();
if (!ndk) {
throw new Error('NDK not initialized');
}
if (!isAuthenticated || !currentUser) {
throw new Error('Not authenticated');
}
// Create event
const event = new NDKEvent(ndk);
event.kind = kind;
event.content = content;
event.tags = tags;
// Define custom function for random bytes generation
const customRandomBytes = (length: number): Uint8Array => {
console.log('Using custom randomBytes in event signing');
return (Crypto as any).getRandomBytes(length);
};
// Try to find and override the randomBytes function
const nostrTools = require('nostr-tools');
const nobleHashes = require('@noble/hashes/utils');
// Backup original functions
const originalNobleRandomBytes = nobleHashes.randomBytes;
// Override with our implementation
(nobleHashes as any).randomBytes = customRandomBytes;
// Sign the event but don't publish
try {
await event.sign();
} finally {
// Restore original functions
(nobleHashes as any).randomBytes = originalNobleRandomBytes;
}
return event;
} catch (error) {
console.error('Error creating event:', error);
set({ error: error instanceof Error ? error : new Error('Failed to create event') });
return null;
}
},
// Queue an event for publishing when online
queueEventForPublishing: async (event: NDKEvent): Promise<boolean> => {
try {
// Only proceed if the event has an ID and signature
if (!event.id || !event.sig) {
throw new Error('Event must be signed before queueing');
}
// First cache the event itself
try {
const EventCache = (await import('@/lib/db/services/EventCache')).EventCache;
const db = openDatabaseSync('powr.db');
const cache = new EventCache(db);
// Convert NDKEvent to NostrEvent for caching
await cache.setEvent({
id: event.id,
pubkey: event.pubkey,
kind: event.kind || 0,
created_at: event.created_at || Math.floor(Date.now() / 1000),
content: event.content,
tags: event.tags.map(tag => tag.map(item => String(item))),
sig: event.sig
});
// Then add to publication queue
await db.runAsync(
`INSERT OR REPLACE INTO publication_queue
(event_id, attempts, created_at, payload)
VALUES (?, ?, ?, ?)`,
[
event.id,
0,
Date.now(),
JSON.stringify({
id: event.id,
pubkey: event.pubkey,
kind: event.kind,
created_at: event.created_at,
content: event.content,
tags: event.tags,
sig: event.sig
})
]
);
} catch (cacheError) {
console.error('Error caching event:', cacheError);
// Continue to try publishing even if caching fails
}
// Try to publish immediately if online
try {
const ConnectivityService = (await import('@/lib/db/services/ConnectivityService')).ConnectivityService;
if (ConnectivityService.getInstance().getConnectionStatus()) {
try {
await event.publish();
// Remove from queue if successful
const db = openDatabaseSync('powr.db');
await db.runAsync(
`DELETE FROM publication_queue WHERE event_id = ?`,
[event.id]
);
console.log('Event published successfully:', event.id);
return true;
} catch (publishError) {
console.log('Event queued for later publishing:', event.id);
return false;
}
} else {
console.log('Event queued for later publishing (offline):', event.id);
return false;
}
} catch (connectivityError) {
console.error('Error checking connectivity:', connectivityError);
// Assume offline if connectivity service fails
return false;
}
} catch (error) {
console.error('Error queueing event for publishing:', error);
return false;
}
},
// Process the publication queue
processPublicationQueue: async (): Promise<void> => {
try {
const { ndk } = get();
if (!ndk) return;
const db = openDatabaseSync('powr.db');
// Get all queued events that haven't exceeded max attempts
const queuedEvents = await db.getAllAsync<{
event_id: string;
attempts: number;
payload: string;
}>(
`SELECT event_id, attempts, payload
FROM publication_queue
WHERE attempts < 5
ORDER BY created_at ASC`
);
console.log(`Processing publication queue: ${queuedEvents.length} events`);
for (const item of queuedEvents) {
try {
// Update attempt count and timestamp
await db.runAsync(
`UPDATE publication_queue
SET attempts = attempts + 1,
last_attempt = ?
WHERE event_id = ?`,
[Date.now(), item.event_id]
);
// Parse the event from payload
const eventData = JSON.parse(item.payload);
// Create a new NDKEvent
const event = new NDKEvent(ndk);
// Copy properties
event.id = eventData.id;
event.pubkey = eventData.pubkey;
event.kind = eventData.kind;
event.created_at = eventData.created_at;
event.content = eventData.content;
event.tags = eventData.tags;
event.sig = eventData.sig;
// Publish the event
await event.publish();
// Remove from queue on success
await db.runAsync(
`DELETE FROM publication_queue WHERE event_id = ?`,
[item.event_id]
);
console.log(`Published queued event: ${item.event_id}`);
} catch (error) {
console.error(`Error publishing queued event ${item.event_id}:`, error);
}
}
} catch (error) {
console.error('Error processing publication queue:', error);
}
},
fetchEventsByFilter: async (filter: NDKFilter) => { fetchEventsByFilter: async (filter: NDKFilter) => {
try { try {
const { ndk } = get(); const { ndk } = get();

10
package-lock.json generated

@ -16,6 +16,7 @@
"@nostr-dev-kit/ndk-mobile": "^0.4.1", "@nostr-dev-kit/ndk-mobile": "^0.4.1",
"@radix-ui/react-alert-dialog": "^1.1.6", "@radix-ui/react-alert-dialog": "^1.1.6",
"@react-native-clipboard/clipboard": "^1.16.1", "@react-native-clipboard/clipboard": "^1.16.1",
"@react-native-community/netinfo": "^11.4.1",
"@react-navigation/material-top-tabs": "^7.1.0", "@react-navigation/material-top-tabs": "^7.1.0",
"@react-navigation/native": "^7.0.0", "@react-navigation/native": "^7.0.0",
"@rn-primitives/accordion": "^1.1.0", "@rn-primitives/accordion": "^1.1.0",
@ -4540,6 +4541,15 @@
} }
} }
}, },
"node_modules/@react-native-community/netinfo": {
"version": "11.4.1",
"resolved": "https://registry.npmjs.org/@react-native-community/netinfo/-/netinfo-11.4.1.tgz",
"integrity": "sha512-B0BYAkghz3Q2V09BF88RA601XursIEA111tnc2JOaN7axJWmNefmfjZqw/KdSxKZp7CZUuPpjBmz/WCR9uaHYg==",
"license": "MIT",
"peerDependencies": {
"react-native": ">=0.59"
}
},
"node_modules/@react-native/assets-registry": { "node_modules/@react-native/assets-registry": {
"version": "0.76.7", "version": "0.76.7",
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.76.7.tgz", "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.76.7.tgz",

@ -30,6 +30,7 @@
"@nostr-dev-kit/ndk-mobile": "^0.4.1", "@nostr-dev-kit/ndk-mobile": "^0.4.1",
"@radix-ui/react-alert-dialog": "^1.1.6", "@radix-ui/react-alert-dialog": "^1.1.6",
"@react-native-clipboard/clipboard": "^1.16.1", "@react-native-clipboard/clipboard": "^1.16.1",
"@react-native-community/netinfo": "^11.4.1",
"@react-navigation/material-top-tabs": "^7.1.0", "@react-navigation/material-top-tabs": "^7.1.0",
"@react-navigation/native": "^7.0.0", "@react-navigation/native": "^7.0.0",
"@rn-primitives/accordion": "^1.1.0", "@rn-primitives/accordion": "^1.1.0",

@ -2,6 +2,7 @@
"extends": "expo/tsconfig.base", "extends": "expo/tsconfig.base",
"compilerOptions": { "compilerOptions": {
"moduleResolution": "bundler", "moduleResolution": "bundler",
"module": "esnext",
"strict": true, "strict": true,
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {