From 87cdf3fc1c2218aa8af5b182b8de99b2e42b30b8 Mon Sep 17 00:00:00 2001 From: DocNR Date: Sun, 9 Feb 2025 20:38:38 -0500 Subject: [PATCH] Initial commit of new POWR version --- .github/ISSUE_TEMPLATE/bug_report.yml | 78 + .github/ISSUE_TEMPLATE/config.yml | 8 + .github/ISSUE_TEMPLATE/documentation.yml | 59 + .github/ISSUE_TEMPLATE/feature_request.yml | 58 + .../pull_request_template.md | 81 + .vscode/settings.json | 2 + CHANGELOG.md | 84 + CONTRIBUTING.md | 154 + README.md | 154 +- app/(tabs)/_layout.tsx | 78 + app/(tabs)/history.tsx | 11 + app/(tabs)/index.tsx | 11 + app/(tabs)/library/_layout.native.tsx | 59 + app/(tabs)/library/_layout.tsx | 3 + app/(tabs)/library/_layout.web.tsx | 80 + app/(tabs)/library/exercises.tsx | 180 + app/(tabs)/library/index.tsx | 93 + app/(tabs)/library/programs.tsx | 11 + app/(tabs)/library/templates.tsx | 214 + app/(tabs)/profile.tsx | 100 + app/(tabs)/social.tsx | 11 + app/(workout)/_layout.tsx | 15 + app/+not-found.tsx | 2 +- app/_layout.tsx | 54 +- babel.config.js | 18 +- components.json | 6 + components/Header.tsx | 19 + components/ThemeToggle.tsx | 10 +- components/examples/ProfileCard.tsx | 97 + components/exercises/ExerciseCard.tsx | 233 + components/library/FilterSheet.tsx | 124 + components/library/NewExerciseSheet.tsx | 190 + components/library/NewTemplateSheet.tsx | 146 + components/library/SearchHeader.tsx | 47 + components/pager/index.ts | 14 + components/pager/pager.native.tsx | 8 + components/pager/pager.web.tsx | 70 + components/pager/types.ts | 20 + components/shared/FloatingActionButton.tsx | 35 + components/templates/TemplateCard.tsx | 248 + components/ui/accordion.tsx | 125 + components/ui/alert-dialog.tsx | 160 + components/ui/alert.tsx | 75 + components/ui/aspect-ratio.tsx | 5 + components/ui/avatar.tsx | 2 +- components/ui/badge.tsx | 51 + components/ui/button.tsx | 19 +- components/ui/card.tsx | 4 +- components/ui/checkbox.tsx | 32 + components/ui/collapsible.tsx | 9 + components/ui/context-menu.tsx | 245 + components/ui/dialog.tsx | 147 + components/ui/dropdown-menu.tsx | 253 + components/ui/hover-card.tsx | 45 + components/ui/input.tsx | 40 + components/ui/label.tsx | 27 + components/ui/menubar.tsx | 261 + components/ui/navigation-menu.tsx | 181 + components/ui/popover.tsx | 39 + components/ui/progress.tsx | 2 +- components/ui/radio-group.tsx | 36 + components/ui/select.tsx | 173 + components/ui/separator.tsx | 22 + components/ui/sheet/CloseButton.tsx | 46 + components/ui/sheet/Sheet.native.tsx | 142 + components/ui/sheet/Sheet.tsx | 4 + components/ui/sheet/Sheet.types.ts | 20 + components/ui/sheet/Sheet.web.tsx | 66 + components/ui/sheet/index.ts | 6 + components/ui/skeleton.tsx | 39 + components/ui/switch.tsx | 95 + components/ui/table.tsx | 92 + components/ui/tabs.tsx | 62 + components/ui/text.tsx | 2 +- components/ui/textarea.tsx | 27 + components/ui/toggle-group.tsx | 84 + components/ui/toggle.tsx | 85 + components/ui/tooltip.tsx | 4 +- components/ui/typography.tsx | 205 + docs/ai_collaboration_guide.md | 215 + docs/coding_style.md | 277 + .../design/RNR-original-example.tsx | 14 +- docs/design/library_tab.md | 343 ++ docs/design_doc.md | 137 + docs/writing_good_interfaces.md | 198 + global.css | 32 +- lib/android-navigation-bar.ts | 2 +- lib/constants.ts | 7 + lib/icons/Check.tsx | 4 + lib/icons/ChevronDown.tsx | 4 + lib/icons/ChevronRight.tsx | 4 + lib/icons/ChevronUp.tsx | 4 + lib/icons/X.tsx | 4 + metro.config.js | 7 +- package-lock.json | 4673 +++++++++++++++++ package.json | 27 + tests/type-test.ts | 19 + tsconfig.json | 2 +- types/exercise.ts | 139 + types/library.ts | 55 + types/shared.ts | 54 + utils/ids.ts | 48 + 102 files changed, 12008 insertions(+), 78 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/documentation.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/pull_request_template/pull_request_template.md create mode 100644 .vscode/settings.json create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 app/(tabs)/_layout.tsx create mode 100644 app/(tabs)/history.tsx create mode 100644 app/(tabs)/index.tsx create mode 100644 app/(tabs)/library/_layout.native.tsx create mode 100644 app/(tabs)/library/_layout.tsx create mode 100644 app/(tabs)/library/_layout.web.tsx create mode 100644 app/(tabs)/library/exercises.tsx create mode 100644 app/(tabs)/library/index.tsx create mode 100644 app/(tabs)/library/programs.tsx create mode 100644 app/(tabs)/library/templates.tsx create mode 100644 app/(tabs)/profile.tsx create mode 100644 app/(tabs)/social.tsx create mode 100644 app/(workout)/_layout.tsx create mode 100644 components.json create mode 100644 components/Header.tsx create mode 100644 components/examples/ProfileCard.tsx create mode 100644 components/exercises/ExerciseCard.tsx create mode 100644 components/library/FilterSheet.tsx create mode 100644 components/library/NewExerciseSheet.tsx create mode 100644 components/library/NewTemplateSheet.tsx create mode 100644 components/library/SearchHeader.tsx create mode 100644 components/pager/index.ts create mode 100644 components/pager/pager.native.tsx create mode 100644 components/pager/pager.web.tsx create mode 100644 components/pager/types.ts create mode 100644 components/shared/FloatingActionButton.tsx create mode 100644 components/templates/TemplateCard.tsx create mode 100644 components/ui/accordion.tsx create mode 100644 components/ui/alert-dialog.tsx create mode 100644 components/ui/alert.tsx create mode 100644 components/ui/aspect-ratio.tsx create mode 100644 components/ui/badge.tsx create mode 100644 components/ui/checkbox.tsx create mode 100644 components/ui/collapsible.tsx create mode 100644 components/ui/context-menu.tsx create mode 100644 components/ui/dialog.tsx create mode 100644 components/ui/dropdown-menu.tsx create mode 100644 components/ui/hover-card.tsx create mode 100644 components/ui/input.tsx create mode 100644 components/ui/label.tsx create mode 100644 components/ui/menubar.tsx create mode 100644 components/ui/navigation-menu.tsx create mode 100644 components/ui/popover.tsx create mode 100644 components/ui/radio-group.tsx create mode 100644 components/ui/select.tsx create mode 100644 components/ui/separator.tsx create mode 100644 components/ui/sheet/CloseButton.tsx create mode 100644 components/ui/sheet/Sheet.native.tsx create mode 100644 components/ui/sheet/Sheet.tsx create mode 100644 components/ui/sheet/Sheet.types.ts create mode 100644 components/ui/sheet/Sheet.web.tsx create mode 100644 components/ui/sheet/index.ts create mode 100644 components/ui/skeleton.tsx create mode 100644 components/ui/switch.tsx create mode 100644 components/ui/table.tsx create mode 100644 components/ui/tabs.tsx create mode 100644 components/ui/textarea.tsx create mode 100644 components/ui/toggle-group.tsx create mode 100644 components/ui/toggle.tsx create mode 100644 components/ui/typography.tsx create mode 100644 docs/ai_collaboration_guide.md create mode 100644 docs/coding_style.md rename app/index.tsx => docs/design/RNR-original-example.tsx (90%) create mode 100644 docs/design/library_tab.md create mode 100644 docs/design_doc.md create mode 100644 docs/writing_good_interfaces.md create mode 100644 lib/icons/Check.tsx create mode 100644 lib/icons/ChevronDown.tsx create mode 100644 lib/icons/ChevronRight.tsx create mode 100644 lib/icons/ChevronUp.tsx create mode 100644 lib/icons/X.tsx create mode 100644 tests/type-test.ts create mode 100644 types/exercise.ts create mode 100644 types/library.ts create mode 100644 types/shared.ts create mode 100644 utils/ids.ts diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..7ea8386 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,78 @@ +name: Bug Report +description: File a bug report +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this bug report! + Please provide as much detail as possible to help us reproduce and fix the issue. + + - type: dropdown + id: platform + attributes: + label: Platform + description: What platform(s) are you seeing this issue on? + multiple: true + options: + - iOS + - Android + - Both + validations: + required: true + + - type: input + id: device + attributes: + label: Device & OS Version + description: What device and OS version are you using? (e.g., iPhone 14 iOS 17.2, Pixel 7 Android 14) + placeholder: iPhone 14 Pro iOS 17.2 + validations: + required: true + + - type: input + id: app-version + attributes: + label: App Version + description: What version of POWR are you running? + placeholder: 1.0.0 + validations: + required: true + + - type: textarea + id: what-happened + attributes: + label: What happened? + description: Describe the issue and what you expected to happen instead + placeholder: | + 1. Go to '...' + 2. Click on '...' + 3. Scroll down to '...' + 4. See error + validations: + required: true + + - type: textarea + id: reproduction + attributes: + label: Steps to Reproduce + description: Clear steps to reproduce the behavior + placeholder: | + 1. Open the app + 2. Navigate to... + 3. Click on... + validations: + required: true + + - type: textarea + id: logs + attributes: + label: Relevant log output or error messages + description: Please copy and paste any relevant log output. This will be automatically formatted into code. + render: shell + + - type: textarea + id: additional-context + attributes: + label: Additional context + description: Add any other context about the problem here (screenshots, videos, etc.) \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..4aba3f9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: POWR Community Discussions + url: https://github.com/yourusername/powr/discussions + about: Please ask and answer questions here. + - name: POWR Documentation + url: https://github.com/yourusername/powr/tree/main/docs + about: Check out our documentation for guides and troubleshooting. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/documentation.yml b/.github/ISSUE_TEMPLATE/documentation.yml new file mode 100644 index 0000000..29630c8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/documentation.yml @@ -0,0 +1,59 @@ +name: Documentation Update +description: Help us improve our documentation +labels: ["documentation"] +body: + - type: markdown + attributes: + value: | + Thanks for helping improve POWR's documentation! + Please provide details about what needs to be updated or added. + + - type: dropdown + id: doc-type + attributes: + label: Documentation Type + description: What type of documentation needs updating? + options: + - README + - API Documentation + - Setup Guide + - Tutorial + - Code Comments + - Other + validations: + required: true + + - type: input + id: page-link + attributes: + label: Documentation Link/Location + description: Which document or section needs updating? Provide a link if available. + placeholder: https://github.com/yourusername/powr/blob/main/docs/... + + - type: textarea + id: current-content + attributes: + label: Current Content + description: What does the current documentation say? (if applicable) + + - type: textarea + id: proposed-changes + attributes: + label: Proposed Changes + description: What would you like to add, remove, or modify? + validations: + required: true + + - type: textarea + id: reason + attributes: + label: Reason for Change + description: Why should this documentation be updated? + validations: + required: true + + - type: textarea + id: additional-context + attributes: + label: Additional Context + description: Add any other context about the documentation update here \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..e613f32 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,58 @@ +name: Feature Request +description: Suggest an idea for POWR +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to suggest a new feature! + Please provide as much detail as possible to help us understand your suggestion. + + - type: textarea + id: problem + attributes: + label: Is your feature request related to a problem? + description: A clear and concise description of what the problem is. + placeholder: I'm always frustrated when... + validations: + required: true + + - type: textarea + id: solution + attributes: + label: Describe the solution you'd like + description: A clear and concise description of what you want to happen. + placeholder: | + Describe your proposed solution: + - What should it do? + - How should it work? + - What would the user experience be like? + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Describe alternatives you've considered + description: A clear and concise description of any alternative solutions or features you've considered. + placeholder: | + What other approaches have you thought about? + What are their pros and cons? + + - type: dropdown + id: priority + attributes: + label: Priority + description: How important is this feature to you? + options: + - Nice to have + - Important + - Critical + validations: + required: true + + - type: textarea + id: additional-context + attributes: + label: Additional context + description: Add any other context, screenshots, or mockups about the feature request here. \ No newline at end of file diff --git a/.github/pull_request_template/pull_request_template.md b/.github/pull_request_template/pull_request_template.md new file mode 100644 index 0000000..1031a9a --- /dev/null +++ b/.github/pull_request_template/pull_request_template.md @@ -0,0 +1,81 @@ +# Description + +Please include: +- A summary of the changes +- Motivation and context +- Any dependencies that are required +- Which issue is fixed, if applicable + +Fixes # (issue) + +## Type of change + +Please delete options that are not relevant. + +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] Documentation update +- [ ] Performance improvement +- [ ] Code refactoring +- [ ] Test updates + +## How Has This Been Tested? + +Please describe the tests you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration. + +- [ ] Test A +- [ ] Test B + +## Checklist: + +- [ ] My code follows the style guidelines of this project +- [ ] I have performed a self-review of my own code +- [ ] I have commented my code, particularly in hard-to-understand areas +- [ ] I have made corresponding changes to the documentation +- [ ] My changes generate no new warnings +- [ ] I have added tests that prove my fix is effective or that my feature works +- [ ] New and existing unit tests pass locally with my changes +- [ ] Any dependent changes have been merged and published in downstream modules +- [ ] I have checked my code and corrected any misspellings + +## Screenshots (if appropriate): + +## Additional context: + +Add any other context about the pull request here. + +## Mobile Testing Checklist: + +If applicable, please confirm testing on: + +### iOS +- [ ] iPhone (latest iOS version) +- [ ] iPad (if applicable) +- [ ] Different screen sizes tested +- [ ] Dark mode tested + +### Android +- [ ] Latest Android version +- [ ] Different screen sizes tested +- [ ] Dark mode tested + +## Performance Impact: + +- [ ] No significant performance impact +- [ ] Performance improved +- [ ] Performance regressed (please explain) + +## Database Changes: + +If applicable: +- [ ] Database migrations are included +- [ ] Backward compatibility is maintained +- [ ] Data integrity is preserved + +## Future Impact: + +Please describe any potential future impact this change might have: +- Impact on planned features +- Technical debt considerations +- Scalability implications \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..7a73a41 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,2 @@ +{ +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..547b16d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,84 @@ +# Changelog + +All notable changes to the POWR project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- Basic exercise template creation functionality + - Input validation for required fields + - Schema-compliant field constraints + - Native picker components for standardized inputs +- Enhanced error handling in database operations + - Detailed SQLite error logging + - Improved transaction management + - Added proper error types and propagation + +### Changed +- Updated NewExerciseScreen with constrained inputs + - Added dropdowns for equipment selection + - Added movement pattern selection + - Added difficulty selection + - Added exercise type selection +- Improved DbService with better error handling + - Added proper SQLite error types + - Enhanced transaction rollback handling + - Added detailed debug logging + +### Technical Details +1. 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 + +2. 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 + +3. Error Handling: + - Added SQLite error type definitions + - Improved error propagation in LibraryService + - Added transaction rollback on constraint violations + +### Migration Notes +- Exercise creation now enforces schema constraints +- Input validation prevents invalid data entry +- Enhanced error messages provide better debugging information + +## [0.1.0] - 2024-02-09 + +### Added +- Initial project setup with Expo and React Native +- Basic tab navigation structure +- Theme support (light/dark mode) +- SQLite database integration +- Basic exercise library interface + +### Changed +- Migrated to TypeScript +- Updated to latest Expo SDK +- Implemented NativeWind for styling + +### Fixed +- iOS status bar appearance +- Android back button handling +- SQLite transaction management + +### Security +- Added basic input validation +- Implemented secure storage for sensitive data + +## [0.0.1] - 2024-02-01 + +### Added +- Initial repository setup +- Basic project structure +- Development environment configuration +- Documentation templates \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..562a79d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,154 @@ +# Contributing to POWR + +First off, thank you for considering contributing to POWR! This document provides guidelines and steps for contributing. + +## Getting Started + +1. Fork the Repository +2. Clone your fork +3. Create a new branch: `git checkout -b feature/your-feature-name` +4. Make your changes +5. Commit with clear messages +6. Push to your fork +7. Submit a Pull Request + +## Development Setup + +1. Install dependencies: +```bash +npm install +``` + +2. Start the development server: +```bash +npx expo start +``` + +## Code Style Guidelines + +### TypeScript +- Use TypeScript for all new code +- Provide proper type definitions +- Avoid using `any` +- Document complex types + +### React/React Native +- Use functional components +- Implement proper error boundaries +- Follow React hooks best practices +- Keep components focused and reusable + +### Testing +- Write tests for new features +- Maintain existing test coverage +- Use descriptive test names +- Follow the "Arrange-Act-Assert" pattern + +## Documentation + +### Code Documentation +- Use JSDoc comments for functions and classes +- Document component props +- Explain complex logic +- Keep inline comments clear and necessary + +### Updating Documentation +- Update README.md if needed +- Document new features +- Update CHANGELOG.md +- Add migration notes if needed + +## Pull Request Process + +1. Create a descriptive PR title +2. Fill out the PR template +3. Link related issues +4. Update documentation +5. Ensure tests pass +6. Request review +7. Address feedback + +## Commit Messages + +Follow the conventional commits specification: + +- feat: New feature +- fix: Bug fix +- docs: Documentation changes +- style: Code style changes +- refactor: Code refactoring +- test: Test updates +- chore: Build process updates + +Example: +``` +feat(exercise): add custom exercise creation + +- Add exercise form component +- Implement validation +- Add database integration +``` + +## Working with Issues + +1. Check existing issues first +2. Use issue templates when available +3. Provide clear reproduction steps +4. Include relevant information: + - Platform (iOS/Android/Web) + - React Native version + - Error messages + - Screenshots if applicable + +## Code Review Process + +### Submitting Code +- Keep PRs focused and small +- Explain complex changes +- Update tests and documentation +- Ensure CI checks pass + +### Reviewing Code +- Be respectful and constructive +- Focus on code, not the author +- Explain your reasoning +- Approve once satisfied + +## Design Guidelines + +### UI/UX Principles +- Follow platform conventions +- Maintain consistency +- Consider accessibility +- Support dark mode + +### Component Design +- Keep components focused +- Use proper prop types +- Implement error handling +- Consider reusability + +## Release Process + +1. Version bump +2. Update CHANGELOG.md +3. Create release notes +4. Tag the release +5. Build and test +6. Deploy + +## Questions? + +If you have questions: +1. Check existing issues +2. Review documentation +3. Open a discussion +4. Ask in our community channels + +## Community Guidelines + +- Be respectful and inclusive +- Help others learn +- Share knowledge +- Give constructive feedback +- Follow the code of conduct \ No newline at end of file diff --git a/README.md b/README.md index 44d05e0..813574f 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,148 @@ -# Starter base +# POWR - Cross-Platform Fitness Tracking App -A starting point to help you set up your project quickly and use the common components provided by `react-native-reusables`. The idea is to make it easier for you to get started. +POWR is a local-first fitness tracking application built with React Native and Expo, featuring planned Nostr protocol integration for decentralized social features. ## Features -- NativeWind v4 -- Dark and light mode - - Android Navigation Bar matches mode - - Persistent mode -- Common components - - ThemeToggle, Avatar, Button, Card, Progress, Text, Tooltip +### Current +- Exercise library management +- Workout template creation +- Local-first data architecture +- Cross-platform support (iOS, Android) +- Dark mode support -starter-base-template +### Planned +- Workout record and template sharing +- Nostr integration +- Social features +- Training programs +- Performance analytics + +## Getting Started + +### Prerequisites +- Node.js (v18 or later) +- npm or yarn +- Expo CLI +- iOS Simulator (for iOS development) +- Android Studio (for Android development) + +### Installation + +1. Clone the repository +```bash +git clone https://github.com/docNR/powr.git +cd powr +``` + +2. Install dependencies +```bash +npm install +``` + +3. Start the development server +```bash +npx expo start +``` + +### Development Options +- Press 'i' for iOS simulator +- Press 'a' for Android simulator +- Scan QR code with Expo Go app for physical device + +## 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 +``` + +## Technology Stack + +### Core +- React Native +- Expo +- TypeScript +- SQLite (via expo-sqlite) + +### UI Components +- NativeWind +- React Navigation +- Lucide Icons + +### Testing +- Jest +- React Native Testing Library + +## Development + +### Environment Setup +1. Install development tools +```bash +npm install -g expo-cli +``` + +2. Configure environment +```bash +cp .env.example .env +``` + +3. Configure development settings +```bash +npm run setup-dev +``` + +### Running Tests +```bash +# Run all tests +npm test + +# Run with coverage +npm test -- --coverage + +# Run in watch mode +npm test -- --watch +``` + +### Building for Production +```bash +# Build for iOS +eas build -p ios + +# Build for Android +eas build -p android +``` + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Commit your changes +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. + +## Acknowledgments + +- [Expo](https://expo.dev/) +- [React Native](https://reactnative.dev/) +- [Nostr Protocol](https://github.com/nostr-protocol/nostr) \ No newline at end of file diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx new file mode 100644 index 0000000..38a48c8 --- /dev/null +++ b/app/(tabs)/_layout.tsx @@ -0,0 +1,78 @@ +// app/(tabs)/_layout.tsx +import React from 'react'; +import { Platform } from 'react-native'; +import { Tabs } from 'expo-router'; +import { useTheme } from '@react-navigation/native'; // Change this import +import { Dumbbell, Library, Users, History, User } from 'lucide-react-native'; +import { CUSTOM_COLORS } from '@/lib/constants'; + +export default function TabLayout() { + const { colors } = useTheme(); + + return ( + + ( + + ), + }} + /> + ( + + ), + }} + /> + ( + + ), + }} + /> + ( + + ), + }} + /> + ( + + ), + }} + /> + + ); +} \ No newline at end of file diff --git a/app/(tabs)/history.tsx b/app/(tabs)/history.tsx new file mode 100644 index 0000000..bcaf98b --- /dev/null +++ b/app/(tabs)/history.tsx @@ -0,0 +1,11 @@ +// app/(tabs)/history.tsx +import { View } from 'react-native'; +import { Text } from '@/components/ui/text'; + +export default function HomeScreen() { + return ( + + Home Screen + + ); +} \ No newline at end of file diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx new file mode 100644 index 0000000..8847d4c --- /dev/null +++ b/app/(tabs)/index.tsx @@ -0,0 +1,11 @@ +// app/(tabs)/index.tsx +import { View } from 'react-native'; +import { Text } from '@/components/ui/text'; + +export default function HomeScreen() { + return ( + + Home Screen + + ); +} \ No newline at end of file diff --git a/app/(tabs)/library/_layout.native.tsx b/app/(tabs)/library/_layout.native.tsx new file mode 100644 index 0000000..a9328ce --- /dev/null +++ b/app/(tabs)/library/_layout.native.tsx @@ -0,0 +1,59 @@ +// app/(tabs)/library/_layout.native.tsx +import { View } from 'react-native'; +import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs'; +import { useTheme } from '@react-navigation/native'; +import { Text } from '@/components/ui/text'; +import { ThemeToggle } from '@/components/ThemeToggle'; +import ExercisesScreen from './exercises'; +import TemplatesScreen from './templates'; +import ProgramsScreen from './programs'; +import { CUSTOM_COLORS } from '@/lib/constants'; + +const Tab = createMaterialTopTabNavigator(); + +export default function LibraryLayout() { + const { colors } = useTheme(); + + return ( + + {/* Header */} + + Library + + + + + + + + + + ); +} \ No newline at end of file diff --git a/app/(tabs)/library/_layout.tsx b/app/(tabs)/library/_layout.tsx new file mode 100644 index 0000000..2195c6e --- /dev/null +++ b/app/(tabs)/library/_layout.tsx @@ -0,0 +1,3 @@ +// app/(tabs)/library/_layout.tsx +// This is the fallback layout that gets overridden by platform-specific files +export { default } from './_layout.native'; \ No newline at end of file diff --git a/app/(tabs)/library/_layout.web.tsx b/app/(tabs)/library/_layout.web.tsx new file mode 100644 index 0000000..40a499c --- /dev/null +++ b/app/(tabs)/library/_layout.web.tsx @@ -0,0 +1,80 @@ +// app/(tabs)/library/_layout.web.tsx +import React from 'react'; +import { View, Pressable } from 'react-native'; +import { Text } from '@/components/ui/text'; +import { ThemeToggle } from '@/components/ThemeToggle'; +import Pager from '@/components/pager'; +import { CUSTOM_COLORS } from '@/lib/constants'; +import type { PagerRef } from '@/components/pager/types'; +import ExercisesScreen from './exercises'; +import TemplatesScreen from './templates'; +import ProgramsScreen from './programs'; + +const tabs = [ + { key: 'exercises', title: 'Exercises', component: ExercisesScreen }, + { key: 'templates', title: 'Templates', component: TemplatesScreen }, + { key: 'programs', title: 'Programs', component: ProgramsScreen }, +]; + +export default function LibraryLayout() { + const [activeIndex, setActiveIndex] = React.useState(0); + const pagerRef = React.useRef(null); + + const handleTabPress = (index: number) => { + setActiveIndex(index); + pagerRef.current?.setPage(index); + }; + + return ( + + {/* Header */} + + Library + + + + {/* Tab Headers */} + + {tabs.map((tab, index) => ( + + handleTabPress(index)} + className="px-4 py-3 items-center" + > + + {tab.title} + + + {activeIndex === index && ( + + )} + + ))} + + + {/* Content */} + + setActiveIndex(e.nativeEvent.position)} + style={{ flex: 1 }} + > + {tabs.map((tab) => ( + + + + ))} + + + + ); +} \ No newline at end of file diff --git a/app/(tabs)/library/exercises.tsx b/app/(tabs)/library/exercises.tsx new file mode 100644 index 0000000..a92fe26 --- /dev/null +++ b/app/(tabs)/library/exercises.tsx @@ -0,0 +1,180 @@ +// app/(tabs)/library/exercises.tsx +import React, { useState, useCallback } from 'react'; +import { View, ScrollView } from 'react-native'; +import { Text } from '@/components/ui/text'; +import { ExerciseCard } from '@/components/exercises/ExerciseCard'; +import { FloatingActionButton } from '@/components/shared/FloatingActionButton'; +import { SearchHeader } from '@/components/library/SearchHeader'; +import { FilterSheet, type FilterOptions } from '@/components/library/FilterSheet'; +import { NewExerciseSheet } from '@/components/library/NewExerciseSheet'; +import { Dumbbell } from 'lucide-react-native'; +import { Exercise, ExerciseCategory, ExerciseEquipment, ContentSource } from '@/types/library'; + +const initialExercises: Exercise[] = [ + { + id: '1', + title: 'Barbell Back Squat', + category: 'Legs' as ExerciseCategory, + equipment: 'barbell' as ExerciseEquipment, + tags: ['compound', 'strength'], + source: 'local' as ContentSource, + description: 'A compound exercise that primarily targets the quadriceps, hamstrings, and glutes.', + }, + { + id: '2', + title: 'Pull-ups', + category: 'Pull' as ExerciseCategory, + equipment: 'bodyweight' as ExerciseEquipment, + tags: ['upper-body', 'compound'], + source: 'local' as ContentSource, + description: 'An upper body pulling exercise that targets the latissimus dorsi and biceps.', + }, + { + id: '3', + title: 'Bench Press', + category: 'Push' as ExerciseCategory, + equipment: 'barbell' as ExerciseEquipment, + tags: ['push', 'strength'], + source: 'nostr' as ContentSource, + description: 'A compound pushing exercise that targets the chest, shoulders, and triceps.', + }, +]; + +export default function ExercisesScreen() { + const [exercises, setExercises] = useState(initialExercises); + const [searchQuery, setSearchQuery] = useState(''); + const [showFilters, setShowFilters] = useState(false); + const [showNewExercise, setShowNewExercise] = useState(false); + const [filterOptions, setFilterOptions] = useState({ + equipment: [], + tags: [], + source: [] + }); + + // Filter exercises + const filteredExercises = useCallback(() => { + return exercises.filter(exercise => { + // Search filter - make case insensitive + if (searchQuery.trim()) { + const searchLower = searchQuery.toLowerCase().trim(); + const matchesSearch = + exercise.title.toLowerCase().includes(searchLower) || + exercise.description?.toLowerCase().includes(searchLower) || + exercise.tags.some(tag => tag.toLowerCase().includes(searchLower)) || + exercise.equipment?.toLowerCase().includes(searchLower); + + if (!matchesSearch) return false; + } + + // Equipment filter + if (filterOptions.equipment.length > 0) { + if (!filterOptions.equipment.includes(exercise.equipment || '')) { + return false; + } + } + + // Tags filter + if (filterOptions.tags.length > 0) { + if (!exercise.tags.some(tag => filterOptions.tags.includes(tag))) { + return false; + } + } + + // Source filter + if (filterOptions.source.length > 0) { + if (!filterOptions.source.includes(exercise.source)) { + return false; + } + } + + return true; + }); + }, [exercises, searchQuery, filterOptions]); + + const handleAddExercise = (newExercise: Exercise) => { + setExercises(prev => [...prev, newExercise]); + setShowNewExercise(false); + }; + + // Get recent and filtered exercises + const recentExercises = exercises.slice(0, 2); + const allExercises = filteredExercises(); + const activeFilterCount = Object.values(filterOptions) + .reduce((count, filters) => count + filters.length, 0); + + const handleDelete = (id: string) => { + setExercises(current => current.filter(ex => ex.id !== id)); + }; + + const handleExercisePress = (exerciseId: string) => { + console.log('Selected exercise:', exerciseId); + }; + + const availableFilters = { + equipment: ['barbell', 'dumbbell', 'bodyweight', 'machine', 'cable', 'other'] as ExerciseEquipment[], + tags: ['strength', 'compound', 'isolation', 'push', 'pull', 'legs'], + source: ['local', 'powr', 'nostr'] as ContentSource[] + }; + + return ( + + setShowFilters(true)} + /> + + + {/* Recent Exercises Section */} + + Recent Exercises + + {recentExercises.map(exercise => ( + handleExercisePress(exercise.id)} + onDelete={() => handleDelete(exercise.id)} + /> + ))} + + + + {/* All Exercises Section */} + + All Exercises + + {allExercises.map(exercise => ( + handleExercisePress(exercise.id)} + onDelete={() => handleDelete(exercise.id)} + /> + ))} + + + + + setShowNewExercise(true)} + /> + + setShowFilters(false)} + options={filterOptions} + onApplyFilters={setFilterOptions} + availableFilters={availableFilters} + /> + + setShowNewExercise(false)} + onSubmit={handleAddExercise} + /> + + ); +} \ No newline at end of file diff --git a/app/(tabs)/library/index.tsx b/app/(tabs)/library/index.tsx new file mode 100644 index 0000000..ea5aa52 --- /dev/null +++ b/app/(tabs)/library/index.tsx @@ -0,0 +1,93 @@ +// app/(tabs)/library/index.tsx +import React from 'react'; +import { View, StyleSheet, Platform } from 'react-native'; +import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs'; +import { useTheme } from '@react-navigation/native'; +import { Text } from '@/components/ui/text'; +import { ThemeToggle } from '@/components/ThemeToggle'; +import ExercisesScreen from './exercises'; + +const Tab = createMaterialTopTabNavigator(); + +function TemplatesTab() { + return ( + + Templates Content + + ); +} + +function ProgramsTab() { + return ( + + Programs (Coming Soon) + + ); +} + +export default function LibraryScreen() { + const { colors } = useTheme(); + + return ( + + {/* Header with Theme Toggle */} + + Library + + + + {/* Material Top Tabs */} + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 16, + paddingTop: Platform.OS === 'ios' ? 60 : 16, + paddingBottom: 16, + }, +}); \ No newline at end of file diff --git a/app/(tabs)/library/programs.tsx b/app/(tabs)/library/programs.tsx new file mode 100644 index 0000000..acc790a --- /dev/null +++ b/app/(tabs)/library/programs.tsx @@ -0,0 +1,11 @@ +// app/(tabs)/library/(tabs)/programs.tsx +import { View } from 'react-native'; +import { Text } from '@/components/ui/text'; + +export default function ProgramsScreen() { + return ( + + Programs (Coming Soon) + + ); +} \ No newline at end of file diff --git a/app/(tabs)/library/templates.tsx b/app/(tabs)/library/templates.tsx new file mode 100644 index 0000000..af729ef --- /dev/null +++ b/app/(tabs)/library/templates.tsx @@ -0,0 +1,214 @@ +// app/(tabs)/library/templates.tsx +import { View, ScrollView } from 'react-native'; +import { useState, useCallback } from 'react'; +import { Text } from '@/components/ui/text'; +import { TemplateCard } from '@/components/templates/TemplateCard'; +import { FloatingActionButton } from '@/components/shared/FloatingActionButton'; +import { SearchHeader } from '@/components/library/SearchHeader'; +import { FilterSheet } from '@/components/library/FilterSheet'; +import { NewTemplateSheet } from '@/components/library/NewTemplateSheet'; +import { useRouter } from 'expo-router'; +import { Plus } from 'lucide-react-native'; +import { Template, FilterOptions, ContentSource, ExerciseEquipment } from '@/types/library'; + +// Mock data - move to a separate file later +const initialTemplates: Template[] = [ + { + id: '1', + title: 'Full Body Strength', + type: 'strength', + category: 'Full Body', + exercises: [ + { title: 'Barbell Squat', targetSets: 3, targetReps: 8 }, + { title: 'Bench Press', targetSets: 3, targetReps: 8 }, + { title: 'Bent Over Row', targetSets: 3, targetReps: 8 } + ], + tags: ['strength', 'compound'], + source: 'local', + isFavorite: true + }, + { + id: '2', + title: '20min EMOM', + type: 'emom', + category: 'Conditioning', + exercises: [ + { title: 'Kettlebell Swings', targetSets: 1, targetReps: 15 }, + { title: 'Push-ups', targetSets: 1, targetReps: 10 }, + { title: 'Air Squats', targetSets: 1, targetReps: 20 } + ], + tags: ['conditioning', 'kettlebell'], + source: 'powr', + isFavorite: false + } +]; + +export default function TemplatesScreen() { + const [searchQuery, setSearchQuery] = useState(''); + const [showFilters, setShowFilters] = useState(false); + const [showNewTemplate, setShowNewTemplate] = useState(false); + const [filterOptions, setFilterOptions] = useState({ + equipment: [], + tags: [], + source: [] + }); + const [templates, setTemplates] = useState(initialTemplates); + + const availableFilters = { + equipment: ['barbell', 'dumbbell', 'bodyweight', 'machine', 'cable', 'other'] as ExerciseEquipment[], + tags: ['strength', 'circuit', 'emom', 'amrap', 'Full Body', 'Upper Body', 'Lower Body', 'Conditioning'], + source: ['local', 'powr', 'nostr'] as ContentSource[] + }; + + const filteredTemplates = useCallback(() => { + return templates.filter(template => { + // Search filter + if (searchQuery) { + const searchLower = searchQuery.toLowerCase(); + if (!template.title.toLowerCase().includes(searchLower) && + !template.exercises.some(ex => + ex.title.toLowerCase().includes(searchLower) + )) { + return false; + } + } + + // Tags filter (includes type and category) + if (filterOptions.tags.length > 0) { + if (!filterOptions.tags.includes(template.type) && + !filterOptions.tags.includes(template.category) && + !template.tags.some(tag => filterOptions.tags.includes(tag))) { + return false; + } + } + + // Source filter + if (filterOptions.source.length > 0) { + if (!filterOptions.source.includes(template.source)) { + return false; + } + } + + return true; + }); + }, [templates, searchQuery, filterOptions]); + + const activeFilterCount = Object.values(filterOptions) + .reduce((count, filters) => count + filters.length, 0); + + const handleDelete = (id: string) => { + setTemplates(current => current.filter(t => t.id !== id)); + }; + + const handleTemplatePress = (template: Template) => { + // TODO: Show template details + console.log('Selected template:', template); + }; + + const handleStartWorkout = (template: Template) => { + // TODO: Navigate to workout screen with template + console.log('Starting workout with template:', template); + }; + + const handleFavorite = (template: Template) => { + setTemplates(current => + current.map(t => + t.id === template.id + ? { ...t, isFavorite: !t.isFavorite } + : t + ) + ); + }; + + const handleAddTemplate = (template: Template) => { + setTemplates(prev => [...prev, template]); + setShowNewTemplate(false); + }; + + // Separate favorites and regular templates + const favoriteTemplates = templates.filter(t => t.isFavorite); + const regularTemplates = templates.filter(t => !t.isFavorite); + + return ( + + setShowFilters(true)} + /> + + + {/* Favorites Section */} + {favoriteTemplates.length > 0 && ( + + + Favorites + + + {favoriteTemplates.map(template => ( + handleTemplatePress(template)} + onDelete={handleDelete} + onFavorite={() => handleFavorite(template)} + onStartWorkout={() => handleStartWorkout(template)} + /> + ))} + + + )} + + {/* All Templates Section */} + + + All Templates + + {regularTemplates.length > 0 ? ( + + {regularTemplates.map(template => ( + handleTemplatePress(template)} + onDelete={handleDelete} + onFavorite={() => handleFavorite(template)} + onStartWorkout={() => handleStartWorkout(template)} + /> + ))} + + ) : ( + + + No templates found. Create one by clicking the + button. + + + )} + + + {/* Add some bottom padding for FAB */} + + + + setShowNewTemplate(true)} + /> + + setShowNewTemplate(false)} + onSubmit={handleAddTemplate} + /> + + setShowFilters(false)} + options={filterOptions} + onApplyFilters={setFilterOptions} + availableFilters={availableFilters} + /> + + ); +} \ No newline at end of file diff --git a/app/(tabs)/profile.tsx b/app/(tabs)/profile.tsx new file mode 100644 index 0000000..d7407df --- /dev/null +++ b/app/(tabs)/profile.tsx @@ -0,0 +1,100 @@ +// app/(tabs)/profile.tsx +import React from 'react'; +import { View, ScrollView } from 'react-native'; +import { Settings } from 'lucide-react-native'; +import { H1 } from '@/components/ui/typography'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Button } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; +import Header from '@/components/Header'; + +const PLACEHOLDER_IMAGE = 'https://github.com/shadcn.png'; // Placeholder profile image + +export default function ProfileScreen() { + return ( + +
{ + // TODO: Navigate to settings + console.log('Open settings'); + }} + > + + + } + /> + + + {/* Profile Header Section */} + + + + + JD + + +

John Doe

+ @johndoe +
+ + {/* Stats Section */} + + + 24 + Workouts + + + 12 + Templates + + + 3 + Programs + + + + {/* Profile Actions */} + + + + + + + + +
+ + ); +} \ No newline at end of file diff --git a/app/(tabs)/social.tsx b/app/(tabs)/social.tsx new file mode 100644 index 0000000..a6deae2 --- /dev/null +++ b/app/(tabs)/social.tsx @@ -0,0 +1,11 @@ +// app/(tabs)/index.tsx (and similar for other tab screens) +import { View } from 'react-native'; +import { Text } from '@/components/ui/text'; + +export default function HomeScreen() { + return ( + + Home Screen + + ); +} \ No newline at end of file diff --git a/app/(workout)/_layout.tsx b/app/(workout)/_layout.tsx new file mode 100644 index 0000000..ebd18e7 --- /dev/null +++ b/app/(workout)/_layout.tsx @@ -0,0 +1,15 @@ +// app/(workout)/_layout.tsx +import { Stack } from 'expo-router'; + +export default function WorkoutLayout() { + return ( + + + + ); +} \ No newline at end of file diff --git a/app/+not-found.tsx b/app/+not-found.tsx index 08c97b6..ae21740 100644 --- a/app/+not-found.tsx +++ b/app/+not-found.tsx @@ -1,6 +1,6 @@ import { Link, Stack } from 'expo-router'; import { View } from 'react-native'; -import { Text } from '~/components/ui/text'; +import { Text } from '@/components/ui/text'; export default function NotFoundScreen() { return ( diff --git a/app/_layout.tsx b/app/_layout.tsx index f6639b8..4dc2c74 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,15 +1,15 @@ -import '~/global.css'; - +// app/_layout.tsx +import '@/global.css'; import { DarkTheme, DefaultTheme, Theme, ThemeProvider } from '@react-navigation/native'; import { Stack } from 'expo-router'; import { StatusBar } from 'expo-status-bar'; import * as React from 'react'; import { Platform } from 'react-native'; -import { NAV_THEME } from '~/lib/constants'; -import { useColorScheme } from '~/lib/useColorScheme'; +import { NAV_THEME } from '@/lib/constants'; +import { useColorScheme } from '@/lib/useColorScheme'; import { PortalHost } from '@rn-primitives/portal'; -import { ThemeToggle } from '~/components/ThemeToggle'; -import { setAndroidNavigationBar } from '~/lib/android-navigation-bar'; +import { setAndroidNavigationBar } from '@/lib/android-navigation-bar'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; const LIGHT_THEME: Theme = { ...DefaultTheme, @@ -20,23 +20,17 @@ const DARK_THEME: Theme = { colors: NAV_THEME.dark, }; -export { - // Catch any errors thrown by the Layout component. - ErrorBoundary, -} from 'expo-router'; - export default function RootLayout() { const hasMounted = React.useRef(false); const { colorScheme, isDarkColorScheme } = useColorScheme(); const [isColorSchemeLoaded, setIsColorSchemeLoaded] = React.useState(false); - useIsomorphicLayoutEffect(() => { + React.useEffect(() => { if (hasMounted.current) { return; } if (Platform.OS === 'web') { - // Adds the background color to the html element to prevent white background on overscroll. document.documentElement.classList.add('bg-background'); } setAndroidNavigationBar(colorScheme); @@ -49,21 +43,21 @@ export default function RootLayout() { } return ( - - - - , - }} - /> - - - + + + + + + + + + ); -} - -const useIsomorphicLayoutEffect = - Platform.OS === 'web' && typeof window === 'undefined' ? React.useEffect : React.useLayoutEffect; +} \ No newline at end of file diff --git a/babel.config.js b/babel.config.js index 7d507e1..145ab71 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,6 +1,20 @@ module.exports = function (api) { api.cache(true); return { - presets: [['babel-preset-expo', { jsxImportSource: 'nativewind' }], 'nativewind/babel'], + presets: [ + ['babel-preset-expo', { jsxImportSource: 'nativewind' }], + 'nativewind/babel' + ], + plugins: [ + [ + 'module-resolver', + { + root: ['.'], + alias: { + '@': './', + }, + }, + ], + ], }; -}; +}; \ No newline at end of file diff --git a/components.json b/components.json new file mode 100644 index 0000000..a8e16a5 --- /dev/null +++ b/components.json @@ -0,0 +1,6 @@ +{ + "aliases": { + "components": "@/components", + "lib": "@/lib" + } +} \ No newline at end of file diff --git a/components/Header.tsx b/components/Header.tsx new file mode 100644 index 0000000..7928ff0 --- /dev/null +++ b/components/Header.tsx @@ -0,0 +1,19 @@ +// components/Header.tsx +import React from 'react'; +import { View } from 'react-native'; +import { Text } from '@/components/ui/text'; +import { ThemeToggle } from '@/components/ThemeToggle'; + +interface HeaderProps { + title: string; + rightElement?: React.ReactNode; +} + +export default function Header({ title, rightElement }: HeaderProps) { + return ( + + {title} + {rightElement || } + + ); +} \ No newline at end of file diff --git a/components/ThemeToggle.tsx b/components/ThemeToggle.tsx index c89471f..248f0ff 100644 --- a/components/ThemeToggle.tsx +++ b/components/ThemeToggle.tsx @@ -1,9 +1,9 @@ import { Pressable, View } from 'react-native'; -import { setAndroidNavigationBar } from '~/lib/android-navigation-bar'; -import { MoonStar } from '~/lib/icons/MoonStar'; -import { Sun } from '~/lib/icons/Sun'; -import { useColorScheme } from '~/lib/useColorScheme'; -import { cn } from '~/lib/utils'; +import { setAndroidNavigationBar } from '@/lib/android-navigation-bar'; +import { MoonStar } from '@/lib/icons/MoonStar'; +import { Sun } from '@/lib/icons/Sun'; +import { useColorScheme } from '@/lib/useColorScheme'; +import { cn } from '@/lib/utils'; export function ThemeToggle() { const { isDarkColorScheme, setColorScheme } = useColorScheme(); diff --git a/components/examples/ProfileCard.tsx b/components/examples/ProfileCard.tsx new file mode 100644 index 0000000..10bc73e --- /dev/null +++ b/components/examples/ProfileCard.tsx @@ -0,0 +1,97 @@ +// components/examples/ProfileCard.tsx +import * as React from 'react'; +import { View } from 'react-native'; +import Animated, { FadeInUp, FadeOutDown, LayoutAnimationConfig } from 'react-native-reanimated'; +import { Info } from '@/lib/icons/Info'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Progress } from '@/components/ui/progress'; +import { Text } from '@/components/ui/text'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; + +const GITHUB_AVATAR_URI = + 'https://i.pinimg.com/originals/ef/a2/8d/efa28d18a04e7fa40ed49eeb0ab660db.jpg'; + +export default function ProfileCardExample() { + const [progress, setProgress] = React.useState(78); + + function updateProgressValue() { + setProgress(Math.floor(Math.random() * 100)); + } + + return ( + + + + + + + RS + + + + Rick Sanchez + + Scientist + + + + + + Freelance + + + + + + + + Dimension + C-137 + + + Age + 70 + + + Species + Human + + + + + + Productivity: + + + {progress}% + + + + + + + + + + ); +} \ No newline at end of file diff --git a/components/exercises/ExerciseCard.tsx b/components/exercises/ExerciseCard.tsx new file mode 100644 index 0000000..c304a8f --- /dev/null +++ b/components/exercises/ExerciseCard.tsx @@ -0,0 +1,233 @@ +// components/exercises/ExerciseCard.tsx +import React from 'react'; +import { View, TouchableOpacity, Platform } from 'react-native'; +import { Text } from '@/components/ui/text'; +import { Card, CardContent } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Trash2, Star } from 'lucide-react-native'; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle +} from '@/components/ui/sheet'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog'; +import { Exercise } from '@/types/library'; + +interface ExerciseCardProps extends Exercise { + onPress: () => void; + onDelete: (id: string) => void; + onFavorite?: () => void; +} + +export function ExerciseCard({ + id, + title, + category, + equipment, + description, + tags = [], + source = 'local', + usageCount, + lastUsed, + onPress, + onDelete, + onFavorite +}: ExerciseCardProps) { + const [showSheet, setShowSheet] = React.useState(false); + const [showDeleteAlert, setShowDeleteAlert] = React.useState(false); + + const handleDeletePress = () => { + setShowDeleteAlert(true); + }; + + const handleConfirmDelete = () => { + onDelete(id); + setShowDeleteAlert(false); + }; + + const handleCardPress = () => { + setShowSheet(true); + onPress(); + }; + + return ( + <> + + + + + + + + {title} + + + {source} + + + + + {category} + + {equipment && ( + + {equipment} + + )} + {description && ( + + {description} + + )} + + {(usageCount || lastUsed) && ( + + {usageCount && ( + + Used {usageCount} times + + )} + {lastUsed && ( + + Last used: {lastUsed.toLocaleDateString()} + + )} + + )} + + {tags.length > 0 && ( + + {tags.map(tag => ( + + {tag} + + ))} + + )} + + + + {onFavorite && ( + + )} + + + + + + + + + Delete Exercise + + + Are you sure you want to delete {title}? This action cannot be undone. + + + + + Cancel + + + Delete + + + + + + + + + + + {/* Bottom sheet section */} + setShowSheet(false)}> + + + {title} + + + + + {description && ( + + Description + {description} + + )} + + Details + + Category: {category} + {equipment && Equipment: {equipment}} + Source: {source} + + + {(usageCount || lastUsed) && ( + + Statistics + + {usageCount && ( + Used {usageCount} times + )} + {lastUsed && ( + + Last used: {lastUsed.toLocaleDateString()} + + )} + + + )} + {tags.length > 0 && ( + + Tags + + {tags.map(tag => ( + + {tag} + + ))} + + + )} + + + + + ); +} \ No newline at end of file diff --git a/components/library/FilterSheet.tsx b/components/library/FilterSheet.tsx new file mode 100644 index 0000000..d604e50 --- /dev/null +++ b/components/library/FilterSheet.tsx @@ -0,0 +1,124 @@ +import React from 'react'; +import { View } from 'react-native'; +import { Text } from '@/components/ui/text'; +import { Button } from '@/components/ui/button'; +import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; +import { Badge } from '@/components/ui/badge'; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'; + +export type SourceType = 'local' | 'powr' | 'nostr'; + +export interface FilterOptions { + equipment: string[]; + tags: string[]; + source: SourceType[]; +} + +interface FilterSheetProps { + isOpen: boolean; + onClose: () => void; + options: FilterOptions; + onApplyFilters: (filters: FilterOptions) => void; + availableFilters: { + equipment: string[]; + tags: string[]; + source: SourceType[]; + }; +} + +function renderFilterSection( + title: string, + category: keyof FilterOptions, + values: T[], + selectedValues: T[], + onToggle: (category: keyof FilterOptions, value: T) => void +) { + return ( + + + + {title} + {selectedValues.length > 0 && ( + + {selectedValues.length} + + )} + + + + + {values.map(value => { + const isSelected = selectedValues.includes(value); + return ( + + ); + })} + + + + ); +} + +export function FilterSheet({ + isOpen, + onClose, + options, + onApplyFilters, + availableFilters +}: FilterSheetProps) { + const [localOptions, setLocalOptions] = React.useState(options); + + const toggleFilter = ( + category: keyof FilterOptions, + value: string | SourceType + ) => { + setLocalOptions(prev => ({ + ...prev, + [category]: prev[category].includes(value as any) + ? prev[category].filter(v => v !== value) + : [...prev[category], value as any] + })); + }; + + return ( + + + Filter Exercises + + + + + {renderFilterSection('Equipment', 'equipment', availableFilters.equipment, localOptions.equipment, toggleFilter)} + {renderFilterSection('Tags', 'tags', availableFilters.tags, localOptions.tags, toggleFilter)} + {renderFilterSection('Source', 'source', availableFilters.source, localOptions.source, toggleFilter)} + + + + + + + + + ); +} \ No newline at end of file diff --git a/components/library/NewExerciseSheet.tsx b/components/library/NewExerciseSheet.tsx new file mode 100644 index 0000000..d339ea3 --- /dev/null +++ b/components/library/NewExerciseSheet.tsx @@ -0,0 +1,190 @@ +// components/library/NewExerciseSheet.tsx +import React from 'react'; +import { View } 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 } 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] = React.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 + }, + format_units: { + weight: 'kg' as const, + reps: 'count' as const, + rpe: '0-10' as const, + set_type: 'warmup|normal|drop|failure' as const + } + }); + + const handleSubmit = () => { + if (!formData.title || !formData.equipment) return; + + // Cast to any as a temporary workaround for the TypeScript error + const exercise = { + // BaseExercise properties + title: formData.title, + type: formData.type, + category: formData.category, + equipment: formData.equipment, + description: formData.description, + tags: formData.tags, + format: formData.format, + format_units: formData.format_units, + // SyncableContent properties + id: generateId('local'), + created_at: Date.now(), + availability: { + source: ['local' as StorageSource] + } + } as BaseExercise; + + onSubmit(exercise); + onClose(); + + // 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' + } + }); + }; + + return ( + + + New Exercise + + + + + Exercise Name + setFormData(prev => ({ ...prev, title: text }))} + placeholder="e.g., Barbell Back Squat" + /> + + + + Type + + {EXERCISE_TYPES.map((type) => ( + + ))} + + + + + Category + + {CATEGORIES.map((category) => ( + + ))} + + + + + Equipment + + {EQUIPMENT_OPTIONS.map((eq) => ( + + ))} + + + + + Description + setFormData(prev => ({ ...prev, description: text }))} + placeholder="Exercise description..." + multiline + numberOfLines={4} + /> + + + + + + + ); +} \ No newline at end of file diff --git a/components/library/NewTemplateSheet.tsx b/components/library/NewTemplateSheet.tsx new file mode 100644 index 0000000..91d3046 --- /dev/null +++ b/components/library/NewTemplateSheet.tsx @@ -0,0 +1,146 @@ +// components/library/NewTemplateSheet.tsx +import React from 'react'; +import { View } 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 { TemplateType, TemplateCategory } from '@/types/library'; +import { cn } from '@/lib/utils'; + +interface NewTemplateSheetProps { + isOpen: boolean; + onClose: () => void; + onSubmit: (template: Template) => void; +} + +const WORKOUT_TYPES: TemplateType[] = ['strength', 'circuit', 'emom', 'amrap']; + +const CATEGORIES: TemplateCategory[] = [ + 'Full Body', + 'Upper/Lower', + 'Push/Pull/Legs', + 'Cardio', + 'CrossFit', + 'Strength', + 'Conditioning', + 'Custom' +]; + +export function NewTemplateSheet({ isOpen, onClose, onSubmit }: NewTemplateSheetProps) { + const [formData, setFormData] = React.useState({ + title: '', + type: '' as TemplateType, + category: '' as TemplateCategory, + description: '', + }); + + const handleSubmit = () => { + const template: Template = { + id: generateId(), + title: formData.title, + type: formData.type, + category: formData.category, + description: formData.description, + exercises: [], + tags: [], + source: 'local', + isFavorite: false, + created_at: Date.now(), + }; + + onSubmit(template); + onClose(); + setFormData({ + title: '', + type: '' as TemplateType, + category: '' as TemplateCategory, + description: '', + }); + }; + + return ( + + + New Template + + + + + Template Name + setFormData(prev => ({ ...prev, title: text }))} + placeholder="e.g., Full Body Strength" + /> + + + + Workout Type + + {WORKOUT_TYPES.map((type) => ( + + ))} + + + + + Category + + {CATEGORIES.map((category) => ( + + ))} + + + + + Description + setFormData(prev => ({ ...prev, description: text }))} + placeholder="Template description..." + multiline + numberOfLines={4} + /> + + + + + + + ); +} \ No newline at end of file diff --git a/components/library/SearchHeader.tsx b/components/library/SearchHeader.tsx new file mode 100644 index 0000000..29bb224 --- /dev/null +++ b/components/library/SearchHeader.tsx @@ -0,0 +1,47 @@ +// components/library/SearchHeader.tsx +import React from 'react'; +import { View } from 'react-native'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { SlidersHorizontal } from 'lucide-react-native'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { Badge } from '@/components/ui/badge'; +import { useTheme } from '@react-navigation/native'; + +interface SearchHeaderProps { + searchQuery: string; + onSearchChange: (query: string) => void; + activeFilters: number; + onOpenFilters: () => void; +} + +export function SearchHeader({ searchQuery, onSearchChange, activeFilters, onOpenFilters }: SearchHeaderProps) { + const { colors } = useTheme(); + return ( + + + + + {activeFilters > 0 && ( + + {activeFilters} + + )} + + + ); +} \ No newline at end of file diff --git a/components/pager/index.ts b/components/pager/index.ts new file mode 100644 index 0000000..7cd3703 --- /dev/null +++ b/components/pager/index.ts @@ -0,0 +1,14 @@ +// components/pager/index.ts +import { Platform } from 'react-native'; +import type { PagerProps, PagerRef } from './types'; + +let PagerComponent: React.ForwardRefExoticComponent>; + +if (Platform.OS === 'web') { + PagerComponent = require('./pager.web').default; +} else { + PagerComponent = require('./pager.native').default; +} + +export type { PagerProps, PagerRef, PageSelectedEvent } from './types'; +export default PagerComponent; \ No newline at end of file diff --git a/components/pager/pager.native.tsx b/components/pager/pager.native.tsx new file mode 100644 index 0000000..6c30444 --- /dev/null +++ b/components/pager/pager.native.tsx @@ -0,0 +1,8 @@ +// components/pager/pager.native.tsx +import React from 'react'; +import PagerView from 'react-native-pager-view'; +import type { PagerProps, PagerRef } from './types'; + +const NativePager: React.ForwardRefExoticComponent = PagerView as unknown as React.ForwardRefExoticComponent; + +export default NativePager; \ No newline at end of file diff --git a/components/pager/pager.web.tsx b/components/pager/pager.web.tsx new file mode 100644 index 0000000..e74488a --- /dev/null +++ b/components/pager/pager.web.tsx @@ -0,0 +1,70 @@ +// components/pager/pager.web.tsx +import React from 'react'; +import { ScrollView, StyleSheet, View, useWindowDimensions } from 'react-native'; +import type { PagerProps, PagerRef, PageSelectedEvent } from './types'; + +const Pager = React.forwardRef( + ({ children, onPageSelected, initialPage = 0, style }, ref) => { + const scrollRef = React.useRef(null); + const [currentPage, setCurrentPage] = React.useState(initialPage); + const { width } = useWindowDimensions(); + + React.useImperativeHandle(ref, () => ({ + setPage: (pageNumber: number) => { + const scrollView = scrollRef.current; + if (scrollView) { + const width = scrollView.getInnerViewNode?.()?.getBoundingClientRect?.()?.width ?? 0; + scrollView.scrollTo({ x: pageNumber * width, animated: true }); + } + }, + scrollTo: (options) => { + scrollRef.current?.scrollTo(options); + } + })); + + const handleScroll = (event: any) => { + const offsetX = event.nativeEvent.contentOffset.x; + const page = Math.round(offsetX / event.nativeEvent.layoutMeasurement.width); + + if (page !== currentPage) { + setCurrentPage(page); + onPageSelected?.({ + nativeEvent: { position: page } + } as PageSelectedEvent); + } + }; + + React.useEffect(() => { + if (initialPage > 0) { + scrollRef.current?.scrollTo({ + x: initialPage * width, + animated: false + }); + } + }, [initialPage, width]); + + return ( + + {React.Children.map(children, (child) => ( + {child} + ))} + + ); + } +); + +export default Pager; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, +}); \ No newline at end of file diff --git a/components/pager/types.ts b/components/pager/types.ts new file mode 100644 index 0000000..7cde2ec --- /dev/null +++ b/components/pager/types.ts @@ -0,0 +1,20 @@ +// components/pager/types.ts +import { StyleProp, ViewStyle } from 'react-native'; + +export interface PageSelectedEvent { + nativeEvent: { + position: number; + }; +} + +export interface PagerProps { + children: React.ReactNode[]; + style?: StyleProp; + initialPage?: number; + onPageSelected?: (e: PageSelectedEvent) => void; +} + +export interface PagerRef { + setPage: (page: number) => void; + scrollTo?: (options: { x: number; animated?: boolean }) => void; +} \ No newline at end of file diff --git a/components/shared/FloatingActionButton.tsx b/components/shared/FloatingActionButton.tsx new file mode 100644 index 0000000..960020c --- /dev/null +++ b/components/shared/FloatingActionButton.tsx @@ -0,0 +1,35 @@ +// components/shared/FloatingActionButton.tsx +import React from 'react'; +import { View, ViewStyle } from 'react-native'; +import { LucideIcon, Dumbbell } from 'lucide-react-native'; +import { Button } from '@/components/ui/button'; + +interface FloatingActionButtonProps { + icon?: LucideIcon; + onPress?: () => void; + className?: string; +} + +export function FloatingActionButton({ + icon: Icon = Dumbbell, + onPress, + className, + style +}: FloatingActionButtonProps & { style?: ViewStyle }) { + return ( + + + + ); +} \ No newline at end of file diff --git a/components/templates/TemplateCard.tsx b/components/templates/TemplateCard.tsx new file mode 100644 index 0000000..915c08e --- /dev/null +++ b/components/templates/TemplateCard.tsx @@ -0,0 +1,248 @@ +// components/templates/TemplateCard.tsx +import React from 'react'; +import { View, TouchableOpacity, Platform } from 'react-native'; +import { Text } from '@/components/ui/text'; +import { Card, CardContent } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Trash2, Star, Play } from 'lucide-react-native'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog'; +import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; +import { Template } from '@/types/library'; + +interface TemplateCardProps { + template: Template; + onPress: () => void; + onDelete: (id: string) => void; + onFavorite: () => void; + onStartWorkout: () => void; +} + +export function TemplateCard({ + template, + onPress, + onDelete, + onFavorite, + onStartWorkout +}: TemplateCardProps) { + const [showSheet, setShowSheet] = React.useState(false); + const [showDeleteAlert, setShowDeleteAlert] = React.useState(false); + + const { + id, + title, + type, + category, + exercises, + description, + tags = [], + source, + lastUsed, + isFavorite + } = template; + + const handleConfirmDelete = () => { + onDelete(id); + setShowDeleteAlert(false); + }; + + const handleCardPress = () => { + setShowSheet(true); + onPress(); + }; + + return ( + <> + + + + + + + + {title} + + + {source} + + + + + + {type} + + + {category} + + + + {exercises.length > 0 && ( + + + Exercises: + + + {exercises.slice(0, 3).map((exercise, index) => ( + + • {exercise.title} ({exercise.targetSets}×{exercise.targetReps}) + + ))} + {exercises.length > 3 && ( + + +{exercises.length - 3} more + + )} + + + )} + + {description && ( + + {description} + + )} + + {tags.length > 0 && ( + + {tags.map(tag => ( + + {tag} + + ))} + + )} + + {lastUsed && ( + + Last used: {lastUsed.toLocaleDateString()} + + )} + + + + + + + + + + + + + Delete Template + + + Are you sure you want to delete {title}? This action cannot be undone. + + + + + Cancel + + + Delete + + + + + + + + + + + {/* Sheet for detailed view */} + setShowSheet(false)}> + + + {title} + + + + + {description && ( + + Description + {description} + + )} + + Details + + Type: {type} + Category: {category} + Source: {source} + + + + Exercises + + {exercises.map((exercise, index) => ( + + {exercise.title} ({exercise.targetSets}×{exercise.targetReps}) + + ))} + + + {tags.length > 0 && ( + + Tags + + {tags.map(tag => ( + + {tag} + + ))} + + + )} + + + + + ); +} \ No newline at end of file diff --git a/components/ui/accordion.tsx b/components/ui/accordion.tsx new file mode 100644 index 0000000..89622df --- /dev/null +++ b/components/ui/accordion.tsx @@ -0,0 +1,125 @@ +import * as AccordionPrimitive from '@rn-primitives/accordion'; +import * as React from 'react'; +import { Platform, Pressable, View } from 'react-native'; +import Animated, { + Extrapolation, + FadeIn, + FadeOutUp, + LayoutAnimationConfig, + LinearTransition, + interpolate, + useAnimatedStyle, + useDerivedValue, + withTiming, +} from 'react-native-reanimated'; +import { ChevronDown } from '@/lib/icons/ChevronDown'; +import { cn } from '@/lib/utils'; +import { TextClassContext } from '@/components/ui/text'; + +const Accordion = React.forwardRef( + ({ children, ...props }, ref) => { + return ( + + + {children} + + + ); + } +); + +Accordion.displayName = AccordionPrimitive.Root.displayName; + +const AccordionItem = React.forwardRef( + ({ className, value, ...props }, ref) => { + return ( + + + + ); + } +); +AccordionItem.displayName = AccordionPrimitive.Item.displayName; + +const Trigger = Platform.OS === 'web' ? View : Pressable; + +const AccordionTrigger = React.forwardRef< + AccordionPrimitive.TriggerRef, + AccordionPrimitive.TriggerProps +>(({ className, children, ...props }, ref) => { + const { isExpanded } = AccordionPrimitive.useItemContext(); + + const progress = useDerivedValue(() => + isExpanded ? withTiming(1, { duration: 250 }) : withTiming(0, { duration: 200 }) + ); + const chevronStyle = useAnimatedStyle(() => ({ + transform: [{ rotate: `${progress.value * 180}deg` }], + opacity: interpolate(progress.value, [0, 1], [1, 0.8], Extrapolation.CLAMP), + })); + + return ( + + + + + <>{children} + + + + + + + + ); +}); +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; + +const AccordionContent = React.forwardRef< + AccordionPrimitive.ContentRef, + AccordionPrimitive.ContentProps +>(({ className, children, ...props }, ref) => { + const { isExpanded } = AccordionPrimitive.useItemContext(); + return ( + + + {children} + + + ); +}); + +function InnerContent({ children, className }: { children: React.ReactNode; className?: string }) { + if (Platform.OS === 'web') { + return {children}; + } + return ( + + {children} + + ); +} + +AccordionContent.displayName = AccordionPrimitive.Content.displayName; + +export { Accordion, AccordionContent, AccordionItem, AccordionTrigger }; diff --git a/components/ui/alert-dialog.tsx b/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..18c09d3 --- /dev/null +++ b/components/ui/alert-dialog.tsx @@ -0,0 +1,160 @@ +import * as AlertDialogPrimitive from '@rn-primitives/alert-dialog'; +import * as React from 'react'; +import { Platform, StyleSheet, View, type ViewProps } from 'react-native'; +import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; +import { buttonTextVariants, buttonVariants } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import { TextClassContext } from '@/components/ui/text'; + +const AlertDialog = AlertDialogPrimitive.Root; + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger; + +const AlertDialogPortal = AlertDialogPrimitive.Portal; + +const AlertDialogOverlayWeb = React.forwardRef< + AlertDialogPrimitive.OverlayRef, + AlertDialogPrimitive.OverlayProps +>(({ className, ...props }, ref) => { + const { open } = AlertDialogPrimitive.useRootContext(); + return ( + + ); +}); + +AlertDialogOverlayWeb.displayName = 'AlertDialogOverlayWeb'; + +const AlertDialogOverlayNative = React.forwardRef< + AlertDialogPrimitive.OverlayRef, + AlertDialogPrimitive.OverlayProps +>(({ className, children, ...props }, ref) => { + return ( + + + {children} + + + ); +}); + +AlertDialogOverlayNative.displayName = 'AlertDialogOverlayNative'; + +const AlertDialogOverlay = Platform.select({ + web: AlertDialogOverlayWeb, + default: AlertDialogOverlayNative, +}); + +const AlertDialogContent = React.forwardRef< + AlertDialogPrimitive.ContentRef, + AlertDialogPrimitive.ContentProps & { portalHost?: string } +>(({ className, portalHost, ...props }, ref) => { + const { open } = AlertDialogPrimitive.useRootContext(); + + return ( + + + + + + ); +}); +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; + +const AlertDialogHeader = ({ className, ...props }: ViewProps) => ( + +); +AlertDialogHeader.displayName = 'AlertDialogHeader'; + +const AlertDialogFooter = ({ className, ...props }: ViewProps) => ( + +); +AlertDialogFooter.displayName = 'AlertDialogFooter'; + +const AlertDialogTitle = React.forwardRef< + AlertDialogPrimitive.TitleRef, + AlertDialogPrimitive.TitleProps +>(({ className, ...props }, ref) => ( + +)); +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; + +const AlertDialogDescription = React.forwardRef< + AlertDialogPrimitive.DescriptionRef, + AlertDialogPrimitive.DescriptionProps +>(({ className, ...props }, ref) => ( + +)); +AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName; + +const AlertDialogAction = React.forwardRef< + AlertDialogPrimitive.ActionRef, + AlertDialogPrimitive.ActionProps +>(({ className, ...props }, ref) => ( + + + +)); +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; + +const AlertDialogCancel = React.forwardRef< + AlertDialogPrimitive.CancelRef, + AlertDialogPrimitive.CancelProps +>(({ className, ...props }, ref) => ( + + + +)); +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; + +export { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogOverlay, + AlertDialogPortal, + AlertDialogTitle, + AlertDialogTrigger, +}; diff --git a/components/ui/alert.tsx b/components/ui/alert.tsx new file mode 100644 index 0000000..155f6e8 --- /dev/null +++ b/components/ui/alert.tsx @@ -0,0 +1,75 @@ +import { useTheme } from '@react-navigation/native'; +import { cva, type VariantProps } from 'class-variance-authority'; +import type { LucideIcon } from 'lucide-react-native'; +import * as React from 'react'; +import { View, type ViewProps } from 'react-native'; +import { cn } from '@/lib/utils'; +import { Text } from '@/components/ui/text'; + +const alertVariants = cva( + 'relative bg-background w-full rounded-lg border border-border p-4 shadow shadow-foreground/10', + { + variants: { + variant: { + default: '', + destructive: 'border-destructive', + }, + }, + defaultVariants: { + variant: 'default', + }, + } +); + +const Alert = React.forwardRef< + React.ElementRef, + ViewProps & + VariantProps & { + icon: LucideIcon; + iconSize?: number; + iconClassName?: string; + } +>(({ className, variant, children, icon: Icon, iconSize = 16, iconClassName, ...props }, ref) => { + const { colors } = useTheme(); + return ( + + + + + {children} + + ); +}); +Alert.displayName = 'Alert'; + +const AlertTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertTitle.displayName = 'AlertTitle'; + +const AlertDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDescription.displayName = 'AlertDescription'; + +export { Alert, AlertDescription, AlertTitle }; diff --git a/components/ui/aspect-ratio.tsx b/components/ui/aspect-ratio.tsx new file mode 100644 index 0000000..d5f98c2 --- /dev/null +++ b/components/ui/aspect-ratio.tsx @@ -0,0 +1,5 @@ +import * as AspectRatioPrimitive from '@rn-primitives/aspect-ratio'; + +const AspectRatio = AspectRatioPrimitive.Root; + +export { AspectRatio }; diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx index b2343d7..98090d3 100644 --- a/components/ui/avatar.tsx +++ b/components/ui/avatar.tsx @@ -1,6 +1,6 @@ import * as AvatarPrimitive from '@rn-primitives/avatar'; import * as React from 'react'; -import { cn } from '~/lib/utils'; +import { cn } from '@/lib/utils'; const AvatarPrimitiveRoot = AvatarPrimitive.Root; const AvatarPrimitiveImage = AvatarPrimitive.Image; diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx new file mode 100644 index 0000000..fbb6a95 --- /dev/null +++ b/components/ui/badge.tsx @@ -0,0 +1,51 @@ +import * as Slot from '@rn-primitives/slot'; +import type { SlottableViewProps } from '@rn-primitives/types'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { View } from 'react-native'; +import { cn } from '@/lib/utils'; +import { TextClassContext } from '@/components/ui/text'; + +const badgeVariants = cva( + 'web:inline-flex items-center rounded-full border border-border px-2.5 py-0.5 web:transition-colors web:focus:outline-none web:focus:ring-2 web:focus:ring-ring web:focus:ring-offset-2', + { + variants: { + variant: { + default: 'border-transparent bg-primary web:hover:opacity-80 active:opacity-80', + secondary: 'border-transparent bg-secondary web:hover:opacity-80 active:opacity-80', + destructive: 'border-transparent bg-destructive web:hover:opacity-80 active:opacity-80', + outline: 'text-foreground', + }, + }, + defaultVariants: { + variant: 'default', + }, + } +); + +const badgeTextVariants = cva('text-xs font-semibold ', { + variants: { + variant: { + default: 'text-primary-foreground', + secondary: 'text-secondary-foreground', + destructive: 'text-destructive-foreground', + outline: 'text-foreground', + }, + }, + defaultVariants: { + variant: 'default', + }, +}); + +type BadgeProps = SlottableViewProps & VariantProps; + +function Badge({ className, variant, asChild, ...props }: BadgeProps) { + const Component = asChild ? Slot.View : View; + return ( + + + + ); +} + +export { Badge, badgeTextVariants, badgeVariants }; +export type { BadgeProps }; diff --git a/components/ui/button.tsx b/components/ui/button.tsx index 5e0e4a0..e4e0ac5 100644 --- a/components/ui/button.tsx +++ b/components/ui/button.tsx @@ -1,8 +1,16 @@ +// lib/constants.ts - First update the CUSTOM_COLORS +export const CUSTOM_COLORS = { + purple: '#8B5CF6', + purplePressed: '#7C3AED', // Slightly darker for pressed state + orange: '#F97316' +} as const; + +// components/ui/button.tsx import { cva, type VariantProps } from 'class-variance-authority'; import * as React from 'react'; import { Pressable } from 'react-native'; -import { TextClassContext } from '~/components/ui/text'; -import { cn } from '~/lib/utils'; +import { TextClassContext } from '@/components/ui/text'; +import { cn } from '@/lib/utils'; const buttonVariants = cva( 'group flex items-center justify-center rounded-md web:ring-offset-background web:transition-colors web:focus-visible:outline-none web:focus-visible:ring-2 web:focus-visible:ring-ring web:focus-visible:ring-offset-2', @@ -15,7 +23,8 @@ const buttonVariants = cva( 'border border-input bg-background web:hover:bg-accent web:hover:text-accent-foreground active:bg-accent', secondary: 'bg-secondary web:hover:opacity-80 active:opacity-80', ghost: 'web:hover:bg-accent web:hover:text-accent-foreground active:bg-accent', - link: 'web:underline-offset-4 web:hover:underline web:focus:underline ', + link: 'web:underline-offset-4 web:hover:underline web:focus:underline', + purple: 'bg-[#8B5CF6] web:hover:bg-[#7C3AED] active:bg-[#7C3AED]', // Added purple variant }, size: { default: 'h-10 px-4 py-2 native:h-12 native:px-5 native:py-3', @@ -42,6 +51,7 @@ const buttonTextVariants = cva( secondary: 'text-secondary-foreground group-active:text-secondary-foreground', ghost: 'group-active:text-accent-foreground', link: 'text-primary group-active:underline', + purple: 'text-white', // Added purple variant text color }, size: { default: '', @@ -57,6 +67,7 @@ const buttonTextVariants = cva( } ); +// Rest of the code remains the same type ButtonProps = React.ComponentPropsWithoutRef & VariantProps; @@ -85,4 +96,4 @@ const Button = React.forwardRef, ButtonProps> Button.displayName = 'Button'; export { Button, buttonTextVariants, buttonVariants }; -export type { ButtonProps }; +export type { ButtonProps }; \ No newline at end of file diff --git a/components/ui/card.tsx b/components/ui/card.tsx index 9f190c2..72f6abf 100644 --- a/components/ui/card.tsx +++ b/components/ui/card.tsx @@ -1,8 +1,8 @@ import type { TextRef, ViewRef } from '@rn-primitives/types'; import * as React from 'react'; import { Text, TextProps, View, ViewProps } from 'react-native'; -import { TextClassContext } from '~/components/ui/text'; -import { cn } from '~/lib/utils'; +import { TextClassContext } from '@/components/ui/text'; +import { cn } from '@/lib/utils'; const Card = React.forwardRef(({ className, ...props }, ref) => ( ( + ({ className, ...props }, ref) => { + return ( + + + + + + ); + } +); +Checkbox.displayName = CheckboxPrimitive.Root.displayName; + +export { Checkbox }; diff --git a/components/ui/collapsible.tsx b/components/ui/collapsible.tsx new file mode 100644 index 0000000..a2d66dd --- /dev/null +++ b/components/ui/collapsible.tsx @@ -0,0 +1,9 @@ +import * as CollapsiblePrimitive from '@rn-primitives/collapsible'; + +const Collapsible = CollapsiblePrimitive.Root; + +const CollapsibleTrigger = CollapsiblePrimitive.Trigger; + +const CollapsibleContent = CollapsiblePrimitive.Content; + +export { Collapsible, CollapsibleTrigger, CollapsibleContent }; diff --git a/components/ui/context-menu.tsx b/components/ui/context-menu.tsx new file mode 100644 index 0000000..2ea29bf --- /dev/null +++ b/components/ui/context-menu.tsx @@ -0,0 +1,245 @@ +import * as ContextMenuPrimitive from '@rn-primitives/context-menu'; +import * as React from 'react'; +import { + Platform, + type StyleProp, + StyleSheet, + Text, + type TextProps, + View, + type ViewStyle, +} from 'react-native'; +import { Check } from '@/lib/icons/Check'; +import { ChevronDown } from '@/lib/icons/ChevronDown'; +import { ChevronRight } from '@/lib/icons/ChevronRight'; +import { ChevronUp } from '@/lib/icons/ChevronUp'; +import { cn } from '@/lib/utils'; +import { TextClassContext } from '@/components/ui/text'; + +const ContextMenu = ContextMenuPrimitive.Root; +const ContextMenuTrigger = ContextMenuPrimitive.Trigger; +const ContextMenuGroup = ContextMenuPrimitive.Group; +const ContextMenuSub = ContextMenuPrimitive.Sub; +const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup; + +const ContextMenuSubTrigger = React.forwardRef< + ContextMenuPrimitive.SubTriggerRef, + ContextMenuPrimitive.SubTriggerProps & { + inset?: boolean; + } +>(({ className, inset, children, ...props }, ref) => { + const { open } = ContextMenuPrimitive.useSubContext(); + const Icon = Platform.OS === 'web' ? ChevronRight : open ? ChevronUp : ChevronDown; + return ( + + + <>{children} + + + + ); +}); +ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName; + +const ContextMenuSubContent = React.forwardRef< + ContextMenuPrimitive.SubContentRef, + ContextMenuPrimitive.SubContentProps +>(({ className, ...props }, ref) => { + const { open } = ContextMenuPrimitive.useSubContext(); + return ( + + ); +}); +ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName; + +const ContextMenuContent = React.forwardRef< + ContextMenuPrimitive.ContentRef, + ContextMenuPrimitive.ContentProps & { + overlayStyle?: StyleProp; + overlayClassName?: string; + portalHost?: string; + } +>(({ className, overlayClassName, overlayStyle, portalHost, ...props }, ref) => { + const { open } = ContextMenuPrimitive.useRootContext(); + return ( + + + + + + ); +}); +ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName; + +const ContextMenuItem = React.forwardRef< + ContextMenuPrimitive.ItemRef, + ContextMenuPrimitive.ItemProps & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + + + +)); +ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName; + +const ContextMenuCheckboxItem = React.forwardRef< + ContextMenuPrimitive.CheckboxItemRef, + ContextMenuPrimitive.CheckboxItemProps +>(({ className, children, ...props }, ref) => ( + + + + + + + <>{children} + +)); +ContextMenuCheckboxItem.displayName = ContextMenuPrimitive.CheckboxItem.displayName; + +const ContextMenuRadioItem = React.forwardRef< + ContextMenuPrimitive.RadioItemRef, + ContextMenuPrimitive.RadioItemProps +>(({ className, children, ...props }, ref) => ( + + + + + + + <>{children} + +)); +ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName; + +const ContextMenuLabel = React.forwardRef< + ContextMenuPrimitive.LabelRef, + ContextMenuPrimitive.LabelProps & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName; + +const ContextMenuSeparator = React.forwardRef< + ContextMenuPrimitive.SeparatorRef, + ContextMenuPrimitive.SeparatorProps +>(({ className, ...props }, ref) => ( + +)); +ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName; + +const ContextMenuShortcut = ({ className, ...props }: TextProps) => { + return ( + + ); +}; +ContextMenuShortcut.displayName = 'ContextMenuShortcut'; + +export { + ContextMenu, + ContextMenuCheckboxItem, + ContextMenuContent, + ContextMenuGroup, + ContextMenuItem, + ContextMenuLabel, + ContextMenuRadioGroup, + ContextMenuRadioItem, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuTrigger, +}; diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx new file mode 100644 index 0000000..1937352 --- /dev/null +++ b/components/ui/dialog.tsx @@ -0,0 +1,147 @@ +import * as DialogPrimitive from '@rn-primitives/dialog'; +import * as React from 'react'; +import { Platform, StyleSheet, View, type ViewProps } from 'react-native'; +import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; +import { X } from '@/lib/icons/X'; +import { cn } from '@/lib/utils'; + +const Dialog = DialogPrimitive.Root; + +const DialogTrigger = DialogPrimitive.Trigger; + +const DialogPortal = DialogPrimitive.Portal; + +const DialogClose = DialogPrimitive.Close; + +const DialogOverlayWeb = React.forwardRef( + ({ className, ...props }, ref) => { + const { open } = DialogPrimitive.useRootContext(); + return ( + + ); + } +); + +DialogOverlayWeb.displayName = 'DialogOverlayWeb'; + +const DialogOverlayNative = React.forwardRef< + DialogPrimitive.OverlayRef, + DialogPrimitive.OverlayProps +>(({ className, children, ...props }, ref) => { + return ( + + + <>{children} + + + ); +}); + +DialogOverlayNative.displayName = 'DialogOverlayNative'; + +const DialogOverlay = Platform.select({ + web: DialogOverlayWeb, + default: DialogOverlayNative, +}); + +const DialogContent = React.forwardRef< + DialogPrimitive.ContentRef, + DialogPrimitive.ContentProps & { portalHost?: string } +>(({ className, children, portalHost, ...props }, ref) => { + const { open } = DialogPrimitive.useRootContext(); + return ( + + + + {children} + + + + + + + ); +}); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ className, ...props }: ViewProps) => ( + +); +DialogHeader.displayName = 'DialogHeader'; + +const DialogFooter = ({ className, ...props }: ViewProps) => ( + +); +DialogFooter.displayName = 'DialogFooter'; + +const DialogTitle = React.forwardRef( + ({ className, ...props }, ref) => ( + + ) +); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + DialogPrimitive.DescriptionRef, + DialogPrimitive.DescriptionProps +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +}; diff --git a/components/ui/dropdown-menu.tsx b/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..e9d98a6 --- /dev/null +++ b/components/ui/dropdown-menu.tsx @@ -0,0 +1,253 @@ +import * as DropdownMenuPrimitive from '@rn-primitives/dropdown-menu'; +import * as React from 'react'; +import { + Platform, + type StyleProp, + StyleSheet, + Text, + type TextProps, + View, + type ViewStyle, +} from 'react-native'; +import { Check } from '@/lib/icons/Check'; +import { ChevronDown } from '@/lib/icons/ChevronDown'; +import { ChevronRight } from '@/lib/icons/ChevronRight'; +import { ChevronUp } from '@/lib/icons/ChevronUp'; +import { cn } from '@/lib/utils'; +import { TextClassContext } from '@/components/ui/text'; + +const DropdownMenu = DropdownMenuPrimitive.Root; + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; + +const DropdownMenuGroup = DropdownMenuPrimitive.Group; + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal; + +const DropdownMenuSub = DropdownMenuPrimitive.Sub; + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; + +const DropdownMenuSubTrigger = React.forwardRef< + DropdownMenuPrimitive.SubTriggerRef, + DropdownMenuPrimitive.SubTriggerProps & { + inset?: boolean; + } +>(({ className, inset, children, ...props }, ref) => { + const { open } = DropdownMenuPrimitive.useSubContext(); + const Icon = Platform.OS === 'web' ? ChevronRight : open ? ChevronUp : ChevronDown; + return ( + + + <>{children} + + + + ); +}); +DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName; + +const DropdownMenuSubContent = React.forwardRef< + DropdownMenuPrimitive.SubContentRef, + DropdownMenuPrimitive.SubContentProps +>(({ className, ...props }, ref) => { + const { open } = DropdownMenuPrimitive.useSubContext(); + return ( + + ); +}); +DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName; + +const DropdownMenuContent = React.forwardRef< + DropdownMenuPrimitive.ContentRef, + DropdownMenuPrimitive.ContentProps & { + overlayStyle?: StyleProp; + overlayClassName?: string; + portalHost?: string; + } +>(({ className, overlayClassName, overlayStyle, portalHost, ...props }, ref) => { + const { open } = DropdownMenuPrimitive.useRootContext(); + return ( + + + + + + ); +}); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +const DropdownMenuItem = React.forwardRef< + DropdownMenuPrimitive.ItemRef, + DropdownMenuPrimitive.ItemProps & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + + + +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +const DropdownMenuCheckboxItem = React.forwardRef< + DropdownMenuPrimitive.CheckboxItemRef, + DropdownMenuPrimitive.CheckboxItemProps +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + <>{children} + +)); +DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName; + +const DropdownMenuRadioItem = React.forwardRef< + DropdownMenuPrimitive.RadioItemRef, + DropdownMenuPrimitive.RadioItemProps +>(({ className, children, ...props }, ref) => ( + + + + + + + <>{children} + +)); +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; + +const DropdownMenuLabel = React.forwardRef< + DropdownMenuPrimitive.LabelRef, + DropdownMenuPrimitive.LabelProps & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; + +const DropdownMenuSeparator = React.forwardRef< + DropdownMenuPrimitive.SeparatorRef, + DropdownMenuPrimitive.SeparatorProps +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; + +const DropdownMenuShortcut = ({ className, ...props }: TextProps) => { + return ( + + ); +}; +DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'; + +export { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuPortal, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +}; diff --git a/components/ui/hover-card.tsx b/components/ui/hover-card.tsx new file mode 100644 index 0000000..3ab52c3 --- /dev/null +++ b/components/ui/hover-card.tsx @@ -0,0 +1,45 @@ +import * as HoverCardPrimitive from '@rn-primitives/hover-card'; +import * as React from 'react'; +import { Platform, StyleSheet } from 'react-native'; +import Animated, { FadeIn } from 'react-native-reanimated'; +import { cn } from '@/lib/utils'; +import { TextClassContext } from '@/components/ui/text'; + +const HoverCard = HoverCardPrimitive.Root; + +const HoverCardTrigger = HoverCardPrimitive.Trigger; + +const HoverCardContent = React.forwardRef< + HoverCardPrimitive.ContentRef, + HoverCardPrimitive.ContentProps +>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => { + const { open } = HoverCardPrimitive.useRootContext(); + return ( + + + + + + + + + + ); +}); +HoverCardContent.displayName = HoverCardPrimitive.Content.displayName; + +export { HoverCard, HoverCardContent, HoverCardTrigger }; diff --git a/components/ui/input.tsx b/components/ui/input.tsx new file mode 100644 index 0000000..835d37e --- /dev/null +++ b/components/ui/input.tsx @@ -0,0 +1,40 @@ +import * as React from 'react'; +import { TextInput, type TextInputProps } from 'react-native'; +import { cn } from '@/lib/utils'; + +const Input = React.forwardRef, TextInputProps>( + ({ className, placeholderClassName, ...props }, ref) => { + return ( + + ); + } +); + +Input.displayName = 'Input'; + +export { Input }; \ No newline at end of file diff --git a/components/ui/label.tsx b/components/ui/label.tsx new file mode 100644 index 0000000..1655e47 --- /dev/null +++ b/components/ui/label.tsx @@ -0,0 +1,27 @@ +import * as LabelPrimitive from '@rn-primitives/label'; +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +const Label = React.forwardRef( + ({ className, onPress, onLongPress, onPressIn, onPressOut, ...props }, ref) => ( + + + + ) +); +Label.displayName = LabelPrimitive.Root.displayName; + +export { Label }; diff --git a/components/ui/menubar.tsx b/components/ui/menubar.tsx new file mode 100644 index 0000000..a8b539b --- /dev/null +++ b/components/ui/menubar.tsx @@ -0,0 +1,261 @@ +import * as MenubarPrimitive from '@rn-primitives/menubar'; +import * as React from 'react'; +import { Platform, Text, type TextProps, View } from 'react-native'; +import { Check } from '@/lib/icons/Check'; +import { ChevronDown } from '@/lib/icons/ChevronDown'; +import { ChevronRight } from '@/lib/icons/ChevronRight'; +import { ChevronUp } from '@/lib/icons/ChevronUp'; +import { cn } from '@/lib/utils'; +import { TextClassContext } from '@/components/ui/text'; + +const MenubarMenu = MenubarPrimitive.Menu; + +const MenubarGroup = MenubarPrimitive.Group; + +const MenubarPortal = MenubarPrimitive.Portal; + +const MenubarSub = MenubarPrimitive.Sub; + +const MenubarRadioGroup = MenubarPrimitive.RadioGroup; + +const Menubar = React.forwardRef( + ({ className, ...props }, ref) => ( + + ) +); +Menubar.displayName = MenubarPrimitive.Root.displayName; + +const MenubarTrigger = React.forwardRef( + ({ className, ...props }, ref) => { + const { value } = MenubarPrimitive.useRootContext(); + const { value: itemValue } = MenubarPrimitive.useMenuContext(); + + return ( + + ); + } +); +MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName; + +const MenubarSubTrigger = React.forwardRef< + MenubarPrimitive.SubTriggerRef, + MenubarPrimitive.SubTriggerProps & { + inset?: boolean; + } +>(({ className, inset, children, ...props }, ref) => { + const { open } = MenubarPrimitive.useSubContext(); + const Icon = Platform.OS === 'web' ? ChevronRight : open ? ChevronUp : ChevronDown; + return ( + + + <>{children} + + + + ); +}); +MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName; + +const MenubarSubContent = React.forwardRef< + MenubarPrimitive.SubContentRef, + MenubarPrimitive.SubContentProps +>(({ className, ...props }, ref) => { + const { open } = MenubarPrimitive.useSubContext(); + return ( + + ); +}); +MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName; + +const MenubarContent = React.forwardRef< + MenubarPrimitive.ContentRef, + MenubarPrimitive.ContentProps & { portalHost?: string } +>(({ className, portalHost, ...props }, ref) => { + const { value } = MenubarPrimitive.useRootContext(); + const { value: itemValue } = MenubarPrimitive.useMenuContext(); + return ( + + + + ); +}); +MenubarContent.displayName = MenubarPrimitive.Content.displayName; + +const MenubarItem = React.forwardRef< + MenubarPrimitive.ItemRef, + MenubarPrimitive.ItemProps & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + + + +)); +MenubarItem.displayName = MenubarPrimitive.Item.displayName; + +const MenubarCheckboxItem = React.forwardRef< + MenubarPrimitive.CheckboxItemRef, + MenubarPrimitive.CheckboxItemProps +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + <>{children} + +)); +MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName; + +const MenubarRadioItem = React.forwardRef< + MenubarPrimitive.RadioItemRef, + MenubarPrimitive.RadioItemProps +>(({ className, children, ...props }, ref) => ( + + + + + + + <>{children} + +)); +MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName; + +const MenubarLabel = React.forwardRef< + MenubarPrimitive.LabelRef, + MenubarPrimitive.LabelProps & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +MenubarLabel.displayName = MenubarPrimitive.Label.displayName; + +const MenubarSeparator = React.forwardRef< + MenubarPrimitive.SeparatorRef, + MenubarPrimitive.SeparatorProps +>(({ className, ...props }, ref) => ( + +)); +MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName; + +const MenubarShortcut = ({ className, ...props }: TextProps) => { + return ( + + ); +}; +MenubarShortcut.displayName = 'MenubarShortcut'; + +export { + Menubar, + MenubarCheckboxItem, + MenubarContent, + MenubarGroup, + MenubarItem, + MenubarLabel, + MenubarMenu, + MenubarPortal, + MenubarRadioGroup, + MenubarRadioItem, + MenubarSeparator, + MenubarShortcut, + MenubarSub, + MenubarSubContent, + MenubarSubTrigger, + MenubarTrigger, +}; diff --git a/components/ui/navigation-menu.tsx b/components/ui/navigation-menu.tsx new file mode 100644 index 0000000..8afd151 --- /dev/null +++ b/components/ui/navigation-menu.tsx @@ -0,0 +1,181 @@ +import * as NavigationMenuPrimitive from '@rn-primitives/navigation-menu'; +import { cva } from 'class-variance-authority'; +import * as React from 'react'; +import { Platform, View } from 'react-native'; +import Animated, { + Extrapolation, + FadeInLeft, + FadeOutLeft, + interpolate, + useAnimatedStyle, + useDerivedValue, + withTiming, +} from 'react-native-reanimated'; +import { ChevronDown } from '@/lib/icons/ChevronDown'; +import { cn } from '@/lib/utils'; + +const NavigationMenu = React.forwardRef< + NavigationMenuPrimitive.RootRef, + NavigationMenuPrimitive.RootProps +>(({ className, children, ...props }, ref) => ( + + {children} + {Platform.OS === 'web' && } + +)); +NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName; + +const NavigationMenuList = React.forwardRef< + NavigationMenuPrimitive.ListRef, + NavigationMenuPrimitive.ListProps +>(({ className, ...props }, ref) => ( + +)); +NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName; + +const NavigationMenuItem = NavigationMenuPrimitive.Item; + +const navigationMenuTriggerStyle = cva( + 'web:group web:inline-flex flex-row h-10 native:h-12 native:px-3 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium web:transition-colors web:hover:bg-accent active:bg-accent web:hover:text-accent-foreground web:focus:bg-accent web:focus:text-accent-foreground web:focus:outline-none web:disabled:pointer-events-none disabled:opacity-50 web:data-[active]:bg-accent/50 web:data-[state=open]:bg-accent/50' +); + +const NavigationMenuTrigger = React.forwardRef< + NavigationMenuPrimitive.TriggerRef, + NavigationMenuPrimitive.TriggerProps +>(({ className, children, ...props }, ref) => { + const { value } = NavigationMenuPrimitive.useRootContext(); + const { value: itemValue } = NavigationMenuPrimitive.useItemContext(); + + const progress = useDerivedValue(() => + value === itemValue ? withTiming(1, { duration: 250 }) : withTiming(0, { duration: 200 }) + ); + const chevronStyle = useAnimatedStyle(() => ({ + transform: [{ rotate: `${progress.value * 180}deg` }], + opacity: interpolate(progress.value, [0, 1], [1, 0.8], Extrapolation.CLAMP), + })); + + return ( + + <>{children} + + + + + ); +}); +NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName; + +const NavigationMenuContent = React.forwardRef< + NavigationMenuPrimitive.ContentRef, + NavigationMenuPrimitive.ContentProps & { + portalHost?: string; + } +>(({ className, children, portalHost, ...props }, ref) => { + const { value } = NavigationMenuPrimitive.useRootContext(); + const { value: itemValue } = NavigationMenuPrimitive.useItemContext(); + return ( + + + + {children} + + + + ); +}); +NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName; + +const NavigationMenuLink = NavigationMenuPrimitive.Link; + +const NavigationMenuViewport = React.forwardRef< + NavigationMenuPrimitive.ViewportRef, + NavigationMenuPrimitive.ViewportProps +>(({ className, ...props }, ref) => { + return ( + + + + + + ); +}); +NavigationMenuViewport.displayName = NavigationMenuPrimitive.Viewport.displayName; + +const NavigationMenuIndicator = React.forwardRef< + NavigationMenuPrimitive.IndicatorRef, + NavigationMenuPrimitive.IndicatorProps +>(({ className, ...props }, ref) => { + const { value } = NavigationMenuPrimitive.useRootContext(); + const { value: itemValue } = NavigationMenuPrimitive.useItemContext(); + + return ( + + + + ); +}); +NavigationMenuIndicator.displayName = NavigationMenuPrimitive.Indicator.displayName; + +export { + NavigationMenu, + NavigationMenuContent, + NavigationMenuIndicator, + NavigationMenuItem, + NavigationMenuLink, + NavigationMenuList, + NavigationMenuTrigger, + navigationMenuTriggerStyle, + NavigationMenuViewport, +}; diff --git a/components/ui/popover.tsx b/components/ui/popover.tsx new file mode 100644 index 0000000..fced000 --- /dev/null +++ b/components/ui/popover.tsx @@ -0,0 +1,39 @@ +import * as PopoverPrimitive from '@rn-primitives/popover'; +import * as React from 'react'; +import { Platform, StyleSheet } from 'react-native'; +import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; +import { cn } from '@/lib/utils'; +import { TextClassContext } from '@/components/ui/text'; + +const Popover = PopoverPrimitive.Root; + +const PopoverTrigger = PopoverPrimitive.Trigger; + +const PopoverContent = React.forwardRef< + PopoverPrimitive.ContentRef, + PopoverPrimitive.ContentProps & { portalHost?: string } +>(({ className, align = 'center', sideOffset = 4, portalHost, ...props }, ref) => { + return ( + + + + + + + + + + ); +}); +PopoverContent.displayName = PopoverPrimitive.Content.displayName; + +export { Popover, PopoverContent, PopoverTrigger }; diff --git a/components/ui/progress.tsx b/components/ui/progress.tsx index f73bf40..cd53b7d 100644 --- a/components/ui/progress.tsx +++ b/components/ui/progress.tsx @@ -8,7 +8,7 @@ import Animated, { useDerivedValue, withSpring, } from 'react-native-reanimated'; -import { cn } from '~/lib/utils'; +import { cn } from '@/lib/utils'; const Progress = React.forwardRef< ProgressPrimitive.RootRef, diff --git a/components/ui/radio-group.tsx b/components/ui/radio-group.tsx new file mode 100644 index 0000000..575abac --- /dev/null +++ b/components/ui/radio-group.tsx @@ -0,0 +1,36 @@ +import * as RadioGroupPrimitive from '@rn-primitives/radio-group'; +import * as React from 'react'; +import { View } from 'react-native'; +import { cn } from '@/lib/utils'; + +const RadioGroup = React.forwardRef( + ({ className, ...props }, ref) => { + return ( + + ); + } +); +RadioGroup.displayName = RadioGroupPrimitive.Root.displayName; + +const RadioGroupItem = React.forwardRef( + ({ className, ...props }, ref) => { + return ( + + + + + + ); + } +); +RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName; + +export { RadioGroup, RadioGroupItem }; diff --git a/components/ui/select.tsx b/components/ui/select.tsx new file mode 100644 index 0000000..4329e82 --- /dev/null +++ b/components/ui/select.tsx @@ -0,0 +1,173 @@ +import * as SelectPrimitive from '@rn-primitives/select'; +import * as React from 'react'; +import { Platform, StyleSheet, View } from 'react-native'; +import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; +import { Check } from '@/lib/icons/Check'; +import { ChevronDown } from '@/lib/icons/ChevronDown'; +import { ChevronUp } from '@/lib/icons/ChevronUp'; +import { cn } from '@/lib/utils'; + +type Option = SelectPrimitive.Option; + +const Select = SelectPrimitive.Root; + +const SelectGroup = SelectPrimitive.Group; + +const SelectValue = SelectPrimitive.Value; + +const SelectTrigger = React.forwardRef( + ({ className, children, ...props }, ref) => ( + span]:line-clamp-1', + props.disabled && 'web:cursor-not-allowed opacity-50', + className + )} + {...props} + > + <>{children} + + + ) +); +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; + +/** + * Platform: WEB ONLY + */ +const SelectScrollUpButton = ({ className, ...props }: SelectPrimitive.ScrollUpButtonProps) => { + if (Platform.OS !== 'web') { + return null; + } + return ( + + + + ); +}; + +/** + * Platform: WEB ONLY + */ +const SelectScrollDownButton = ({ className, ...props }: SelectPrimitive.ScrollDownButtonProps) => { + if (Platform.OS !== 'web') { + return null; + } + return ( + + + + ); +}; + +const SelectContent = React.forwardRef< + SelectPrimitive.ContentRef, + SelectPrimitive.ContentProps & { portalHost?: string } +>(({ className, children, position = 'popper', portalHost, ...props }, ref) => { + const { open } = SelectPrimitive.useRootContext(); + + return ( + + + + + + + {children} + + + + + + + ); +}); +SelectContent.displayName = SelectPrimitive.Content.displayName; + +const SelectLabel = React.forwardRef( + ({ className, ...props }, ref) => ( + + ) +); +SelectLabel.displayName = SelectPrimitive.Label.displayName; + +const SelectItem = React.forwardRef( + ({ className, children, ...props }, ref) => ( + + + + + + + + + ) +); +SelectItem.displayName = SelectPrimitive.Item.displayName; + +const SelectSeparator = React.forwardRef< + SelectPrimitive.SeparatorRef, + SelectPrimitive.SeparatorProps +>(({ className, ...props }, ref) => ( + +)); +SelectSeparator.displayName = SelectPrimitive.Separator.displayName; + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, + type Option, +}; diff --git a/components/ui/separator.tsx b/components/ui/separator.tsx new file mode 100644 index 0000000..20aba57 --- /dev/null +++ b/components/ui/separator.tsx @@ -0,0 +1,22 @@ +import * as SeparatorPrimitive from '@rn-primitives/separator'; +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +const Separator = React.forwardRef( + ({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => ( + + ) +); +Separator.displayName = SeparatorPrimitive.Root.displayName; + +export { Separator }; diff --git a/components/ui/sheet/CloseButton.tsx b/components/ui/sheet/CloseButton.tsx new file mode 100644 index 0000000..069c270 --- /dev/null +++ b/components/ui/sheet/CloseButton.tsx @@ -0,0 +1,46 @@ +// components/ui/sheet/CloseButton.tsx +import React from 'react'; +import { TouchableOpacity, View, StyleSheet } from 'react-native'; +import { X } from 'lucide-react-native'; +import { useColorScheme } from '@/lib/useColorScheme'; +import { NAV_THEME } from '@/lib/constants'; + +interface CloseButtonProps { + onPress: () => void; +} + +export function CloseButton({ onPress }: CloseButtonProps) { + const { isDarkColorScheme } = useColorScheme(); + const theme = isDarkColorScheme ? NAV_THEME.dark : NAV_THEME.light; + + return ( + + + + + + ); +} + +const styles = StyleSheet.create({ + button: { + minWidth: 40, + minHeight: 40, + shadowColor: "#000", + shadowOffset: { + width: 0, + height: 1, + }, + shadowOpacity: 0.2, + shadowRadius: 1.41, + elevation: 2, + }, +}); \ No newline at end of file diff --git a/components/ui/sheet/Sheet.native.tsx b/components/ui/sheet/Sheet.native.tsx new file mode 100644 index 0000000..3356b6e --- /dev/null +++ b/components/ui/sheet/Sheet.native.tsx @@ -0,0 +1,142 @@ +// components/ui/Sheet.native.tsx +import React from 'react'; +import { + Modal, + View, + TouchableOpacity, + Platform, + Dimensions, + ScrollView, + StyleSheet, + Animated, + BackHandler +} from 'react-native'; +import { Text } from '../text'; +import { CloseButton } from './CloseButton'; +import type { SheetProps, SheetContentProps, SheetHeaderProps, SheetTitleProps } from './Sheet.types'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +const { height: SCREEN_HEIGHT } = Dimensions.get('window'); +const SHEET_HEIGHT = SCREEN_HEIGHT * 0.7; + +export function Sheet({ isOpen, onClose, children }: SheetProps) { + const translateY = React.useRef(new Animated.Value(SCREEN_HEIGHT)).current; + const [isVisible, setIsVisible] = React.useState(false); + + // Handle back button on Android + React.useEffect(() => { + const backHandler = BackHandler.addEventListener('hardwareBackPress', () => { + if (isOpen) { + onClose(); + return true; + } + return false; + }); + + return () => backHandler.remove(); + }, [isOpen, onClose]); + + React.useEffect(() => { + if (isOpen) { + setIsVisible(true); + Animated.spring(translateY, { + toValue: SCREEN_HEIGHT - SHEET_HEIGHT, + useNativeDriver: true, + damping: 25, + mass: 0.7, + stiffness: 300, + }).start(); + } else { + Animated.timing(translateY, { + toValue: SCREEN_HEIGHT, + duration: 200, + useNativeDriver: true, + }).start(() => { + setIsVisible(false); + }); + } + }, [isOpen]); + + if (!isVisible && !isOpen) return null; + + return ( + + + + + {/* Handle indicator */} + + + + + + + {children} + + + + ); +} + +export function SheetHeader({ children }: SheetHeaderProps) { + return ( + + {children} + + ); +} + +export function SheetTitle({ children }: SheetTitleProps) { + return {children}; +} + +export function SheetContent({ children }: SheetContentProps) { + const insets = useSafeAreaInsets(); + + return ( + + {children} + + ); +} + +const styles = StyleSheet.create({ + backdrop: { + backgroundColor: 'rgba(0,0,0,0.25)', + }, + sheetContainer: { + height: SHEET_HEIGHT, + shadowColor: "#000", + shadowOffset: { + width: 0, + height: -2, + }, + shadowOpacity: 0.25, + shadowRadius: 3.84, + elevation: 5, + }, +}); \ No newline at end of file diff --git a/components/ui/sheet/Sheet.tsx b/components/ui/sheet/Sheet.tsx new file mode 100644 index 0000000..352c21a --- /dev/null +++ b/components/ui/sheet/Sheet.tsx @@ -0,0 +1,4 @@ +// components/ui/sheet.ts +export * from './Sheet.native'; + +// The above line will automatically be replaced with sheet.web.tsx on web platform \ No newline at end of file diff --git a/components/ui/sheet/Sheet.types.ts b/components/ui/sheet/Sheet.types.ts new file mode 100644 index 0000000..6452bd9 --- /dev/null +++ b/components/ui/sheet/Sheet.types.ts @@ -0,0 +1,20 @@ +// components/ui/Sheet/types.ts +import { ReactNode } from 'react'; + +export interface SheetProps { + isOpen: boolean; + onClose: () => void; + children: ReactNode; +} + +export interface SheetContentProps { + children: ReactNode; +} + +export interface SheetHeaderProps { + children: ReactNode; +} + +export interface SheetTitleProps { + children: ReactNode; +} \ No newline at end of file diff --git a/components/ui/sheet/Sheet.web.tsx b/components/ui/sheet/Sheet.web.tsx new file mode 100644 index 0000000..6666300 --- /dev/null +++ b/components/ui/sheet/Sheet.web.tsx @@ -0,0 +1,66 @@ +// components/ui/Sheet.web.tsx +import React from 'react'; +import { + View, + TouchableOpacity, + StyleSheet, + Modal as RNModal +} from 'react-native'; +import { CloseButton } from './CloseButton'; +import type { SheetProps } from './sheet.types'; + +// Re-export components +export { SheetContent, SheetHeader, SheetTitle } from './Sheet.native'; + +export function Sheet({ isOpen, onClose, children }: SheetProps) { + if (!isOpen) return null; + + return ( + + + + + {/* Handle indicator */} + + + + + + + {children} + + + + ); +} + +const styles = StyleSheet.create({ + backdrop: { + backgroundColor: 'rgba(0,0,0,0.25)', + }, + sheetContainer: { + height: '70%', + shadowColor: "#000", + shadowOffset: { + width: 0, + height: -2, + }, + shadowOpacity: 0.1, + shadowRadius: 10, + }, +}); \ No newline at end of file diff --git a/components/ui/sheet/index.ts b/components/ui/sheet/index.ts new file mode 100644 index 0000000..9495028 --- /dev/null +++ b/components/ui/sheet/index.ts @@ -0,0 +1,6 @@ +// components/ui/sheet/index.ts +export { Sheet } from './Sheet.native'; +export { SheetHeader } from './Sheet.native'; +export { SheetTitle } from './Sheet.native'; +export { SheetContent } from './Sheet.native'; +export type { SheetProps, SheetContentProps, SheetHeaderProps, SheetTitleProps } from './sheet.types'; \ No newline at end of file diff --git a/components/ui/skeleton.tsx b/components/ui/skeleton.tsx new file mode 100644 index 0000000..e42d5ab --- /dev/null +++ b/components/ui/skeleton.tsx @@ -0,0 +1,39 @@ +import * as React from 'react'; +import Animated, { + useAnimatedStyle, + useSharedValue, + withRepeat, + withSequence, + withTiming, +} from 'react-native-reanimated'; +import { cn } from '@/lib/utils'; + +const duration = 1000; + +function Skeleton({ + className, + ...props +}: Omit, 'style'>) { + const sv = useSharedValue(1); + + React.useEffect(() => { + sv.value = withRepeat( + withSequence(withTiming(0.5, { duration }), withTiming(1, { duration })), + -1 + ); + }, []); + + const style = useAnimatedStyle(() => ({ + opacity: sv.value, + })); + + return ( + + ); +} + +export { Skeleton }; diff --git a/components/ui/switch.tsx b/components/ui/switch.tsx new file mode 100644 index 0000000..27237c0 --- /dev/null +++ b/components/ui/switch.tsx @@ -0,0 +1,95 @@ +import * as SwitchPrimitives from '@rn-primitives/switch'; +import * as React from 'react'; +import { Platform } from 'react-native'; +import Animated, { + interpolateColor, + useAnimatedStyle, + useDerivedValue, + withTiming, +} from 'react-native-reanimated'; +import { useColorScheme } from '@/lib/useColorScheme'; +import { cn } from '@/lib/utils'; + +const SwitchWeb = React.forwardRef( + ({ className, ...props }, ref) => ( + + + + ) +); + +SwitchWeb.displayName = 'SwitchWeb'; + +const RGB_COLORS = { + light: { + primary: 'rgb(24, 24, 27)', + input: 'rgb(228, 228, 231)', + }, + dark: { + primary: 'rgb(250, 250, 250)', + input: 'rgb(39, 39, 42)', + }, +} as const; + +const SwitchNative = React.forwardRef( + ({ className, ...props }, ref) => { + const { colorScheme } = useColorScheme(); + const translateX = useDerivedValue(() => (props.checked ? 18 : 0)); + const animatedRootStyle = useAnimatedStyle(() => { + return { + backgroundColor: interpolateColor( + translateX.value, + [0, 18], + [RGB_COLORS[colorScheme].input, RGB_COLORS[colorScheme].primary] + ), + }; + }); + const animatedThumbStyle = useAnimatedStyle(() => ({ + transform: [{ translateX: withTiming(translateX.value, { duration: 200 }) }], + })); + return ( + + + + + + + + ); + } +); +SwitchNative.displayName = 'SwitchNative'; + +const Switch = Platform.select({ + web: SwitchWeb, + default: SwitchNative, +}); + +export { Switch }; diff --git a/components/ui/table.tsx b/components/ui/table.tsx new file mode 100644 index 0000000..2be7439 --- /dev/null +++ b/components/ui/table.tsx @@ -0,0 +1,92 @@ +import * as TablePrimitive from '@rn-primitives/table'; +import * as React from 'react'; +import { cn } from '@/lib/utils'; +import { TextClassContext } from '@/components/ui/text'; + +const Table = React.forwardRef( + ({ className, ...props }, ref) => ( + + ) +); +Table.displayName = 'Table'; + +const TableHeader = React.forwardRef( + ({ className, ...props }, ref) => ( + + ) +); +TableHeader.displayName = 'TableHeader'; + +const TableBody = React.forwardRef( + ({ className, style, ...props }, ref) => ( + + ) +); +TableBody.displayName = 'TableBody'; + +const TableFooter = React.forwardRef( + ({ className, ...props }, ref) => ( + tr]:last:border-b-0', className)} + {...props} + /> + ) +); +TableFooter.displayName = 'TableFooter'; + +const TableRow = React.forwardRef( + ({ className, ...props }, ref) => ( + + ) +); +TableRow.displayName = 'TableRow'; + +const TableHead = React.forwardRef( + ({ className, ...props }, ref) => ( + + + + ) +); +TableHead.displayName = 'TableHead'; + +const TableCell = React.forwardRef( + ({ className, ...props }, ref) => ( + + ) +); +TableCell.displayName = 'TableCell'; + +export { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow }; diff --git a/components/ui/tabs.tsx b/components/ui/tabs.tsx new file mode 100644 index 0000000..0dadfe6 --- /dev/null +++ b/components/ui/tabs.tsx @@ -0,0 +1,62 @@ +import * as TabsPrimitive from '@rn-primitives/tabs'; +import * as React from 'react'; +import { cn } from '@/lib/utils'; +import { TextClassContext } from '@/components/ui/text'; + +const Tabs = TabsPrimitive.Root; + +const TabsList = React.forwardRef( + ({ className, ...props }, ref) => ( + + ) +); +TabsList.displayName = TabsPrimitive.List.displayName; + +const TabsTrigger = React.forwardRef( + ({ className, ...props }, ref) => { + const { value } = TabsPrimitive.useRootContext(); + return ( + + + + ); + } +); +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; + +const TabsContent = React.forwardRef( + ({ className, ...props }, ref) => ( + + ) +); +TabsContent.displayName = TabsPrimitive.Content.displayName; + +export { Tabs, TabsContent, TabsList, TabsTrigger }; diff --git a/components/ui/text.tsx b/components/ui/text.tsx index 0d7af0f..2ca5713 100644 --- a/components/ui/text.tsx +++ b/components/ui/text.tsx @@ -2,7 +2,7 @@ import * as Slot from '@rn-primitives/slot'; import type { SlottableTextProps, TextRef } from '@rn-primitives/types'; import * as React from 'react'; import { Text as RNText } from 'react-native'; -import { cn } from '~/lib/utils'; +import { cn } from '@/lib/utils'; const TextClassContext = React.createContext(undefined); diff --git a/components/ui/textarea.tsx b/components/ui/textarea.tsx new file mode 100644 index 0000000..771e6b2 --- /dev/null +++ b/components/ui/textarea.tsx @@ -0,0 +1,27 @@ +import * as React from 'react'; +import { TextInput, type TextInputProps } from 'react-native'; +import { cn } from '@/lib/utils'; + +const Textarea = React.forwardRef, TextInputProps>( + ({ className, multiline = true, numberOfLines = 4, placeholderClassName, ...props }, ref) => { + return ( + + ); + } +); + +Textarea.displayName = 'Textarea'; + +export { Textarea }; diff --git a/components/ui/toggle-group.tsx b/components/ui/toggle-group.tsx new file mode 100644 index 0000000..eb4a3cf --- /dev/null +++ b/components/ui/toggle-group.tsx @@ -0,0 +1,84 @@ +import type { VariantProps } from 'class-variance-authority'; +import type { LucideIcon } from 'lucide-react-native'; +import * as React from 'react'; +import { toggleTextVariants, toggleVariants } from '@/components/ui/toggle'; +import { TextClassContext } from '@/components/ui/text'; +import * as ToggleGroupPrimitive from '@rn-primitives/toggle-group'; +import { cn } from '@/lib/utils'; + +const ToggleGroupContext = React.createContext | null>(null); + +const ToggleGroup = React.forwardRef< + ToggleGroupPrimitive.RootRef, + ToggleGroupPrimitive.RootProps & VariantProps +>(({ className, variant, size, children, ...props }, ref) => ( + + {children} + +)); + +ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName; + +function useToggleGroupContext() { + const context = React.useContext(ToggleGroupContext); + if (context === null) { + throw new Error( + 'ToggleGroup compound components cannot be rendered outside the ToggleGroup component' + ); + } + return context; +} + +const ToggleGroupItem = React.forwardRef< + ToggleGroupPrimitive.ItemRef, + ToggleGroupPrimitive.ItemProps & VariantProps +>(({ className, children, variant, size, ...props }, ref) => { + const context = useToggleGroupContext(); + const { value } = ToggleGroupPrimitive.useRootContext(); + + return ( + + + {children} + + + ); +}); + +ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName; + +function ToggleGroupIcon({ + className, + icon: Icon, + ...props +}: React.ComponentPropsWithoutRef & { + icon: LucideIcon; +}) { + const textClass = React.useContext(TextClassContext); + return ; +} + +export { ToggleGroup, ToggleGroupIcon, ToggleGroupItem }; diff --git a/components/ui/toggle.tsx b/components/ui/toggle.tsx new file mode 100644 index 0000000..e2e6940 --- /dev/null +++ b/components/ui/toggle.tsx @@ -0,0 +1,85 @@ +import * as TogglePrimitive from '@rn-primitives/toggle'; +import { cva, type VariantProps } from 'class-variance-authority'; +import type { LucideIcon } from 'lucide-react-native'; +import * as React from 'react'; +import { cn } from '@/lib/utils'; +import { TextClassContext } from '@/components/ui/text'; + +const toggleVariants = cva( + 'web:group web:inline-flex items-center justify-center rounded-md web:ring-offset-background web:transition-colors web:hover:bg-muted active:bg-muted web:focus-visible:outline-none web:focus-visible:ring-2 web:focus-visible:ring-ring web:focus-visible:ring-offset-2', + { + variants: { + variant: { + default: 'bg-transparent', + outline: + 'border border-input bg-transparent web:hover:bg-accent active:bg-accent active:bg-accent', + }, + size: { + default: 'h-10 px-3 native:h-12 native:px-[12]', + sm: 'h-9 px-2.5 native:h-10 native:px-[9]', + lg: 'h-11 px-5 native:h-14 native:px-6', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + } +); + +const toggleTextVariants = cva('text-sm native:text-base text-foreground font-medium', { + variants: { + variant: { + default: '', + outline: 'web:group-hover:text-accent-foreground web:group-active:text-accent-foreground', + }, + size: { + default: '', + sm: '', + lg: '', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, +}); + +const Toggle = React.forwardRef< + TogglePrimitive.RootRef, + TogglePrimitive.RootProps & VariantProps +>(({ className, variant, size, ...props }, ref) => ( + + + +)); + +Toggle.displayName = TogglePrimitive.Root.displayName; + +function ToggleIcon({ + className, + icon: Icon, + ...props +}: React.ComponentPropsWithoutRef & { + icon: LucideIcon; +}) { + const textClass = React.useContext(TextClassContext); + return ; +} + +export { Toggle, ToggleIcon, toggleTextVariants, toggleVariants }; diff --git a/components/ui/tooltip.tsx b/components/ui/tooltip.tsx index 82b65a9..950ab53 100644 --- a/components/ui/tooltip.tsx +++ b/components/ui/tooltip.tsx @@ -2,8 +2,8 @@ import * as TooltipPrimitive from '@rn-primitives/tooltip'; import * as React from 'react'; import { Platform, StyleSheet } from 'react-native'; import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; -import { TextClassContext } from '~/components/ui/text'; -import { cn } from '~/lib/utils'; +import { TextClassContext } from '@/components/ui/text'; +import { cn } from '@/lib/utils'; const Tooltip = TooltipPrimitive.Root; diff --git a/components/ui/typography.tsx b/components/ui/typography.tsx new file mode 100644 index 0000000..7b1ec7c --- /dev/null +++ b/components/ui/typography.tsx @@ -0,0 +1,205 @@ +import * as Slot from '@rn-primitives/slot'; +import type { SlottableTextProps, TextRef } from '@rn-primitives/types'; +import * as React from 'react'; +import { Platform, Text as RNText } from 'react-native'; +import { cn } from '@/lib/utils'; + +const H1 = React.forwardRef( + ({ className, asChild = false, ...props }, ref) => { + const Component = asChild ? Slot.Text : RNText; + return ( + + ); + } +); + +H1.displayName = 'H1'; + +const H2 = React.forwardRef( + ({ className, asChild = false, ...props }, ref) => { + const Component = asChild ? Slot.Text : RNText; + return ( + + ); + } +); + +H2.displayName = 'H2'; + +const H3 = React.forwardRef( + ({ className, asChild = false, ...props }, ref) => { + const Component = asChild ? Slot.Text : RNText; + return ( + + ); + } +); + +H3.displayName = 'H3'; + +const H4 = React.forwardRef( + ({ className, asChild = false, ...props }, ref) => { + const Component = asChild ? Slot.Text : RNText; + return ( + + ); + } +); + +H4.displayName = 'H4'; + +const P = React.forwardRef( + ({ className, asChild = false, ...props }, ref) => { + const Component = asChild ? Slot.Text : RNText; + return ( + + ); + } +); + +P.displayName = 'P'; + +const BlockQuote = React.forwardRef( + ({ className, asChild = false, ...props }, ref) => { + const Component = asChild ? Slot.Text : RNText; + return ( + + ); + } +); + +BlockQuote.displayName = 'BlockQuote'; + +const Code = React.forwardRef( + ({ className, asChild = false, ...props }, ref) => { + const Component = asChild ? Slot.Text : RNText; + return ( + + ); + } +); + +Code.displayName = 'Code'; + +const Lead = React.forwardRef( + ({ className, asChild = false, ...props }, ref) => { + const Component = asChild ? Slot.Text : RNText; + return ( + + ); + } +); + +Lead.displayName = 'Lead'; + +const Large = React.forwardRef( + ({ className, asChild = false, ...props }, ref) => { + const Component = asChild ? Slot.Text : RNText; + return ( + + ); + } +); + +Large.displayName = 'Large'; + +const Small = React.forwardRef( + ({ className, asChild = false, ...props }, ref) => { + const Component = asChild ? Slot.Text : RNText; + return ( + + ); + } +); + +Small.displayName = 'Small'; + +const Muted = React.forwardRef( + ({ className, asChild = false, ...props }, ref) => { + const Component = asChild ? Slot.Text : RNText; + return ( + + ); + } +); + +Muted.displayName = 'Muted'; + +export { BlockQuote, Code, H1, H2, H3, H4, Large, Lead, Muted, P, Small }; diff --git a/docs/ai_collaboration_guide.md b/docs/ai_collaboration_guide.md new file mode 100644 index 0000000..d5f6393 --- /dev/null +++ b/docs/ai_collaboration_guide.md @@ -0,0 +1,215 @@ +# AI Collaboration Guidelines for POWR Project + +## Project Overview + +POWR is a cross-platform fitness tracking application built with React Native and Expo. It follows a local-first architecture with planned Nostr protocol integration for decentralized social features. + +### Key Features +- Exercise and workout tracking +- Workout template creation and management +- Local-first data storage +- Cross-platform compatibility (iOS, Android) +- Future Nostr integration for social features + +### Technical Stack +- React Native & Expo +- TypeScript +- SQLite for local storage +- Nostr protocol (planned) + +## Collaboration Guidelines + +### 1. Development Process + +#### 1.1 Problem Statement +Before starting any implementation: +- Define the specific problem or feature +- Outline key requirements and constraints +- Identify success criteria +- Document any dependencies or prerequisites + +Example: +```markdown +Problem: Users need a way to create and manage custom exercise templates +Requirements: +- Support for different exercise types +- Custom fields for sets/reps/weight +- Offline functionality +- Future Nostr compatibility +Success Criteria: +- Users can create, edit, and delete exercises +- Exercise data persists locally +- UI performs smoothly +``` + +#### 1.2 Design Document +Create a design document that includes: +- Technical approach +- Data structures +- Component hierarchy +- State management +- Error handling +- Testing strategy + +Store design documents in: `@/docs/design/` + +#### 1.3 Implementation Phases +Break implementation into manageable chunks: +1. Core functionality +2. UI/UX implementation +3. Data persistence +4. Testing and validation +5. Documentation + +#### 1.4 Review Process +- Review code in logical chunks +- Include tests with new features +- Document any configuration changes +- Update relevant documentation + +### 2. Code Quality Standards + +#### 2.1 TypeScript Usage +- Use proper type definitions +- Avoid `any` types +- Document complex types +- Use interfaces for shared types + +Example: +```typescript +interface Exercise { + id: string; + name: string; + type: ExerciseType; + equipment?: Equipment; + notes?: string; + created_at: number; +} +``` + +#### 2.2 Documentation +- Include JSDoc comments for functions +- Document component props +- Explain complex logic +- Keep README files updated + +Example: +```typescript +/** + * Creates a new exercise template in the local database + * @param exercise - The exercise data to save + * @returns Promise - The ID of the created exercise + * @throws {DatabaseError} If the save operation fails + */ +async function createExercise(exercise: Exercise): Promise +``` + +#### 2.3 Error Handling +- Use typed errors +- Implement error boundaries +- Log errors appropriately +- Provide user feedback + +#### 2.4 Testing +- Write unit tests for utilities +- Component testing +- Integration tests for workflows +- Document test cases + +### 3. 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 +``` + +### 4. Contribution Process + +1. **Start with Documentation** + - Create/update design doc + - Document planned changes + - Update relevant READMEs + +2. **Implementation** + - Follow TypeScript best practices + - Add tests for new features + - Include error handling + - Add logging where appropriate + +3. **Review** + - Self-review checklist + - Documentation updates + - Test coverage + - Performance considerations + +### 5. Future-Proofing + +#### 5.1 Nostr Integration +- Design data structures for Nostr compatibility +- Plan for event-based architecture +- Consider relay infrastructure +- Document Nostr-specific features + +#### 5.2 Offline First +- Local data persistence +- Sync status tracking +- Conflict resolution strategy +- Clear offline indicators + +## Communication Guidelines + +### 1. When Asking for Help +- Provide context +- Share relevant code +- Describe expected vs actual behavior +- Include any error messages + +### 2. When Implementing Features +- Break down complex tasks +- Document assumptions +- Ask for clarification when needed +- Provide progress updates + +### 3. When Reviewing Code +- Follow the checklist +- Provide constructive feedback +- Suggest improvements +- Document decisions + +## Resources + +### Documentation Templates +- Problem Statement Template +- Design Document Template +- Pull Request Template + +### Style Guides +- TypeScript Style Guide +- React Native Best Practices +- Component Design Guidelines + +### Tools +- ESLint Configuration +- Prettier Setup +- Testing Utilities + +## Getting Started + +1. Review project documentation +2. Set up development environment +3. Run initial build +4. Review current codebase +5. Start with small tasks + +Remember to: +- Ask questions when stuck +- Document decisions +- Follow the process +- Think about maintainability \ No newline at end of file diff --git a/docs/coding_style.md b/docs/coding_style.md new file mode 100644 index 0000000..714fb70 --- /dev/null +++ b/docs/coding_style.md @@ -0,0 +1,277 @@ +## **Overview** + +This guide is written in the spirit of [Google Style Guides](https://github.com/google/styleguide), especially the most well written ones like for [Obj-C](https://github.com/google/styleguide/blob/gh-pages/objcguide.md). + +Coding style guides are meant to help everyone who contributes to a project to forget about how code feels and easily understand the logic. + +These are guidelines with rationales for all rules. If the rationale doesn't apply, or changes make the rationale moot, the guidelines can safely be ignored. + +## **General Principles** + +### **Consistency is king** + +Above all other principles, be consistent. + +If a single file all follows one convention, just keep following the convention. Separate style changes from logic changes. + +**Rationale**: If same thing is named differently (`Apple`, `a`, `fruit`, `redThing`), it becomes hard to understand how they're related. + +### **Readability above efficiency** + +Prefer readable code over fewer lines of cryptic code. + +**Rationale**: Code will be read many more times than it will be written, by different people. Different people includes you, only a year from now. + +### **All code is either obviously right, or non-obviously wrong.** + +Almost all code should strive to be obviously right at first glance. It shouldn't strike readers as "somewhat odd", and need detailed study to read and decipher. + +**Rationale**: If code is obviously right, it's probably right. The goal is to have suspicious code look suspiciously wrong and stick out like a sore thumb. This happens when everything else is very clear, and there's a tricky bit that needs to be worked on. + +*Corollary*: Code comments are a sign that the code isn't particularly well explained via the code itself. Either through naming, or ordering, or chunking of the logic. Both code and comments have maintenance cost, but comments don't explicitly do work, and often go out of sync with associated code. While not explicitly disallowed, strive to make code require almost no comments. + +Good cases to use comments include describing **why** the code is written that way (if naming, ordering, scoping, etc doesn't work) or explaining details which couldn't be easily explained in code (e.g., which algorithm/pattern/approach is used and why). + +*Exception*: API interfaces *should* be commented, as close to code as possible for keeping up to date easily. + +**Further Reading**: https://www.joelonsoftware.com/2005/05/11/making-wrong-code-look-wrong/ + +### **Boring is best** + +Make your code the most boring version it could be. + +**Rationale**: While you may have won competitions in code golf, the goal of production code is NOT to have the smartest code that only geniuses can figure out, but that which can easily be maintained. On the other hand, devote your creativity to making interesting test cases with fun constant values. + +### **Split implementation from interface** + +Storage, presentation, communication protocols should always be separate. + +**Rationale**: While the content may coincidentally look the same, all these layers have different uses. If you tie things in the wrong place, then you will break unintentionally in non-obvious bad ways. + +### **Split "policy" and "mechanics"** + +Always separate the configuration/policy ("the why") from the implementation/mechanics ("the how"). + +**Rationale**: You can test the implementation of what needs to be done. You can also test the policy triggers at the right time. Turning a feature on and off makes it much easier to throw in more features and later turn them on/off and canary. + +**Corollary**: Create separate functions for "doing" and for "choosing when to do". + +**Corollary**: Create flags for all implementation features. + +# **Deficiency Documentation (`TODO`s and `FIXME`s)** + +### **`TODO` comments** + +Use `TODO` comments *liberally* to describe anything that is potentially missing. + +Code reviewers can also liberally ask for adding `TODO`s to different places. + +Format: + +`// TODO[(Context)]: by/when ` + +`TODO` comments should have these parts: + +- **Context** - (*Optional*) JIRA issue, etc. that can describe what this thing means better. + - Issues or other documentation should be used when the explanations are pretty long or involved. + - Code reviewers should verify that important `TODO`s have filed JIRA Issues. + - Examples: + - `CARE-XXX` - Issue description +- **Action** - Very specific actionable thing to be done. Explanations can come after the particular action. + - Examples: + - `Refactor into single class...` + - `Add ability to query Grafana...` + - `Replace this hack with ...` +- **Deadline Condition** - when to get the thing done by. + - Deadline Conditions should **NOT** be relied on to *track* something done by a time or milestone. + - Examples: + - `... before General Availability release.` + - `... when we add capability.` + - `... when XXX bug/feature is fixed.` + - `... once approves of .` + - `... when first customer asks for it.` + - Empty case implies "`...when we get time`". Use *only* for relatively unimportant things. + +**Rationale**: `TODO` comments help readers understand what is missing. Sometimes you know what you're doing is not the best it could be, but is good enough. That's fine. Just explain how it can be improved. + +Feel free to add `TODO` comments as you edit code and read other code that you interact with. If you don't understand something, add `TODO` to document how it might be better, so others may be able to help out. + +Good Examples: + +`// TODO: Replace certificate with staging version once we get letsencrypt to work. + +// TODO(CARE-XXX): Replace old logic with new logic when out of experimental mode. + +// TODO(SCIENCE-XXX): Colonize new planets when we get cold fusion capability.` + +Mediocre examples(lacks Deadline Condition) - Good for documenting, but not as important: + +`// TODO: Add precompiling templates here if we need it. + +// TODO: Remove use of bash. + +// TODO: Clean up for GetPatient/GetCaseWorker, which might be called from http handlers separately. + +// TODO: Figure out how to launch spaceships instead of rubber duckies.` + +Bad examples: + +`// TODO: wtf? (what are we f'ing about?) + +// TODO: We shouldn't do this. (doesn't say what to do instead, or why it exists) + +// TODO: err...` + +### **`FIXME` comments** + +Use `FIXME` comments as **stronger** `TODO`s that **MUST** be done before code submission. These comments are **merge-blocking**. + +`FIXME` should be liberally used for notes during development, either for developer or reviewers, to remind and prompt discussion. Remove these comments by fixing them before submitting code. + +During code review, reviewer *may* suggest converting `FIXME` -> `TODO`, if it's not important to get done before getting something submitted. Then [`TODO` comment](https://github.com/MindStrongHealth/experimental/blob/master/users/teejae/coding-style.md#todo-comments) formatting applies. + +Format (same as [`TODO` comments](https://github.com/MindStrongHealth/experimental/blob/master/users/teejae/coding-style.md#todo-comments), but more relaxed): + +`// FIXME: + +// FIXME: Remove hack + +// FIXME: Revert hardcoding of server URL + +// FIXME: Implement function + +// FIXME: Refactor these usages across codebase. + +// FIXME: Why does this work this way? ` + +**Rationale**: These are great self-reminders as you code that you haven't finished something, like stubbed out functions, unimplemented parts, etc. The reviewer can also see the `FIXME`s to eye potential problems, and help out things that are not understandable, suggesting better fixes. + +# **Code** + +## **Naming** + +### **Variables should always be named semantically.** + +Names of variables should reflect their content and intent. Try to pick very specific names. Avoid adding parts that don't add any context to the name. Use only well-known abbreviations, otherwise, don't shorten the name in order to save a couple of symbols. + +``` +// Bad +input = "123-4567" +dialPhoneNumber(input) // unclear whether this makes semantic sense. + +// Good +phoneNumber = "123-4567" +dialPhoneNumber(phoneNumber) // more obvious that this is intentional. + +// Bad +text = 1234 +address = "http://some-address/patient/" + text // why is text being added to a string? + +// Good +patientId = 1234 +address = "http://some-address/patient/" + patientId // ah, a patient id is added to an address. + +``` + +**Rationale**: Good semantic names make bugs obvious and expresses intention, without needing lots of comments. + +### **Always add units for measures.** + +Time is especially ambiguous. + +Time intervals (duration): `timeoutSec`, `timeoutMs`, `refreshIntervalHours` + +Timestamp (specific instant in time): `startTimestamp`. (Use language-provided representations once inside code, rather than generic `number`, `int` for raw timestamps. JS/Java: `Date`, Python: `datetime.date/time`, Go: `time.Time`) + +Distances: `LengthFt`, `LengthMeter`, `LengthCm`, `LengthMm` + +Computer Space: `DiskMib` (1 Mebibyte is 1024 Kibibytes), `RamMb` (1 Megabyte is 1000 Kilobytes) + +``` +// Bad +Cost = Disk * Cents + +// Good +CostCents = DiskMib * 1024 * CentsPerKilobyte + +``` + +**Rationale**: Large classes of bugs are avoided when you name everything with units. + +## **Constants** + +### **All literals should be assigned to constants (or constant-like treatments).** + +Every string or numeric literal needs to be assigned to a constant. + +**Exceptions**: Identity-type zero/one values: `0`, `1`, `-1`, `""` + +**Rationale**: It is never obvious why random number or string literals appear in different places. Even if they are somewhat obvious, it's hard to debug/find random constants and what they mean unless they are explicitly defined. Looking at collected constants allows the reader to see what is important, and see tricky edge cases while spelunking through the rest of the code. + +# **Tests** + +All commentary in the Code section applies here as well, with a few relaxations. + +### **Repetitive test code allowed** + +In general, do not repeat yourself. However, IF the test code is clearer, it's ok to repeat. + +**Rationale**: Readability above all else. Sometimes tests are meant to test lots of niggling nefarious code, so we make exceptions for those cases. + +### **Small Test Cases** + +Make test cases as small and targeted as possible. + +**Rationale**: Large tests are both unwieldy to write, and hard to debug. If something takes lots of setup, it's usually a sign of a design problem with the thing you're testing. Try breaking up the code/class/object into more manageable pieces. + +### **No complex logic** + +Avoid adding complex logic to test cases. It's more likely to have a bug in this case, while the purpose of the test cases is to prevent bugs. It's better to [repeat](https://github.com/MindStrongHealth/experimental/blob/master/users/teejae/coding-style.md#repetitive-test-code-allowed) or use a helper function covered with test cases. + +### **Be creative in the content, but *not* the variable names.** + +Just as for regular code, name variables for how they will be used. Intent is unclear when placeholders litter the code. + +Use creative values for testing that don't look too "normal", so maintainers can tell values are obviously test values. + +``` +// Bad +login("abc", "badpassword") // Are "abc" and "badpassword" important? +testMemberId = "NA12312412" // Looks too "real", and unclear if it needs to follow this form + +// Good +testMemberId = "some random member id" +testName = "abc" +testPassword = "open sesame" +testBadPassword = "really bad password! stop it!" + +login(testName, testPassword) // Success +login(testName, testBadPassword) // Failure + +``` + +**Rationale**: When the names of the variables are obvious, it becomes clear what is important in the tests. + +### **No "spooky action at a distance"** + +Collect all related logic/conditions into a single place, so it's easy to grasp how the different parts are related. + +``` +// Bad +startingInvestmentDollars = 100 +invest() +... lots of test code ... +invest() +loseMoney() +expect(investment == 167) // Why 167? + +// Good +startingInvestmentDollars = 100 +returnInterestRatePercent = 67 +endingInvestmentDollars = 167 // More obvious where 167 comes from. +... lots of test code ... +expect(investment == endingInvestmentDollars) + +``` + +**Rationale**: When all related things are collected in a single place, you can more clearly understand what you think you'll read. The rest is just checking for mechanics. \ No newline at end of file diff --git a/app/index.tsx b/docs/design/RNR-original-example.tsx similarity index 90% rename from app/index.tsx rename to docs/design/RNR-original-example.tsx index fbbe261..a585794 100644 --- a/app/index.tsx +++ b/docs/design/RNR-original-example.tsx @@ -1,9 +1,9 @@ import * as React from 'react'; import { View } from 'react-native'; import Animated, { FadeInUp, FadeOutDown, LayoutAnimationConfig } from 'react-native-reanimated'; -import { Info } from '~/lib/icons/Info'; -import { Avatar, AvatarFallback, AvatarImage } from '~/components/ui/avatar'; -import { Button } from '~/components/ui/button'; +import { Info } from '@/lib/icons/Info'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Button } from '@/components/ui/button'; import { Card, CardContent, @@ -11,10 +11,10 @@ import { CardFooter, CardHeader, CardTitle, -} from '~/components/ui/card'; -import { Progress } from '~/components/ui/progress'; -import { Text } from '~/components/ui/text'; -import { Tooltip, TooltipContent, TooltipTrigger } from '~/components/ui/tooltip'; +} from '@/components/ui/card'; +import { Progress } from '@/components/ui/progress'; +import { Text } from '@/components/ui/text'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; const GITHUB_AVATAR_URI = 'https://i.pinimg.com/originals/ef/a2/8d/efa28d18a04e7fa40ed49eeb0ab660db.jpg'; diff --git a/docs/design/library_tab.md b/docs/design/library_tab.md new file mode 100644 index 0000000..30cbcc2 --- /dev/null +++ b/docs/design/library_tab.md @@ -0,0 +1,343 @@ +# POWR Library Tab PRD + +## Overview + +### Problem Statement +Users need a centralized location to manage their fitness content (exercises and workout templates) while supporting both local content creation and Nostr-based content discovery. The library must maintain usability in offline scenarios while preparing for future social features. + +### Goals +1. Provide organized access to exercises and workout templates +2. Enable efficient content discovery and reuse +3. Support clear content ownership and source tracking +4. Maintain offline-first functionality +5. Prepare for future Nostr integration + +## Feature Requirements + +### Navigation Structure +- Material Top Tabs navigation with three sections: + - Templates (default tab) + - Exercises + - Programs (placeholder for future implementation) + +### Templates Tab + +#### Content Organization +- Favorites section +- Recently performed section +- Alphabetical list of remaining templates +- Clear source badges (Local/POWR/Nostr) + +#### Template Item Display +- Template title +- Workout type (strength, circuit, EMOM, etc.) +- Preview of included exercises (first 3) +- Source badge +- Favorite star button +- Usage stats + +#### Search & Filtering +- Persistent search bar with real-time filtering +- Filter options: + - Workout type + - Equipment needed + - Tags + +### Exercises Tab + +#### Content Organization +- Recent section (10 most recent exercises) +- Alphabetical list of all exercises +- Tag-based categorization +- Clear source badges + +#### Exercise Item Display +- Exercise name +- Category/tags +- Equipment type +- Source badge +- Usage stats + +#### Search & Filtering +- Persistent search bar with real-time filtering +- Filter options: + - Equipment + - Tags + - Source + +### Programs Tab (Future) +- Placeholder implementation +- "Coming Soon" messaging +- Basic description of future functionality + +## Content Interaction + +### Progressive Disclosure Pattern + +#### 1. Card Display +- Basic info +- Source badge (Local/POWR/Nostr) +- Quick stats/preview +- Favorite button (templates only) + +#### 2. Quick Preview (Hover/Long Press) +- Extended preview info +- Key stats +- Quick actions + +#### 3. Bottom Sheet Details +- Basic Information: + - Full title and description + - Category/tags + - Equipment requirements + +- Stats & History: + - Personal records + - Usage history + - Performance trends + +- Source Information: + - For local content: + - Creation date + - Last modified + - For Nostr content: + - Author information + - Original post date + - Relay source + +- Action Buttons: + - For local content: + - Start Workout (templates) + - Edit + - Publish to Nostr + - Delete + - For Nostr content: + - Start Workout (templates) + - Delete from Library + +#### 4. Full Details Modal +- Comprehensive view +- Complete history +- Advanced options + +## Technical Requirements + +### Data Storage +- SQLite for local storage +- Schema supporting: + - Exercise templates + - Workout templates + - Usage history + - Source tracking + - Nostr metadata + +### Content Management +- No limit on custom exercises/templates +- Tag character limit: 30 characters +- Support for external media links (images/videos) +- Local caching of Nostr content + +### Media Content Handling +- For Nostr content: + - Store media URLs in metadata + - Cache images locally when saved + - Lazy load images when online + - Show placeholders when offline +- For local content: + - Optional image/video links + - No direct media upload in MVP + +### Offline Capabilities +- Full functionality without internet +- Local-first architecture +- Graceful degradation of Nostr features +- Clear offline state indicators + +## User Interface Components + +### Core Components +1. MaterialTopTabs navigation +2. Persistent search header +3. Filter button and sheet +4. Content cards +5. Bottom sheet previews +6. Tab-specific FABs: + - Templates Tab: FAB for creating new workout templates + - Exercises Tab: FAB for creating new custom exercises + - Programs Tab: FAB for creating training programs (future) + +### Component Details + +#### Templates Tab FAB +- Primary action: Create new workout template +- Icon: Layout/Template icon +- Navigation: Routes to template creation flow +- Fixed position at bottom right + +#### Exercises Tab FAB +- Primary action: Create new exercise +- Icon: Dumbbell icon +- Navigation: Routes to exercise creation flow +- Fixed position at bottom right + +#### Programs Tab FAB (Future) +- Primary action: Create new program +- Icon: Calendar/Program icon +- Navigation: Routes to program creation flow +- Fixed position at bottom right + +### Component States +1. Loading states +2. Empty states +3. Error states +4. Offline states +5. Content creation/editing modes + +## Implementation Phases + +### Phase 1: Core Structure +1. Tab navigation setup +2. Basic content display +3. Search and filtering +4. Local content management + +### Phase 2: Enhanced Features +1. Favorite system +2. History tracking +3. Performance stats +4. Tag management + +### Phase 3: Nostr Integration +1. Content syncing +2. Publishing flow +3. Author attribution +4. Media handling + +## Success Metrics + +### Performance +- Search response: < 100ms +- Scroll performance: 60fps +- Image load time: < 500ms + +### User Experience +- Content discovery time +- Search success rate +- Template reuse rate +- Exercise reference frequency + +### Technical +- Offline reliability +- Storage efficiency +- Cache hit rate +- Sync success rate + +## Future Considerations + +### Programs Tab Development +- Program creation +- Calendar integration +- Progress tracking +- Social sharing + +### Enhanced Social Features +- Content recommendations +- Author following +- Usage analytics +- Community features + +### Additional Enhancements +- Advanced media support +- Custom collections +- Export/import functionality +- Backup solutions + +2025-02-09 Update + +Progress Analysis: + +✅ COMPLETED: +1. Navigation Structure +- Implemented Material Top Tabs with Templates, Exercises, and Programs sections +- Clear visual hierarchy with proper styling + +2. Basic Content Management +- Search functionality +- Filter system with proper categorization +- Source badges (Local/POWR/Nostr) +- Basic CRUD operations for exercises and templates + +3. UI Components +- SearchHeader component +- FilterSheet with proper categorization +- Content cards with consistent styling +- FAB for content creation +- Sheet components for new content creation + +🟡 IN PROGRESS/PARTIAL: +1. Content Organization +- We have basic favorites for templates but need to implement: + - Recently performed section + - Usage stats tracking + - Better categorization system + +2. Progressive Disclosure Pattern +- We have basic cards and creation sheets but need: + - Quick Preview on long press + - Bottom Sheet Details view + - Full Details Modal + +3. Content Interaction +- Basic CRUD operations exist but need: + - Performance tracking + - History integration + - Better stats visualization + +❌ NOT STARTED: +1. Technical Implementation +- Nostr integration preparation +- SQLite database setup +- Proper caching system +- Offline capabilities + +2. Advanced Features +- Performance tracking +- Usage history +- Media content handling +- Import/export functionality + +Recommended Next Steps: + +1. Data Layer Implementation +```typescript +// First set up SQLite database schema and service +class LibraryService { + // Exercise management + getExercises(): Promise + createExercise(exercise: Exercise): Promise + updateExercise(id: string, exercise: Partial): Promise + deleteExercise(id: string): Promise + + // Template management + getTemplates(): Promise + createTemplate(template: Template): Promise + updateTemplate(id: string, template: Partial