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