mirror of
https://github.com/DocNR/POWR.git
synced 2025-05-25 11:22:05 +00:00
updated exercise/template UI, publication queue, forking of exercises (templates next)
This commit is contained in:
parent
98a5b9ed09
commit
4eb9d428a2
CHANGELOG.mdREADME.mdpackage-lock.jsonpackage.jsontsconfig.json
app
components
exercises
library
templates
lib
db
hooks
stores
111
CHANGELOG.md
111
CHANGELOG.md
@ -8,6 +8,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- Comprehensive exercise management features
|
||||||
|
- Added exercise editing functionality
|
||||||
|
- Implemented exercise forking for Nostr exercises
|
||||||
|
- Created local-first editing with offline support
|
||||||
|
- Added publication queue for deferred Nostr publishing
|
||||||
|
- Built robust exercise update workflow
|
||||||
|
- Implemented source-aware editing permissions
|
||||||
|
- Connectivity service for network state management
|
||||||
|
- Added real-time connectivity monitoring
|
||||||
|
- Implemented persistence for offline state
|
||||||
|
- Built automatic retry system for failed requests
|
||||||
|
- Created hook-based connectivity API for components
|
||||||
|
- Extended database schema for publication queuing
|
||||||
|
- Added publication_queue table
|
||||||
|
- Implemented attempt tracking and rate limiting
|
||||||
|
- Added app_status table for system-wide states
|
||||||
- Successful Nostr protocol integration
|
- Successful Nostr protocol integration
|
||||||
- Implemented NDK-mobile for React Native compatibility
|
- Implemented NDK-mobile for React Native compatibility
|
||||||
- Added secure key management with Expo SecureStore
|
- Added secure key management with Expo SecureStore
|
||||||
@ -33,12 +49,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Added workout persistence and recovery
|
- Added workout persistence and recovery
|
||||||
- Built automatic timer management with background support
|
- Built automatic timer management with background support
|
||||||
- Developed minimization and maximization functionality
|
- Developed minimization and maximization functionality
|
||||||
- Zustand workout store for state management
|
|
||||||
- Created comprehensive workout state store with Zustand
|
|
||||||
- Implemented selectors for efficient state access
|
|
||||||
- Added workout persistence and recovery
|
|
||||||
- Built automatic timer management with background support
|
|
||||||
- Developed minimization and maximization functionality
|
|
||||||
- Workout tracking implementation with real-time tracking
|
- Workout tracking implementation with real-time tracking
|
||||||
- Added workout timer with proper background handling
|
- Added workout timer with proper background handling
|
||||||
- Implemented rest timer functionality
|
- Implemented rest timer functionality
|
||||||
@ -85,6 +95,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Improved workout history visualization
|
- Improved workout history visualization
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
- Enhanced exercise detail viewer
|
||||||
|
- Replaced bottom sheet with full-screen modal
|
||||||
|
- Added tabbed interface for information organization
|
||||||
|
- Implemented edit capability with ownership detection
|
||||||
|
- Added fork functionality for Nostr exercises from other users
|
||||||
|
- Improved progress visualization with charts
|
||||||
|
- Redesigned exercise editor
|
||||||
|
- Created multi-purpose editor for create/edit/fork workflows
|
||||||
|
- Added context-aware UI based on exercise source
|
||||||
|
- Implemented specialized buttons based on workflow type
|
||||||
|
- Added better form validation and feedback
|
||||||
|
- Improved keyboard handling across platforms
|
||||||
|
- Improved workflow architecture for model context protocol
|
||||||
|
- Implemented offline-first editing paradigm
|
||||||
|
- Added cryptographic signing before submission
|
||||||
|
- Built local caching with deferred publishing
|
||||||
|
- Created connectivity-aware operation queueing
|
||||||
|
- Added proper error recovery and retry mechanisms
|
||||||
- Improved workout screen navigation consistency
|
- Improved workout screen navigation consistency
|
||||||
- Standardized screen transitions and gestures
|
- Standardized screen transitions and gestures
|
||||||
- Added back buttons for clearer navigation
|
- Added back buttons for clearer navigation
|
||||||
@ -128,6 +156,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Enhanced visual separation between template metadata and content
|
- Enhanced visual separation between template metadata and content
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
- Exercise update functionality using delete-recreate pattern
|
||||||
|
- Exercise data type handling in forking operation
|
||||||
|
- TypeScript errors in exercise component interfaces
|
||||||
|
- Nostr event queuing and retry mechanism
|
||||||
|
- Exercise ownership detection for edit vs fork workflows
|
||||||
|
- Connectivity monitoring edge cases
|
||||||
- Workout navigation gesture handling issues
|
- Workout navigation gesture handling issues
|
||||||
- Workout timer inconsistency during app background state
|
- Workout timer inconsistency during app background state
|
||||||
- Exercise deletion functionality
|
- Exercise deletion functionality
|
||||||
@ -144,7 +178,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Content rendering issues in bottom sheet components
|
- Content rendering issues in bottom sheet components
|
||||||
|
|
||||||
### Technical Details
|
### Technical Details
|
||||||
1. Nostr Integration:
|
1. Exercise Management:
|
||||||
|
- Implemented edit/fork/create workflows with unified interface
|
||||||
|
- Built local-first editing pattern with offline support
|
||||||
|
- Added publication queue for deferred Nostr submissions
|
||||||
|
- Created robust update mechanism in useExercises hook
|
||||||
|
- Implemented source-aware editing permissions
|
||||||
|
- Added ownership detection for exercise operations
|
||||||
|
|
||||||
|
2. Connectivity Management:
|
||||||
|
- Implemented singleton ConnectivityService for app-wide monitoring
|
||||||
|
- Added NetInfo integration for real-time status detection
|
||||||
|
- Built React hook for component-level connectivity awareness
|
||||||
|
- Created database persistence for connectivity state
|
||||||
|
- Implemented event-based notification system
|
||||||
|
|
||||||
|
3. Nostr Integration:
|
||||||
- Implemented @nostr-dev-kit/ndk-mobile package for React Native compatibility
|
- Implemented @nostr-dev-kit/ndk-mobile package for React Native compatibility
|
||||||
- Created dedicated NDK store using Zustand for state management
|
- Created dedicated NDK store using Zustand for state management
|
||||||
- Built secure key storage and retrieval using Expo SecureStore
|
- Built secure key storage and retrieval using Expo SecureStore
|
||||||
@ -152,55 +201,65 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Added relay connection management with status tracking
|
- Added relay connection management with status tracking
|
||||||
- Developed proper error handling for network operations
|
- Developed proper error handling for network operations
|
||||||
|
|
||||||
2. Cryptographic Implementation:
|
4. Cryptographic Implementation:
|
||||||
- Integrated react-native-get-random-values for crypto API polyfill
|
- Integrated react-native-get-random-values for crypto API polyfill
|
||||||
- Implemented NDKMobilePrivateKeySigner for key operations
|
- Implemented NDKMobilePrivateKeySigner for key operations
|
||||||
- Added proper key format handling (hex, nsec)
|
- Added proper key format handling (hex, nsec)
|
||||||
- Created secure key generation functionality
|
- Created secure key generation functionality
|
||||||
- Built robust error handling for cryptographic operations
|
- Built robust error handling for cryptographic operations
|
||||||
|
|
||||||
3. Programs Testing Component:
|
5. Programs Testing Component:
|
||||||
- Developed dual-purpose interface for Database and Nostr testing
|
- Developed dual-purpose interface for Database and Nostr testing
|
||||||
- Implemented login system with key generation and secure storage
|
- Implemented login system with key generation and secure storage
|
||||||
- Built event creation interface with multiple event kinds
|
- Built event creation interface with multiple event kinds
|
||||||
- Added event querying and display functionality
|
- Added event querying and display functionality
|
||||||
- Created detailed event inspection with tag visualization
|
- Created detailed event inspection with tag visualization
|
||||||
- Added relay status monitoring
|
- Added relay status monitoring
|
||||||
4. Database Schema Enforcement:
|
|
||||||
|
6. Database Schema Enforcement:
|
||||||
- Added CHECK constraints for equipment types
|
- Added CHECK constraints for equipment types
|
||||||
- Added CHECK constraints for exercise types
|
- Added CHECK constraints for exercise types
|
||||||
- Added CHECK constraints for categories
|
- Added CHECK constraints for categories
|
||||||
- Proper handling of foreign key constraints
|
- Proper handling of foreign key constraints
|
||||||
5. Input Validation:
|
|
||||||
|
7. Input Validation:
|
||||||
- Equipment options: bodyweight, barbell, dumbbell, kettlebell, machine, cable, other
|
- Equipment options: bodyweight, barbell, dumbbell, kettlebell, machine, cable, other
|
||||||
- Exercise types: strength, cardio, bodyweight
|
- Exercise types: strength, cardio, bodyweight
|
||||||
- Categories: Push, Pull, Legs, Core
|
- Categories: Push, Pull, Legs, Core
|
||||||
- Difficulty levels: beginner, intermediate, advanced
|
- Difficulty levels: beginner, intermediate, advanced
|
||||||
- Movement patterns: push, pull, squat, hinge, carry, rotation
|
- Movement patterns: push, pull, squat, hinge, carry, rotation
|
||||||
6. Error Handling:
|
|
||||||
|
8. Error Handling:
|
||||||
- Added SQLite error type definitions
|
- Added SQLite error type definitions
|
||||||
- Improved error propagation in LibraryService
|
- Improved error propagation in LibraryService
|
||||||
- Added transaction rollback on constraint violations
|
- Added transaction rollback on constraint violations
|
||||||
7. Database Services:
|
|
||||||
|
9. Database Services:
|
||||||
- Added EventCache service for Nostr events
|
- Added EventCache service for Nostr events
|
||||||
- Improved ExerciseService with transaction awareness
|
- Improved ExerciseService with transaction awareness
|
||||||
- Added DevSeederService for development data
|
- Added DevSeederService for development data
|
||||||
- Enhanced error handling and logging
|
- Enhanced error handling and logging
|
||||||
8. Workout State Management with Zustand:
|
|
||||||
- Implemented selector pattern for performance optimization
|
10. Workout State Management with Zustand:
|
||||||
- Added module-level timer references for background operation
|
- Implemented selector pattern for performance optimization
|
||||||
- Created workout persistence with auto-save functionality
|
- Added module-level timer references for background operation
|
||||||
- Developed state recovery for crash protection
|
- Created workout persistence with auto-save functionality
|
||||||
- Added support for future Nostr integration
|
- Developed state recovery for crash protection
|
||||||
- Implemented workout minimization for multi-tasking
|
- Added support for future Nostr integration
|
||||||
9. Template Details UI Architecture:
|
- Implemented workout minimization for multi-tasking
|
||||||
- Implemented MaterialTopTabNavigator for content organization
|
|
||||||
- Created screen-specific components for each tab
|
11. Template Details UI Architecture:
|
||||||
- Developed conditional rendering based on template source
|
- Implemented MaterialTopTabNavigator for content organization
|
||||||
- Implemented context-aware action buttons
|
- Created screen-specific components for each tab
|
||||||
- Added proper navigation state handling
|
- Developed conditional rendering based on template source
|
||||||
|
- Implemented context-aware action buttons
|
||||||
|
- Added proper navigation state handling
|
||||||
|
|
||||||
### Migration Notes
|
### Migration Notes
|
||||||
|
- Exercise editing now follows an offline-first approach with Nostr awareness
|
||||||
|
- ExerciseSheet component replaces separate create/edit components
|
||||||
|
- Exercise updates require proper source and metadata handling
|
||||||
|
- Publication queue provides automatic retry for Nostr events
|
||||||
- Exercise creation now enforces schema constraints
|
- Exercise creation now enforces schema constraints
|
||||||
- Input validation prevents invalid data entry
|
- Input validation prevents invalid data entry
|
||||||
- Enhanced error messages provide better debugging information
|
- Enhanced error messages provide better debugging information
|
||||||
|
129
README.md
129
README.md
@ -1,29 +1,30 @@
|
|||||||
# POWR - Cross-Platform Fitness Tracking App
|
# POWR - Cross-Platform Fitness Tracking App
|
||||||
|
|
||||||
POWR is a local-first fitness tracking application built with React Native and Expo, featuring planned Nostr protocol integration for decentralized social features.
|
POWR is a local-first fitness tracking application built with React Native and Expo, featuring integration with the Nostr protocol for decentralized social features and improved control of your fitness data.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Current
|
### Current
|
||||||
- Exercise library management
|
- Exercise library management with local SQLite database
|
||||||
- Workout template creation
|
- Workout template creation
|
||||||
- Local-first data architecture
|
- Local-first data architecture with Nostr sync capability
|
||||||
- Cross-platform support (iOS, Android)
|
- Cross-platform support (iOS, Android)
|
||||||
- Dark mode support
|
- Dark/light mode support
|
||||||
|
- Nostr authentication and event publishing
|
||||||
|
|
||||||
### Planned
|
### Planned
|
||||||
- Workout record and template sharing
|
- Workout record and template sharing
|
||||||
- Nostr integration
|
- Enhanced social features
|
||||||
- Social features
|
|
||||||
- Training programs
|
- Training programs
|
||||||
- Performance analytics
|
- Performance analytics
|
||||||
|
- Public/private workout sharing options
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
- Node.js (v18 or later)
|
- Node.js (v18 or later)
|
||||||
- npm or yarn
|
- npm or yarn
|
||||||
- Expo CLI
|
- EAS CLI (`npm install -g eas-cli`)
|
||||||
- iOS Simulator (for iOS development)
|
- iOS Simulator (for iOS development)
|
||||||
- Android Studio (for Android development)
|
- Android Studio (for Android development)
|
||||||
|
|
||||||
@ -40,78 +41,96 @@ cd powr
|
|||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Start the development server
|
3. Install development client modules
|
||||||
```bash
|
```bash
|
||||||
npx expo start
|
npx expo install expo-dev-client expo-crypto expo-nip55
|
||||||
```
|
```
|
||||||
|
|
||||||
### Development Options
|
### Development Using Expo Dev Client
|
||||||
- Press 'i' for iOS simulator
|
|
||||||
- Press 'a' for Android simulator
|
POWR now uses Expo Dev Client for development instead of Expo Go. This allows us to use native modules required for Nostr integration.
|
||||||
- Scan QR code with Expo Go app for physical device
|
|
||||||
|
1. Configure EAS (if not already done)
|
||||||
|
```bash
|
||||||
|
eas build:configure
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create a development build
|
||||||
|
```bash
|
||||||
|
# For Android
|
||||||
|
eas build --profile development --platform android
|
||||||
|
|
||||||
|
# For iOS
|
||||||
|
eas build --profile development --platform ios
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Start the development server with dev client
|
||||||
|
```bash
|
||||||
|
npx expo start --dev-client
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Install the build on your device and scan the QR code to connect
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```plaintext
|
```plaintext
|
||||||
powr/
|
powr/
|
||||||
├── app/ # Main application code
|
├── app/ # Main application code
|
||||||
│ ├── (tabs)/ # Tab-based navigation
|
│ ├── (tabs)/ # Tab-based navigation
|
||||||
│ └── components/ # Shared components
|
│ ├── (workout)/ # Workout screens
|
||||||
├── assets/ # Static assets
|
│ └── _layout.tsx # Root layout
|
||||||
├── docs/ # Documentation
|
├── components/ # Shared components
|
||||||
│ └── design/ # Design documents
|
│ ├── ui/ # UI components
|
||||||
├── lib/ # Shared utilities
|
│ ├── sheets/ # Bottom sheets
|
||||||
└── types/ # TypeScript definitions
|
│ └── library/ # Library components
|
||||||
|
├── lib/ # Shared utilities
|
||||||
|
│ ├── db/ # Database services
|
||||||
|
│ ├── hooks/ # Custom React hooks
|
||||||
|
│ ├── stores/ # Zustand stores
|
||||||
|
│ └── mobile-signer.ts # Nostr signer implementation
|
||||||
|
├── types/ # TypeScript definitions
|
||||||
|
└── utils/ # Utility functions
|
||||||
```
|
```
|
||||||
|
|
||||||
## Technology Stack
|
## Technology Stack
|
||||||
|
|
||||||
### Core
|
### Core
|
||||||
- React Native
|
- React Native
|
||||||
- Expo
|
- Expo (with Dev Client)
|
||||||
- TypeScript
|
- TypeScript
|
||||||
- SQLite (via expo-sqlite)
|
- SQLite (via expo-sqlite)
|
||||||
|
- Zustand (state management)
|
||||||
|
|
||||||
### UI Components
|
### UI Components
|
||||||
- NativeWind
|
- NativeWind/Tailwind
|
||||||
- React Navigation
|
- React Navigation
|
||||||
- Lucide Icons
|
- Lucide Icons
|
||||||
|
|
||||||
### Testing
|
### Nostr Integration
|
||||||
- Jest
|
- NDK (Nostr Development Kit)
|
||||||
- React Native Testing Library
|
- Custom mobile signer implementation
|
||||||
|
- Local event caching
|
||||||
|
|
||||||
## Development
|
## Database Architecture
|
||||||
|
|
||||||
### Environment Setup
|
POWR uses a SQLite database with a service-oriented architecture:
|
||||||
1. Install development tools
|
- Exercise data
|
||||||
```bash
|
- Workout templates
|
||||||
npm install -g expo-cli
|
- Nostr event caching
|
||||||
```
|
- User profiles
|
||||||
|
|
||||||
2. Configure environment
|
Each domain has dedicated service classes for data operations.
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Configure development settings
|
## Nostr Integration
|
||||||
```bash
|
|
||||||
npm run setup-dev
|
|
||||||
```
|
|
||||||
|
|
||||||
### Running Tests
|
POWR implements the Nostr protocol via NDK with:
|
||||||
```bash
|
- Secure key management using expo-secure-store
|
||||||
# Run all tests
|
- Event publishing for exercises, templates, and workouts
|
||||||
npm test
|
- Profile discovery and following
|
||||||
|
- Custom event kinds for fitness data
|
||||||
|
|
||||||
# Run with coverage
|
## Building for Production
|
||||||
npm test -- --coverage
|
|
||||||
|
|
||||||
# Run in watch mode
|
|
||||||
npm test -- --watch
|
|
||||||
```
|
|
||||||
|
|
||||||
### Building for Production
|
|
||||||
```bash
|
```bash
|
||||||
# Build for iOS
|
# Build for iOS
|
||||||
eas build -p ios
|
eas build -p ios
|
||||||
@ -128,15 +147,6 @@ eas build -p android
|
|||||||
4. Push to the branch
|
4. Push to the branch
|
||||||
5. Open a Pull Request
|
5. Open a Pull Request
|
||||||
|
|
||||||
Please read [CONTRIBUTING.md](CONTRIBUTING.md) for details on our code of conduct and development process.
|
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
- [Project Overview](docs/project-overview.md)
|
|
||||||
- [Architecture Guide](docs/architecture.md)
|
|
||||||
- [API Documentation](docs/api.md)
|
|
||||||
- [Testing Guide](docs/testing.md)
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||||
@ -145,4 +155,5 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
|
|||||||
|
|
||||||
- [Expo](https://expo.dev/)
|
- [Expo](https://expo.dev/)
|
||||||
- [React Native](https://reactnative.dev/)
|
- [React Native](https://reactnative.dev/)
|
||||||
|
- [NDK](https://github.com/nostr-dev-kit/ndk)
|
||||||
- [Nostr Protocol](https://github.com/nostr-protocol/nostr)
|
- [Nostr Protocol](https://github.com/nostr-protocol/nostr)
|
@ -5,13 +5,15 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Search, Dumbbell, ListFilter } from 'lucide-react-native';
|
import { Search, Dumbbell, ListFilter } from 'lucide-react-native';
|
||||||
import { FloatingActionButton } from '@/components/shared/FloatingActionButton';
|
import { FloatingActionButton } from '@/components/shared/FloatingActionButton';
|
||||||
import { NewExerciseSheet } from '@/components/library/NewExerciseSheet';
|
import { ExerciseSheet } from '@/components/library/ExerciseSheet';
|
||||||
import { SimplifiedExerciseList } from '@/components/exercises/SimplifiedExerciseList';
|
import { SimplifiedExerciseList } from '@/components/exercises/SimplifiedExerciseList';
|
||||||
import { ExerciseDetails } from '@/components/exercises/ExerciseDetails';
|
import { ModalExerciseDetails } from '@/components/exercises/ModalExerciseDetails';
|
||||||
import { ExerciseDisplay, ExerciseType, BaseExercise, Equipment } from '@/types/exercise';
|
import { ExerciseDisplay, ExerciseType, BaseExercise, Equipment } from '@/types/exercise';
|
||||||
import { useExercises } from '@/lib/hooks/useExercises';
|
import { useExercises } from '@/lib/hooks/useExercises';
|
||||||
import { FilterSheet, type FilterOptions, type SourceType } from '@/components/library/FilterSheet';
|
import { FilterSheet, type FilterOptions, type SourceType } from '@/components/library/FilterSheet';
|
||||||
import { useWorkoutStore } from '@/stores/workoutStore';
|
import { useWorkoutStore } from '@/stores/workoutStore';
|
||||||
|
import { generateId } from '@/utils/ids';
|
||||||
|
import { useNDKStore } from '@/lib/stores/ndk';
|
||||||
|
|
||||||
// Default available filters
|
// Default available filters
|
||||||
const availableFilters = {
|
const availableFilters = {
|
||||||
@ -28,13 +30,23 @@ const initialFilters: FilterOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function ExercisesScreen() {
|
export default function ExercisesScreen() {
|
||||||
const [showNewExercise, setShowNewExercise] = useState(false);
|
// Basic state
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [selectedExercise, setSelectedExercise] = useState<ExerciseDisplay | null>(null);
|
|
||||||
const [filterSheetOpen, setFilterSheetOpen] = useState(false);
|
const [filterSheetOpen, setFilterSheetOpen] = useState(false);
|
||||||
const [currentFilters, setCurrentFilters] = useState<FilterOptions>(initialFilters);
|
const [currentFilters, setCurrentFilters] = useState<FilterOptions>(initialFilters);
|
||||||
const [activeFilters, setActiveFilters] = useState(0);
|
const [activeFilters, setActiveFilters] = useState(0);
|
||||||
|
|
||||||
|
// Exercise sheet state
|
||||||
|
const [showExerciseSheet, setShowExerciseSheet] = useState(false);
|
||||||
|
const [exerciseToEdit, setExerciseToEdit] = useState<ExerciseDisplay | undefined>(undefined);
|
||||||
|
const [editMode, setEditMode] = useState<'create' | 'edit' | 'fork'>('create');
|
||||||
|
|
||||||
|
// Exercise details state
|
||||||
|
const [selectedExercise, setSelectedExercise] = useState<ExerciseDisplay | null>(null);
|
||||||
|
|
||||||
|
// Other hooks
|
||||||
const { isActive, isMinimized } = useWorkoutStore();
|
const { isActive, isMinimized } = useWorkoutStore();
|
||||||
|
const { currentUser } = useNDKStore();
|
||||||
const shouldShowFAB = !isActive || !isMinimized;
|
const shouldShowFAB = !isActive || !isMinimized;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@ -43,6 +55,7 @@ export default function ExercisesScreen() {
|
|||||||
error,
|
error,
|
||||||
createExercise,
|
createExercise,
|
||||||
deleteExercise,
|
deleteExercise,
|
||||||
|
updateExercise,
|
||||||
refreshExercises,
|
refreshExercises,
|
||||||
updateFilters,
|
updateFilters,
|
||||||
clearFilters
|
clearFilters
|
||||||
@ -61,22 +74,98 @@ export default function ExercisesScreen() {
|
|||||||
setSelectedExercise(exercise);
|
setSelectedExercise(exercise);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEdit = async () => {
|
// Mock exercise update function
|
||||||
// TODO: Implement edit functionality
|
const handleUpdateExercise = async (id: string, updatedData: Partial<BaseExercise>): Promise<void> => {
|
||||||
setSelectedExercise(null);
|
try {
|
||||||
|
// Since we don't have a real update function, we'll fake it with delete + create
|
||||||
|
// In a real app, this would be replaced with an actual update API call
|
||||||
|
console.log(`Updating exercise ${id} with data:`, updatedData);
|
||||||
|
|
||||||
|
// Delete the old exercise
|
||||||
|
await deleteExercise(id);
|
||||||
|
|
||||||
|
// Create a new exercise with the same ID and updated data
|
||||||
|
await createExercise({
|
||||||
|
...updatedData,
|
||||||
|
availability: updatedData.availability || { source: ['local'] }
|
||||||
|
} as Omit<BaseExercise, 'id'>);
|
||||||
|
|
||||||
|
// Refresh the exercise list
|
||||||
|
refreshExercises();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating exercise:', error);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateExercise = async (exerciseData: BaseExercise) => {
|
// Handle editing an exercise
|
||||||
// Convert BaseExercise to include required source information
|
const handleEdit = () => {
|
||||||
const exerciseWithSource: Omit<BaseExercise, 'id'> = {
|
if (!selectedExercise) return;
|
||||||
...exerciseData,
|
|
||||||
availability: {
|
|
||||||
source: ['local']
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
await createExercise(exerciseWithSource);
|
// Close the details modal
|
||||||
setShowNewExercise(false);
|
setSelectedExercise(null);
|
||||||
|
|
||||||
|
// Determine if we should edit or fork based on Nostr ownership
|
||||||
|
const isNostrExercise = selectedExercise.source === 'nostr';
|
||||||
|
const isCurrentUserAuthor = isNostrExercise &&
|
||||||
|
selectedExercise.availability?.lastSynced?.nostr?.metadata?.pubkey === currentUser?.pubkey;
|
||||||
|
|
||||||
|
const mode = isNostrExercise && !isCurrentUserAuthor ? 'fork' : 'edit';
|
||||||
|
|
||||||
|
// Set up edit state
|
||||||
|
setEditMode(mode);
|
||||||
|
setExerciseToEdit(selectedExercise);
|
||||||
|
|
||||||
|
// Open the exercise sheet
|
||||||
|
setShowExerciseSheet(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle creating a new exercise
|
||||||
|
const handleCreateExercise = () => {
|
||||||
|
setEditMode('create');
|
||||||
|
setExerciseToEdit(undefined);
|
||||||
|
setShowExerciseSheet(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle submitting exercise form (create, edit, or fork)
|
||||||
|
const handleSubmitExercise = async (exerciseData: BaseExercise) => {
|
||||||
|
try {
|
||||||
|
if (editMode === 'create') {
|
||||||
|
// For new exercises, ensure the availability is set
|
||||||
|
const exerciseWithSource: Omit<BaseExercise, 'id'> = {
|
||||||
|
...exerciseData,
|
||||||
|
availability: {
|
||||||
|
source: ['local']
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Remove the ID from the data for new creation
|
||||||
|
delete (exerciseWithSource as any).id;
|
||||||
|
|
||||||
|
await createExercise(exerciseWithSource);
|
||||||
|
}
|
||||||
|
else if (editMode === 'edit') {
|
||||||
|
// Use the new updateExercise function directly
|
||||||
|
await updateExercise(exerciseData.id, exerciseData);
|
||||||
|
}
|
||||||
|
else if (editMode === 'fork') {
|
||||||
|
// For forking, create a new exercise but keep the original data
|
||||||
|
const { id: _, ...forkedExerciseData } = exerciseData;
|
||||||
|
const forkedExercise: Omit<BaseExercise, 'id'> = {
|
||||||
|
...forkedExerciseData,
|
||||||
|
availability: {
|
||||||
|
source: ['local'] // Start as a local exercise
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await createExercise(forkedExercise);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh the exercise list after changes
|
||||||
|
refreshExercises();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error handling exercise submission:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close the sheet regardless of success/failure
|
||||||
|
setShowExerciseSheet(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleApplyFilters = (filters: FilterOptions) => {
|
const handleApplyFilters = (filters: FilterOptions) => {
|
||||||
@ -158,30 +247,28 @@ export default function ExercisesScreen() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Exercise details sheet */}
|
{/* Exercise details sheet */}
|
||||||
{selectedExercise && (
|
<ModalExerciseDetails
|
||||||
<ExerciseDetails
|
exercise={selectedExercise} // This can now be null
|
||||||
exercise={selectedExercise}
|
open={!!selectedExercise}
|
||||||
open={!!selectedExercise}
|
onClose={() => setSelectedExercise(null)}
|
||||||
onOpenChange={(open) => {
|
onEdit={handleEdit}
|
||||||
if (!open) setSelectedExercise(null);
|
/>
|
||||||
}}
|
|
||||||
onEdit={handleEdit}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* FAB for adding new exercise */}
|
{/* FAB for adding new exercise */}
|
||||||
{shouldShowFAB && (
|
{shouldShowFAB && (
|
||||||
<FloatingActionButton
|
<FloatingActionButton
|
||||||
icon={Dumbbell}
|
icon={Dumbbell}
|
||||||
onPress={() => setShowNewExercise(true)}
|
onPress={handleCreateExercise}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* New exercise sheet */}
|
{/* Exercise sheet for create/edit/fork */}
|
||||||
<NewExerciseSheet
|
<ExerciseSheet
|
||||||
isOpen={showNewExercise}
|
isOpen={showExerciseSheet}
|
||||||
onClose={() => setShowNewExercise(false)}
|
onClose={() => setShowExerciseSheet(false)}
|
||||||
onSubmit={handleCreateExercise}
|
onSubmit={handleSubmitExercise}
|
||||||
|
exerciseToEdit={exerciseToEdit}
|
||||||
|
mode={editMode}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
@ -10,6 +10,7 @@ import { FloatingActionButton } from '@/components/shared/FloatingActionButton';
|
|||||||
import { NewTemplateSheet } from '@/components/library/NewTemplateSheet';
|
import { NewTemplateSheet } from '@/components/library/NewTemplateSheet';
|
||||||
import { FilterSheet, type FilterOptions, type SourceType } from '@/components/library/FilterSheet';
|
import { FilterSheet, type FilterOptions, type SourceType } from '@/components/library/FilterSheet';
|
||||||
import { TemplateCard } from '@/components/templates/TemplateCard';
|
import { TemplateCard } from '@/components/templates/TemplateCard';
|
||||||
|
import { ModalTemplateDetails } from '@/components/templates/ModalTemplateDetails';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Template,
|
Template,
|
||||||
@ -74,12 +75,21 @@ export default function TemplatesScreen() {
|
|||||||
const { isActive, isMinimized } = useWorkoutStore();
|
const { isActive, isMinimized } = useWorkoutStore();
|
||||||
const shouldShowFAB = !isActive || !isMinimized;
|
const shouldShowFAB = !isActive || !isMinimized;
|
||||||
|
|
||||||
|
// State for the modal template details
|
||||||
|
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null);
|
||||||
|
const [showTemplateModal, setShowTemplateModal] = useState(false);
|
||||||
|
|
||||||
const handleDelete = (id: string) => {
|
const handleDelete = (id: string) => {
|
||||||
setTemplates(current => current.filter(t => t.id !== id));
|
setTemplates(current => current.filter(t => t.id !== id));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTemplatePress = (template: Template) => {
|
const handleTemplatePress = (template: Template) => {
|
||||||
router.push(`/template/${template.id}`);
|
// Just open the modal without navigating to a route
|
||||||
|
setSelectedTemplateId(template.id);
|
||||||
|
setShowTemplateModal(true);
|
||||||
|
|
||||||
|
// We're no longer using this:
|
||||||
|
// router.push(`/template/${template.id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStartWorkout = async (template: Template) => {
|
const handleStartWorkout = async (template: Template) => {
|
||||||
@ -127,6 +137,23 @@ export default function TemplatesScreen() {
|
|||||||
setActiveFilters(totalFilters);
|
setActiveFilters(totalFilters);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle modal close
|
||||||
|
const handleModalClose = () => {
|
||||||
|
setShowTemplateModal(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle favorite change from modal
|
||||||
|
const handleModalFavoriteChange = (templateId: string, isFavorite: boolean) => {
|
||||||
|
// Update local state to reflect change
|
||||||
|
setTemplates(current =>
|
||||||
|
current.map(t =>
|
||||||
|
t.id === templateId
|
||||||
|
? { ...t, isFavorite }
|
||||||
|
: t
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
React.useCallback(() => {
|
React.useCallback(() => {
|
||||||
// Refresh template favorite status when tab gains focus
|
// Refresh template favorite status when tab gains focus
|
||||||
@ -275,6 +302,15 @@ export default function TemplatesScreen() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Template Details Modal */}
|
||||||
|
<ModalTemplateDetails
|
||||||
|
templateId={selectedTemplateId || ''}
|
||||||
|
open={showTemplateModal}
|
||||||
|
onClose={handleModalClose}
|
||||||
|
onFavoriteChange={handleModalFavoriteChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* New Template Sheet */}
|
||||||
<NewTemplateSheet
|
<NewTemplateSheet
|
||||||
isOpen={showNewTemplate}
|
isOpen={showNewTemplate}
|
||||||
onClose={() => setShowNewTemplate(false)}
|
onClose={() => setShowNewTemplate(false)}
|
||||||
|
@ -13,7 +13,7 @@ import { TabScreen } from '@/components/layout/TabScreen';
|
|||||||
import { ChevronLeft, Search, Plus } from 'lucide-react-native';
|
import { ChevronLeft, Search, Plus } from 'lucide-react-native';
|
||||||
import { BaseExercise } from '@/types/exercise';
|
import { BaseExercise } from '@/types/exercise';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { NewExerciseSheet } from '@/components/library/NewExerciseSheet';
|
import { ExerciseSheet } from '@/components/library/ExerciseSheet';
|
||||||
|
|
||||||
export default function AddExercisesScreen() {
|
export default function AddExercisesScreen() {
|
||||||
const db = useSQLiteContext();
|
const db = useSQLiteContext();
|
||||||
@ -178,7 +178,7 @@ export default function AddExercisesScreen() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* New Exercise Sheet */}
|
{/* New Exercise Sheet */}
|
||||||
<NewExerciseSheet
|
<ExerciseSheet
|
||||||
isOpen={isNewExerciseSheetOpen}
|
isOpen={isNewExerciseSheetOpen}
|
||||||
onClose={() => setIsNewExerciseSheetOpen(false)}
|
onClose={() => setIsNewExerciseSheetOpen(false)}
|
||||||
onSubmit={handleNewExerciseSubmit}
|
onSubmit={handleNewExerciseSubmit}
|
||||||
|
@ -1,351 +0,0 @@
|
|||||||
// components/exercises/ExerciseDetails.tsx
|
|
||||||
import React from 'react';
|
|
||||||
import { View, ScrollView } from 'react-native';
|
|
||||||
import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs';
|
|
||||||
import { Text } from '@/components/ui/text';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Separator } from '@/components/ui/separator';
|
|
||||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
|
||||||
import {
|
|
||||||
Edit2,
|
|
||||||
Dumbbell,
|
|
||||||
Target,
|
|
||||||
Calendar,
|
|
||||||
Hash,
|
|
||||||
AlertCircle,
|
|
||||||
LineChart,
|
|
||||||
Settings
|
|
||||||
} from 'lucide-react-native';
|
|
||||||
import { ExerciseDisplay } from '@/types/exercise';
|
|
||||||
import { useTheme } from '@react-navigation/native';
|
|
||||||
import type { CustomTheme } from '@/lib/theme';
|
|
||||||
|
|
||||||
const Tab = createMaterialTopTabNavigator();
|
|
||||||
|
|
||||||
interface ExerciseDetailsProps {
|
|
||||||
exercise: ExerciseDisplay;
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
onEdit?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Info Tab Component
|
|
||||||
function InfoTab({ exercise, onEdit }: { exercise: ExerciseDisplay; onEdit?: () => void }) {
|
|
||||||
const {
|
|
||||||
title,
|
|
||||||
type,
|
|
||||||
category,
|
|
||||||
equipment,
|
|
||||||
description,
|
|
||||||
instructions = [],
|
|
||||||
tags = [],
|
|
||||||
source = 'local',
|
|
||||||
usageCount,
|
|
||||||
lastUsed
|
|
||||||
} = exercise;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollView
|
|
||||||
className="flex-1 px-4"
|
|
||||||
contentContainerStyle={{ paddingBottom: 20 }}
|
|
||||||
>
|
|
||||||
<View className="gap-6 py-4">
|
|
||||||
{/* Basic Info Section */}
|
|
||||||
<View className="flex-row items-center gap-2">
|
|
||||||
<Badge
|
|
||||||
variant={source === 'local' ? 'outline' : 'secondary'}
|
|
||||||
className="capitalize"
|
|
||||||
>
|
|
||||||
<Text>{source}</Text>
|
|
||||||
</Badge>
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="capitalize bg-muted"
|
|
||||||
>
|
|
||||||
<Text>{type}</Text>
|
|
||||||
</Badge>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Separator className="bg-border" />
|
|
||||||
|
|
||||||
{/* Category & Equipment Section */}
|
|
||||||
<View className="space-y-4">
|
|
||||||
<View className="flex-row items-center gap-2">
|
|
||||||
<View className="w-8 h-8 items-center justify-center rounded-md bg-muted">
|
|
||||||
<Target size={18} className="text-muted-foreground" />
|
|
||||||
</View>
|
|
||||||
<View>
|
|
||||||
<Text className="text-sm text-muted-foreground">Category</Text>
|
|
||||||
<Text className="text-base font-medium text-foreground">{category}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{equipment && (
|
|
||||||
<View className="flex-row items-center gap-2">
|
|
||||||
<View className="w-8 h-8 items-center justify-center rounded-md bg-muted">
|
|
||||||
<Dumbbell size={18} className="text-muted-foreground" />
|
|
||||||
</View>
|
|
||||||
<View>
|
|
||||||
<Text className="text-sm text-muted-foreground">Equipment</Text>
|
|
||||||
<Text className="text-base font-medium text-foreground capitalize">{equipment}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Description Section */}
|
|
||||||
{description && (
|
|
||||||
<View>
|
|
||||||
<Text className="text-base font-semibold text-foreground mb-2">Description</Text>
|
|
||||||
<Text className="text-base text-muted-foreground leading-relaxed">{description}</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tags Section */}
|
|
||||||
{tags.length > 0 && (
|
|
||||||
<View>
|
|
||||||
<View className="flex-row items-center gap-2 mb-2">
|
|
||||||
<Hash size={16} className="text-muted-foreground" />
|
|
||||||
<Text className="text-base font-semibold text-foreground">Tags</Text>
|
|
||||||
</View>
|
|
||||||
<View className="flex-row flex-wrap gap-2">
|
|
||||||
{tags.map((tag: string) => (
|
|
||||||
<Badge key={tag} variant="secondary">
|
|
||||||
<Text>{tag}</Text>
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Usage Stats Section */}
|
|
||||||
{(usageCount || lastUsed) && (
|
|
||||||
<View>
|
|
||||||
<View className="flex-row items-center gap-2 mb-2">
|
|
||||||
<Calendar size={16} className="text-muted-foreground" />
|
|
||||||
<Text className="text-base font-semibold text-foreground">Usage</Text>
|
|
||||||
</View>
|
|
||||||
<View className="gap-2">
|
|
||||||
{usageCount && (
|
|
||||||
<Text className="text-base text-muted-foreground">
|
|
||||||
Used {usageCount} times
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
{lastUsed && (
|
|
||||||
<Text className="text-base text-muted-foreground">
|
|
||||||
Last used: {lastUsed.toLocaleDateString()}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Edit Button */}
|
|
||||||
{onEdit && (
|
|
||||||
<Button
|
|
||||||
onPress={onEdit}
|
|
||||||
className="w-full mt-2"
|
|
||||||
>
|
|
||||||
<Edit2 size={18} className="mr-2 text-primary-foreground" />
|
|
||||||
<Text className="text-primary-foreground font-semibold">
|
|
||||||
Edit Exercise
|
|
||||||
</Text>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Progress Tab Component
|
|
||||||
function ProgressTab({ exercise }: { exercise: ExerciseDisplay }) {
|
|
||||||
return (
|
|
||||||
<ScrollView
|
|
||||||
className="flex-1 px-4"
|
|
||||||
contentContainerStyle={{ paddingBottom: 20 }}
|
|
||||||
>
|
|
||||||
<View className="gap-6 py-4">
|
|
||||||
{/* Placeholder for Charts */}
|
|
||||||
<View className="h-48 bg-muted rounded-lg items-center justify-center">
|
|
||||||
<LineChart size={24} className="text-muted-foreground mb-2" />
|
|
||||||
<Text className="text-muted-foreground">Progress charts coming soon</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Personal Records Section */}
|
|
||||||
<View>
|
|
||||||
<Text className="text-base font-semibold text-foreground mb-4">Personal Records</Text>
|
|
||||||
<View className="gap-4">
|
|
||||||
<View className="bg-card p-4 rounded-lg">
|
|
||||||
<Text className="text-sm text-muted-foreground">Max Weight</Text>
|
|
||||||
<Text className="text-lg font-semibold text-foreground">-- kg</Text>
|
|
||||||
</View>
|
|
||||||
<View className="bg-card p-4 rounded-lg">
|
|
||||||
<Text className="text-sm text-muted-foreground">Max Reps</Text>
|
|
||||||
<Text className="text-lg font-semibold text-foreground">--</Text>
|
|
||||||
</View>
|
|
||||||
<View className="bg-card p-4 rounded-lg">
|
|
||||||
<Text className="text-sm text-muted-foreground">Best Volume</Text>
|
|
||||||
<Text className="text-lg font-semibold text-foreground">-- kg</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Form Tab Component
|
|
||||||
function FormTab({ exercise }: { exercise: ExerciseDisplay }) {
|
|
||||||
const { instructions = [] } = exercise;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollView
|
|
||||||
className="flex-1 px-4"
|
|
||||||
contentContainerStyle={{ paddingBottom: 20 }}
|
|
||||||
>
|
|
||||||
<View className="gap-6 py-4">
|
|
||||||
{/* Instructions Section */}
|
|
||||||
{instructions.length > 0 ? (
|
|
||||||
<View>
|
|
||||||
<Text className="text-base font-semibold text-foreground mb-4">Instructions</Text>
|
|
||||||
<View className="gap-4">
|
|
||||||
{instructions.map((instruction: string, index: number) => (
|
|
||||||
<View key={index} className="flex-row gap-3">
|
|
||||||
<Text className="text-sm font-medium text-muted-foreground min-w-[24px]">
|
|
||||||
{index + 1}.
|
|
||||||
</Text>
|
|
||||||
<Text className="text-base text-foreground flex-1">{instruction}</Text>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<View className="items-center justify-center py-8">
|
|
||||||
<AlertCircle size={24} className="text-muted-foreground mb-2" />
|
|
||||||
<Text className="text-muted-foreground">No form instructions available</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Placeholder for Media */}
|
|
||||||
<View className="h-48 bg-muted rounded-lg items-center justify-center">
|
|
||||||
<Text className="text-muted-foreground">Video demos coming soon</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Settings Tab Component
|
|
||||||
function SettingsTab({ exercise }: { exercise: ExerciseDisplay }) {
|
|
||||||
return (
|
|
||||||
<ScrollView
|
|
||||||
className="flex-1 px-4"
|
|
||||||
contentContainerStyle={{ paddingBottom: 20 }}
|
|
||||||
>
|
|
||||||
<View className="gap-6 py-4">
|
|
||||||
{/* Format Settings */}
|
|
||||||
<View>
|
|
||||||
<Text className="text-base font-semibold text-foreground mb-4">Exercise Settings</Text>
|
|
||||||
<View className="gap-4">
|
|
||||||
<View className="bg-card p-4 rounded-lg">
|
|
||||||
<Text className="text-sm text-muted-foreground mb-1">Format</Text>
|
|
||||||
<View className="flex-row flex-wrap gap-2">
|
|
||||||
{exercise.format && Object.entries(exercise.format).map(([key, enabled]) => (
|
|
||||||
enabled && (
|
|
||||||
<Badge key={key} variant="secondary">
|
|
||||||
<Text>{key}</Text>
|
|
||||||
</Badge>
|
|
||||||
)
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<View className="bg-card p-4 rounded-lg">
|
|
||||||
<Text className="text-sm text-muted-foreground mb-1">Units</Text>
|
|
||||||
<View className="flex-row flex-wrap gap-2">
|
|
||||||
{exercise.format_units && Object.entries(exercise.format_units).map(([key, unit]) => (
|
|
||||||
<Badge key={key} variant="secondary">
|
|
||||||
<Text>{key}: {String(unit)}</Text>
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ExerciseDetails({
|
|
||||||
exercise,
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
onEdit
|
|
||||||
}: ExerciseDetailsProps) {
|
|
||||||
const theme = useTheme() as CustomTheme;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Sheet isOpen={open} onClose={() => onOpenChange(false)}>
|
|
||||||
<SheetHeader>
|
|
||||||
<SheetTitle>
|
|
||||||
<Text className="text-xl font-bold text-foreground">{exercise.title}</Text>
|
|
||||||
</SheetTitle>
|
|
||||||
</SheetHeader>
|
|
||||||
<SheetContent>
|
|
||||||
<View style={{ flex: 1, minHeight: 400 }} className="rounded-t-[10px]">
|
|
||||||
<Tab.Navigator
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
screenOptions={{
|
|
||||||
tabBarActiveTintColor: theme.colors.tabIndicator,
|
|
||||||
tabBarInactiveTintColor: theme.colors.tabInactive,
|
|
||||||
tabBarLabelStyle: {
|
|
||||||
fontSize: 13,
|
|
||||||
textTransform: 'capitalize',
|
|
||||||
fontWeight: 'bold',
|
|
||||||
marginHorizontal: -4,
|
|
||||||
},
|
|
||||||
tabBarIndicatorStyle: {
|
|
||||||
backgroundColor: theme.colors.tabIndicator,
|
|
||||||
height: 2,
|
|
||||||
},
|
|
||||||
tabBarStyle: {
|
|
||||||
backgroundColor: theme.colors.background,
|
|
||||||
elevation: 0,
|
|
||||||
shadowOpacity: 0,
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: theme.colors.border,
|
|
||||||
},
|
|
||||||
tabBarPressColor: theme.colors.primary,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Tab.Screen
|
|
||||||
name="info"
|
|
||||||
options={{ title: 'Info' }}
|
|
||||||
>
|
|
||||||
{() => <InfoTab exercise={exercise} onEdit={onEdit} />}
|
|
||||||
</Tab.Screen>
|
|
||||||
<Tab.Screen
|
|
||||||
name="progress"
|
|
||||||
options={{ title: 'Progress' }}
|
|
||||||
>
|
|
||||||
{() => <ProgressTab exercise={exercise} />}
|
|
||||||
</Tab.Screen>
|
|
||||||
<Tab.Screen
|
|
||||||
name="form"
|
|
||||||
options={{ title: 'Form' }}
|
|
||||||
>
|
|
||||||
{() => <FormTab exercise={exercise} />}
|
|
||||||
</Tab.Screen>
|
|
||||||
<Tab.Screen
|
|
||||||
name="settings"
|
|
||||||
options={{ title: 'Settings' }}
|
|
||||||
>
|
|
||||||
{() => <SettingsTab exercise={exercise} />}
|
|
||||||
</Tab.Screen>
|
|
||||||
</Tab.Navigator>
|
|
||||||
</View>
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
);
|
|
||||||
}
|
|
556
components/exercises/ModalExerciseDetails.tsx
Normal file
556
components/exercises/ModalExerciseDetails.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
475
components/library/ExerciseSheet.tsx
Normal file
475
components/library/ExerciseSheet.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
@ -1,230 +0,0 @@
|
|||||||
// components/library/NewExerciseSheet.tsx
|
|
||||||
import React, { useState } from 'react';
|
|
||||||
import { View, ScrollView, KeyboardAvoidingView, Platform, TouchableWithoutFeedback, Keyboard } from 'react-native';
|
|
||||||
import { Text } from '@/components/ui/text';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
|
||||||
import { generateId } from '@/utils/ids';
|
|
||||||
import {
|
|
||||||
BaseExercise,
|
|
||||||
ExerciseType,
|
|
||||||
ExerciseCategory,
|
|
||||||
Equipment,
|
|
||||||
ExerciseFormat,
|
|
||||||
ExerciseFormatUnits
|
|
||||||
} from '@/types/exercise';
|
|
||||||
import { StorageSource } from '@/types/shared';
|
|
||||||
|
|
||||||
interface NewExerciseSheetProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onSubmit: (exercise: BaseExercise) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const EXERCISE_TYPES: ExerciseType[] = ['strength', 'cardio', 'bodyweight'];
|
|
||||||
const CATEGORIES: ExerciseCategory[] = ['Push', 'Pull', 'Legs', 'Core'];
|
|
||||||
const EQUIPMENT_OPTIONS: Equipment[] = [
|
|
||||||
'bodyweight',
|
|
||||||
'barbell',
|
|
||||||
'dumbbell',
|
|
||||||
'kettlebell',
|
|
||||||
'machine',
|
|
||||||
'cable',
|
|
||||||
'other'
|
|
||||||
];
|
|
||||||
|
|
||||||
export function NewExerciseSheet({ isOpen, onClose, onSubmit }: NewExerciseSheetProps) {
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
title: '',
|
|
||||||
type: 'strength' as ExerciseType,
|
|
||||||
category: 'Push' as ExerciseCategory,
|
|
||||||
equipment: undefined as Equipment | undefined,
|
|
||||||
description: '',
|
|
||||||
tags: [] as string[],
|
|
||||||
format: {
|
|
||||||
weight: true,
|
|
||||||
reps: true,
|
|
||||||
rpe: true,
|
|
||||||
set_type: true
|
|
||||||
} as ExerciseFormat,
|
|
||||||
format_units: {
|
|
||||||
weight: 'kg',
|
|
||||||
reps: 'count',
|
|
||||||
rpe: '0-10',
|
|
||||||
set_type: 'warmup|normal|drop|failure'
|
|
||||||
} as ExerciseFormatUnits
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
|
||||||
if (!formData.title || !formData.equipment) return;
|
|
||||||
|
|
||||||
const timestamp = Date.now();
|
|
||||||
|
|
||||||
// Create BaseExercise
|
|
||||||
const exercise: BaseExercise = {
|
|
||||||
id: generateId(),
|
|
||||||
title: formData.title,
|
|
||||||
type: formData.type,
|
|
||||||
category: formData.category,
|
|
||||||
equipment: formData.equipment,
|
|
||||||
description: formData.description,
|
|
||||||
tags: formData.tags.length ? formData.tags : [formData.category.toLowerCase()],
|
|
||||||
format: formData.format,
|
|
||||||
format_units: formData.format_units,
|
|
||||||
created_at: timestamp,
|
|
||||||
availability: {
|
|
||||||
source: ['local' as StorageSource],
|
|
||||||
lastSynced: undefined
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
onSubmit(exercise);
|
|
||||||
|
|
||||||
// Reset form
|
|
||||||
setFormData({
|
|
||||||
title: '',
|
|
||||||
type: 'strength',
|
|
||||||
category: 'Push',
|
|
||||||
equipment: undefined,
|
|
||||||
description: '',
|
|
||||||
tags: [],
|
|
||||||
format: {
|
|
||||||
weight: true,
|
|
||||||
reps: true,
|
|
||||||
rpe: true,
|
|
||||||
set_type: true
|
|
||||||
},
|
|
||||||
format_units: {
|
|
||||||
weight: 'kg',
|
|
||||||
reps: 'count',
|
|
||||||
rpe: '0-10',
|
|
||||||
set_type: 'warmup|normal|drop|failure'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Purple color used throughout the app
|
|
||||||
const purpleColor = 'hsl(261, 90%, 66%)';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Sheet isOpen={isOpen} onClose={onClose}>
|
|
||||||
<SheetContent>
|
|
||||||
<KeyboardAvoidingView
|
|
||||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
>
|
|
||||||
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
|
|
||||||
<View style={{ flex: 1 }}>
|
|
||||||
<SheetHeader>
|
|
||||||
<SheetTitle>Create New Exercise</SheetTitle>
|
|
||||||
</SheetHeader>
|
|
||||||
<ScrollView className="flex-1" showsVerticalScrollIndicator={false}>
|
|
||||||
<View className="gap-5 py-5">
|
|
||||||
<View>
|
|
||||||
<Text className="text-base font-medium mb-2">Exercise Name</Text>
|
|
||||||
<Input
|
|
||||||
value={formData.title}
|
|
||||||
onChangeText={(text) => setFormData(prev => ({ ...prev, title: text }))}
|
|
||||||
placeholder="e.g., Barbell Back Squat"
|
|
||||||
className="text-foreground"
|
|
||||||
/>
|
|
||||||
{!formData.title && (
|
|
||||||
<Text className="text-xs text-muted-foreground mt-1 ml-1">
|
|
||||||
* Required field
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View>
|
|
||||||
<Text className="text-base font-medium mb-2">Type</Text>
|
|
||||||
<View className="flex-row flex-wrap gap-2">
|
|
||||||
{EXERCISE_TYPES.map((type) => (
|
|
||||||
<Button
|
|
||||||
key={type}
|
|
||||||
variant={formData.type === type ? 'default' : 'outline'}
|
|
||||||
onPress={() => setFormData(prev => ({ ...prev, type }))}
|
|
||||||
style={formData.type === type ? { backgroundColor: purpleColor } : {}}
|
|
||||||
>
|
|
||||||
<Text className={formData.type === type ? 'text-white' : ''}>
|
|
||||||
{type.charAt(0).toUpperCase() + type.slice(1)}
|
|
||||||
</Text>
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View>
|
|
||||||
<Text className="text-base font-medium mb-2">Category</Text>
|
|
||||||
<View className="flex-row flex-wrap gap-2">
|
|
||||||
{CATEGORIES.map((category) => (
|
|
||||||
<Button
|
|
||||||
key={category}
|
|
||||||
variant={formData.category === category ? 'default' : 'outline'}
|
|
||||||
onPress={() => setFormData(prev => ({ ...prev, category }))}
|
|
||||||
style={formData.category === category ? { backgroundColor: purpleColor } : {}}
|
|
||||||
>
|
|
||||||
<Text className={formData.category === category ? 'text-white' : ''}>
|
|
||||||
{category}
|
|
||||||
</Text>
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View>
|
|
||||||
<Text className="text-base font-medium mb-2">Equipment</Text>
|
|
||||||
<View className="flex-row flex-wrap gap-2">
|
|
||||||
{EQUIPMENT_OPTIONS.map((eq) => (
|
|
||||||
<Button
|
|
||||||
key={eq}
|
|
||||||
variant={formData.equipment === eq ? 'default' : 'outline'}
|
|
||||||
onPress={() => setFormData(prev => ({ ...prev, equipment: eq }))}
|
|
||||||
style={formData.equipment === eq ? { backgroundColor: purpleColor } : {}}
|
|
||||||
>
|
|
||||||
<Text className={formData.equipment === eq ? 'text-white' : ''}>
|
|
||||||
{eq.charAt(0).toUpperCase() + eq.slice(1)}
|
|
||||||
</Text>
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
{!formData.equipment && (
|
|
||||||
<Text className="text-xs text-muted-foreground mt-1 ml-1">
|
|
||||||
* Required field
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View>
|
|
||||||
<Text className="text-base font-medium mb-2">Description</Text>
|
|
||||||
<Input
|
|
||||||
value={formData.description}
|
|
||||||
onChangeText={(text) => setFormData(prev => ({ ...prev, description: text }))}
|
|
||||||
placeholder="Exercise description..."
|
|
||||||
multiline
|
|
||||||
numberOfLines={4}
|
|
||||||
textAlignVertical="top"
|
|
||||||
className="min-h-24 py-2"
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
className="mt-6 py-5"
|
|
||||||
variant='default'
|
|
||||||
onPress={handleSubmit}
|
|
||||||
disabled={!formData.title || !formData.equipment}
|
|
||||||
style={{ backgroundColor: purpleColor }}
|
|
||||||
>
|
|
||||||
<Text className="text-white font-semibold">Create Exercise</Text>
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
</TouchableWithoutFeedback>
|
|
||||||
</KeyboardAvoidingView>
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,11 +1,10 @@
|
|||||||
// components/library/NewTemplateSheet.tsx
|
// components/library/NewTemplateSheet.tsx
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { View, ScrollView, TouchableOpacity } from 'react-native';
|
import { View, ScrollView, TouchableOpacity, Modal } from 'react-native';
|
||||||
import { Text } from '@/components/ui/text';
|
import { Text } from '@/components/ui/text';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import {
|
import {
|
||||||
Template,
|
Template,
|
||||||
@ -17,7 +16,8 @@ import { ExerciseDisplay } from '@/types/exercise';
|
|||||||
import { generateId } from '@/utils/ids';
|
import { generateId } from '@/utils/ids';
|
||||||
import { useSQLiteContext } from 'expo-sqlite';
|
import { useSQLiteContext } from 'expo-sqlite';
|
||||||
import { LibraryService } from '@/lib/db/services/LibraryService';
|
import { LibraryService } from '@/lib/db/services/LibraryService';
|
||||||
import { ChevronLeft, ChevronRight, Dumbbell, Clock, RotateCw, List, Search } from 'lucide-react-native';
|
import { ChevronLeft, Dumbbell, Clock, RotateCw, List, Search, X } from 'lucide-react-native';
|
||||||
|
import { useColorScheme } from '@/lib/useColorScheme';
|
||||||
|
|
||||||
interface NewTemplateSheetProps {
|
interface NewTemplateSheetProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@ -71,7 +71,7 @@ function WorkoutTypeStep({ onSelectType, onCancel }: WorkoutTypeStepProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView className="flex-1">
|
<ScrollView className="flex-1">
|
||||||
<View className="gap-4 py-4">
|
<View className="gap-4 py-4 px-4">
|
||||||
<Text className="text-base mb-4">Select the type of workout template you want to create:</Text>
|
<Text className="text-base mb-4">Select the type of workout template you want to create:</Text>
|
||||||
|
|
||||||
<View className="gap-3">
|
<View className="gap-3">
|
||||||
@ -92,9 +92,6 @@ function WorkoutTypeStep({ onSelectType, onCancel }: WorkoutTypeStepProps) {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View className="pl-2 pr-1">
|
|
||||||
<ChevronRight color={purpleColor} size={20} />
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
) : (
|
) : (
|
||||||
<View>
|
<View>
|
||||||
@ -117,10 +114,6 @@ function WorkoutTypeStep({ onSelectType, onCancel }: WorkoutTypeStepProps) {
|
|||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Button variant="outline" onPress={onCancel} className="mt-4 py-4">
|
|
||||||
<Text>Cancel</Text>
|
|
||||||
</Button>
|
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
@ -152,7 +145,7 @@ function BasicInfoStep({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView className="flex-1">
|
<ScrollView className="flex-1">
|
||||||
<View className="gap-4 py-4">
|
<View className="gap-4 py-4 px-4">
|
||||||
<View>
|
<View>
|
||||||
<Text className="text-base font-medium mb-2">Workout Name</Text>
|
<Text className="text-base font-medium mb-2">Workout Name</Text>
|
||||||
<Input
|
<Input
|
||||||
@ -199,9 +192,6 @@ function BasicInfoStep({
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="flex-row justify-end gap-3 mt-4">
|
<View className="flex-row justify-end gap-3 mt-4">
|
||||||
<Button variant="outline" onPress={onCancel}>
|
|
||||||
<Text>Cancel</Text>
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
onPress={onNext}
|
onPress={onNext}
|
||||||
disabled={!title}
|
disabled={!title}
|
||||||
@ -314,13 +304,11 @@ function ExerciseSelectionStep({
|
|||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
<View className="p-4 flex-row justify-between border-t border-border">
|
<View className="p-4 flex-row justify-between border-t border-border">
|
||||||
<Button variant="outline" onPress={onBack}>
|
|
||||||
<Text>Back</Text>
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
onPress={handleContinue}
|
onPress={handleContinue}
|
||||||
disabled={selectedIds.length === 0}
|
disabled={selectedIds.length === 0}
|
||||||
style={selectedIds.length === 0 ? {} : { backgroundColor: purpleColor }}
|
style={selectedIds.length === 0 ? {} : { backgroundColor: purpleColor }}
|
||||||
|
className="w-full"
|
||||||
>
|
>
|
||||||
<Text className={selectedIds.length === 0 ? '' : 'text-white'}>
|
<Text className={selectedIds.length === 0 ? '' : 'text-white'}>
|
||||||
Continue with {selectedIds.length} Exercise{selectedIds.length !== 1 ? 's' : ''}
|
Continue with {selectedIds.length} Exercise{selectedIds.length !== 1 ? 's' : ''}
|
||||||
@ -390,13 +378,11 @@ function ExerciseConfigStep({
|
|||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
<View className="p-4 flex-row justify-between border-t border-border">
|
<View className="p-4 border-t border-border">
|
||||||
<Button variant="outline" onPress={onBack}>
|
|
||||||
<Text>Back</Text>
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
onPress={onNext}
|
onPress={onNext}
|
||||||
style={{ backgroundColor: purpleColor }}
|
style={{ backgroundColor: purpleColor }}
|
||||||
|
className="w-full"
|
||||||
>
|
>
|
||||||
<Text className="text-white">Review Template</Text>
|
<Text className="text-white">Review Template</Text>
|
||||||
</Button>
|
</Button>
|
||||||
@ -463,13 +449,11 @@ function ReviewStep({
|
|||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
<View className="p-4 flex-row justify-between border-t border-border">
|
<View className="p-4 border-t border-border">
|
||||||
<Button variant="outline" onPress={onBack}>
|
|
||||||
<Text>Back</Text>
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
onPress={onSubmit}
|
onPress={onSubmit}
|
||||||
style={{ backgroundColor: purpleColor }}
|
style={{ backgroundColor: purpleColor }}
|
||||||
|
className="w-full"
|
||||||
>
|
>
|
||||||
<Text className="text-white">Create Template</Text>
|
<Text className="text-white">Create Template</Text>
|
||||||
</Button>
|
</Button>
|
||||||
@ -486,6 +470,7 @@ export function NewTemplateSheet({ isOpen, onClose, onSubmit }: NewTemplateSheet
|
|||||||
const [exercises, setExercises] = useState<ExerciseDisplay[]>([]);
|
const [exercises, setExercises] = useState<ExerciseDisplay[]>([]);
|
||||||
const [selectedExercises, setSelectedExercises] = useState<ExerciseDisplay[]>([]);
|
const [selectedExercises, setSelectedExercises] = useState<ExerciseDisplay[]>([]);
|
||||||
const [configuredExercises, setConfiguredExercises] = useState<Template['exercises']>([]);
|
const [configuredExercises, setConfiguredExercises] = useState<Template['exercises']>([]);
|
||||||
|
const { isDarkColorScheme } = useColorScheme();
|
||||||
|
|
||||||
// Template info
|
// Template info
|
||||||
const [templateInfo, setTemplateInfo] = useState<{
|
const [templateInfo, setTemplateInfo] = useState<{
|
||||||
@ -519,16 +504,21 @@ export function NewTemplateSheet({ isOpen, onClose, onSubmit }: NewTemplateSheet
|
|||||||
// Reset state when sheet closes
|
// Reset state when sheet closes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
setStep('type');
|
// Add a delay to ensure the closing animation completes first
|
||||||
setWorkoutType('strength');
|
const timer = setTimeout(() => {
|
||||||
setSelectedExercises([]);
|
setStep('type');
|
||||||
setConfiguredExercises([]);
|
setWorkoutType('strength');
|
||||||
setTemplateInfo({
|
setSelectedExercises([]);
|
||||||
title: '',
|
setConfiguredExercises([]);
|
||||||
description: '',
|
setTemplateInfo({
|
||||||
category: 'Full Body',
|
title: '',
|
||||||
tags: ['strength']
|
description: '',
|
||||||
});
|
category: 'Full Body',
|
||||||
|
tags: ['strength']
|
||||||
|
});
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
}
|
}
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
@ -594,11 +584,28 @@ export function NewTemplateSheet({ isOpen, onClose, onSubmit }: NewTemplateSheet
|
|||||||
isFavorite: false
|
isFavorite: false
|
||||||
};
|
};
|
||||||
|
|
||||||
onSubmit(newTemplate);
|
// Close first, then submit with a small delay
|
||||||
onClose();
|
onClose();
|
||||||
|
setTimeout(() => {
|
||||||
|
onSubmit(newTemplate);
|
||||||
|
}, 50);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Render different content based on current step
|
// Get title based on current step
|
||||||
|
const getStepTitle = () => {
|
||||||
|
switch (step) {
|
||||||
|
case 'type': return 'Select Workout Type';
|
||||||
|
case 'info': return `New ${workoutType.charAt(0).toUpperCase() + workoutType.slice(1)} Workout`;
|
||||||
|
case 'exercises': return 'Select Exercises';
|
||||||
|
case 'config': return 'Configure Exercises';
|
||||||
|
case 'review': return 'Review Template';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show back button for all steps except the first
|
||||||
|
const showBackButton = step !== 'type';
|
||||||
|
|
||||||
|
// Render content based on current step
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
switch (step) {
|
switch (step) {
|
||||||
case 'type':
|
case 'type':
|
||||||
@ -658,40 +665,45 @@ export function NewTemplateSheet({ isOpen, onClose, onSubmit }: NewTemplateSheet
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get title based on current step
|
// Return null if not open
|
||||||
const getStepTitle = () => {
|
if (!isOpen) return null;
|
||||||
switch (step) {
|
|
||||||
case 'type': return 'Select Workout Type';
|
|
||||||
case 'info': return `New ${workoutType.charAt(0).toUpperCase() + workoutType.slice(1)} Workout`;
|
|
||||||
case 'exercises': return 'Select Exercises';
|
|
||||||
case 'config': return 'Configure Exercises';
|
|
||||||
case 'review': return 'Review Template';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Show back button for all steps except the first
|
|
||||||
const showBackButton = step !== 'type';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet isOpen={isOpen} onClose={onClose}>
|
<Modal
|
||||||
<SheetHeader>
|
visible={isOpen}
|
||||||
<View className="flex-row items-center">
|
transparent={true}
|
||||||
{showBackButton && (
|
animationType="slide"
|
||||||
<Button
|
onRequestClose={onClose}
|
||||||
variant="ghost"
|
>
|
||||||
size="icon"
|
<View className="flex-1 justify-center items-center bg-black/70">
|
||||||
className="mr-2"
|
<View
|
||||||
onPress={handleGoBack}
|
className={`bg-background ${isDarkColorScheme ? 'bg-card border border-border' : ''} rounded-lg w-[95%] h-[85%] max-w-xl shadow-xl overflow-hidden`}
|
||||||
>
|
style={{ maxHeight: 700 }}
|
||||||
<ChevronLeft className="text-foreground" size={20} />
|
>
|
||||||
</Button>
|
{/* Header */}
|
||||||
)}
|
<View className="flex-row justify-between items-center p-4 border-b border-border">
|
||||||
<SheetTitle>{getStepTitle()}</SheetTitle>
|
<View className="flex-row items-center">
|
||||||
|
{showBackButton && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleGoBack}
|
||||||
|
className="mr-2 p-1"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={24} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
<Text className="text-xl font-bold text-foreground">{getStepTitle()}</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity onPress={onClose} className="p-1">
|
||||||
|
<X size={24} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<View className="flex-1">
|
||||||
|
{renderContent()}
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</SheetHeader>
|
</View>
|
||||||
<SheetContent>
|
</Modal>
|
||||||
{renderContent()}
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
947
components/templates/ModalTemplateDetails.tsx
Normal file
947
components/templates/ModalTemplateDetails.tsx
Normal 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>
|
||||||
|
)};
|
@ -1,7 +1,6 @@
|
|||||||
// components/templates/TemplateCard.tsx
|
// components/templates/TemplateCard.tsx
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, TouchableOpacity, Platform } from 'react-native';
|
import { View, TouchableOpacity, Platform } from 'react-native';
|
||||||
import { router } from 'expo-router'; // Add this import
|
|
||||||
import { Text } from '@/components/ui/text';
|
import { Text } from '@/components/ui/text';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@ -55,13 +54,23 @@ export function TemplateCard({
|
|||||||
setShowDeleteAlert(false);
|
setShowDeleteAlert(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle navigation to template details
|
// Prevent event propagation when clicking on action buttons
|
||||||
const handleTemplatePress = () => {
|
const handleStartWorkout = (e: any) => {
|
||||||
router.push(`/template/${id}`);
|
if (e && e.stopPropagation) {
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
onStartWorkout();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFavorite = (e: any) => {
|
||||||
|
if (e && e.stopPropagation) {
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
onFavorite();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity onPress={handleTemplatePress} activeOpacity={0.7}>
|
<TouchableOpacity onPress={onPress} activeOpacity={0.7}>
|
||||||
<Card className="mx-4">
|
<Card className="mx-4">
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<View className="flex-row justify-between items-start">
|
<View className="flex-row justify-between items-start">
|
||||||
@ -134,7 +143,7 @@ export function TemplateCard({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onPress={onStartWorkout}
|
onPress={handleStartWorkout}
|
||||||
className="native:h-10 native:w-10"
|
className="native:h-10 native:w-10"
|
||||||
accessibilityLabel="Start workout"
|
accessibilityLabel="Start workout"
|
||||||
>
|
>
|
||||||
@ -143,7 +152,7 @@ export function TemplateCard({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onPress={onFavorite}
|
onPress={handleFavorite}
|
||||||
className="native:h-10 native:w-10"
|
className="native:h-10 native:w-10"
|
||||||
accessibilityLabel={isFavorite ? "Remove from favorites" : "Add to favorites"}
|
accessibilityLabel={isFavorite ? "Remove from favorites" : "Add to favorites"}
|
||||||
>
|
>
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
import { SQLiteDatabase } from 'expo-sqlite';
|
import { SQLiteDatabase } from 'expo-sqlite';
|
||||||
import { Platform } from 'react-native';
|
import { Platform } from 'react-native';
|
||||||
|
|
||||||
export const SCHEMA_VERSION = 4; // Updated to version 4 for user_profiles table
|
export const SCHEMA_VERSION = 5; // Updated to version 5 for publication queue table
|
||||||
|
|
||||||
class Schema {
|
class Schema {
|
||||||
private async getCurrentVersion(db: SQLiteDatabase): Promise<number> {
|
private async getCurrentVersion(db: SQLiteDatabase): Promise<number> {
|
||||||
@ -210,6 +210,41 @@ class Schema {
|
|||||||
console.log('[Schema] Version 4 upgrade completed');
|
console.log('[Schema] Version 4 upgrade completed');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update to version 5 if needed - Publication Queue
|
||||||
|
if (currentVersion < 5) {
|
||||||
|
console.log('[Schema] Upgrading to version 5');
|
||||||
|
|
||||||
|
// Create publication queue table
|
||||||
|
await db.execAsync(`
|
||||||
|
CREATE TABLE IF NOT EXISTS publication_queue (
|
||||||
|
event_id TEXT PRIMARY KEY,
|
||||||
|
attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
last_attempt INTEGER,
|
||||||
|
payload TEXT NOT NULL,
|
||||||
|
FOREIGN KEY(event_id) REFERENCES nostr_events(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_publication_queue_created
|
||||||
|
ON publication_queue(created_at ASC);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Create app status table for tracking connectivity
|
||||||
|
await db.execAsync(`
|
||||||
|
CREATE TABLE IF NOT EXISTS app_status (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await db.runAsync(
|
||||||
|
'INSERT INTO schema_version (version, updated_at) VALUES (?, ?)',
|
||||||
|
[5, Date.now()]
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('[Schema] Version 5 upgrade completed');
|
||||||
|
}
|
||||||
|
|
||||||
// Verify final schema
|
// Verify final schema
|
||||||
const tables = await db.getAllAsync<{ name: string }>(
|
const tables = await db.getAllAsync<{ name: string }>(
|
||||||
"SELECT name FROM sqlite_master WHERE type='table'"
|
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||||
|
133
lib/db/services/ConnectivityService.ts
Normal file
133
lib/db/services/ConnectivityService.ts
Normal 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 };
|
||||||
|
}
|
172
lib/db/services/PublicationQueueService.ts
Normal file
172
lib/db/services/PublicationQueueService.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -161,6 +161,44 @@ export function useExercises() {
|
|||||||
}
|
}
|
||||||
}, [libraryService, loadExercises]);
|
}, [libraryService, loadExercises]);
|
||||||
|
|
||||||
|
// Update an exercise
|
||||||
|
const updateExercise = useCallback(async (id: string, updateData: Partial<BaseExercise>) => {
|
||||||
|
try {
|
||||||
|
// Get the existing exercise first
|
||||||
|
const existingExercises = await libraryService.getExercises();
|
||||||
|
const existingExercise = existingExercises.find(ex => ex.id === id);
|
||||||
|
|
||||||
|
if (!existingExercise) {
|
||||||
|
throw new Error(`Exercise with ID ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the old exercise
|
||||||
|
await libraryService.deleteExercise(id);
|
||||||
|
|
||||||
|
// Prepare the updated exercise data (without id since it's Omit<ExerciseDisplay, "id">)
|
||||||
|
const updatedExercise: Omit<ExerciseDisplay, 'id'> = {
|
||||||
|
...existingExercise,
|
||||||
|
...updateData,
|
||||||
|
source: existingExercise.source || 'local',
|
||||||
|
isFavorite: existingExercise.isFavorite || false
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove id property since it's not allowed in this type
|
||||||
|
const { id: _, ...exerciseWithoutId } = updatedExercise as any;
|
||||||
|
|
||||||
|
// Add the updated exercise with the same ID
|
||||||
|
await libraryService.addExercise(exerciseWithoutId);
|
||||||
|
|
||||||
|
// Reload exercises to get the updated list
|
||||||
|
await loadExercises();
|
||||||
|
|
||||||
|
return id;
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err : new Error('Failed to update exercise'));
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}, [libraryService, loadExercises]);
|
||||||
|
|
||||||
// Update filters
|
// Update filters
|
||||||
const updateFilters = useCallback((newFilters: Partial<ExerciseFilters>) => {
|
const updateFilters = useCallback((newFilters: Partial<ExerciseFilters>) => {
|
||||||
setFilters(current => ({
|
setFilters(current => ({
|
||||||
@ -189,6 +227,7 @@ export function useExercises() {
|
|||||||
clearFilters,
|
clearFilters,
|
||||||
createExercise,
|
createExercise,
|
||||||
deleteExercise,
|
deleteExercise,
|
||||||
|
updateExercise,
|
||||||
refreshExercises: loadExercises
|
refreshExercises: loadExercises
|
||||||
};
|
};
|
||||||
}
|
}
|
@ -9,6 +9,7 @@ import NDK, { NDKFilter } from '@nostr-dev-kit/ndk';
|
|||||||
import { NDKEvent, NDKUser } from '@nostr-dev-kit/ndk-mobile';
|
import { NDKEvent, NDKUser } from '@nostr-dev-kit/ndk-mobile';
|
||||||
import * as SecureStore from 'expo-secure-store';
|
import * as SecureStore from 'expo-secure-store';
|
||||||
import * as Crypto from 'expo-crypto';
|
import * as Crypto from 'expo-crypto';
|
||||||
|
import { openDatabaseSync } from 'expo-sqlite';
|
||||||
import { NDKMobilePrivateKeySigner, generateKeyPair } from '@/lib/mobile-signer';
|
import { NDKMobilePrivateKeySigner, generateKeyPair } from '@/lib/mobile-signer';
|
||||||
|
|
||||||
// Constants for SecureStore
|
// Constants for SecureStore
|
||||||
@ -37,6 +38,9 @@ type NDKStoreActions = {
|
|||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
generateKeys: () => { privateKey: string; publicKey: string; nsec: string; npub: string };
|
generateKeys: () => { privateKey: string; publicKey: string; nsec: string; npub: string };
|
||||||
publishEvent: (kind: number, content: string, tags: string[][]) => Promise<NDKEvent | null>;
|
publishEvent: (kind: number, content: string, tags: string[][]) => Promise<NDKEvent | null>;
|
||||||
|
createEvent: (kind: number, content: string, tags: string[][]) => Promise<NDKEvent | null>;
|
||||||
|
queueEventForPublishing: (event: NDKEvent) => Promise<boolean>;
|
||||||
|
processPublicationQueue: () => Promise<void>;
|
||||||
fetchEventsByFilter: (filter: NDKFilter) => Promise<NDKEvent[]>;
|
fetchEventsByFilter: (filter: NDKFilter) => Promise<NDKEvent[]>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -152,6 +156,26 @@ export const useNDKStore = create<NDKStoreState & NDKStoreActions>((set, get) =>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set up connectivity monitoring to process publication queue
|
||||||
|
try {
|
||||||
|
const { ConnectivityService } = await import('@/lib/db/services/ConnectivityService');
|
||||||
|
|
||||||
|
// Process queue on initial connection
|
||||||
|
if (ConnectivityService.getInstance().getConnectionStatus()) {
|
||||||
|
get().processPublicationQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add listener to process queue when coming online
|
||||||
|
ConnectivityService.getInstance().addListener((isOnline) => {
|
||||||
|
if (isOnline) {
|
||||||
|
console.log('[NDK] Connection restored, processing publication queue');
|
||||||
|
get().processPublicationQueue();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[NDK] Error setting up connectivity monitoring:', error);
|
||||||
|
}
|
||||||
|
|
||||||
set({ isLoading: false });
|
set({ isLoading: false });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[NDK] Initialization error:', error);
|
console.error('[NDK] Initialization error:', error);
|
||||||
@ -330,6 +354,210 @@ export const useNDKStore = create<NDKStoreState & NDKStoreActions>((set, get) =>
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Create and sign a Nostr event without publishing it
|
||||||
|
createEvent: async (kind: number, content: string, tags: string[][]): Promise<NDKEvent | null> => {
|
||||||
|
try {
|
||||||
|
const { ndk, isAuthenticated, currentUser } = get();
|
||||||
|
|
||||||
|
if (!ndk) {
|
||||||
|
throw new Error('NDK not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated || !currentUser) {
|
||||||
|
throw new Error('Not authenticated');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create event
|
||||||
|
const event = new NDKEvent(ndk);
|
||||||
|
event.kind = kind;
|
||||||
|
event.content = content;
|
||||||
|
event.tags = tags;
|
||||||
|
|
||||||
|
// Define custom function for random bytes generation
|
||||||
|
const customRandomBytes = (length: number): Uint8Array => {
|
||||||
|
console.log('Using custom randomBytes in event signing');
|
||||||
|
return (Crypto as any).getRandomBytes(length);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Try to find and override the randomBytes function
|
||||||
|
const nostrTools = require('nostr-tools');
|
||||||
|
const nobleHashes = require('@noble/hashes/utils');
|
||||||
|
|
||||||
|
// Backup original functions
|
||||||
|
const originalNobleRandomBytes = nobleHashes.randomBytes;
|
||||||
|
|
||||||
|
// Override with our implementation
|
||||||
|
(nobleHashes as any).randomBytes = customRandomBytes;
|
||||||
|
|
||||||
|
// Sign the event but don't publish
|
||||||
|
try {
|
||||||
|
await event.sign();
|
||||||
|
} finally {
|
||||||
|
// Restore original functions
|
||||||
|
(nobleHashes as any).randomBytes = originalNobleRandomBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
return event;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating event:', error);
|
||||||
|
set({ error: error instanceof Error ? error : new Error('Failed to create event') });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Queue an event for publishing when online
|
||||||
|
queueEventForPublishing: async (event: NDKEvent): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
// Only proceed if the event has an ID and signature
|
||||||
|
if (!event.id || !event.sig) {
|
||||||
|
throw new Error('Event must be signed before queueing');
|
||||||
|
}
|
||||||
|
|
||||||
|
// First cache the event itself
|
||||||
|
try {
|
||||||
|
const EventCache = (await import('@/lib/db/services/EventCache')).EventCache;
|
||||||
|
const db = openDatabaseSync('powr.db');
|
||||||
|
const cache = new EventCache(db);
|
||||||
|
|
||||||
|
// Convert NDKEvent to NostrEvent for caching
|
||||||
|
await cache.setEvent({
|
||||||
|
id: event.id,
|
||||||
|
pubkey: event.pubkey,
|
||||||
|
kind: event.kind || 0,
|
||||||
|
created_at: event.created_at || Math.floor(Date.now() / 1000),
|
||||||
|
content: event.content,
|
||||||
|
tags: event.tags.map(tag => tag.map(item => String(item))),
|
||||||
|
sig: event.sig
|
||||||
|
});
|
||||||
|
|
||||||
|
// Then add to publication queue
|
||||||
|
await db.runAsync(
|
||||||
|
`INSERT OR REPLACE INTO publication_queue
|
||||||
|
(event_id, attempts, created_at, payload)
|
||||||
|
VALUES (?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
event.id,
|
||||||
|
0,
|
||||||
|
Date.now(),
|
||||||
|
JSON.stringify({
|
||||||
|
id: event.id,
|
||||||
|
pubkey: event.pubkey,
|
||||||
|
kind: event.kind,
|
||||||
|
created_at: event.created_at,
|
||||||
|
content: event.content,
|
||||||
|
tags: event.tags,
|
||||||
|
sig: event.sig
|
||||||
|
})
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} catch (cacheError) {
|
||||||
|
console.error('Error caching event:', cacheError);
|
||||||
|
// Continue to try publishing even if caching fails
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to publish immediately if online
|
||||||
|
try {
|
||||||
|
const ConnectivityService = (await import('@/lib/db/services/ConnectivityService')).ConnectivityService;
|
||||||
|
|
||||||
|
if (ConnectivityService.getInstance().getConnectionStatus()) {
|
||||||
|
try {
|
||||||
|
await event.publish();
|
||||||
|
|
||||||
|
// Remove from queue if successful
|
||||||
|
const db = openDatabaseSync('powr.db');
|
||||||
|
await db.runAsync(
|
||||||
|
`DELETE FROM publication_queue WHERE event_id = ?`,
|
||||||
|
[event.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('Event published successfully:', event.id);
|
||||||
|
return true;
|
||||||
|
} catch (publishError) {
|
||||||
|
console.log('Event queued for later publishing:', event.id);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('Event queued for later publishing (offline):', event.id);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (connectivityError) {
|
||||||
|
console.error('Error checking connectivity:', connectivityError);
|
||||||
|
// Assume offline if connectivity service fails
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error queueing event for publishing:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Process the publication queue
|
||||||
|
processPublicationQueue: async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { ndk } = get();
|
||||||
|
if (!ndk) return;
|
||||||
|
|
||||||
|
const db = openDatabaseSync('powr.db');
|
||||||
|
|
||||||
|
// Get all queued events that haven't exceeded max attempts
|
||||||
|
const queuedEvents = await db.getAllAsync<{
|
||||||
|
event_id: string;
|
||||||
|
attempts: number;
|
||||||
|
payload: string;
|
||||||
|
}>(
|
||||||
|
`SELECT event_id, attempts, payload
|
||||||
|
FROM publication_queue
|
||||||
|
WHERE attempts < 5
|
||||||
|
ORDER BY created_at ASC`
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Processing publication queue: ${queuedEvents.length} events`);
|
||||||
|
|
||||||
|
for (const item of queuedEvents) {
|
||||||
|
try {
|
||||||
|
// Update attempt count and timestamp
|
||||||
|
await db.runAsync(
|
||||||
|
`UPDATE publication_queue
|
||||||
|
SET attempts = attempts + 1,
|
||||||
|
last_attempt = ?
|
||||||
|
WHERE event_id = ?`,
|
||||||
|
[Date.now(), item.event_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Parse the event from payload
|
||||||
|
const eventData = JSON.parse(item.payload);
|
||||||
|
|
||||||
|
// Create a new NDKEvent
|
||||||
|
const event = new NDKEvent(ndk);
|
||||||
|
|
||||||
|
// Copy properties
|
||||||
|
event.id = eventData.id;
|
||||||
|
event.pubkey = eventData.pubkey;
|
||||||
|
event.kind = eventData.kind;
|
||||||
|
event.created_at = eventData.created_at;
|
||||||
|
event.content = eventData.content;
|
||||||
|
event.tags = eventData.tags;
|
||||||
|
event.sig = eventData.sig;
|
||||||
|
|
||||||
|
// Publish the event
|
||||||
|
await event.publish();
|
||||||
|
|
||||||
|
// Remove from queue on success
|
||||||
|
await db.runAsync(
|
||||||
|
`DELETE FROM publication_queue WHERE event_id = ?`,
|
||||||
|
[item.event_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Published queued event: ${item.event_id}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error publishing queued event ${item.event_id}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error processing publication queue:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
fetchEventsByFilter: async (filter: NDKFilter) => {
|
fetchEventsByFilter: async (filter: NDKFilter) => {
|
||||||
try {
|
try {
|
||||||
const { ndk } = get();
|
const { ndk } = get();
|
||||||
|
10
package-lock.json
generated
10
package-lock.json
generated
@ -16,6 +16,7 @@
|
|||||||
"@nostr-dev-kit/ndk-mobile": "^0.4.1",
|
"@nostr-dev-kit/ndk-mobile": "^0.4.1",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.6",
|
"@radix-ui/react-alert-dialog": "^1.1.6",
|
||||||
"@react-native-clipboard/clipboard": "^1.16.1",
|
"@react-native-clipboard/clipboard": "^1.16.1",
|
||||||
|
"@react-native-community/netinfo": "^11.4.1",
|
||||||
"@react-navigation/material-top-tabs": "^7.1.0",
|
"@react-navigation/material-top-tabs": "^7.1.0",
|
||||||
"@react-navigation/native": "^7.0.0",
|
"@react-navigation/native": "^7.0.0",
|
||||||
"@rn-primitives/accordion": "^1.1.0",
|
"@rn-primitives/accordion": "^1.1.0",
|
||||||
@ -4540,6 +4541,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@react-native-community/netinfo": {
|
||||||
|
"version": "11.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-native-community/netinfo/-/netinfo-11.4.1.tgz",
|
||||||
|
"integrity": "sha512-B0BYAkghz3Q2V09BF88RA601XursIEA111tnc2JOaN7axJWmNefmfjZqw/KdSxKZp7CZUuPpjBmz/WCR9uaHYg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react-native": ">=0.59"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@react-native/assets-registry": {
|
"node_modules/@react-native/assets-registry": {
|
||||||
"version": "0.76.7",
|
"version": "0.76.7",
|
||||||
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.76.7.tgz",
|
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.76.7.tgz",
|
||||||
|
@ -30,6 +30,7 @@
|
|||||||
"@nostr-dev-kit/ndk-mobile": "^0.4.1",
|
"@nostr-dev-kit/ndk-mobile": "^0.4.1",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.6",
|
"@radix-ui/react-alert-dialog": "^1.1.6",
|
||||||
"@react-native-clipboard/clipboard": "^1.16.1",
|
"@react-native-clipboard/clipboard": "^1.16.1",
|
||||||
|
"@react-native-community/netinfo": "^11.4.1",
|
||||||
"@react-navigation/material-top-tabs": "^7.1.0",
|
"@react-navigation/material-top-tabs": "^7.1.0",
|
||||||
"@react-navigation/native": "^7.0.0",
|
"@react-navigation/native": "^7.0.0",
|
||||||
"@rn-primitives/accordion": "^1.1.0",
|
"@rn-primitives/accordion": "^1.1.0",
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
"extends": "expo/tsconfig.base",
|
"extends": "expo/tsconfig.base",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
|
"module": "esnext",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user