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

View File

@ -8,6 +8,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### 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
- Implemented NDK-mobile for React Native compatibility
- 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
- Built automatic timer management with background support
- 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
- Added workout timer with proper background handling
- 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
### 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
- Standardized screen transitions and gestures
- 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
### 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 timer inconsistency during app background state
- 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
### 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
- Created dedicated NDK store using Zustand for state management
- Built secure key storage and retrieval using Expo SecureStore
@ -152,48 +201,54 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added relay connection management with status tracking
- Developed proper error handling for network operations
2. Cryptographic Implementation:
4. Cryptographic Implementation:
- Integrated react-native-get-random-values for crypto API polyfill
- Implemented NDKMobilePrivateKeySigner for key operations
- Added proper key format handling (hex, nsec)
- Created secure key generation functionality
- Built robust error handling for cryptographic operations
3. Programs Testing Component:
5. Programs Testing Component:
- Developed dual-purpose interface for Database and Nostr testing
- Implemented login system with key generation and secure storage
- Built event creation interface with multiple event kinds
- Added event querying and display functionality
- Created detailed event inspection with tag visualization
- Added relay status monitoring
4. Database Schema Enforcement:
6. Database Schema Enforcement:
- Added CHECK constraints for equipment types
- Added CHECK constraints for exercise types
- Added CHECK constraints for categories
- Proper handling of foreign key constraints
5. Input Validation:
7. Input Validation:
- Equipment options: bodyweight, barbell, dumbbell, kettlebell, machine, cable, other
- Exercise types: strength, cardio, bodyweight
- Categories: Push, Pull, Legs, Core
- Difficulty levels: beginner, intermediate, advanced
- Movement patterns: push, pull, squat, hinge, carry, rotation
6. Error Handling:
8. Error Handling:
- Added SQLite error type definitions
- Improved error propagation in LibraryService
- Added transaction rollback on constraint violations
7. Database Services:
9. Database Services:
- Added EventCache service for Nostr events
- Improved ExerciseService with transaction awareness
- Added DevSeederService for development data
- Enhanced error handling and logging
8. Workout State Management with Zustand:
10. Workout State Management with Zustand:
- Implemented selector pattern for performance optimization
- Added module-level timer references for background operation
- Created workout persistence with auto-save functionality
- Developed state recovery for crash protection
- Added support for future Nostr integration
- Implemented workout minimization for multi-tasking
9. Template Details UI Architecture:
11. Template Details UI Architecture:
- Implemented MaterialTopTabNavigator for content organization
- Created screen-specific components for each tab
- Developed conditional rendering based on template source
@ -201,6 +256,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added proper navigation state handling
### 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
- Input validation prevents invalid data entry
- Enhanced error messages provide better debugging information

125
README.md
View File

@ -1,29 +1,30 @@
# 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
### Current
- Exercise library management
- Exercise library management with local SQLite database
- Workout template creation
- Local-first data architecture
- Local-first data architecture with Nostr sync capability
- Cross-platform support (iOS, Android)
- Dark mode support
- Dark/light mode support
- Nostr authentication and event publishing
### Planned
- Workout record and template sharing
- Nostr integration
- Social features
- Enhanced social features
- Training programs
- Performance analytics
- Public/private workout sharing options
## Getting Started
### Prerequisites
- Node.js (v18 or later)
- npm or yarn
- Expo CLI
- EAS CLI (`npm install -g eas-cli`)
- iOS Simulator (for iOS development)
- Android Studio (for Android development)
@ -40,15 +41,35 @@ cd powr
npm install
```
3. Start the development server
3. Install development client modules
```bash
npx expo start
npx expo install expo-dev-client expo-crypto expo-nip55
```
### Development Options
- Press 'i' for iOS simulator
- Press 'a' for Android simulator
- Scan QR code with Expo Go app for physical device
### Development Using Expo Dev Client
POWR now uses Expo Dev Client for development instead of Expo Go. This allows us to use native modules required for Nostr integration.
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
@ -56,62 +77,60 @@ npx expo start
powr/
├── app/ # Main application code
│ ├── (tabs)/ # Tab-based navigation
│ └── components/ # Shared components
├── assets/ # Static assets
├── docs/ # Documentation
│ └── design/ # Design documents
│ ├── (workout)/ # Workout screens
│ └── _layout.tsx # Root layout
├── components/ # Shared components
│ ├── ui/ # UI components
│ ├── sheets/ # Bottom sheets
│ └── library/ # Library components
├── lib/ # Shared utilities
└── types/ # TypeScript definitions
│ ├── db/ # Database services
│ ├── hooks/ # Custom React hooks
│ ├── stores/ # Zustand stores
│ └── mobile-signer.ts # Nostr signer implementation
├── types/ # TypeScript definitions
└── utils/ # Utility functions
```
## Technology Stack
### Core
- React Native
- Expo
- Expo (with Dev Client)
- TypeScript
- SQLite (via expo-sqlite)
- Zustand (state management)
### UI Components
- NativeWind
- NativeWind/Tailwind
- React Navigation
- Lucide Icons
### Testing
- Jest
- React Native Testing Library
### Nostr Integration
- NDK (Nostr Development Kit)
- Custom mobile signer implementation
- Local event caching
## Development
## Database Architecture
### Environment Setup
1. Install development tools
```bash
npm install -g expo-cli
```
POWR uses a SQLite database with a service-oriented architecture:
- Exercise data
- Workout templates
- Nostr event caching
- User profiles
2. Configure environment
```bash
cp .env.example .env
```
Each domain has dedicated service classes for data operations.
3. Configure development settings
```bash
npm run setup-dev
```
## Nostr Integration
### Running Tests
```bash
# Run all tests
npm test
POWR implements the Nostr protocol via NDK with:
- Secure key management using expo-secure-store
- Event publishing for exercises, templates, and workouts
- Profile discovery and following
- Custom event kinds for fitness data
# Run with coverage
npm test -- --coverage
## Building for Production
# Run in watch mode
npm test -- --watch
```
### Building for Production
```bash
# Build for iOS
eas build -p ios
@ -128,15 +147,6 @@ eas build -p android
4. Push to the branch
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
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/)
- [React Native](https://reactnative.dev/)
- [NDK](https://github.com/nostr-dev-kit/ndk)
- [Nostr Protocol](https://github.com/nostr-protocol/nostr)

View File

@ -5,13 +5,15 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Search, Dumbbell, ListFilter } from 'lucide-react-native';
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 { ExerciseDetails } from '@/components/exercises/ExerciseDetails';
import { ModalExerciseDetails } from '@/components/exercises/ModalExerciseDetails';
import { ExerciseDisplay, ExerciseType, BaseExercise, Equipment } from '@/types/exercise';
import { useExercises } from '@/lib/hooks/useExercises';
import { FilterSheet, type FilterOptions, type SourceType } from '@/components/library/FilterSheet';
import { useWorkoutStore } from '@/stores/workoutStore';
import { generateId } from '@/utils/ids';
import { useNDKStore } from '@/lib/stores/ndk';
// Default available filters
const availableFilters = {
@ -28,13 +30,23 @@ const initialFilters: FilterOptions = {
};
export default function ExercisesScreen() {
const [showNewExercise, setShowNewExercise] = useState(false);
// Basic state
const [searchQuery, setSearchQuery] = useState('');
const [selectedExercise, setSelectedExercise] = useState<ExerciseDisplay | null>(null);
const [filterSheetOpen, setFilterSheetOpen] = useState(false);
const [currentFilters, setCurrentFilters] = useState<FilterOptions>(initialFilters);
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 { currentUser } = useNDKStore();
const shouldShowFAB = !isActive || !isMinimized;
const {
@ -43,6 +55,7 @@ export default function ExercisesScreen() {
error,
createExercise,
deleteExercise,
updateExercise,
refreshExercises,
updateFilters,
clearFilters
@ -61,22 +74,98 @@ export default function ExercisesScreen() {
setSelectedExercise(exercise);
};
const handleEdit = async () => {
// TODO: Implement edit functionality
setSelectedExercise(null);
// Mock exercise update function
const handleUpdateExercise = async (id: string, updatedData: Partial<BaseExercise>): Promise<void> => {
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) => {
// Convert BaseExercise to include required source information
// Handle editing an exercise
const handleEdit = () => {
if (!selectedExercise) return;
// Close the details modal
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);
setShowNewExercise(false);
}
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) => {
@ -158,30 +247,28 @@ export default function ExercisesScreen() {
/>
{/* Exercise details sheet */}
{selectedExercise && (
<ExerciseDetails
exercise={selectedExercise}
<ModalExerciseDetails
exercise={selectedExercise} // This can now be null
open={!!selectedExercise}
onOpenChange={(open) => {
if (!open) setSelectedExercise(null);
}}
onClose={() => setSelectedExercise(null)}
onEdit={handleEdit}
/>
)}
{/* FAB for adding new exercise */}
{shouldShowFAB && (
<FloatingActionButton
icon={Dumbbell}
onPress={() => setShowNewExercise(true)}
onPress={handleCreateExercise}
/>
)}
{/* New exercise sheet */}
<NewExerciseSheet
isOpen={showNewExercise}
onClose={() => setShowNewExercise(false)}
onSubmit={handleCreateExercise}
{/* Exercise sheet for create/edit/fork */}
<ExerciseSheet
isOpen={showExerciseSheet}
onClose={() => setShowExerciseSheet(false)}
onSubmit={handleSubmitExercise}
exerciseToEdit={exerciseToEdit}
mode={editMode}
/>
</View>
);

View File

@ -10,6 +10,7 @@ import { FloatingActionButton } from '@/components/shared/FloatingActionButton';
import { NewTemplateSheet } from '@/components/library/NewTemplateSheet';
import { FilterSheet, type FilterOptions, type SourceType } from '@/components/library/FilterSheet';
import { TemplateCard } from '@/components/templates/TemplateCard';
import { ModalTemplateDetails } from '@/components/templates/ModalTemplateDetails';
import { Button } from '@/components/ui/button';
import {
Template,
@ -74,12 +75,21 @@ export default function TemplatesScreen() {
const { isActive, isMinimized } = useWorkoutStore();
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) => {
setTemplates(current => current.filter(t => t.id !== id));
};
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) => {
@ -127,6 +137,23 @@ export default function TemplatesScreen() {
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(
React.useCallback(() => {
// 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
isOpen={showNewTemplate}
onClose={() => setShowNewTemplate(false)}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,11 +1,10 @@
// components/library/NewTemplateSheet.tsx
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 { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { Badge } from '@/components/ui/badge';
import {
Template,
@ -17,7 +16,8 @@ import { ExerciseDisplay } from '@/types/exercise';
import { generateId } from '@/utils/ids';
import { useSQLiteContext } from 'expo-sqlite';
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 {
isOpen: boolean;
@ -71,7 +71,7 @@ function WorkoutTypeStep({ onSelectType, onCancel }: WorkoutTypeStepProps) {
return (
<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>
<View className="gap-3">
@ -92,9 +92,6 @@ function WorkoutTypeStep({ onSelectType, onCancel }: WorkoutTypeStepProps) {
</Text>
</View>
</View>
<View className="pl-2 pr-1">
<ChevronRight color={purpleColor} size={20} />
</View>
</TouchableOpacity>
) : (
<View>
@ -117,10 +114,6 @@ function WorkoutTypeStep({ onSelectType, onCancel }: WorkoutTypeStepProps) {
</View>
))}
</View>
<Button variant="outline" onPress={onCancel} className="mt-4 py-4">
<Text>Cancel</Text>
</Button>
</View>
</ScrollView>
);
@ -152,7 +145,7 @@ function BasicInfoStep({
return (
<ScrollView className="flex-1">
<View className="gap-4 py-4">
<View className="gap-4 py-4 px-4">
<View>
<Text className="text-base font-medium mb-2">Workout Name</Text>
<Input
@ -199,9 +192,6 @@ function BasicInfoStep({
</View>
<View className="flex-row justify-end gap-3 mt-4">
<Button variant="outline" onPress={onCancel}>
<Text>Cancel</Text>
</Button>
<Button
onPress={onNext}
disabled={!title}
@ -314,13 +304,11 @@ function ExerciseSelectionStep({
</ScrollView>
<View className="p-4 flex-row justify-between border-t border-border">
<Button variant="outline" onPress={onBack}>
<Text>Back</Text>
</Button>
<Button
onPress={handleContinue}
disabled={selectedIds.length === 0}
style={selectedIds.length === 0 ? {} : { backgroundColor: purpleColor }}
className="w-full"
>
<Text className={selectedIds.length === 0 ? '' : 'text-white'}>
Continue with {selectedIds.length} Exercise{selectedIds.length !== 1 ? 's' : ''}
@ -390,13 +378,11 @@ function ExerciseConfigStep({
</View>
</ScrollView>
<View className="p-4 flex-row justify-between border-t border-border">
<Button variant="outline" onPress={onBack}>
<Text>Back</Text>
</Button>
<View className="p-4 border-t border-border">
<Button
onPress={onNext}
style={{ backgroundColor: purpleColor }}
className="w-full"
>
<Text className="text-white">Review Template</Text>
</Button>
@ -463,13 +449,11 @@ function ReviewStep({
</View>
</ScrollView>
<View className="p-4 flex-row justify-between border-t border-border">
<Button variant="outline" onPress={onBack}>
<Text>Back</Text>
</Button>
<View className="p-4 border-t border-border">
<Button
onPress={onSubmit}
style={{ backgroundColor: purpleColor }}
className="w-full"
>
<Text className="text-white">Create Template</Text>
</Button>
@ -486,6 +470,7 @@ export function NewTemplateSheet({ isOpen, onClose, onSubmit }: NewTemplateSheet
const [exercises, setExercises] = useState<ExerciseDisplay[]>([]);
const [selectedExercises, setSelectedExercises] = useState<ExerciseDisplay[]>([]);
const [configuredExercises, setConfiguredExercises] = useState<Template['exercises']>([]);
const { isDarkColorScheme } = useColorScheme();
// Template info
const [templateInfo, setTemplateInfo] = useState<{
@ -519,6 +504,8 @@ export function NewTemplateSheet({ isOpen, onClose, onSubmit }: NewTemplateSheet
// Reset state when sheet closes
useEffect(() => {
if (!isOpen) {
// Add a delay to ensure the closing animation completes first
const timer = setTimeout(() => {
setStep('type');
setWorkoutType('strength');
setSelectedExercises([]);
@ -529,6 +516,9 @@ export function NewTemplateSheet({ isOpen, onClose, onSubmit }: NewTemplateSheet
category: 'Full Body',
tags: ['strength']
});
}, 300);
return () => clearTimeout(timer);
}
}, [isOpen]);
@ -594,11 +584,28 @@ export function NewTemplateSheet({ isOpen, onClose, onSubmit }: NewTemplateSheet
isFavorite: false
};
onSubmit(newTemplate);
// Close first, then submit with a small delay
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 = () => {
switch (step) {
case 'type':
@ -658,40 +665,45 @@ export function NewTemplateSheet({ isOpen, onClose, onSubmit }: NewTemplateSheet
}
};
// 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';
// Return null if not open
if (!isOpen) return null;
return (
<Sheet isOpen={isOpen} onClose={onClose}>
<SheetHeader>
<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">
<View className="flex-row items-center">
{showBackButton && (
<Button
variant="ghost"
size="icon"
className="mr-2"
<TouchableOpacity
onPress={handleGoBack}
className="mr-2 p-1"
>
<ChevronLeft className="text-foreground" size={20} />
</Button>
<ChevronLeft size={24} />
</TouchableOpacity>
)}
<SheetTitle>{getStepTitle()}</SheetTitle>
<Text className="text-xl font-bold text-foreground">{getStepTitle()}</Text>
</View>
</SheetHeader>
<SheetContent>
<TouchableOpacity onPress={onClose} className="p-1">
<X size={24} />
</TouchableOpacity>
</View>
{/* Content */}
<View className="flex-1">
{renderContent()}
</SheetContent>
</Sheet>
</View>
</View>
</View>
</Modal>
);
}

View File

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

View File

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

View File

@ -2,7 +2,7 @@
import { SQLiteDatabase } from 'expo-sqlite';
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 {
private async getCurrentVersion(db: SQLiteDatabase): Promise<number> {
@ -210,6 +210,41 @@ class Schema {
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
const tables = await db.getAllAsync<{ name: string }>(
"SELECT name FROM sqlite_master WHERE type='table'"

View File

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

View File

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

View File

@ -161,6 +161,44 @@ export function useExercises() {
}
}, [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
const updateFilters = useCallback((newFilters: Partial<ExerciseFilters>) => {
setFilters(current => ({
@ -189,6 +227,7 @@ export function useExercises() {
clearFilters,
createExercise,
deleteExercise,
updateExercise,
refreshExercises: loadExercises
};
}

View File

@ -9,6 +9,7 @@ import NDK, { NDKFilter } from '@nostr-dev-kit/ndk';
import { NDKEvent, NDKUser } from '@nostr-dev-kit/ndk-mobile';
import * as SecureStore from 'expo-secure-store';
import * as Crypto from 'expo-crypto';
import { openDatabaseSync } from 'expo-sqlite';
import { NDKMobilePrivateKeySigner, generateKeyPair } from '@/lib/mobile-signer';
// Constants for SecureStore
@ -37,6 +38,9 @@ type NDKStoreActions = {
logout: () => Promise<void>;
generateKeys: () => { privateKey: string; publicKey: string; nsec: string; npub: string };
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[]>;
};
@ -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 });
} catch (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) => {
try {
const { ndk } = get();

10
package-lock.json generated
View File

@ -16,6 +16,7 @@
"@nostr-dev-kit/ndk-mobile": "^0.4.1",
"@radix-ui/react-alert-dialog": "^1.1.6",
"@react-native-clipboard/clipboard": "^1.16.1",
"@react-native-community/netinfo": "^11.4.1",
"@react-navigation/material-top-tabs": "^7.1.0",
"@react-navigation/native": "^7.0.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": {
"version": "0.76.7",
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.76.7.tgz",

View File

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

View File

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