mirror of
https://github.com/DocNR/POWR.git
synced 2025-06-02 23:32:08 +00:00
Initial commit of new POWR version
This commit is contained in:
parent
08fc64a6a3
commit
87cdf3fc1c
78
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
78
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@ -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.)
|
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@ -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.
|
59
.github/ISSUE_TEMPLATE/documentation.yml
vendored
Normal file
59
.github/ISSUE_TEMPLATE/documentation.yml
vendored
Normal file
@ -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
|
58
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
58
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@ -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.
|
81
.github/pull_request_template/pull_request_template.md
vendored
Normal file
81
.github/pull_request_template/pull_request_template.md
vendored
Normal file
@ -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
|
2
.vscode/settings.json
vendored
Normal file
2
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
{
|
||||
}
|
84
CHANGELOG.md
Normal file
84
CHANGELOG.md
Normal file
@ -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
|
154
CONTRIBUTING.md
Normal file
154
CONTRIBUTING.md
Normal file
@ -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
|
154
README.md
154
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
|
||||
|
||||
<img src="https://github.com/mrzachnugent/react-native-reusables/assets/63797719/42c94108-38a7-498b-9c70-18640420f1bc"
|
||||
alt="starter-base-template"
|
||||
style="width:270px;" />
|
||||
### 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)
|
78
app/(tabs)/_layout.tsx
Normal file
78
app/(tabs)/_layout.tsx
Normal file
@ -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 (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
tabBarStyle: {
|
||||
backgroundColor: colors.card,
|
||||
borderTopColor: colors.border,
|
||||
borderTopWidth: Platform.OS === 'ios' ? 0.5 : 1,
|
||||
elevation: 0,
|
||||
shadowOpacity: 0,
|
||||
},
|
||||
tabBarActiveTintColor: CUSTOM_COLORS.purple,
|
||||
tabBarInactiveTintColor: colors.text, // Changed this from colors.background
|
||||
tabBarShowLabel: true,
|
||||
tabBarLabelStyle: {
|
||||
fontSize: 12,
|
||||
marginBottom: Platform.OS === 'ios' ? 0 : 4,
|
||||
},
|
||||
}}>
|
||||
<Tabs.Screen
|
||||
name="profile"
|
||||
options={{
|
||||
title: 'Profile',
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<User size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="library"
|
||||
options={{
|
||||
title: 'Library',
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Library size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: 'Workout',
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Dumbbell size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="social"
|
||||
options={{
|
||||
title: 'Social',
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Users size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="history"
|
||||
options={{
|
||||
title: 'History',
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<History size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
11
app/(tabs)/history.tsx
Normal file
11
app/(tabs)/history.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
// app/(tabs)/history.tsx
|
||||
import { View } from 'react-native';
|
||||
import { Text } from '@/components/ui/text';
|
||||
|
||||
export default function HomeScreen() {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<Text>Home Screen</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
11
app/(tabs)/index.tsx
Normal file
11
app/(tabs)/index.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
// app/(tabs)/index.tsx
|
||||
import { View } from 'react-native';
|
||||
import { Text } from '@/components/ui/text';
|
||||
|
||||
export default function HomeScreen() {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<Text>Home Screen</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
59
app/(tabs)/library/_layout.native.tsx
Normal file
59
app/(tabs)/library/_layout.native.tsx
Normal file
@ -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 (
|
||||
<View className="flex-1">
|
||||
{/* Header */}
|
||||
<View className="flex-row justify-between items-center px-4 pt-14 pb-4 bg-card">
|
||||
<Text className="text-2xl font-bold">Library</Text>
|
||||
<ThemeToggle />
|
||||
</View>
|
||||
|
||||
<Tab.Navigator
|
||||
initialRouteName="templates"
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: CUSTOM_COLORS.purple,
|
||||
tabBarInactiveTintColor: colors.text,
|
||||
tabBarLabelStyle: {
|
||||
fontSize: 14,
|
||||
textTransform: 'capitalize',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
tabBarIndicatorStyle: {
|
||||
backgroundColor: CUSTOM_COLORS.purple,
|
||||
},
|
||||
tabBarStyle: { backgroundColor: colors.card }
|
||||
}}
|
||||
>
|
||||
<Tab.Screen
|
||||
name="exercises"
|
||||
component={ExercisesScreen}
|
||||
options={{ title: 'Exercises' }}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="templates"
|
||||
component={TemplatesScreen}
|
||||
options={{ title: 'Templates' }}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="programs"
|
||||
component={ProgramsScreen}
|
||||
options={{ title: 'Programs' }}
|
||||
/>
|
||||
</Tab.Navigator>
|
||||
</View>
|
||||
);
|
||||
}
|
3
app/(tabs)/library/_layout.tsx
Normal file
3
app/(tabs)/library/_layout.tsx
Normal file
@ -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';
|
80
app/(tabs)/library/_layout.web.tsx
Normal file
80
app/(tabs)/library/_layout.web.tsx
Normal file
@ -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<PagerRef>(null);
|
||||
|
||||
const handleTabPress = (index: number) => {
|
||||
setActiveIndex(index);
|
||||
pagerRef.current?.setPage(index);
|
||||
};
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-background">
|
||||
{/* Header */}
|
||||
<View className="flex-row justify-between items-center px-4 pt-14 pb-4 bg-card">
|
||||
<Text className="text-2xl font-bold">Library</Text>
|
||||
<ThemeToggle />
|
||||
</View>
|
||||
|
||||
{/* Tab Headers */}
|
||||
<View className="flex-row bg-card border-b border-border">
|
||||
{tabs.map((tab, index) => (
|
||||
<View key={tab.key} className="flex-1">
|
||||
<Pressable
|
||||
onPress={() => handleTabPress(index)}
|
||||
className="px-4 py-3 items-center"
|
||||
>
|
||||
<Text
|
||||
className={activeIndex === index
|
||||
? 'font-semibold text-primary'
|
||||
: 'font-semibold text-muted-foreground'}
|
||||
style={activeIndex === index ? { color: CUSTOM_COLORS.purple } : undefined}
|
||||
>
|
||||
{tab.title}
|
||||
</Text>
|
||||
</Pressable>
|
||||
{activeIndex === index && (
|
||||
<View
|
||||
className="h-0.5"
|
||||
style={{ backgroundColor: CUSTOM_COLORS.purple }}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Content */}
|
||||
<View style={{ flex: 1 }}>
|
||||
<Pager
|
||||
ref={pagerRef}
|
||||
initialPage={0}
|
||||
onPageSelected={(e) => setActiveIndex(e.nativeEvent.position)}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<View key={tab.key} style={{ flex: 1 }}>
|
||||
<tab.component />
|
||||
</View>
|
||||
))}
|
||||
</Pager>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
180
app/(tabs)/library/exercises.tsx
Normal file
180
app/(tabs)/library/exercises.tsx
Normal file
@ -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<Exercise[]>(initialExercises);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [showNewExercise, setShowNewExercise] = useState(false);
|
||||
const [filterOptions, setFilterOptions] = useState<FilterOptions>({
|
||||
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 (
|
||||
<View className="flex-1 bg-background">
|
||||
<SearchHeader
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
activeFilters={activeFilterCount}
|
||||
onOpenFilters={() => setShowFilters(true)}
|
||||
/>
|
||||
|
||||
<ScrollView className="flex-1">
|
||||
{/* Recent Exercises Section */}
|
||||
<View className="py-4">
|
||||
<Text className="text-lg font-semibold mb-4 px-4">Recent Exercises</Text>
|
||||
<View className="gap-3">
|
||||
{recentExercises.map(exercise => (
|
||||
<ExerciseCard
|
||||
key={exercise.id}
|
||||
{...exercise}
|
||||
onPress={() => handleExercisePress(exercise.id)}
|
||||
onDelete={() => handleDelete(exercise.id)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* All Exercises Section */}
|
||||
<View className="py-4">
|
||||
<Text className="text-lg font-semibold mb-4 px-4">All Exercises</Text>
|
||||
<View className="gap-3">
|
||||
{allExercises.map(exercise => (
|
||||
<ExerciseCard
|
||||
key={exercise.id}
|
||||
{...exercise}
|
||||
onPress={() => handleExercisePress(exercise.id)}
|
||||
onDelete={() => handleDelete(exercise.id)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<FloatingActionButton
|
||||
icon={Dumbbell}
|
||||
onPress={() => setShowNewExercise(true)}
|
||||
/>
|
||||
|
||||
<FilterSheet
|
||||
isOpen={showFilters}
|
||||
onClose={() => setShowFilters(false)}
|
||||
options={filterOptions}
|
||||
onApplyFilters={setFilterOptions}
|
||||
availableFilters={availableFilters}
|
||||
/>
|
||||
|
||||
<NewExerciseSheet
|
||||
isOpen={showNewExercise}
|
||||
onClose={() => setShowNewExercise(false)}
|
||||
onSubmit={handleAddExercise}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
93
app/(tabs)/library/index.tsx
Normal file
93
app/(tabs)/library/index.tsx
Normal file
@ -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 (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<Text>Templates Content</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ProgramsTab() {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<Text>Programs (Coming Soon)</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LibraryScreen() {
|
||||
const { colors } = useTheme();
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Header with Theme Toggle */}
|
||||
<View style={[styles.header, { backgroundColor: colors.card }]}>
|
||||
<Text className="text-2xl font-bold">Library</Text>
|
||||
<ThemeToggle />
|
||||
</View>
|
||||
|
||||
{/* Material Top Tabs */}
|
||||
<Tab.Navigator
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: colors.text,
|
||||
tabBarInactiveTintColor: 'grey',
|
||||
tabBarLabelStyle: {
|
||||
fontSize: 14,
|
||||
textTransform: 'capitalize',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
tabBarIndicatorStyle: {
|
||||
backgroundColor: colors.text,
|
||||
},
|
||||
tabBarStyle: { backgroundColor: colors.card }
|
||||
}}
|
||||
>
|
||||
<Tab.Screen
|
||||
name="exercises"
|
||||
component={ExercisesScreen}
|
||||
options={{
|
||||
title: 'Exercises',
|
||||
}}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="templates"
|
||||
component={TemplatesTab}
|
||||
options={{
|
||||
title: 'Templates',
|
||||
}}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="programs"
|
||||
component={ProgramsTab}
|
||||
options={{
|
||||
title: 'Programs',
|
||||
}}
|
||||
/>
|
||||
</Tab.Navigator>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
11
app/(tabs)/library/programs.tsx
Normal file
11
app/(tabs)/library/programs.tsx
Normal file
@ -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 (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<Text>Programs (Coming Soon)</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
214
app/(tabs)/library/templates.tsx
Normal file
214
app/(tabs)/library/templates.tsx
Normal file
@ -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<FilterOptions>({
|
||||
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 (
|
||||
<View className="flex-1 bg-background">
|
||||
<SearchHeader
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
activeFilters={activeFilterCount}
|
||||
onOpenFilters={() => setShowFilters(true)}
|
||||
/>
|
||||
|
||||
<ScrollView className="flex-1">
|
||||
{/* Favorites Section */}
|
||||
{favoriteTemplates.length > 0 && (
|
||||
<View className="py-4">
|
||||
<Text className="text-lg font-semibold mb-4 px-4">
|
||||
Favorites
|
||||
</Text>
|
||||
<View className="gap-3">
|
||||
{favoriteTemplates.map(template => (
|
||||
<TemplateCard
|
||||
key={template.id}
|
||||
template={template}
|
||||
onPress={() => handleTemplatePress(template)}
|
||||
onDelete={handleDelete}
|
||||
onFavorite={() => handleFavorite(template)}
|
||||
onStartWorkout={() => handleStartWorkout(template)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* All Templates Section */}
|
||||
<View className="py-4">
|
||||
<Text className="text-lg font-semibold mb-4 px-4">
|
||||
All Templates
|
||||
</Text>
|
||||
{regularTemplates.length > 0 ? (
|
||||
<View className="gap-3">
|
||||
{regularTemplates.map(template => (
|
||||
<TemplateCard
|
||||
key={template.id}
|
||||
template={template}
|
||||
onPress={() => handleTemplatePress(template)}
|
||||
onDelete={handleDelete}
|
||||
onFavorite={() => handleFavorite(template)}
|
||||
onStartWorkout={() => handleStartWorkout(template)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<View className="px-4">
|
||||
<Text className="text-muted-foreground">
|
||||
No templates found. Create one by clicking the + button.
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Add some bottom padding for FAB */}
|
||||
<View className="h-20" />
|
||||
</ScrollView>
|
||||
|
||||
<FloatingActionButton
|
||||
icon={Plus}
|
||||
onPress={() => setShowNewTemplate(true)}
|
||||
/>
|
||||
|
||||
<NewTemplateSheet
|
||||
isOpen={showNewTemplate}
|
||||
onClose={() => setShowNewTemplate(false)}
|
||||
onSubmit={handleAddTemplate}
|
||||
/>
|
||||
|
||||
<FilterSheet
|
||||
isOpen={showFilters}
|
||||
onClose={() => setShowFilters(false)}
|
||||
options={filterOptions}
|
||||
onApplyFilters={setFilterOptions}
|
||||
availableFilters={availableFilters}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
100
app/(tabs)/profile.tsx
Normal file
100
app/(tabs)/profile.tsx
Normal file
@ -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 (
|
||||
<View className="flex-1">
|
||||
<Header
|
||||
title="Profile"
|
||||
rightElement={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onPress={() => {
|
||||
// TODO: Navigate to settings
|
||||
console.log('Open settings');
|
||||
}}
|
||||
>
|
||||
<Settings className="text-foreground" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<ScrollView className="flex-1">
|
||||
{/* Profile Header Section */}
|
||||
<View className="items-center pt-6 pb-8">
|
||||
<Avatar className="w-24 h-24 mb-4" alt="Profile picture">
|
||||
<AvatarImage source={{ uri: PLACEHOLDER_IMAGE }} />
|
||||
<AvatarFallback>
|
||||
<Text className="text-2xl">JD</Text>
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<H1 className="text-xl font-semibold mb-1">John Doe</H1>
|
||||
<Text className="text-muted-foreground">@johndoe</Text>
|
||||
</View>
|
||||
|
||||
{/* Stats Section */}
|
||||
<View className="flex-row justify-around px-4 py-6 bg-card">
|
||||
<View className="items-center">
|
||||
<Text className="text-2xl font-bold">24</Text>
|
||||
<Text className="text-muted-foreground">Workouts</Text>
|
||||
</View>
|
||||
<View className="items-center">
|
||||
<Text className="text-2xl font-bold">12</Text>
|
||||
<Text className="text-muted-foreground">Templates</Text>
|
||||
</View>
|
||||
<View className="items-center">
|
||||
<Text className="text-2xl font-bold">3</Text>
|
||||
<Text className="text-muted-foreground">Programs</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Profile Actions */}
|
||||
<View className="p-4 gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mb-2"
|
||||
onPress={() => {
|
||||
// TODO: Navigate to edit profile
|
||||
console.log('Edit profile');
|
||||
}}
|
||||
>
|
||||
<Text>Edit Profile</Text>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mb-2"
|
||||
onPress={() => {
|
||||
// TODO: Navigate to account settings
|
||||
console.log('Account settings');
|
||||
}}
|
||||
>
|
||||
<Text>Account Settings</Text>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mb-2"
|
||||
onPress={() => {
|
||||
// TODO: Navigate to preferences
|
||||
console.log('Preferences');
|
||||
}}
|
||||
>
|
||||
<Text>Preferences</Text>
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
11
app/(tabs)/social.tsx
Normal file
11
app/(tabs)/social.tsx
Normal file
@ -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 (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<Text>Home Screen</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
15
app/(workout)/_layout.tsx
Normal file
15
app/(workout)/_layout.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
// app/(workout)/_layout.tsx
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
export default function WorkoutLayout() {
|
||||
return (
|
||||
<Stack>
|
||||
<Stack.Screen
|
||||
name="new-exercise"
|
||||
options={{
|
||||
title: 'New Exercise'
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
@ -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 (
|
||||
|
@ -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 (
|
||||
<ThemeProvider value={isDarkColorScheme ? DARK_THEME : LIGHT_THEME}>
|
||||
<StatusBar style={isDarkColorScheme ? 'light' : 'dark'} />
|
||||
<Stack>
|
||||
<Stack.Screen
|
||||
name='index'
|
||||
options={{
|
||||
title: 'Starter Base',
|
||||
headerRight: () => <ThemeToggle />,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
<PortalHost />
|
||||
</ThemeProvider>
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<ThemeProvider value={isDarkColorScheme ? DARK_THEME : LIGHT_THEME}>
|
||||
<StatusBar style={isDarkColorScheme ? 'light' : 'dark'} />
|
||||
<Stack screenOptions={{
|
||||
headerShown: false,
|
||||
}}>
|
||||
<Stack.Screen
|
||||
name="(tabs)"
|
||||
options={{
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
<PortalHost />
|
||||
</ThemeProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
|
||||
const useIsomorphicLayoutEffect =
|
||||
Platform.OS === 'web' && typeof window === 'undefined' ? React.useEffect : React.useLayoutEffect;
|
||||
}
|
@ -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: {
|
||||
'@': './',
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
};
|
||||
};
|
6
components.json
Normal file
6
components.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"lib": "@/lib"
|
||||
}
|
||||
}
|
19
components/Header.tsx
Normal file
19
components/Header.tsx
Normal file
@ -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 (
|
||||
<View className="flex-row justify-between items-center px-4 pt-14 pb-4 bg-card">
|
||||
<Text className="text-2xl font-bold">{title}</Text>
|
||||
{rightElement || <ThemeToggle />}
|
||||
</View>
|
||||
);
|
||||
}
|
@ -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();
|
||||
|
97
components/examples/ProfileCard.tsx
Normal file
97
components/examples/ProfileCard.tsx
Normal file
@ -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 (
|
||||
<View className='flex-1 justify-center items-center gap-5 p-6 bg-secondary/30'>
|
||||
<Card className='w-full max-w-sm p-6 rounded-2xl'>
|
||||
<CardHeader className='items-center'>
|
||||
<Avatar alt="Rick Sanchez's Avatar" className='w-24 h-24'>
|
||||
<AvatarImage source={{ uri: GITHUB_AVATAR_URI }} />
|
||||
<AvatarFallback>
|
||||
<Text>RS</Text>
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<View className='p-3' />
|
||||
<CardTitle className='pb-2 text-center'>Rick Sanchez</CardTitle>
|
||||
<View className='flex-row'>
|
||||
<CardDescription className='text-base font-semibold'>Scientist</CardDescription>
|
||||
<Tooltip delayDuration={150}>
|
||||
<TooltipTrigger className='px-2 pb-0.5 active:opacity-50'>
|
||||
<Info size={14} strokeWidth={2.5} className='w-4 h-4 text-foreground/70' />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className='py-2 px-4 shadow'>
|
||||
<Text className='native:text-lg'>Freelance</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</View>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<View className='flex-row justify-around gap-3'>
|
||||
<View className='items-center'>
|
||||
<Text className='text-sm text-muted-foreground'>Dimension</Text>
|
||||
<Text className='text-xl font-semibold'>C-137</Text>
|
||||
</View>
|
||||
<View className='items-center'>
|
||||
<Text className='text-sm text-muted-foreground'>Age</Text>
|
||||
<Text className='text-xl font-semibold'>70</Text>
|
||||
</View>
|
||||
<View className='items-center'>
|
||||
<Text className='text-sm text-muted-foreground'>Species</Text>
|
||||
<Text className='text-xl font-semibold'>Human</Text>
|
||||
</View>
|
||||
</View>
|
||||
</CardContent>
|
||||
<CardFooter className='flex-col gap-3 pb-0'>
|
||||
<View className='flex-row items-center overflow-hidden'>
|
||||
<Text className='text-sm text-muted-foreground'>Productivity:</Text>
|
||||
<LayoutAnimationConfig skipEntering>
|
||||
<Animated.View
|
||||
key={progress}
|
||||
entering={FadeInUp}
|
||||
exiting={FadeOutDown}
|
||||
className='w-11 items-center'
|
||||
>
|
||||
<Text className='text-sm font-bold text-sky-600'>{progress}%</Text>
|
||||
</Animated.View>
|
||||
</LayoutAnimationConfig>
|
||||
</View>
|
||||
<Progress value={progress} className='h-2' indicatorClassName='bg-sky-600' />
|
||||
<View />
|
||||
<Button
|
||||
variant='outline'
|
||||
className='shadow shadow-foreground/5'
|
||||
onPress={updateProgressValue}
|
||||
>
|
||||
<Text>Update</Text>
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</View>
|
||||
);
|
||||
}
|
233
components/exercises/ExerciseCard.tsx
Normal file
233
components/exercises/ExerciseCard.tsx
Normal file
@ -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 (
|
||||
<>
|
||||
<TouchableOpacity onPress={handleCardPress} activeOpacity={0.7}>
|
||||
<Card className="mx-4">
|
||||
<CardContent className="p-4">
|
||||
<View className="flex-row justify-between items-start">
|
||||
<View className="flex-1">
|
||||
<View className="flex-row items-center gap-2 mb-1">
|
||||
<Text className="text-lg font-semibold text-card-foreground">
|
||||
{title}
|
||||
</Text>
|
||||
<Badge
|
||||
variant={source === 'local' ? 'outline' : 'secondary'}
|
||||
className="text-xs"
|
||||
>
|
||||
<Text>{source}</Text>
|
||||
</Badge>
|
||||
</View>
|
||||
|
||||
<Text className="text-sm text-muted-foreground">
|
||||
{category}
|
||||
</Text>
|
||||
{equipment && (
|
||||
<Text className="text-sm text-muted-foreground mt-0.5">
|
||||
{equipment}
|
||||
</Text>
|
||||
)}
|
||||
{description && (
|
||||
<Text className="text-sm text-muted-foreground mt-2 native:pr-12">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{(usageCount || lastUsed) && (
|
||||
<View className="flex-row gap-4 mt-2">
|
||||
{usageCount && (
|
||||
<Text className="text-xs text-muted-foreground">
|
||||
Used {usageCount} times
|
||||
</Text>
|
||||
)}
|
||||
{lastUsed && (
|
||||
<Text className="text-xs text-muted-foreground">
|
||||
Last used: {lastUsed.toLocaleDateString()}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{tags.length > 0 && (
|
||||
<View className="flex-row flex-wrap gap-2 mt-2">
|
||||
{tags.map(tag => (
|
||||
<Badge key={tag} variant="outline" className="text-xs">
|
||||
<Text>{tag}</Text>
|
||||
</Badge>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className="flex-row gap-1 native:absolute native:right-0 native:top-0 native:p-2">
|
||||
{onFavorite && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onPress={onFavorite}
|
||||
className="native:h-10 native:w-10"
|
||||
>
|
||||
<Star className="text-muted-foreground" size={20} />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<AlertDialog open={showDeleteAlert} onOpenChange={setShowDeleteAlert}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="native:h-10 native:w-10 native:bg-muted/50 items-center justify-center"
|
||||
>
|
||||
<Trash2
|
||||
size={20}
|
||||
color={Platform.select({
|
||||
ios: undefined, // Let className handle it
|
||||
android: '#8B5CF6' // Explicit color for Android
|
||||
})}
|
||||
className="text-destructive"
|
||||
/>
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
<Text>Delete Exercise</Text>
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
<Text>Are you sure you want to delete {title}? This action cannot be undone.</Text>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
<Text>Cancel</Text>
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onPress={handleConfirmDelete}>
|
||||
<Text>Delete</Text>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</View>
|
||||
</View>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Bottom sheet section */}
|
||||
<Sheet isOpen={showSheet} onClose={() => setShowSheet(false)}>
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
<Text className="text-xl font-bold">{title}</Text>
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<SheetContent>
|
||||
<View className="gap-6">
|
||||
{description && (
|
||||
<View>
|
||||
<Text className="text-base font-semibold mb-2">Description</Text>
|
||||
<Text className="text-base leading-relaxed">{description}</Text>
|
||||
</View>
|
||||
)}
|
||||
<View>
|
||||
<Text className="text-base font-semibold mb-2">Details</Text>
|
||||
<View className="gap-2">
|
||||
<Text className="text-base">Category: {category}</Text>
|
||||
{equipment && <Text className="text-base">Equipment: {equipment}</Text>}
|
||||
<Text className="text-base">Source: {source}</Text>
|
||||
</View>
|
||||
</View>
|
||||
{(usageCount || lastUsed) && (
|
||||
<View>
|
||||
<Text className="text-base font-semibold mb-2">Statistics</Text>
|
||||
<View className="gap-2">
|
||||
{usageCount && (
|
||||
<Text className="text-base">Used {usageCount} times</Text>
|
||||
)}
|
||||
{lastUsed && (
|
||||
<Text className="text-base">
|
||||
Last used: {lastUsed.toLocaleDateString()}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
{tags.length > 0 && (
|
||||
<View>
|
||||
<Text className="text-base font-semibold mb-2">Tags</Text>
|
||||
<View className="flex-row flex-wrap gap-2">
|
||||
{tags.map(tag => (
|
||||
<Badge key={tag} variant="secondary">
|
||||
<Text>{tag}</Text>
|
||||
</Badge>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
);
|
||||
}
|
124
components/library/FilterSheet.tsx
Normal file
124
components/library/FilterSheet.tsx
Normal file
@ -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<T extends string>(
|
||||
title: string,
|
||||
category: keyof FilterOptions,
|
||||
values: T[],
|
||||
selectedValues: T[],
|
||||
onToggle: (category: keyof FilterOptions, value: T) => void
|
||||
) {
|
||||
return (
|
||||
<AccordionItem value={category}>
|
||||
<AccordionTrigger>
|
||||
<View className="flex-row items-center gap-2">
|
||||
<Text className="text-base font-medium">{title}</Text>
|
||||
{selectedValues.length > 0 && (
|
||||
<Badge variant="secondary">
|
||||
<Text>{selectedValues.length}</Text>
|
||||
</Badge>
|
||||
)}
|
||||
</View>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<View className="flex-row flex-wrap gap-2">
|
||||
{values.map(value => {
|
||||
const isSelected = selectedValues.includes(value);
|
||||
return (
|
||||
<Button
|
||||
key={value}
|
||||
variant={isSelected ? 'purple' : 'outline'}
|
||||
onPress={() => onToggle(category, value)}
|
||||
>
|
||||
<Text className={isSelected ? 'text-white' : ''}>{value}</Text>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Sheet isOpen={isOpen} onClose={onClose}>
|
||||
<SheetHeader>
|
||||
<SheetTitle>Filter Exercises</SheetTitle>
|
||||
</SheetHeader>
|
||||
<SheetContent>
|
||||
<View className="gap-4">
|
||||
<Accordion type="single" collapsible>
|
||||
{renderFilterSection('Equipment', 'equipment', availableFilters.equipment, localOptions.equipment, toggleFilter)}
|
||||
{renderFilterSection('Tags', 'tags', availableFilters.tags, localOptions.tags, toggleFilter)}
|
||||
{renderFilterSection<SourceType>('Source', 'source', availableFilters.source, localOptions.source, toggleFilter)}
|
||||
</Accordion>
|
||||
<View className="flex-row gap-2 mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onPress={() => setLocalOptions({ equipment: [], tags: [], source: [] })}
|
||||
>
|
||||
<Text>Reset</Text>
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
variant="purple"
|
||||
onPress={() => {
|
||||
onApplyFilters(localOptions);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<Text className="text-white font-semibold">Apply Filters</Text>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
190
components/library/NewExerciseSheet.tsx
Normal file
190
components/library/NewExerciseSheet.tsx
Normal file
@ -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 (
|
||||
<Sheet isOpen={isOpen} onClose={onClose}>
|
||||
<SheetHeader>
|
||||
<SheetTitle>New Exercise</SheetTitle>
|
||||
</SheetHeader>
|
||||
<SheetContent>
|
||||
<View className="gap-4">
|
||||
<View>
|
||||
<Text className="text-base font-medium mb-2">Exercise Name</Text>
|
||||
<Input
|
||||
value={formData.title}
|
||||
onChangeText={(text) => setFormData(prev => ({ ...prev, title: text }))}
|
||||
placeholder="e.g., Barbell Back Squat"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<Text className="text-base font-medium mb-2">Type</Text>
|
||||
<View className="flex-row flex-wrap gap-2">
|
||||
{EXERCISE_TYPES.map((type) => (
|
||||
<Button
|
||||
key={type}
|
||||
variant={formData.type === type ? 'purple' : 'outline'}
|
||||
onPress={() => setFormData(prev => ({ ...prev, type }))}
|
||||
>
|
||||
<Text className={formData.type === type ? 'text-white' : ''}>
|
||||
{type}
|
||||
</Text>
|
||||
</Button>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<Text className="text-base font-medium mb-2">Category</Text>
|
||||
<View className="flex-row flex-wrap gap-2">
|
||||
{CATEGORIES.map((category) => (
|
||||
<Button
|
||||
key={category}
|
||||
variant={formData.category === category ? 'purple' : 'outline'}
|
||||
onPress={() => setFormData(prev => ({ ...prev, category }))}
|
||||
>
|
||||
<Text className={formData.category === category ? 'text-white' : ''}>
|
||||
{category}
|
||||
</Text>
|
||||
</Button>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<Text className="text-base font-medium mb-2">Equipment</Text>
|
||||
<View className="flex-row flex-wrap gap-2">
|
||||
{EQUIPMENT_OPTIONS.map((eq) => (
|
||||
<Button
|
||||
key={eq}
|
||||
variant={formData.equipment === eq ? 'purple' : 'outline'}
|
||||
onPress={() => setFormData(prev => ({ ...prev, equipment: eq }))}
|
||||
>
|
||||
<Text className={formData.equipment === eq ? 'text-white' : ''}>
|
||||
{eq}
|
||||
</Text>
|
||||
</Button>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<Text className="text-base font-medium mb-2">Description</Text>
|
||||
<Input
|
||||
value={formData.description}
|
||||
onChangeText={(text) => setFormData(prev => ({ ...prev, description: text }))}
|
||||
placeholder="Exercise description..."
|
||||
multiline
|
||||
numberOfLines={4}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Button
|
||||
className="mt-4"
|
||||
variant='purple'
|
||||
onPress={handleSubmit}
|
||||
disabled={!formData.title || !formData.equipment}
|
||||
>
|
||||
<Text className="text-white font-semibold">Create Exercise</Text>
|
||||
</Button>
|
||||
</View>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
146
components/library/NewTemplateSheet.tsx
Normal file
146
components/library/NewTemplateSheet.tsx
Normal file
@ -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 (
|
||||
<Sheet isOpen={isOpen} onClose={onClose}>
|
||||
<SheetHeader>
|
||||
<SheetTitle>New Template</SheetTitle>
|
||||
</SheetHeader>
|
||||
<SheetContent>
|
||||
<View className="gap-4">
|
||||
<View>
|
||||
<Text className="text-base font-medium mb-2">Template Name</Text>
|
||||
<Input
|
||||
value={formData.title}
|
||||
onChangeText={(text) => setFormData(prev => ({ ...prev, title: text }))}
|
||||
placeholder="e.g., Full Body Strength"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<Text className="text-base font-medium mb-2">Workout Type</Text>
|
||||
<View className="flex-row flex-wrap gap-2">
|
||||
{WORKOUT_TYPES.map((type) => (
|
||||
<Button
|
||||
key={type}
|
||||
variant={formData.type === type ? 'purple' : 'outline'}
|
||||
onPress={() => setFormData(prev => ({ ...prev, type }))}
|
||||
>
|
||||
<Text
|
||||
className={cn(
|
||||
"text-base font-medium capitalize",
|
||||
formData.type === type ? "text-white" : "text-foreground"
|
||||
)}
|
||||
>
|
||||
{type}
|
||||
</Text>
|
||||
</Button>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<Text className="text-base font-medium mb-2">Category</Text>
|
||||
<View className="flex-row flex-wrap gap-2">
|
||||
{CATEGORIES.map((category) => (
|
||||
<Button
|
||||
key={category}
|
||||
variant={formData.category === category ? 'purple' : 'outline'}
|
||||
onPress={() => setFormData(prev => ({ ...prev, category }))}
|
||||
>
|
||||
<Text
|
||||
className={cn(
|
||||
"text-base font-medium",
|
||||
formData.category === category ? "text-white" : "text-foreground"
|
||||
)}
|
||||
>
|
||||
{category}
|
||||
</Text>
|
||||
</Button>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<Text className="text-base font-medium mb-2">Description</Text>
|
||||
<Input
|
||||
value={formData.description}
|
||||
onChangeText={(text) => setFormData(prev => ({ ...prev, description: text }))}
|
||||
placeholder="Template description..."
|
||||
multiline
|
||||
numberOfLines={4}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Button
|
||||
variant="purple"
|
||||
className="mt-4"
|
||||
onPress={handleSubmit}
|
||||
disabled={!formData.title || !formData.type || !formData.category}
|
||||
>
|
||||
<Text className="text-white font-semibold">Create Template</Text>
|
||||
</Button>
|
||||
</View>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
47
components/library/SearchHeader.tsx
Normal file
47
components/library/SearchHeader.tsx
Normal file
@ -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 (
|
||||
<View className="flex-row items-center gap-2 px-4 py-2 bg-background border-b border-border">
|
||||
<Input
|
||||
placeholder="Search exercises..."
|
||||
value={searchQuery}
|
||||
onChangeText={onSearchChange}
|
||||
className="flex-1 text-foreground"
|
||||
placeholderTextColor={colors.textSecondary}
|
||||
/>
|
||||
<View className="relative">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onPress={onOpenFilters}
|
||||
>
|
||||
<SlidersHorizontal size={20} className="text-foreground" />
|
||||
</Button>
|
||||
{activeFilters > 0 && (
|
||||
<Badge
|
||||
className="absolute -top-2 -right-2 w-5 h-5 flex items-center justify-center p-0"
|
||||
>
|
||||
{activeFilters}
|
||||
</Badge>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
14
components/pager/index.ts
Normal file
14
components/pager/index.ts
Normal file
@ -0,0 +1,14 @@
|
||||
// components/pager/index.ts
|
||||
import { Platform } from 'react-native';
|
||||
import type { PagerProps, PagerRef } from './types';
|
||||
|
||||
let PagerComponent: React.ForwardRefExoticComponent<PagerProps & React.RefAttributes<PagerRef>>;
|
||||
|
||||
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;
|
8
components/pager/pager.native.tsx
Normal file
8
components/pager/pager.native.tsx
Normal file
@ -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<PagerProps> = PagerView as unknown as React.ForwardRefExoticComponent<PagerProps>;
|
||||
|
||||
export default NativePager;
|
70
components/pager/pager.web.tsx
Normal file
70
components/pager/pager.web.tsx
Normal file
@ -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<PagerRef, PagerProps>(
|
||||
({ children, onPageSelected, initialPage = 0, style }, ref) => {
|
||||
const scrollRef = React.useRef<ScrollView>(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 (
|
||||
<ScrollView
|
||||
ref={scrollRef}
|
||||
horizontal
|
||||
pagingEnabled
|
||||
showsHorizontalScrollIndicator={false}
|
||||
onScroll={handleScroll}
|
||||
scrollEventThrottle={16}
|
||||
style={[styles.container, style]}
|
||||
>
|
||||
{React.Children.map(children, (child) => (
|
||||
<View style={{ width }}>{child}</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default Pager;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
20
components/pager/types.ts
Normal file
20
components/pager/types.ts
Normal file
@ -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<ViewStyle>;
|
||||
initialPage?: number;
|
||||
onPageSelected?: (e: PageSelectedEvent) => void;
|
||||
}
|
||||
|
||||
export interface PagerRef {
|
||||
setPage: (page: number) => void;
|
||||
scrollTo?: (options: { x: number; animated?: boolean }) => void;
|
||||
}
|
35
components/shared/FloatingActionButton.tsx
Normal file
35
components/shared/FloatingActionButton.tsx
Normal file
@ -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 (
|
||||
<View style={[{
|
||||
position: 'absolute',
|
||||
right: 16,
|
||||
bottom: 16,
|
||||
zIndex: 50
|
||||
}, style]}>
|
||||
<Button
|
||||
size="icon"
|
||||
className={`h-14 w-14 rounded-full shadow-lg bg-purple-500 ${className || ''}`}
|
||||
onPress={onPress}
|
||||
>
|
||||
<Icon size={24} color="white" />
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
}
|
248
components/templates/TemplateCard.tsx
Normal file
248
components/templates/TemplateCard.tsx
Normal file
@ -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 (
|
||||
<>
|
||||
<TouchableOpacity onPress={handleCardPress} activeOpacity={0.7}>
|
||||
<Card className="mx-4">
|
||||
<CardContent className="p-4">
|
||||
<View className="flex-row justify-between items-start">
|
||||
<View className="flex-1">
|
||||
<View className="flex-row items-center gap-2 mb-1">
|
||||
<Text className="text-lg font-semibold text-card-foreground">
|
||||
{title}
|
||||
</Text>
|
||||
<Badge
|
||||
variant={source === 'local' ? 'outline' : 'secondary'}
|
||||
className="text-xs capitalize"
|
||||
>
|
||||
<Text>{source}</Text>
|
||||
</Badge>
|
||||
</View>
|
||||
|
||||
<View className="flex-row gap-2">
|
||||
<Badge variant="outline" className="text-xs capitalize">
|
||||
<Text>{type}</Text>
|
||||
</Badge>
|
||||
<Text className="text-sm text-muted-foreground">
|
||||
{category}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{exercises.length > 0 && (
|
||||
<View className="mt-2">
|
||||
<Text className="text-sm text-muted-foreground mb-1">
|
||||
Exercises:
|
||||
</Text>
|
||||
<View className="gap-1">
|
||||
{exercises.slice(0, 3).map((exercise, index) => (
|
||||
<Text key={index} className="text-sm text-muted-foreground">
|
||||
• {exercise.title} ({exercise.targetSets}×{exercise.targetReps})
|
||||
</Text>
|
||||
))}
|
||||
{exercises.length > 3 && (
|
||||
<Text className="text-sm text-muted-foreground">
|
||||
+{exercises.length - 3} more
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{description && (
|
||||
<Text className="text-sm text-muted-foreground mt-2 native:pr-12">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{tags.length > 0 && (
|
||||
<View className="flex-row flex-wrap gap-2 mt-2">
|
||||
{tags.map(tag => (
|
||||
<Badge key={tag} variant="outline" className="text-xs">
|
||||
<Text>{tag}</Text>
|
||||
</Badge>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{lastUsed && (
|
||||
<Text className="text-xs text-muted-foreground mt-2">
|
||||
Last used: {lastUsed.toLocaleDateString()}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className="flex-row gap-1 native:absolute native:right-0 native:top-0 native:p-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onPress={onStartWorkout}
|
||||
className="native:h-10 native:w-10"
|
||||
>
|
||||
<Play className="text-primary" size={20} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onPress={onFavorite}
|
||||
className="native:h-10 native:w-10"
|
||||
>
|
||||
<Star
|
||||
className={isFavorite ? "text-primary" : "text-muted-foreground"}
|
||||
fill={isFavorite ? "currentColor" : "none"}
|
||||
size={20}
|
||||
/>
|
||||
</Button>
|
||||
<AlertDialog open={showDeleteAlert} onOpenChange={setShowDeleteAlert}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="native:h-10 native:w-10 native:bg-muted/50 items-center justify-center"
|
||||
>
|
||||
<Trash2
|
||||
size={20}
|
||||
color={Platform.select({
|
||||
ios: undefined,
|
||||
android: '#8B5CF6'
|
||||
})}
|
||||
className="text-destructive"
|
||||
/>
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
<Text>Delete Template</Text>
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
<Text>Are you sure you want to delete {title}? This action cannot be undone.</Text>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
<Text>Cancel</Text>
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onPress={handleConfirmDelete}>
|
||||
<Text>Delete</Text>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</View>
|
||||
</View>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Sheet for detailed view */}
|
||||
<Sheet isOpen={showSheet} onClose={() => setShowSheet(false)}>
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
<Text className="text-xl font-bold">{title}</Text>
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<SheetContent>
|
||||
<View className="gap-6">
|
||||
{description && (
|
||||
<View>
|
||||
<Text className="text-base font-semibold mb-2">Description</Text>
|
||||
<Text className="text-base leading-relaxed">{description}</Text>
|
||||
</View>
|
||||
)}
|
||||
<View>
|
||||
<Text className="text-base font-semibold mb-2">Details</Text>
|
||||
<View className="gap-2">
|
||||
<Text className="text-base">Type: {type}</Text>
|
||||
<Text className="text-base">Category: {category}</Text>
|
||||
<Text className="text-base">Source: {source}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View>
|
||||
<Text className="text-base font-semibold mb-2">Exercises</Text>
|
||||
<View className="gap-2">
|
||||
{exercises.map((exercise, index) => (
|
||||
<Text key={index} className="text-base">
|
||||
{exercise.title} ({exercise.targetSets}×{exercise.targetReps})
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
{tags.length > 0 && (
|
||||
<View>
|
||||
<Text className="text-base font-semibold mb-2">Tags</Text>
|
||||
<View className="flex-row flex-wrap gap-2">
|
||||
{tags.map(tag => (
|
||||
<Badge key={tag} variant="secondary">
|
||||
<Text>{tag}</Text>
|
||||
</Badge>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
);
|
||||
}
|
125
components/ui/accordion.tsx
Normal file
125
components/ui/accordion.tsx
Normal file
@ -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<AccordionPrimitive.RootRef, AccordionPrimitive.RootProps>(
|
||||
({ children, ...props }, ref) => {
|
||||
return (
|
||||
<LayoutAnimationConfig skipEntering>
|
||||
<AccordionPrimitive.Root ref={ref} {...props} asChild={Platform.OS !== 'web'}>
|
||||
<Animated.View layout={LinearTransition.duration(200)}>{children}</Animated.View>
|
||||
</AccordionPrimitive.Root>
|
||||
</LayoutAnimationConfig>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Accordion.displayName = AccordionPrimitive.Root.displayName;
|
||||
|
||||
const AccordionItem = React.forwardRef<AccordionPrimitive.ItemRef, AccordionPrimitive.ItemProps>(
|
||||
({ className, value, ...props }, ref) => {
|
||||
return (
|
||||
<Animated.View className={'overflow-hidden'} layout={LinearTransition.duration(200)}>
|
||||
<AccordionPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn('border-b border-border', className)}
|
||||
value={value}
|
||||
{...props}
|
||||
/>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
);
|
||||
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 (
|
||||
<TextClassContext.Provider value='native:text-lg font-medium web:group-hover:underline'>
|
||||
<AccordionPrimitive.Header className='flex'>
|
||||
<AccordionPrimitive.Trigger ref={ref} {...props} asChild>
|
||||
<Trigger
|
||||
className={cn(
|
||||
'flex flex-row web:flex-1 items-center justify-between py-4 web:transition-all group web:focus-visible:outline-none web:focus-visible:ring-1 web:focus-visible:ring-muted-foreground',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<>{children}</>
|
||||
<Animated.View style={chevronStyle}>
|
||||
<ChevronDown size={18} className={'text-foreground shrink-0'} />
|
||||
</Animated.View>
|
||||
</Trigger>
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
</TextClassContext.Provider>
|
||||
);
|
||||
});
|
||||
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
|
||||
|
||||
const AccordionContent = React.forwardRef<
|
||||
AccordionPrimitive.ContentRef,
|
||||
AccordionPrimitive.ContentProps
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const { isExpanded } = AccordionPrimitive.useItemContext();
|
||||
return (
|
||||
<TextClassContext.Provider value='native:text-lg'>
|
||||
<AccordionPrimitive.Content
|
||||
className={cn(
|
||||
'overflow-hidden text-sm web:transition-all',
|
||||
isExpanded ? 'web:animate-accordion-down' : 'web:animate-accordion-up'
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
<InnerContent className={cn('pb-4', className)}>{children}</InnerContent>
|
||||
</AccordionPrimitive.Content>
|
||||
</TextClassContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
function InnerContent({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||
if (Platform.OS === 'web') {
|
||||
return <View className={cn('pb-4', className)}>{children}</View>;
|
||||
}
|
||||
return (
|
||||
<Animated.View
|
||||
entering={FadeIn}
|
||||
exiting={FadeOutUp.duration(200)}
|
||||
className={cn('pb-4', className)}
|
||||
>
|
||||
{children}
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
|
||||
|
||||
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger };
|
160
components/ui/alert-dialog.tsx
Normal file
160
components/ui/alert-dialog.tsx
Normal file
@ -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 (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
'z-50 bg-black/80 flex justify-center items-center p-2 absolute top-0 right-0 bottom-0 left-0',
|
||||
open ? 'web:animate-in web:fade-in-0' : 'web:animate-out web:fade-out-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
AlertDialogOverlayWeb.displayName = 'AlertDialogOverlayWeb';
|
||||
|
||||
const AlertDialogOverlayNative = React.forwardRef<
|
||||
AlertDialogPrimitive.OverlayRef,
|
||||
AlertDialogPrimitive.OverlayProps
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
style={StyleSheet.absoluteFill}
|
||||
className={cn('z-50 bg-black/80 flex justify-center items-center p-2', className)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
asChild
|
||||
>
|
||||
<Animated.View entering={FadeIn.duration(150)} exiting={FadeOut.duration(150)}>
|
||||
{children}
|
||||
</Animated.View>
|
||||
</AlertDialogPrimitive.Overlay>
|
||||
);
|
||||
});
|
||||
|
||||
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 (
|
||||
<AlertDialogPortal hostName={portalHost}>
|
||||
<AlertDialogOverlay>
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'z-50 max-w-lg gap-4 border border-border bg-background p-6 shadow-lg shadow-foreground/10 web:duration-200 rounded-lg',
|
||||
open
|
||||
? 'web:animate-in web:fade-in-0 web:zoom-in-95'
|
||||
: 'web:animate-out web:fade-out-0 web:zoom-out-95',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogOverlay>
|
||||
</AlertDialogPortal>
|
||||
);
|
||||
});
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
|
||||
|
||||
const AlertDialogHeader = ({ className, ...props }: ViewProps) => (
|
||||
<View className={cn('flex flex-col gap-2', className)} {...props} />
|
||||
);
|
||||
AlertDialogHeader.displayName = 'AlertDialogHeader';
|
||||
|
||||
const AlertDialogFooter = ({ className, ...props }: ViewProps) => (
|
||||
<View
|
||||
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end gap-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
AlertDialogFooter.displayName = 'AlertDialogFooter';
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
AlertDialogPrimitive.TitleRef,
|
||||
AlertDialogPrimitive.TitleProps
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-lg native:text-xl text-foreground font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
AlertDialogPrimitive.DescriptionRef,
|
||||
AlertDialogPrimitive.DescriptionProps
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm native:text-base text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
AlertDialogPrimitive.ActionRef,
|
||||
AlertDialogPrimitive.ActionProps
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TextClassContext.Provider value={buttonTextVariants({ className })}>
|
||||
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
|
||||
</TextClassContext.Provider>
|
||||
));
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
AlertDialogPrimitive.CancelRef,
|
||||
AlertDialogPrimitive.CancelProps
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TextClassContext.Provider value={buttonTextVariants({ className, variant: 'outline' })}>
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(buttonVariants({ variant: 'outline', className }))}
|
||||
{...props}
|
||||
/>
|
||||
</TextClassContext.Provider>
|
||||
));
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
};
|
75
components/ui/alert.tsx
Normal file
75
components/ui/alert.tsx
Normal file
@ -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<typeof View>,
|
||||
ViewProps &
|
||||
VariantProps<typeof alertVariants> & {
|
||||
icon: LucideIcon;
|
||||
iconSize?: number;
|
||||
iconClassName?: string;
|
||||
}
|
||||
>(({ className, variant, children, icon: Icon, iconSize = 16, iconClassName, ...props }, ref) => {
|
||||
const { colors } = useTheme();
|
||||
return (
|
||||
<View ref={ref} role='alert' className={alertVariants({ variant, className })} {...props}>
|
||||
<View className='absolute left-3.5 top-4 -translate-y-0.5'>
|
||||
<Icon
|
||||
size={iconSize}
|
||||
color={variant === 'destructive' ? colors.notification : colors.text}
|
||||
/>
|
||||
</View>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
Alert.displayName = 'Alert';
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
React.ElementRef<typeof Text>,
|
||||
React.ComponentPropsWithoutRef<typeof Text>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<Text
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'pl-7 mb-1 font-medium text-base leading-none tracking-tight text-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertTitle.displayName = 'AlertTitle';
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
React.ElementRef<typeof Text>,
|
||||
React.ComponentPropsWithoutRef<typeof Text>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<Text
|
||||
ref={ref}
|
||||
className={cn('pl-7 text-sm leading-relaxed text-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDescription.displayName = 'AlertDescription';
|
||||
|
||||
export { Alert, AlertDescription, AlertTitle };
|
5
components/ui/aspect-ratio.tsx
Normal file
5
components/ui/aspect-ratio.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import * as AspectRatioPrimitive from '@rn-primitives/aspect-ratio';
|
||||
|
||||
const AspectRatio = AspectRatioPrimitive.Root;
|
||||
|
||||
export { AspectRatio };
|
@ -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;
|
||||
|
51
components/ui/badge.tsx
Normal file
51
components/ui/badge.tsx
Normal file
@ -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<typeof badgeVariants>;
|
||||
|
||||
function Badge({ className, variant, asChild, ...props }: BadgeProps) {
|
||||
const Component = asChild ? Slot.View : View;
|
||||
return (
|
||||
<TextClassContext.Provider value={badgeTextVariants({ variant })}>
|
||||
<Component className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
</TextClassContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeTextVariants, badgeVariants };
|
||||
export type { BadgeProps };
|
@ -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<typeof Pressable> &
|
||||
VariantProps<typeof buttonVariants>;
|
||||
|
||||
@ -85,4 +96,4 @@ const Button = React.forwardRef<React.ElementRef<typeof Pressable>, ButtonProps>
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export { Button, buttonTextVariants, buttonVariants };
|
||||
export type { ButtonProps };
|
||||
export type { ButtonProps };
|
@ -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<ViewRef, ViewProps>(({ className, ...props }, ref) => (
|
||||
<View
|
||||
|
32
components/ui/checkbox.tsx
Normal file
32
components/ui/checkbox.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import * as CheckboxPrimitive from '@rn-primitives/checkbox';
|
||||
import * as React from 'react';
|
||||
import { Platform } from 'react-native';
|
||||
import { Check } from '@/lib/icons/Check';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Checkbox = React.forwardRef<CheckboxPrimitive.RootRef, CheckboxPrimitive.RootProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'web:peer h-4 w-4 native:h-[20] native:w-[20] shrink-0 rounded-sm native:rounded border border-primary web:ring-offset-background web:focus-visible:outline-none web:focus-visible:ring-2 web:focus-visible:ring-ring web:focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
props.checked && 'bg-primary',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator className={cn('items-center justify-center h-full w-full')}>
|
||||
<Check
|
||||
size={12}
|
||||
strokeWidth={Platform.OS === 'web' ? 2.5 : 3.5}
|
||||
className='text-primary-foreground'
|
||||
/>
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
);
|
||||
}
|
||||
);
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||
|
||||
export { Checkbox };
|
9
components/ui/collapsible.tsx
Normal file
9
components/ui/collapsible.tsx
Normal file
@ -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 };
|
245
components/ui/context-menu.tsx
Normal file
245
components/ui/context-menu.tsx
Normal file
@ -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 (
|
||||
<TextClassContext.Provider
|
||||
value={cn(
|
||||
'select-none text-sm native:text-lg text-primary',
|
||||
open && 'native:text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
<ContextMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex flex-row web:cursor-default web:select-none items-center gap-2 web:focus:bg-accent active:bg-accent web:hover:bg-accent rounded-sm px-2 py-1.5 native:py-2 web:outline-none',
|
||||
open && 'bg-accent',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<>{children}</>
|
||||
<Icon size={18} className='ml-auto text-foreground' />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
</TextClassContext.Provider>
|
||||
);
|
||||
});
|
||||
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const ContextMenuSubContent = React.forwardRef<
|
||||
ContextMenuPrimitive.SubContentRef,
|
||||
ContextMenuPrimitive.SubContentProps
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { open } = ContextMenuPrimitive.useSubContext();
|
||||
return (
|
||||
<ContextMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border mt-1 border-border bg-popover p-1 shadow-md shadow-foreground/5 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
open
|
||||
? 'web:animate-in web:fade-in-0 web:zoom-in-95'
|
||||
: 'web:animate-out web:fade-out-0 web:zoom-out',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const ContextMenuContent = React.forwardRef<
|
||||
ContextMenuPrimitive.ContentRef,
|
||||
ContextMenuPrimitive.ContentProps & {
|
||||
overlayStyle?: StyleProp<ViewStyle>;
|
||||
overlayClassName?: string;
|
||||
portalHost?: string;
|
||||
}
|
||||
>(({ className, overlayClassName, overlayStyle, portalHost, ...props }, ref) => {
|
||||
const { open } = ContextMenuPrimitive.useRootContext();
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal hostName={portalHost}>
|
||||
<ContextMenuPrimitive.Overlay
|
||||
style={
|
||||
overlayStyle
|
||||
? StyleSheet.flatten([
|
||||
Platform.OS !== 'web' ? StyleSheet.absoluteFill : undefined,
|
||||
overlayStyle,
|
||||
])
|
||||
: Platform.OS !== 'web'
|
||||
? StyleSheet.absoluteFill
|
||||
: undefined
|
||||
}
|
||||
className={overlayClassName}
|
||||
>
|
||||
<ContextMenuPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover p-1 shadow-md shadow-foreground/5 web:data-[side=bottom]:slide-in-from-top-2 web:data-[side=left]:slide-in-from-right-2 web:data-[side=right]:slide-in-from-left-2 web:data-[side=top]:slide-in-from-bottom-2',
|
||||
open
|
||||
? 'web:animate-in web:fade-in-0 web:zoom-in-95'
|
||||
: 'web:animate-out web:fade-out-0 web:zoom-out-95',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ContextMenuPrimitive.Overlay>
|
||||
</ContextMenuPrimitive.Portal>
|
||||
);
|
||||
});
|
||||
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
|
||||
|
||||
const ContextMenuItem = React.forwardRef<
|
||||
ContextMenuPrimitive.ItemRef,
|
||||
ContextMenuPrimitive.ItemProps & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<TextClassContext.Provider value='select-none text-sm native:text-lg text-popover-foreground web:group-focus:text-accent-foreground'>
|
||||
<ContextMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex flex-row web:cursor-default items-center gap-2 rounded-sm px-2 py-1.5 native:py-2 web:outline-none web:focus:bg-accent active:bg-accent web:hover:bg-accent group',
|
||||
inset && 'pl-8',
|
||||
props.disabled && 'opacity-50 web:pointer-events-none',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TextClassContext.Provider>
|
||||
));
|
||||
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
|
||||
|
||||
const ContextMenuCheckboxItem = React.forwardRef<
|
||||
ContextMenuPrimitive.CheckboxItemRef,
|
||||
ContextMenuPrimitive.CheckboxItemProps
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex flex-row web:cursor-default items-center web:group rounded-sm py-1.5 native:py-2 pl-8 pr-2 web:outline-none web:focus:bg-accent active:bg-accent',
|
||||
props.disabled && 'web:pointer-events-none opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<View className='absolute left-2 flex h-3.5 w-3.5 items-center justify-center'>
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<Check size={14} strokeWidth={3} className='text-foreground' />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</View>
|
||||
<>{children}</>
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
ContextMenuCheckboxItem.displayName = ContextMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const ContextMenuRadioItem = React.forwardRef<
|
||||
ContextMenuPrimitive.RadioItemRef,
|
||||
ContextMenuPrimitive.RadioItemProps
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex flex-row web:cursor-default web:group items-center rounded-sm py-1.5 native:py-2 pl-8 pr-2 web:outline-none web:focus:bg-accent active:bg-accent',
|
||||
props.disabled && 'web:pointer-events-none opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<View className='absolute left-2 flex h-3.5 w-3.5 items-center justify-center'>
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<View className='bg-foreground h-2 w-2 rounded-full' />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</View>
|
||||
<>{children}</>
|
||||
</ContextMenuPrimitive.RadioItem>
|
||||
));
|
||||
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;
|
||||
|
||||
const ContextMenuLabel = React.forwardRef<
|
||||
ContextMenuPrimitive.LabelRef,
|
||||
ContextMenuPrimitive.LabelProps & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'px-2 py-1.5 text-sm native:text-base font-semibold text-foreground web:cursor-default',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
|
||||
|
||||
const ContextMenuSeparator = React.forwardRef<
|
||||
ContextMenuPrimitive.SeparatorRef,
|
||||
ContextMenuPrimitive.SeparatorProps
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-border', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
|
||||
|
||||
const ContextMenuShortcut = ({ className, ...props }: TextProps) => {
|
||||
return (
|
||||
<Text
|
||||
className={cn(
|
||||
'ml-auto text-xs native:text-sm tracking-widest text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
ContextMenuShortcut.displayName = 'ContextMenuShortcut';
|
||||
|
||||
export {
|
||||
ContextMenu,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuContent,
|
||||
ContextMenuGroup,
|
||||
ContextMenuItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuRadioGroup,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuShortcut,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuTrigger,
|
||||
};
|
147
components/ui/dialog.tsx
Normal file
147
components/ui/dialog.tsx
Normal file
@ -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<DialogPrimitive.OverlayRef, DialogPrimitive.OverlayProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
const { open } = DialogPrimitive.useRootContext();
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
className={cn(
|
||||
'bg-black/80 flex justify-center items-center p-2 absolute top-0 right-0 bottom-0 left-0',
|
||||
open ? 'web:animate-in web:fade-in-0' : 'web:animate-out web:fade-out-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
DialogOverlayWeb.displayName = 'DialogOverlayWeb';
|
||||
|
||||
const DialogOverlayNative = React.forwardRef<
|
||||
DialogPrimitive.OverlayRef,
|
||||
DialogPrimitive.OverlayProps
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
style={StyleSheet.absoluteFill}
|
||||
className={cn('flex bg-black/80 justify-center items-center p-2', className)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<Animated.View entering={FadeIn.duration(150)} exiting={FadeOut.duration(150)}>
|
||||
<>{children}</>
|
||||
</Animated.View>
|
||||
</DialogPrimitive.Overlay>
|
||||
);
|
||||
});
|
||||
|
||||
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 (
|
||||
<DialogPortal hostName={portalHost}>
|
||||
<DialogOverlay>
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'max-w-lg gap-4 border border-border web:cursor-default bg-background p-6 shadow-lg web:duration-200 rounded-lg',
|
||||
open
|
||||
? 'web:animate-in web:fade-in-0 web:zoom-in-95'
|
||||
: 'web:animate-out web:fade-out-0 web:zoom-out-95',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close
|
||||
className={
|
||||
'absolute right-4 top-4 p-0.5 web:group rounded-sm opacity-70 web:ring-offset-background web:transition-opacity web:hover:opacity-100 web:focus:outline-none web:focus:ring-2 web:focus:ring-ring web:focus:ring-offset-2 web:disabled:pointer-events-none'
|
||||
}
|
||||
>
|
||||
<X
|
||||
size={Platform.OS === 'web' ? 16 : 18}
|
||||
className={cn('text-muted-foreground', open && 'text-accent-foreground')}
|
||||
/>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogOverlay>
|
||||
</DialogPortal>
|
||||
);
|
||||
});
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({ className, ...props }: ViewProps) => (
|
||||
<View className={cn('flex flex-col gap-1.5 text-center sm:text-left', className)} {...props} />
|
||||
);
|
||||
DialogHeader.displayName = 'DialogHeader';
|
||||
|
||||
const DialogFooter = ({ className, ...props }: ViewProps) => (
|
||||
<View
|
||||
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end gap-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogFooter.displayName = 'DialogFooter';
|
||||
|
||||
const DialogTitle = React.forwardRef<DialogPrimitive.TitleRef, DialogPrimitive.TitleProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-lg native:text-xl text-foreground font-semibold leading-none tracking-tight',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
DialogPrimitive.DescriptionRef,
|
||||
DialogPrimitive.DescriptionProps
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm native:text-base text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
};
|
253
components/ui/dropdown-menu.tsx
Normal file
253
components/ui/dropdown-menu.tsx
Normal file
@ -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 (
|
||||
<TextClassContext.Provider
|
||||
value={cn(
|
||||
'select-none text-sm native:text-lg text-primary',
|
||||
open && 'native:text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex flex-row web:cursor-default web:select-none gap-2 items-center web:focus:bg-accent web:hover:bg-accent active:bg-accent rounded-sm px-2 py-1.5 native:py-2 web:outline-none',
|
||||
open && 'bg-accent',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<>{children}</>
|
||||
<Icon size={18} className='ml-auto text-foreground' />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
</TextClassContext.Provider>
|
||||
);
|
||||
});
|
||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
DropdownMenuPrimitive.SubContentRef,
|
||||
DropdownMenuPrimitive.SubContentProps
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { open } = DropdownMenuPrimitive.useSubContext();
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border border-border mt-1 bg-popover p-1 shadow-md shadow-foreground/5 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
open
|
||||
? 'web:animate-in web:fade-in-0 web:zoom-in-95'
|
||||
: 'web:animate-out web:fade-out-0 web:zoom-out',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
DropdownMenuPrimitive.ContentRef,
|
||||
DropdownMenuPrimitive.ContentProps & {
|
||||
overlayStyle?: StyleProp<ViewStyle>;
|
||||
overlayClassName?: string;
|
||||
portalHost?: string;
|
||||
}
|
||||
>(({ className, overlayClassName, overlayStyle, portalHost, ...props }, ref) => {
|
||||
const { open } = DropdownMenuPrimitive.useRootContext();
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal hostName={portalHost}>
|
||||
<DropdownMenuPrimitive.Overlay
|
||||
style={
|
||||
overlayStyle
|
||||
? StyleSheet.flatten([
|
||||
Platform.OS !== 'web' ? StyleSheet.absoluteFill : undefined,
|
||||
overlayStyle,
|
||||
])
|
||||
: Platform.OS !== 'web'
|
||||
? StyleSheet.absoluteFill
|
||||
: undefined
|
||||
}
|
||||
className={overlayClassName}
|
||||
>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover p-1 shadow-md shadow-foreground/5 web:data-[side=bottom]:slide-in-from-top-2 web:data-[side=left]:slide-in-from-right-2 web:data-[side=right]:slide-in-from-left-2 web:data-[side=top]:slide-in-from-bottom-2',
|
||||
open
|
||||
? 'web:animate-in web:fade-in-0 web:zoom-in-95'
|
||||
: 'web:animate-out web:fade-out-0 web:zoom-out-95',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Overlay>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
);
|
||||
});
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
DropdownMenuPrimitive.ItemRef,
|
||||
DropdownMenuPrimitive.ItemProps & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<TextClassContext.Provider value='select-none text-sm native:text-lg text-popover-foreground web:group-focus:text-accent-foreground'>
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex flex-row web:cursor-default gap-2 items-center rounded-sm px-2 py-1.5 native:py-2 web:outline-none web:focus:bg-accent active:bg-accent web:hover:bg-accent group',
|
||||
inset && 'pl-8',
|
||||
props.disabled && 'opacity-50 web:pointer-events-none',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TextClassContext.Provider>
|
||||
));
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
DropdownMenuPrimitive.CheckboxItemRef,
|
||||
DropdownMenuPrimitive.CheckboxItemProps
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex flex-row web:cursor-default items-center web:group rounded-sm py-1.5 native:py-2 pl-8 pr-2 web:outline-none web:focus:bg-accent active:bg-accent',
|
||||
props.disabled && 'web:pointer-events-none opacity-50',
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<View className='absolute left-2 flex h-3.5 w-3.5 items-center justify-center'>
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check size={14} strokeWidth={3} className='text-foreground' />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</View>
|
||||
<>{children}</>
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
DropdownMenuPrimitive.RadioItemRef,
|
||||
DropdownMenuPrimitive.RadioItemProps
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex flex-row web:cursor-default web:group items-center rounded-sm py-1.5 native:py-2 pl-8 pr-2 web:outline-none web:focus:bg-accent active:bg-accent',
|
||||
props.disabled && 'web:pointer-events-none opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<View className='absolute left-2 flex h-3.5 w-3.5 items-center justify-center'>
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<View className='bg-foreground h-2 w-2 rounded-full' />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</View>
|
||||
<>{children}</>
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
));
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
DropdownMenuPrimitive.LabelRef,
|
||||
DropdownMenuPrimitive.LabelProps & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'px-2 py-1.5 text-sm native:text-base font-semibold text-foreground web:cursor-default',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
DropdownMenuPrimitive.SeparatorRef,
|
||||
DropdownMenuPrimitive.SeparatorProps
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-border', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
const DropdownMenuShortcut = ({ className, ...props }: TextProps) => {
|
||||
return (
|
||||
<Text
|
||||
className={cn(
|
||||
'ml-auto text-xs native:text-sm tracking-widest text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
};
|
45
components/ui/hover-card.tsx
Normal file
45
components/ui/hover-card.tsx
Normal file
@ -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 (
|
||||
<HoverCardPrimitive.Portal>
|
||||
<HoverCardPrimitive.Overlay
|
||||
style={Platform.OS !== 'web' ? StyleSheet.absoluteFill : undefined}
|
||||
>
|
||||
<Animated.View entering={FadeIn}>
|
||||
<TextClassContext.Provider value='text-popover-foreground'>
|
||||
<HoverCardPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 w-64 rounded-md border border-border bg-popover p-4 shadow-md shadow-foreground/5 web:outline-none web:cursor-auto data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
open
|
||||
? 'web:animate-in web:fade-in-0 web:zoom-in-95'
|
||||
: 'web:animate-out web:fade-out-0 web:zoom-out-95',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TextClassContext.Provider>
|
||||
</Animated.View>
|
||||
</HoverCardPrimitive.Overlay>
|
||||
</HoverCardPrimitive.Portal>
|
||||
);
|
||||
});
|
||||
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
|
||||
|
||||
export { HoverCard, HoverCardContent, HoverCardTrigger };
|
40
components/ui/input.tsx
Normal file
40
components/ui/input.tsx
Normal file
@ -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<React.ElementRef<typeof TextInput>, TextInputProps>(
|
||||
({ className, placeholderClassName, ...props }, ref) => {
|
||||
return (
|
||||
<TextInput
|
||||
ref={ref}
|
||||
className={cn(
|
||||
// Base styles
|
||||
'web:flex h-10 native:h-12 web:w-full rounded-md',
|
||||
// Border and background
|
||||
'border border-input',
|
||||
// Use different backgrounds for light/dark modes
|
||||
'bg-background dark:bg-muted',
|
||||
// Padding and typography
|
||||
'px-3 web:py-2 text-base lg:text-sm native:text-lg native:leading-[1.25]',
|
||||
// Text color
|
||||
'text-foreground',
|
||||
// Web-specific focus styles
|
||||
'web:ring-offset-background',
|
||||
'web:file:border-0 web:file:bg-transparent web:file:font-medium',
|
||||
'web:focus-visible:outline-none web:focus-visible:ring-2',
|
||||
'web:focus-visible:ring-ring web:focus-visible:ring-offset-2',
|
||||
// Disabled state
|
||||
props.editable === false && 'opacity-50 web:cursor-not-allowed',
|
||||
className
|
||||
)}
|
||||
// Handle placeholder styling separately
|
||||
placeholderTextColor={placeholderClassName}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export { Input };
|
27
components/ui/label.tsx
Normal file
27
components/ui/label.tsx
Normal file
@ -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<LabelPrimitive.TextRef, LabelPrimitive.TextProps>(
|
||||
({ className, onPress, onLongPress, onPressIn, onPressOut, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
className='web:cursor-default'
|
||||
onPress={onPress}
|
||||
onLongPress={onLongPress}
|
||||
onPressIn={onPressIn}
|
||||
onPressOut={onPressOut}
|
||||
>
|
||||
<LabelPrimitive.Text
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-sm text-foreground native:text-base font-medium leading-none web:peer-disabled:cursor-not-allowed web:peer-disabled:opacity-70',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</LabelPrimitive.Root>
|
||||
)
|
||||
);
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
261
components/ui/menubar.tsx
Normal file
261
components/ui/menubar.tsx
Normal file
@ -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<MenubarPrimitive.RootRef, MenubarPrimitive.RootProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex flex-row h-10 native:h-12 items-center space-x-1 rounded-md border border-border bg-background p-1',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
Menubar.displayName = MenubarPrimitive.Root.displayName;
|
||||
|
||||
const MenubarTrigger = React.forwardRef<MenubarPrimitive.TriggerRef, MenubarPrimitive.TriggerProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
const { value } = MenubarPrimitive.useRootContext();
|
||||
const { value: itemValue } = MenubarPrimitive.useMenuContext();
|
||||
|
||||
return (
|
||||
<MenubarPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex flex-row web:cursor-default web:select-none items-center rounded-sm px-3 py-1.5 text-sm native:h-10 native:px-5 native:py-0 font-medium web:outline-none web:focus:bg-accent active:bg-accent web:focus:text-accent-foreground',
|
||||
value === itemValue && 'bg-accent text-accent-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
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 (
|
||||
<TextClassContext.Provider
|
||||
value={cn(
|
||||
'select-none text-sm native:text-lg text-primary',
|
||||
open && 'native:text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
<MenubarPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex flex-row web:cursor-default web:select-none items-center gap-2 web:focus:bg-accent active:bg-accent web:hover:bg-accent rounded-sm px-2 py-1.5 native:py-2 web:outline-none',
|
||||
open && 'bg-accent',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<>{children}</>
|
||||
<Icon size={18} className='ml-auto text-foreground' />
|
||||
</MenubarPrimitive.SubTrigger>
|
||||
</TextClassContext.Provider>
|
||||
);
|
||||
});
|
||||
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName;
|
||||
|
||||
const MenubarSubContent = React.forwardRef<
|
||||
MenubarPrimitive.SubContentRef,
|
||||
MenubarPrimitive.SubContentProps
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { open } = MenubarPrimitive.useSubContext();
|
||||
return (
|
||||
<MenubarPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border mt-1 border-border bg-popover p-1 shadow-md shadow-foreground/5 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
open
|
||||
? 'web:animate-in web:fade-in-0 web:zoom-in-95'
|
||||
: 'web:animate-out web:fade-out-0 web:zoom-out ',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
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 (
|
||||
<MenubarPrimitive.Portal hostName={portalHost}>
|
||||
<MenubarPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover p-1 shadow-md shadow-foreground/5',
|
||||
value === itemValue
|
||||
? 'web:animate-in web:fade-in-0 web:zoom-in-95'
|
||||
: 'web:animate-out web:fade-out-0 web:zoom-out-95',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</MenubarPrimitive.Portal>
|
||||
);
|
||||
});
|
||||
MenubarContent.displayName = MenubarPrimitive.Content.displayName;
|
||||
|
||||
const MenubarItem = React.forwardRef<
|
||||
MenubarPrimitive.ItemRef,
|
||||
MenubarPrimitive.ItemProps & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<TextClassContext.Provider value='select-none text-sm native:text-lg text-popover-foreground web:group-focus:text-accent-foreground'>
|
||||
<MenubarPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex flex-row web:cursor-default items-center gap-2 rounded-sm px-2 py-1.5 native:py-2 web:outline-none web:focus:bg-accent active:bg-accent web:hover:bg-accent group',
|
||||
inset && 'pl-8',
|
||||
props.disabled && 'opacity-50 web:pointer-events-none',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TextClassContext.Provider>
|
||||
));
|
||||
MenubarItem.displayName = MenubarPrimitive.Item.displayName;
|
||||
|
||||
const MenubarCheckboxItem = React.forwardRef<
|
||||
MenubarPrimitive.CheckboxItemRef,
|
||||
MenubarPrimitive.CheckboxItemProps
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<MenubarPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex flex-row web:cursor-default items-center web:group rounded-sm py-1.5 native:py-2 pl-8 pr-2 web:outline-none web:focus:bg-accent active:bg-accent',
|
||||
props.disabled && 'web:pointer-events-none opacity-50',
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<View className='absolute left-2 flex h-3.5 w-3.5 items-center justify-center'>
|
||||
<MenubarPrimitive.ItemIndicator>
|
||||
<Check size={14} strokeWidth={3} className='text-foreground' />
|
||||
</MenubarPrimitive.ItemIndicator>
|
||||
</View>
|
||||
<>{children}</>
|
||||
</MenubarPrimitive.CheckboxItem>
|
||||
));
|
||||
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const MenubarRadioItem = React.forwardRef<
|
||||
MenubarPrimitive.RadioItemRef,
|
||||
MenubarPrimitive.RadioItemProps
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<MenubarPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex flex-row web:cursor-default web:group items-center rounded-sm py-1.5 native:py-2 pl-8 pr-2 web:outline-none web:focus:bg-accent active:bg-accent',
|
||||
props.disabled && 'web:pointer-events-none opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<View className='absolute left-2 flex h-3.5 w-3.5 items-center justify-center'>
|
||||
<MenubarPrimitive.ItemIndicator>
|
||||
<View className='bg-foreground h-2 w-2 rounded-full' />
|
||||
</MenubarPrimitive.ItemIndicator>
|
||||
</View>
|
||||
<>{children}</>
|
||||
</MenubarPrimitive.RadioItem>
|
||||
));
|
||||
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName;
|
||||
|
||||
const MenubarLabel = React.forwardRef<
|
||||
MenubarPrimitive.LabelRef,
|
||||
MenubarPrimitive.LabelProps & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<MenubarPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'px-2 py-1.5 text-sm native:text-base font-semibold text-foreground web:cursor-default',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
MenubarLabel.displayName = MenubarPrimitive.Label.displayName;
|
||||
|
||||
const MenubarSeparator = React.forwardRef<
|
||||
MenubarPrimitive.SeparatorRef,
|
||||
MenubarPrimitive.SeparatorProps
|
||||
>(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-border', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName;
|
||||
|
||||
const MenubarShortcut = ({ className, ...props }: TextProps) => {
|
||||
return (
|
||||
<Text
|
||||
className={cn(
|
||||
'ml-auto text-xs native:text-sm tracking-widest text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
MenubarShortcut.displayName = 'MenubarShortcut';
|
||||
|
||||
export {
|
||||
Menubar,
|
||||
MenubarCheckboxItem,
|
||||
MenubarContent,
|
||||
MenubarGroup,
|
||||
MenubarItem,
|
||||
MenubarLabel,
|
||||
MenubarMenu,
|
||||
MenubarPortal,
|
||||
MenubarRadioGroup,
|
||||
MenubarRadioItem,
|
||||
MenubarSeparator,
|
||||
MenubarShortcut,
|
||||
MenubarSub,
|
||||
MenubarSubContent,
|
||||
MenubarSubTrigger,
|
||||
MenubarTrigger,
|
||||
};
|
181
components/ui/navigation-menu.tsx
Normal file
181
components/ui/navigation-menu.tsx
Normal file
@ -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) => (
|
||||
<NavigationMenuPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn('relative z-10 flex flex-row max-w-max items-center justify-center', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{Platform.OS === 'web' && <NavigationMenuViewport />}
|
||||
</NavigationMenuPrimitive.Root>
|
||||
));
|
||||
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName;
|
||||
|
||||
const NavigationMenuList = React.forwardRef<
|
||||
NavigationMenuPrimitive.ListRef,
|
||||
NavigationMenuPrimitive.ListProps
|
||||
>(({ className, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'web:group flex flex-1 flex-row web:list-none items-center justify-center gap-1',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
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 (
|
||||
<NavigationMenuPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
navigationMenuTriggerStyle(),
|
||||
'web:group gap-1.5',
|
||||
value === itemValue && 'bg-accent',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<>{children}</>
|
||||
<Animated.View style={chevronStyle}>
|
||||
<ChevronDown
|
||||
size={12}
|
||||
className={cn('relative text-foreground h-3 w-3 web:transition web:duration-200')}
|
||||
aria-hidden={true}
|
||||
/>
|
||||
</Animated.View>
|
||||
</NavigationMenuPrimitive.Trigger>
|
||||
);
|
||||
});
|
||||
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 (
|
||||
<NavigationMenuPrimitive.Portal hostName={portalHost}>
|
||||
<NavigationMenuPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'w-full native:border native:border-border native:rounded-lg native:shadow-lg native:bg-popover native:text-popover-foreground native:overflow-hidden',
|
||||
value === itemValue
|
||||
? 'web:animate-in web:fade-in web:slide-in-from-right-20'
|
||||
: 'web:animate-out web:fade-out web:slide-out-to-left-20',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Animated.View
|
||||
entering={Platform.OS !== 'web' ? FadeInLeft : undefined}
|
||||
exiting={Platform.OS !== 'web' ? FadeOutLeft : undefined}
|
||||
>
|
||||
{children}
|
||||
</Animated.View>
|
||||
</NavigationMenuPrimitive.Content>
|
||||
</NavigationMenuPrimitive.Portal>
|
||||
);
|
||||
});
|
||||
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName;
|
||||
|
||||
const NavigationMenuLink = NavigationMenuPrimitive.Link;
|
||||
|
||||
const NavigationMenuViewport = React.forwardRef<
|
||||
NavigationMenuPrimitive.ViewportRef,
|
||||
NavigationMenuPrimitive.ViewportProps
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<View className={cn('absolute left-0 top-full flex justify-center')}>
|
||||
<View
|
||||
className={cn(
|
||||
'web:origin-top-center relative mt-1.5 web:h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-lg web:animate-in web:zoom-in-90',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
<NavigationMenuPrimitive.Viewport />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
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 (
|
||||
<NavigationMenuPrimitive.Indicator
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden',
|
||||
value === itemValue ? 'web:animate-in web:fade-in' : 'web:animate-out web:fade-out',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<View className='relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md shadow-foreground/5' />
|
||||
</NavigationMenuPrimitive.Indicator>
|
||||
);
|
||||
});
|
||||
NavigationMenuIndicator.displayName = NavigationMenuPrimitive.Indicator.displayName;
|
||||
|
||||
export {
|
||||
NavigationMenu,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuIndicator,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuList,
|
||||
NavigationMenuTrigger,
|
||||
navigationMenuTriggerStyle,
|
||||
NavigationMenuViewport,
|
||||
};
|
39
components/ui/popover.tsx
Normal file
39
components/ui/popover.tsx
Normal file
@ -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 (
|
||||
<PopoverPrimitive.Portal hostName={portalHost}>
|
||||
<PopoverPrimitive.Overlay style={Platform.OS !== 'web' ? StyleSheet.absoluteFill : undefined}>
|
||||
<Animated.View entering={FadeIn.duration(200)} exiting={FadeOut}>
|
||||
<TextClassContext.Provider value='text-popover-foreground'>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 w-72 rounded-md web:cursor-auto border border-border bg-popover p-4 shadow-md shadow-foreground/5 web:outline-none web:data-[side=bottom]:slide-in-from-top-2 web:data-[side=left]:slide-in-from-right-2 web:data-[side=right]:slide-in-from-left-2 web:data-[side=top]:slide-in-from-bottom-2 web:animate-in web:zoom-in-95 web:fade-in-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TextClassContext.Provider>
|
||||
</Animated.View>
|
||||
</PopoverPrimitive.Overlay>
|
||||
</PopoverPrimitive.Portal>
|
||||
);
|
||||
});
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||
|
||||
export { Popover, PopoverContent, PopoverTrigger };
|
@ -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,
|
||||
|
36
components/ui/radio-group.tsx
Normal file
36
components/ui/radio-group.tsx
Normal file
@ -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<RadioGroupPrimitive.RootRef, RadioGroupPrimitive.RootProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root className={cn('web:grid gap-2', className)} {...props} ref={ref} />
|
||||
);
|
||||
}
|
||||
);
|
||||
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
|
||||
|
||||
const RadioGroupItem = React.forwardRef<RadioGroupPrimitive.ItemRef, RadioGroupPrimitive.ItemProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'aspect-square h-4 w-4 native:h-5 native:w-5 rounded-full justify-center items-center border border-primary text-primary web:ring-offset-background web:focus:outline-none web:focus-visible:ring-2 web:focus-visible:ring-ring web:focus-visible:ring-offset-2',
|
||||
props.disabled && 'web:cursor-not-allowed opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator className='flex items-center justify-center'>
|
||||
<View className='aspect-square h-[9px] w-[9px] native:h-[10] native:w-[10] bg-primary rounded-full' />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
);
|
||||
}
|
||||
);
|
||||
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
|
||||
|
||||
export { RadioGroup, RadioGroupItem };
|
173
components/ui/select.tsx
Normal file
173
components/ui/select.tsx
Normal file
@ -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<SelectPrimitive.TriggerRef, SelectPrimitive.TriggerProps>(
|
||||
({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex flex-row h-10 native:h-12 items-center text-sm justify-between rounded-md border border-input bg-background px-3 py-2 web:ring-offset-background text-muted-foreground web:focus:outline-none web:focus:ring-2 web:focus:ring-ring web:focus:ring-offset-2 [&>span]:line-clamp-1',
|
||||
props.disabled && 'web:cursor-not-allowed opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<>{children}</>
|
||||
<ChevronDown size={16} aria-hidden={true} className='text-foreground opacity-50' />
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
);
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
/**
|
||||
* Platform: WEB ONLY
|
||||
*/
|
||||
const SelectScrollUpButton = ({ className, ...props }: SelectPrimitive.ScrollUpButtonProps) => {
|
||||
if (Platform.OS !== 'web') {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
className={cn('flex web:cursor-default items-center justify-center py-1', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp size={14} className='text-foreground' />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Platform: WEB ONLY
|
||||
*/
|
||||
const SelectScrollDownButton = ({ className, ...props }: SelectPrimitive.ScrollDownButtonProps) => {
|
||||
if (Platform.OS !== 'web') {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
className={cn('flex web:cursor-default items-center justify-center py-1', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown size={14} className='text-foreground' />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
);
|
||||
};
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
SelectPrimitive.ContentRef,
|
||||
SelectPrimitive.ContentProps & { portalHost?: string }
|
||||
>(({ className, children, position = 'popper', portalHost, ...props }, ref) => {
|
||||
const { open } = SelectPrimitive.useRootContext();
|
||||
|
||||
return (
|
||||
<SelectPrimitive.Portal hostName={portalHost}>
|
||||
<SelectPrimitive.Overlay style={Platform.OS !== 'web' ? StyleSheet.absoluteFill : undefined}>
|
||||
<Animated.View className='z-50' entering={FadeIn} exiting={FadeOut}>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative z-50 max-h-96 min-w-[8rem] rounded-md border border-border bg-popover shadow-md shadow-foreground/10 py-2 px-1 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
open
|
||||
? 'web:zoom-in-95 web:animate-in web:fade-in-0'
|
||||
: 'web:zoom-out-95 web:animate-out web:fade-out-0',
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
'p-1',
|
||||
position === 'popper' &&
|
||||
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</Animated.View>
|
||||
</SelectPrimitive.Overlay>
|
||||
</SelectPrimitive.Portal>
|
||||
);
|
||||
});
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectLabel = React.forwardRef<SelectPrimitive.LabelRef, SelectPrimitive.LabelProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'py-1.5 native:pb-2 pl-8 native:pl-10 pr-2 text-popover-foreground text-sm native:text-base font-semibold',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<SelectPrimitive.ItemRef, SelectPrimitive.ItemProps>(
|
||||
({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative web:group flex flex-row w-full web:cursor-default web:select-none items-center rounded-sm py-1.5 native:py-2 pl-8 native:pl-10 pr-2 web:hover:bg-accent/50 active:bg-accent web:outline-none web:focus:bg-accent',
|
||||
props.disabled && 'web:pointer-events-none opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<View className='absolute left-2 native:left-3.5 flex h-3.5 native:pt-px w-3.5 items-center justify-center'>
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check size={16} strokeWidth={3} className='text-popover-foreground' />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</View>
|
||||
<SelectPrimitive.ItemText className='text-sm native:text-base text-popover-foreground web:group-focus:text-accent-foreground' />
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
);
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
SelectPrimitive.SeparatorRef,
|
||||
SelectPrimitive.SeparatorProps
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
type Option,
|
||||
};
|
22
components/ui/separator.tsx
Normal file
22
components/ui/separator.tsx
Normal file
@ -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<SeparatorPrimitive.RootRef, SeparatorPrimitive.RootProps>(
|
||||
({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'shrink-0 bg-border',
|
||||
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||
|
||||
export { Separator };
|
46
components/ui/sheet/CloseButton.tsx
Normal file
46
components/ui/sheet/CloseButton.tsx
Normal file
@ -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 (
|
||||
<View className="absolute right-4 top-4 z-50">
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
style={styles.button}
|
||||
className="p-3 rounded-full bg-muted/80 items-center justify-center"
|
||||
>
|
||||
<X
|
||||
size={22}
|
||||
color={theme.text}
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
button: {
|
||||
minWidth: 40,
|
||||
minHeight: 40,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 1,
|
||||
},
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 1.41,
|
||||
elevation: 2,
|
||||
},
|
||||
});
|
142
components/ui/sheet/Sheet.native.tsx
Normal file
142
components/ui/sheet/Sheet.native.tsx
Normal file
@ -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 (
|
||||
<Modal
|
||||
visible={isVisible}
|
||||
transparent
|
||||
statusBarTranslucent
|
||||
onRequestClose={onClose}
|
||||
animationType="fade"
|
||||
>
|
||||
<View style={StyleSheet.absoluteFill}>
|
||||
<TouchableOpacity
|
||||
style={[StyleSheet.absoluteFill, styles.backdrop]}
|
||||
onPress={onClose}
|
||||
activeOpacity={1}
|
||||
/>
|
||||
<Animated.View
|
||||
className="absolute left-0 right-0 bg-secondary rounded-t-3xl border-t border-border"
|
||||
style={[
|
||||
styles.sheetContainer,
|
||||
{ transform: [{ translateY }] }
|
||||
]}
|
||||
>
|
||||
{/* Handle indicator */}
|
||||
<View className="items-center pt-4 pb-2">
|
||||
<View className="w-16 h-1 rounded-full bg-muted-foreground/25 dark:bg-muted-foreground/40" />
|
||||
</View>
|
||||
|
||||
<CloseButton onPress={onClose} />
|
||||
|
||||
{children}
|
||||
</Animated.View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function SheetHeader({ children }: SheetHeaderProps) {
|
||||
return (
|
||||
<View className="flex-row justify-between items-center px-6 py-4 border-b border-border">
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function SheetTitle({ children }: SheetTitleProps) {
|
||||
return <Text className="text-xl font-semibold">{children}</Text>;
|
||||
}
|
||||
|
||||
export function SheetContent({ children }: SheetContentProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
className="flex-1 px-6"
|
||||
contentContainerStyle={{
|
||||
paddingTop: 16,
|
||||
paddingBottom: insets.bottom + 80
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
bounces={Platform.OS === 'ios'}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
4
components/ui/sheet/Sheet.tsx
Normal file
4
components/ui/sheet/Sheet.tsx
Normal file
@ -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
|
20
components/ui/sheet/Sheet.types.ts
Normal file
20
components/ui/sheet/Sheet.types.ts
Normal file
@ -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;
|
||||
}
|
66
components/ui/sheet/Sheet.web.tsx
Normal file
66
components/ui/sheet/Sheet.web.tsx
Normal file
@ -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 (
|
||||
<RNModal
|
||||
visible={isOpen}
|
||||
transparent
|
||||
onRequestClose={onClose}
|
||||
animationType="none"
|
||||
>
|
||||
<View
|
||||
style={StyleSheet.absoluteFill}
|
||||
className="web:fixed web:inset-0 web:z-50"
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={[StyleSheet.absoluteFill, styles.backdrop]}
|
||||
onPress={onClose}
|
||||
activeOpacity={1}
|
||||
/>
|
||||
<View
|
||||
className="web:fixed web:inset-x-0 web:bottom-0 web:z-50 bg-background rounded-t-3xl"
|
||||
style={styles.sheetContainer}
|
||||
>
|
||||
{/* Handle indicator */}
|
||||
<View className="items-center pt-4 pb-2">
|
||||
<View className="w-16 h-1 rounded-full bg-muted-foreground/25" />
|
||||
</View>
|
||||
|
||||
<CloseButton onPress={onClose} />
|
||||
|
||||
{children}
|
||||
</View>
|
||||
</View>
|
||||
</RNModal>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
6
components/ui/sheet/index.ts
Normal file
6
components/ui/sheet/index.ts
Normal file
@ -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';
|
39
components/ui/skeleton.tsx
Normal file
39
components/ui/skeleton.tsx
Normal file
@ -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<React.ComponentPropsWithoutRef<typeof Animated.View>, '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 (
|
||||
<Animated.View
|
||||
style={style}
|
||||
className={cn('rounded-md bg-secondary dark:bg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton };
|
95
components/ui/switch.tsx
Normal file
95
components/ui/switch.tsx
Normal file
@ -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<SwitchPrimitives.RootRef, SwitchPrimitives.RootProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
'peer flex-row h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed',
|
||||
props.checked ? 'bg-primary' : 'bg-input',
|
||||
props.disabled && 'opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-md shadow-foreground/5 ring-0 transition-transform',
|
||||
props.checked ? 'translate-x-5' : 'translate-x-0'
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
)
|
||||
);
|
||||
|
||||
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<SwitchPrimitives.RootRef, SwitchPrimitives.RootProps>(
|
||||
({ 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 (
|
||||
<Animated.View
|
||||
style={animatedRootStyle}
|
||||
className={cn('h-8 w-[46px] rounded-full', props.disabled && 'opacity-50')}
|
||||
>
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
'flex-row h-8 w-[46px] shrink-0 items-center rounded-full border-2 border-transparent',
|
||||
props.checked ? 'bg-primary' : 'bg-input',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<Animated.View style={animatedThumbStyle}>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={'h-7 w-7 rounded-full bg-background shadow-md shadow-foreground/25 ring-0'}
|
||||
/>
|
||||
</Animated.View>
|
||||
</SwitchPrimitives.Root>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
);
|
||||
SwitchNative.displayName = 'SwitchNative';
|
||||
|
||||
const Switch = Platform.select({
|
||||
web: SwitchWeb,
|
||||
default: SwitchNative,
|
||||
});
|
||||
|
||||
export { Switch };
|
92
components/ui/table.tsx
Normal file
92
components/ui/table.tsx
Normal file
@ -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<TablePrimitive.RootRef, TablePrimitive.RootProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<TablePrimitive.Root
|
||||
ref={ref}
|
||||
className={cn('w-full caption-bottom text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
Table.displayName = 'Table';
|
||||
|
||||
const TableHeader = React.forwardRef<TablePrimitive.HeaderRef, TablePrimitive.HeaderProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<TablePrimitive.Header
|
||||
ref={ref}
|
||||
className={cn('border-border [&_tr]:border-b', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
TableHeader.displayName = 'TableHeader';
|
||||
|
||||
const TableBody = React.forwardRef<TablePrimitive.BodyRef, TablePrimitive.BodyProps>(
|
||||
({ className, style, ...props }, ref) => (
|
||||
<TablePrimitive.Body
|
||||
ref={ref}
|
||||
className={cn('flex-1 border-border [&_tr:last-child]:border-0', className)}
|
||||
style={[{ minHeight: 2 }, style]}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
TableBody.displayName = 'TableBody';
|
||||
|
||||
const TableFooter = React.forwardRef<TablePrimitive.FooterRef, TablePrimitive.FooterProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<TablePrimitive.Footer
|
||||
ref={ref}
|
||||
className={cn('bg-muted/50 font-medium [&>tr]:last:border-b-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
TableFooter.displayName = 'TableFooter';
|
||||
|
||||
const TableRow = React.forwardRef<TablePrimitive.RowRef, TablePrimitive.RowProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<TablePrimitive.Row
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex-row border-border border-b web:transition-colors web:hover:bg-muted/50 web:data-[state=selected]:bg-muted',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
TableRow.displayName = 'TableRow';
|
||||
|
||||
const TableHead = React.forwardRef<TablePrimitive.HeadRef, TablePrimitive.HeadProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<TextClassContext.Provider value='text-muted-foreground'>
|
||||
<TablePrimitive.Head
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'h-12 px-4 text-left justify-center font-medium [&:has([role=checkbox])]:pr-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TextClassContext.Provider>
|
||||
)
|
||||
);
|
||||
TableHead.displayName = 'TableHead';
|
||||
|
||||
const TableCell = React.forwardRef<TablePrimitive.CellRef, TablePrimitive.CellProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<TablePrimitive.Cell
|
||||
ref={ref}
|
||||
className={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
TableCell.displayName = 'TableCell';
|
||||
|
||||
export { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow };
|
62
components/ui/tabs.tsx
Normal file
62
components/ui/tabs.tsx
Normal file
@ -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<TabsPrimitive.ListRef, TabsPrimitive.ListProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'web:inline-flex h-10 native:h-12 items-center justify-center rounded-md bg-muted p-1 native:px-1.5',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
const TabsTrigger = React.forwardRef<TabsPrimitive.TriggerRef, TabsPrimitive.TriggerProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
const { value } = TabsPrimitive.useRootContext();
|
||||
return (
|
||||
<TextClassContext.Provider
|
||||
value={cn(
|
||||
'text-sm native:text-base font-medium text-muted-foreground web:transition-all',
|
||||
value === props.value && 'text-foreground'
|
||||
)}
|
||||
>
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center shadow-none web:whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium web:ring-offset-background web:transition-all web:focus-visible:outline-none web:focus-visible:ring-2 web:focus-visible:ring-ring web:focus-visible:ring-offset-2',
|
||||
props.disabled && 'web:pointer-events-none opacity-50',
|
||||
props.value === value && 'bg-background shadow-lg shadow-foreground/10',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TextClassContext.Provider>
|
||||
);
|
||||
}
|
||||
);
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
const TabsContent = React.forwardRef<TabsPrimitive.ContentRef, TabsPrimitive.ContentProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'web:ring-offset-background web:focus-visible:outline-none web:focus-visible:ring-2 web:focus-visible:ring-ring web:focus-visible:ring-offset-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
|
||||
export { Tabs, TabsContent, TabsList, TabsTrigger };
|
@ -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<string | undefined>(undefined);
|
||||
|
||||
|
27
components/ui/textarea.tsx
Normal file
27
components/ui/textarea.tsx
Normal file
@ -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<React.ElementRef<typeof TextInput>, TextInputProps>(
|
||||
({ className, multiline = true, numberOfLines = 4, placeholderClassName, ...props }, ref) => {
|
||||
return (
|
||||
<TextInput
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'web:flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base lg:text-sm native:text-lg native:leading-[1.25] placeholder:text-muted-foreground web:ring-offset-background web:focus-visible:outline-none web:focus-visible:ring-2 web:focus-visible:ring-ring web:focus-visible:ring-offset-2',
|
||||
props.editable === false && 'opacity-50 web:cursor-not-allowed',
|
||||
className
|
||||
)}
|
||||
placeholderClassName={cn('text-muted-foreground', placeholderClassName)}
|
||||
multiline={multiline}
|
||||
numberOfLines={numberOfLines}
|
||||
textAlignVertical='top'
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Textarea.displayName = 'Textarea';
|
||||
|
||||
export { Textarea };
|
84
components/ui/toggle-group.tsx
Normal file
84
components/ui/toggle-group.tsx
Normal file
@ -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<VariantProps<typeof toggleVariants> | null>(null);
|
||||
|
||||
const ToggleGroup = React.forwardRef<
|
||||
ToggleGroupPrimitive.RootRef,
|
||||
ToggleGroupPrimitive.RootProps & VariantProps<typeof toggleVariants>
|
||||
>(({ className, variant, size, children, ...props }, ref) => (
|
||||
<ToggleGroupPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn('flex flex-row items-center justify-center gap-1', className)}
|
||||
{...props}
|
||||
>
|
||||
<ToggleGroupContext.Provider value={{ variant, size }}>{children}</ToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive.Root>
|
||||
));
|
||||
|
||||
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<typeof toggleVariants>
|
||||
>(({ className, children, variant, size, ...props }, ref) => {
|
||||
const context = useToggleGroupContext();
|
||||
const { value } = ToggleGroupPrimitive.useRootContext();
|
||||
|
||||
return (
|
||||
<TextClassContext.Provider
|
||||
value={cn(
|
||||
toggleTextVariants({ variant, size }),
|
||||
ToggleGroupPrimitive.utils.getIsSelected(value, props.value)
|
||||
? 'text-accent-foreground'
|
||||
: 'web:group-hover:text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<ToggleGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
toggleVariants({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
props.disabled && 'web:pointer-events-none opacity-50',
|
||||
ToggleGroupPrimitive.utils.getIsSelected(value, props.value) && 'bg-accent',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ToggleGroupPrimitive.Item>
|
||||
</TextClassContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName;
|
||||
|
||||
function ToggleGroupIcon({
|
||||
className,
|
||||
icon: Icon,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<LucideIcon> & {
|
||||
icon: LucideIcon;
|
||||
}) {
|
||||
const textClass = React.useContext(TextClassContext);
|
||||
return <Icon className={cn(textClass, className)} {...props} />;
|
||||
}
|
||||
|
||||
export { ToggleGroup, ToggleGroupIcon, ToggleGroupItem };
|
85
components/ui/toggle.tsx
Normal file
85
components/ui/toggle.tsx
Normal file
@ -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<typeof toggleVariants>
|
||||
>(({ className, variant, size, ...props }, ref) => (
|
||||
<TextClassContext.Provider
|
||||
value={cn(
|
||||
toggleTextVariants({ variant, size }),
|
||||
props.pressed ? 'text-accent-foreground' : 'web:group-hover:text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<TogglePrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
toggleVariants({ variant, size }),
|
||||
props.disabled && 'web:pointer-events-none opacity-50',
|
||||
props.pressed && 'bg-accent',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TextClassContext.Provider>
|
||||
));
|
||||
|
||||
Toggle.displayName = TogglePrimitive.Root.displayName;
|
||||
|
||||
function ToggleIcon({
|
||||
className,
|
||||
icon: Icon,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<LucideIcon> & {
|
||||
icon: LucideIcon;
|
||||
}) {
|
||||
const textClass = React.useContext(TextClassContext);
|
||||
return <Icon className={cn(textClass, className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Toggle, ToggleIcon, toggleTextVariants, toggleVariants };
|
@ -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;
|
||||
|
||||
|
205
components/ui/typography.tsx
Normal file
205
components/ui/typography.tsx
Normal file
@ -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<TextRef, SlottableTextProps>(
|
||||
({ className, asChild = false, ...props }, ref) => {
|
||||
const Component = asChild ? Slot.Text : RNText;
|
||||
return (
|
||||
<Component
|
||||
role='heading'
|
||||
aria-level='1'
|
||||
className={cn(
|
||||
'web:scroll-m-20 text-4xl text-foreground font-extrabold tracking-tight lg:text-5xl web:select-text',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
H1.displayName = 'H1';
|
||||
|
||||
const H2 = React.forwardRef<TextRef, SlottableTextProps>(
|
||||
({ className, asChild = false, ...props }, ref) => {
|
||||
const Component = asChild ? Slot.Text : RNText;
|
||||
return (
|
||||
<Component
|
||||
role='heading'
|
||||
aria-level='2'
|
||||
className={cn(
|
||||
'web:scroll-m-20 border-b border-border pb-2 text-3xl text-foreground font-semibold tracking-tight first:mt-0 web:select-text',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
H2.displayName = 'H2';
|
||||
|
||||
const H3 = React.forwardRef<TextRef, SlottableTextProps>(
|
||||
({ className, asChild = false, ...props }, ref) => {
|
||||
const Component = asChild ? Slot.Text : RNText;
|
||||
return (
|
||||
<Component
|
||||
role='heading'
|
||||
aria-level='3'
|
||||
className={cn(
|
||||
'web:scroll-m-20 text-2xl text-foreground font-semibold tracking-tight web:select-text',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
H3.displayName = 'H3';
|
||||
|
||||
const H4 = React.forwardRef<TextRef, SlottableTextProps>(
|
||||
({ className, asChild = false, ...props }, ref) => {
|
||||
const Component = asChild ? Slot.Text : RNText;
|
||||
return (
|
||||
<Component
|
||||
role='heading'
|
||||
aria-level='4'
|
||||
className={cn(
|
||||
'web:scroll-m-20 text-xl text-foreground font-semibold tracking-tight web:select-text',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
H4.displayName = 'H4';
|
||||
|
||||
const P = React.forwardRef<TextRef, SlottableTextProps>(
|
||||
({ className, asChild = false, ...props }, ref) => {
|
||||
const Component = asChild ? Slot.Text : RNText;
|
||||
return (
|
||||
<Component
|
||||
className={cn('text-base text-foreground web:select-text', className)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
P.displayName = 'P';
|
||||
|
||||
const BlockQuote = React.forwardRef<TextRef, SlottableTextProps>(
|
||||
({ className, asChild = false, ...props }, ref) => {
|
||||
const Component = asChild ? Slot.Text : RNText;
|
||||
return (
|
||||
<Component
|
||||
// @ts-ignore - role of blockquote renders blockquote element on the web
|
||||
role={Platform.OS === 'web' ? 'blockquote' : undefined}
|
||||
className={cn(
|
||||
'mt-6 native:mt-4 border-l-2 border-border pl-6 native:pl-3 text-base text-foreground italic web:select-text',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
BlockQuote.displayName = 'BlockQuote';
|
||||
|
||||
const Code = React.forwardRef<TextRef, SlottableTextProps>(
|
||||
({ className, asChild = false, ...props }, ref) => {
|
||||
const Component = asChild ? Slot.Text : RNText;
|
||||
return (
|
||||
<Component
|
||||
// @ts-ignore - role of code renders code element on the web
|
||||
role={Platform.OS === 'web' ? 'code' : undefined}
|
||||
className={cn(
|
||||
'relative rounded-md bg-muted px-[0.3rem] py-[0.2rem] text-sm text-foreground font-semibold web:select-text',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Code.displayName = 'Code';
|
||||
|
||||
const Lead = React.forwardRef<TextRef, SlottableTextProps>(
|
||||
({ className, asChild = false, ...props }, ref) => {
|
||||
const Component = asChild ? Slot.Text : RNText;
|
||||
return (
|
||||
<Component
|
||||
className={cn('text-xl text-muted-foreground web:select-text', className)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Lead.displayName = 'Lead';
|
||||
|
||||
const Large = React.forwardRef<TextRef, SlottableTextProps>(
|
||||
({ className, asChild = false, ...props }, ref) => {
|
||||
const Component = asChild ? Slot.Text : RNText;
|
||||
return (
|
||||
<Component
|
||||
className={cn('text-xl text-foreground font-semibold web:select-text', className)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Large.displayName = 'Large';
|
||||
|
||||
const Small = React.forwardRef<TextRef, SlottableTextProps>(
|
||||
({ className, asChild = false, ...props }, ref) => {
|
||||
const Component = asChild ? Slot.Text : RNText;
|
||||
return (
|
||||
<Component
|
||||
className={cn(
|
||||
'text-sm text-foreground font-medium leading-none web:select-text',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Small.displayName = 'Small';
|
||||
|
||||
const Muted = React.forwardRef<TextRef, SlottableTextProps>(
|
||||
({ className, asChild = false, ...props }, ref) => {
|
||||
const Component = asChild ? Slot.Text : RNText;
|
||||
return (
|
||||
<Component
|
||||
className={cn('text-sm text-muted-foreground web:select-text', className)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Muted.displayName = 'Muted';
|
||||
|
||||
export { BlockQuote, Code, H1, H2, H3, H4, Large, Lead, Muted, P, Small };
|
215
docs/ai_collaboration_guide.md
Normal file
215
docs/ai_collaboration_guide.md
Normal file
@ -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<string> - The ID of the created exercise
|
||||
* @throws {DatabaseError} If the save operation fails
|
||||
*/
|
||||
async function createExercise(exercise: Exercise): Promise<string>
|
||||
```
|
||||
|
||||
#### 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
|
277
docs/coding_style.md
Normal file
277
docs/coding_style.md
Normal file
@ -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)]: <Action> by/when <Deadline Condition>`
|
||||
|
||||
`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 <description> ...`
|
||||
- **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 <specific> capability.`
|
||||
- `... when XXX bug/feature is fixed.`
|
||||
- `... once <person> approves of <specific thing>.`
|
||||
- `... 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: <Action or any note to self/reviewer>
|
||||
|
||||
// FIXME: Remove hack
|
||||
|
||||
// FIXME: Revert hardcoding of server URL
|
||||
|
||||
// FIXME: Implement function
|
||||
|
||||
// FIXME: Refactor these usages across codebase. <Reviewer can upgrade to TODO w/ JIRA ticket during review>
|
||||
|
||||
// FIXME: Why does this work this way? <Reviewer should help out here with getting something more understandable>`
|
||||
|
||||
**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.
|
@ -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';
|
343
docs/design/library_tab.md
Normal file
343
docs/design/library_tab.md
Normal file
@ -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<Exercise[]>
|
||||
createExercise(exercise: Exercise): Promise<string>
|
||||
updateExercise(id: string, exercise: Partial<Exercise>): Promise<void>
|
||||
deleteExercise(id: string): Promise<void>
|
||||
|
||||
// Template management
|
||||
getTemplates(): Promise<Template[]>
|
||||
createTemplate(template: Template): Promise<string>
|
||||
updateTemplate(id: string, template: Partial<Template>): Promise<void>
|
||||
deleteTemplate(id: string): Promise<void>
|
||||
|
||||
// Usage tracking
|
||||
logExerciseUse(exerciseId: string): Promise<void>
|
||||
logTemplateUse(templateId: string): Promise<void>
|
||||
getExerciseHistory(exerciseId: string): Promise<ExerciseHistory[]>
|
||||
getTemplateHistory(templateId: string): Promise<TemplateHistory[]>
|
||||
}
|
||||
```
|
||||
|
||||
2. Detail Views
|
||||
- Create a detailed view component for exercises and templates
|
||||
- Implement proper state management for tracking usage
|
||||
- Add performance metrics visualization
|
||||
|
||||
3. Progressive Disclosure
|
||||
- Implement long press preview
|
||||
- Create bottom sheet details view
|
||||
- Add full screen modal for editing
|
137
docs/design_doc.md
Normal file
137
docs/design_doc.md
Normal file
@ -0,0 +1,137 @@
|
||||
# [Feature Name] Design Document
|
||||
|
||||
## Problem Statement
|
||||
[Concise description of the problem being solved]
|
||||
|
||||
## Requirements
|
||||
|
||||
### Functional Requirements
|
||||
- [List of must-have functionality]
|
||||
- [User-facing features]
|
||||
- [Core capabilities]
|
||||
|
||||
### Non-Functional Requirements
|
||||
- Performance targets
|
||||
- Security requirements
|
||||
- Reliability goals
|
||||
- Usability standards
|
||||
|
||||
## Design Decisions
|
||||
|
||||
### 1. [Major Decision Area]
|
||||
[Description of approach chosen]
|
||||
|
||||
Rationale:
|
||||
- [Key reason]
|
||||
- [Supporting factors]
|
||||
- [Trade-offs considered]
|
||||
|
||||
### 2. [Major Decision Area]
|
||||
[Description of approach chosen]
|
||||
|
||||
Rationale:
|
||||
- [Key reason]
|
||||
- [Supporting factors]
|
||||
- [Trade-offs considered]
|
||||
|
||||
## Technical Design
|
||||
|
||||
### Core Components
|
||||
```typescript
|
||||
// Key interfaces/types
|
||||
interface ComponentName {
|
||||
// Critical fields
|
||||
}
|
||||
|
||||
// Core functionality
|
||||
function mainOperation() {
|
||||
// Key logic
|
||||
}
|
||||
```
|
||||
|
||||
### Integration Points
|
||||
- [System interfaces]
|
||||
- [External dependencies]
|
||||
- [API definitions]
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: [Initial Phase]
|
||||
1. [Step 1]
|
||||
2. [Step 2]
|
||||
3. [Step 3]
|
||||
|
||||
### Phase 2: [Next Phase]
|
||||
1. [Step 1]
|
||||
2. [Step 2]
|
||||
3. [Step 3]
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
- [Key test areas]
|
||||
- [Critical test cases]
|
||||
- [Test tooling]
|
||||
|
||||
### Integration Tests
|
||||
- [End-to-end scenarios]
|
||||
- [Cross-component tests]
|
||||
- [Test environments]
|
||||
|
||||
## Observability
|
||||
|
||||
### Logging
|
||||
- [Key log points]
|
||||
- [Log levels]
|
||||
- [Critical events]
|
||||
|
||||
### Metrics
|
||||
- [Performance metrics]
|
||||
- [Business metrics]
|
||||
- [System health metrics]
|
||||
|
||||
## Future Considerations
|
||||
|
||||
### Potential Enhancements
|
||||
- [Future feature ideas]
|
||||
- [Scalability improvements]
|
||||
- [Technical debt items]
|
||||
|
||||
### Known Limitations
|
||||
- [Current constraints]
|
||||
- [Technical restrictions]
|
||||
- [Scope boundaries]
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Runtime Dependencies
|
||||
- [External services]
|
||||
- [Libraries]
|
||||
- [System requirements]
|
||||
|
||||
### Development Dependencies
|
||||
- [Build tools]
|
||||
- [Test frameworks]
|
||||
- [Development utilities]
|
||||
|
||||
## Security Considerations
|
||||
- [Security measures]
|
||||
- [Privacy concerns]
|
||||
- [Data protection]
|
||||
|
||||
## Rollout Strategy
|
||||
|
||||
### Development Phase
|
||||
1. [Development steps]
|
||||
2. [Testing approach]
|
||||
3. [Documentation needs]
|
||||
|
||||
### Production Deployment
|
||||
1. [Deployment steps]
|
||||
2. [Migration plan]
|
||||
3. [Monitoring setup]
|
||||
|
||||
## References
|
||||
- [Related documents]
|
||||
- [External resources]
|
||||
- [Research materials]
|
198
docs/writing_good_interfaces.md
Normal file
198
docs/writing_good_interfaces.md
Normal file
@ -0,0 +1,198 @@
|
||||
# Why?
|
||||
|
||||
We should first ask ourselves why (or if) spending the time to write good interfaces matters. Is it really worth the investment in a fast paced environment (e.g. startup)? We think yes:
|
||||
|
||||
- Well designed interfaces result in localized changes [9]
|
||||
- Well designed interfaces result in less "support" [5]
|
||||
- Well designed interfaces are easier to learn for new teammates [3]
|
||||
|
||||
The essence is that good interfaces are *more scalable* than bad interfaces (in terms of people working on the API and people using it). Consequently, it can be important for a high-growth environment.
|
||||
|
||||
# What?
|
||||
|
||||
If we agree on the value of good interfaces, we should then talk about what makes an interface good or bad, and whether there are objective criteria for determining the goodness of an interface.
|
||||
|
||||
The first thing to get out of the way is that there is no perfect interface and how good an interface is can only be determined within the scope of use cases. For example, reading text via a byte stream is a poor interface for string processing, but could be a great interface for e.g. finding the first 0 bit.
|
||||
|
||||
Good interfaces generally satisfy many of the following properties:
|
||||
|
||||
- Easy to use correctly
|
||||
- Hard to use incorrectly
|
||||
- Unsurprising
|
||||
- Reports actionable errors
|
||||
- Fails fast
|
||||
- Minimal boilerplate
|
||||
- Consistent mental model or theory [3]
|
||||
- Extensible (e.g. via plugins, inheritance, etc)
|
||||
- Limited mutability
|
||||
- Small (i.e. does "one" thing)
|
||||
- Well documented
|
||||
- No implementation details leaked
|
||||
- Easy to learn and memorize
|
||||
|
||||
## Examples
|
||||
|
||||
Here we have a few examples of good and bad APIs
|
||||
|
||||
- **Python defaultdict**
|
||||
|
||||
It's common to group a collection by some key. We often see folks write python code such as:
|
||||
|
||||
```python
|
||||
d = {}
|
||||
for word in words:
|
||||
first_letter = word[0]
|
||||
if first_letter in d:
|
||||
d[first_letter].append(word)
|
||||
else:
|
||||
d[first_letter] = [word]
|
||||
```
|
||||
|
||||
But if we use`defaultdict` instead, we can see it's a much better fit for our use case:
|
||||
|
||||
```python
|
||||
d = defaultdict(list)
|
||||
for word in words:
|
||||
first_letter = word[0]
|
||||
d[first_letter].append(word)
|
||||
```
|
||||
|
||||
Important point is that python dicts are not necessarily a "bad API" — but when we consider our use case here, the standard dict API doesn't quite meet the mark!
|
||||
|
||||
- **Avoiding Roundtrips**
|
||||
|
||||
Often, some APIs expose implementation or storage details of data. For example, if we wanted to fetch messages for users, it's not uncommon to see an API such as:
|
||||
|
||||
```java
|
||||
Collection<MessageId> messageIds = getUsers(userIds).messages;
|
||||
Collection<Message> messages = getMessages(messageIds);
|
||||
```
|
||||
|
||||
The reason APIs tend to look like this is that they generally just reflect how our services and such are organized. Note: code architecture and company org structure tend to change over time. Tying APIs to the data layout (rather than how it's consumed) is a recipe for a brittle API. Instead just give users what they want in a single step:
|
||||
|
||||
```java
|
||||
Collection<Message> messages = getMessagesForUsers(userIds);
|
||||
```
|
||||
|
||||
- **Limit Mutability**
|
||||
|
||||
In general, it's preferable to use immutable objects. Consider the following example:
|
||||
|
||||
```java
|
||||
|
||||
Map<String, String> m = new HashMap<>();
|
||||
m.put("a", "b");
|
||||
|
||||
int z = someFunc(m);
|
||||
|
||||
assert m.containsKey("a"); // Does this pass or fail?
|
||||
```
|
||||
|
||||
When objects are mutable, any where they are passed is a place where it is potentially mutated. Mutable objects make it difficult to locally reason about code and generally results in situations where to understand *anything* you have to understand *everything* . I don't know about you, but I'm generally not smart enough to understand everything 😉. A better option would be to have `someFunc` take an `ImmutableMap` so that readers don't need to understand the function to know what the value of `m` might be later in the code!
|
||||
|
||||
- **Give Good Errors**
|
||||
|
||||
We often see errors in the wild which don't provide a lot of information; for example:
|
||||
|
||||
```python
|
||||
IndexError: index out of range
|
||||
```
|
||||
|
||||
Good errors should have [7]:
|
||||
|
||||
1. What input was wrong
|
||||
2. What is wrong about it
|
||||
3. How to fix it (or next steps for investigation)
|
||||
|
||||
A better error message here would be:
|
||||
|
||||
```python
|
||||
IndexError: index=7 out of range for object (len=6) with items (showing first 3): 'apple', 'strawberry', 'banana', ...
|
||||
Did you forget to check that your index was not beyond the length of the object?
|
||||
```
|
||||
|
||||
- **Returning Exceptional Values**
|
||||
|
||||
In many cases, there's not a difference between returning something like `null` vs an empty collection. Consider the following interface:
|
||||
|
||||
```java
|
||||
// Returns null if no known favorite numbers
|
||||
List<Integer> getFavoriteNumbers(String name);
|
||||
|
||||
// Example usage:
|
||||
List<Integer> gregFave = getFavoriteNumbers("greg");
|
||||
if (gregFave != null) {
|
||||
gregFave.forEach(System.out.println);
|
||||
}
|
||||
```
|
||||
|
||||
It may be better to return an empty List instead of null to simplify a user's code here. Note, that's not to say you should never return null. There are many cases where there is a legitimate and well-intentioned difference between null and empty List.
|
||||
|
||||
- **Make the interface hard to mis-use**
|
||||
|
||||
Users shouldn't easily mis-use your API. One common error prone practice in APIs is initializing resources which callers are expected to release. For example:
|
||||
|
||||
```python
|
||||
f = open('myfile.txt', 'r')
|
||||
f.write("blah")
|
||||
f.close()
|
||||
```
|
||||
|
||||
It's really easy to forget that `close` ! Especially when we consider the possibility of exceptions that may occur between open and close. There are generally language-specific idioms to handle this; such as context managers in python:
|
||||
|
||||
```python
|
||||
with open('myfile.txt', 'r') as f:
|
||||
f.write("blah")
|
||||
# even if error occurs, the file context manager will ensure file is closed
|
||||
```
|
||||
|
||||
or in Java, the analogous pattern is [try with resources](https://docs.oracle.com/javase/tutorial/essential/exceptions/tryResourceClose.html).
|
||||
|
||||
|
||||
See "How" section for some tips, tricks, and best practices for designing good APIs.
|
||||
|
||||
# Who? When? Where?
|
||||
|
||||
Every engineer writes interfaces [2, 5]. As a guideline, you should always try to write good interfaces. However, it is only a guideline; there are of course legitimate reasons to ignore the guideline. However, the guideline should be ignored consciously rather than haphazardly.
|
||||
|
||||
If you are choosing to build a worse-than-you-could API, consider the following actions to limit the effect:
|
||||
|
||||
- Use a non-public access modifier
|
||||
- Document the costs or problems with the interface
|
||||
- Mark the interface as experimental
|
||||
|
||||
# How?
|
||||
|
||||
We generally refer readers to Sean Parent [1], Scott Meyers [2], Joshua Bloch [5], and Jasmin Blanchette [8] for tips on writing good interfaces, but we leave a summary of advice below:
|
||||
|
||||
1. Get use cases to inform requirements
|
||||
- Drive the design based on use cases; remember, an interface can only be good within the context of use cases!
|
||||
2. Get a tight feedback loop with users
|
||||
- Write the API first (without implementation), make many examples and share with potential users. Bonus points if you yourself will be a user
|
||||
- These examples can later become test cases
|
||||
- Users are often ambivalent; find people who will use the API and provide you necessary/critical feedback
|
||||
3. Keep the API as small as possible
|
||||
- Hyrum's Law: all observable behaviors of your implementation will be depended on by someone
|
||||
4. Design for extensibility
|
||||
- Think about what users can extend via inheritance; block inheritance if your API is not designed for it
|
||||
- Think about what hooks/callbacks might be valuable
|
||||
- Accept interfaces instead of implementations in functions/methods
|
||||
5. Keep implementation details out of the interface (where possible)
|
||||
- If you must include implementation details, keep it as separated as possible and use "scary" names to denote that users should generally stay away (e.g. `ImplementationSpecificParameters` )
|
||||
6. Document every public entity: classes, methods, functions, variables
|
||||
- Bonus points for including example usage
|
||||
- Documentation should explain the "why"
|
||||
7. Make errors actionable
|
||||
8. Limit mutability
|
||||
|
||||
# References
|
||||
|
||||
1. [Better Code: Relationships](https://www.youtube.com/watch?v=ejF6qqohp3M) (Sean Parent, Adobe)
|
||||
2. [The Most Important Design Guideline](https://www.aristeia.com/Papers/IEEE_Software_JulAug_2004_revised.htm) (Scott Meyers, Effective C++)
|
||||
3. [Programming as Theory Building](https://gist.github.com/dpritchett/fd7115b6f556e40103ef) (Peter Naur, Turing Award)
|
||||
4. [You Can't Tell People Anything](http://habitatchronicles.com/2004/04/you-cant-tell-people-anything/) (Chip Morningstar, industry veteran)
|
||||
5. [How to Design a Good API and Why it Matters](https://www.youtube.com/watch?v=aAb7hSCtvGw) (Joshua Bloch, Effective Java)
|
||||
6. [The Mythical Man-Month](https://web.eecs.umich.edu/~weimerw/2018-481/readings/mythical-man-month.pdf) (Fred Brooks, Turing Award)
|
||||
7. [What makes a good API?](https://medium.com/@rkuris/good-apis-cd861b8b70a3) (Ron Kuris, industry veteran)
|
||||
8. [The Little Manual of API Design](https://web.archive.org/web/20090520234149/http://chaos.troll.no/~shausman/api-design/api-design.pdf) (Jasmin Blanchette, Nokia / Qt)
|
||||
9. [Coupling (Wikipedia)](https://en.wikipedia.org/wiki/Coupling_(computer_programming)#Disadvantages_of_tight_coupling)
|
32
global.css
32
global.css
@ -4,6 +4,7 @@
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
/* Light mode colors remain unchanged */
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 240 10% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
@ -26,24 +27,39 @@
|
||||
}
|
||||
|
||||
.dark:root {
|
||||
/* Darkest - Main background */
|
||||
--background: 240 10% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 240 10% 3.9%;
|
||||
|
||||
/* Slightly lighter - Cards and popovers */
|
||||
--card: 240 10% 5.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 240 10% 3.9%;
|
||||
--popover: 240 10% 5.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
|
||||
/* Contrast colors */
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 240 5.9% 10%;
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
|
||||
/* Sheet background - Darker than before */
|
||||
--secondary: 240 3.7% 13%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 240 3.7% 15.9%;
|
||||
|
||||
/* Interactive elements - Lighter than secondary */
|
||||
--muted: 240 3.7% 18%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
--accent: 240 3.7% 15.9%;
|
||||
|
||||
/* Accents and highlights */
|
||||
--accent: 240 3.7% 18%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
|
||||
/* Destructive actions */
|
||||
--destructive: 0 72% 51%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 240 3.7% 15.9%;
|
||||
|
||||
/* Interactive elements and borders */
|
||||
--border: 240 3.7% 25%;
|
||||
--input: 240 3.7% 25%;
|
||||
--ring: 240 4.9% 83.9%;
|
||||
}
|
||||
}
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
import * as NavigationBar from 'expo-navigation-bar';
|
||||
import { Platform } from 'react-native';
|
||||
import { NAV_THEME } from '~/lib/constants';
|
||||
import { NAV_THEME } from '@/lib/constants';
|
||||
|
||||
export async function setAndroidNavigationBar(theme: 'light' | 'dark') {
|
||||
if (Platform.OS !== 'android') return;
|
||||
|
@ -1,3 +1,4 @@
|
||||
// lib/constants.ts
|
||||
export const NAV_THEME = {
|
||||
light: {
|
||||
background: 'hsl(0 0% 100%)', // background
|
||||
@ -16,3 +17,9 @@ export const NAV_THEME = {
|
||||
text: 'hsl(0 0% 98%)', // foreground
|
||||
},
|
||||
};
|
||||
|
||||
export const CUSTOM_COLORS = {
|
||||
purple: '#8B5CF6',
|
||||
purplePressed: '#7C3AED', // Slightly darker for pressed state
|
||||
orange: '#F97316'
|
||||
} as const;
|
||||
|
4
lib/icons/Check.tsx
Normal file
4
lib/icons/Check.tsx
Normal file
@ -0,0 +1,4 @@
|
||||
import { Check } from 'lucide-react-native';
|
||||
import { iconWithClassName } from './iconWithClassName';
|
||||
iconWithClassName(Check);
|
||||
export { Check };
|
4
lib/icons/ChevronDown.tsx
Normal file
4
lib/icons/ChevronDown.tsx
Normal file
@ -0,0 +1,4 @@
|
||||
import { ChevronDown } from 'lucide-react-native';
|
||||
import { iconWithClassName } from './iconWithClassName';
|
||||
iconWithClassName(ChevronDown);
|
||||
export { ChevronDown };
|
4
lib/icons/ChevronRight.tsx
Normal file
4
lib/icons/ChevronRight.tsx
Normal file
@ -0,0 +1,4 @@
|
||||
import { ChevronRight } from 'lucide-react-native';
|
||||
import { iconWithClassName } from './iconWithClassName';
|
||||
iconWithClassName(ChevronRight);
|
||||
export { ChevronRight };
|
4
lib/icons/ChevronUp.tsx
Normal file
4
lib/icons/ChevronUp.tsx
Normal file
@ -0,0 +1,4 @@
|
||||
import { ChevronUp } from 'lucide-react-native';
|
||||
import { iconWithClassName } from './iconWithClassName';
|
||||
iconWithClassName(ChevronUp);
|
||||
export { ChevronUp };
|
4
lib/icons/X.tsx
Normal file
4
lib/icons/X.tsx
Normal file
@ -0,0 +1,4 @@
|
||||
import { X } from 'lucide-react-native';
|
||||
import { iconWithClassName } from './iconWithClassName';
|
||||
iconWithClassName(X);
|
||||
export { X };
|
@ -1,6 +1,11 @@
|
||||
// metro.config.js
|
||||
const { getDefaultConfig } = require('expo/metro-config');
|
||||
const { withNativeWind } = require('nativewind/metro');
|
||||
|
||||
const config = getDefaultConfig(__dirname);
|
||||
|
||||
module.exports = withNativeWind(config, { input: './global.css' });
|
||||
// Add platform-specific extensions
|
||||
config.resolver.sourceExts = [...config.resolver.sourceExts, 'web.tsx', 'web.ts', 'web.jsx', 'web.js'];
|
||||
config.resolver.platforms = ['ios', 'android', 'web'];
|
||||
|
||||
module.exports = withNativeWind(config, { input: './global.css' });
|
4673
package-lock.json
generated
4673
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
27
package.json
27
package.json
@ -13,11 +13,34 @@
|
||||
"postinstall": "npx tailwindcss -i ./global.css -o ./node_modules/.cache/nativewind/global.css"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-alert-dialog": "^1.1.6",
|
||||
"@react-navigation/material-top-tabs": "^7.1.0",
|
||||
"@react-navigation/native": "^7.0.0",
|
||||
"@rn-primitives/accordion": "^1.1.0",
|
||||
"@rn-primitives/alert-dialog": "^1.1.0",
|
||||
"@rn-primitives/aspect-ratio": "^1.1.0",
|
||||
"@rn-primitives/avatar": "~1.1.0",
|
||||
"@rn-primitives/checkbox": "^1.1.0",
|
||||
"@rn-primitives/collapsible": "^1.1.0",
|
||||
"@rn-primitives/context-menu": "^1.1.0",
|
||||
"@rn-primitives/dialog": "^1.1.0",
|
||||
"@rn-primitives/dropdown-menu": "^1.1.0",
|
||||
"@rn-primitives/hover-card": "^1.1.0",
|
||||
"@rn-primitives/label": "^1.1.0",
|
||||
"@rn-primitives/menubar": "^1.1.0",
|
||||
"@rn-primitives/navigation-menu": "^1.1.0",
|
||||
"@rn-primitives/popover": "^1.1.0",
|
||||
"@rn-primitives/portal": "~1.1.0",
|
||||
"@rn-primitives/progress": "~1.1.0",
|
||||
"@rn-primitives/radio-group": "^1.1.0",
|
||||
"@rn-primitives/select": "^1.1.0",
|
||||
"@rn-primitives/separator": "^1.1.0",
|
||||
"@rn-primitives/slot": "~1.1.0",
|
||||
"@rn-primitives/switch": "^1.1.0",
|
||||
"@rn-primitives/table": "^1.1.0",
|
||||
"@rn-primitives/tabs": "^1.1.0",
|
||||
"@rn-primitives/toggle": "^1.1.0",
|
||||
"@rn-primitives/toggle-group": "^1.1.0",
|
||||
"@rn-primitives/tooltip": "~1.1.0",
|
||||
"@rn-primitives/types": "~1.1.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
@ -34,10 +57,13 @@
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-native": "0.76.6",
|
||||
"react-native-gesture-handler": "~2.20.2",
|
||||
"react-native-pager-view": "^6.5.1",
|
||||
"react-native-reanimated": "~3.16.1",
|
||||
"react-native-safe-area-context": "4.12.0",
|
||||
"react-native-screens": "~4.4.0",
|
||||
"react-native-svg": "15.8.0",
|
||||
"react-native-tab-view": "^4.0.5",
|
||||
"react-native-web": "~0.19.13",
|
||||
"tailwind-merge": "^2.2.1",
|
||||
"tailwindcss": "3.3.5",
|
||||
@ -47,6 +73,7 @@
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.26.0",
|
||||
"@types/react": "~18.3.12",
|
||||
"babel-plugin-module-resolver": "^5.0.2",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"private": true
|
||||
|
19
tests/type-test.ts
Normal file
19
tests/type-test.ts
Normal file
@ -0,0 +1,19 @@
|
||||
// tests/type-test.ts (just to verify types)
|
||||
import { BaseExercise } from '@/types/exercise';
|
||||
import { StorageSource } from '@/types/shared';
|
||||
|
||||
// This should compile if our types are correct
|
||||
const testExercise: BaseExercise = {
|
||||
// SyncableContent properties
|
||||
id: 'test-id',
|
||||
created_at: Date.now(),
|
||||
availability: {
|
||||
source: ['local' as StorageSource]
|
||||
},
|
||||
|
||||
// BaseExercise properties
|
||||
title: 'Test Exercise',
|
||||
type: 'strength',
|
||||
category: 'Push',
|
||||
tags: [],
|
||||
};
|
@ -4,7 +4,7 @@
|
||||
"strict": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": [
|
||||
"@/*": [
|
||||
"*"
|
||||
]
|
||||
}
|
||||
|
139
types/exercise.ts
Normal file
139
types/exercise.ts
Normal file
@ -0,0 +1,139 @@
|
||||
// types/exercise.ts - handles everything about individual exercises
|
||||
import { NostrEventKind } from './events';
|
||||
import { SyncableContent } from './shared';
|
||||
|
||||
// Exercise classification types
|
||||
export type ExerciseType = 'strength' | 'cardio' | 'bodyweight';
|
||||
export type ExerciseCategory = 'Push' | 'Pull' | 'Legs' | 'Core';
|
||||
export type Equipment =
|
||||
| 'bodyweight'
|
||||
| 'barbell'
|
||||
| 'dumbbell'
|
||||
| 'kettlebell'
|
||||
| 'machine'
|
||||
| 'cable'
|
||||
| 'other';
|
||||
|
||||
// Base library content interface
|
||||
export interface LibraryContent extends SyncableContent {
|
||||
title: string;
|
||||
type: 'exercise' | 'workout' | 'program';
|
||||
description?: string;
|
||||
author?: {
|
||||
name: string;
|
||||
pubkey?: string;
|
||||
};
|
||||
category?: ExerciseCategory;
|
||||
equipment?: Equipment;
|
||||
source: 'local' | 'pow' | 'nostr';
|
||||
tags: string[];
|
||||
isPublic?: boolean;
|
||||
}
|
||||
|
||||
// Basic exercise definition
|
||||
export interface BaseExercise extends SyncableContent {
|
||||
title: string;
|
||||
type: ExerciseType;
|
||||
category: ExerciseCategory;
|
||||
equipment?: Equipment;
|
||||
description?: string;
|
||||
instructions?: string[];
|
||||
tags: string[];
|
||||
format?: {
|
||||
weight?: boolean;
|
||||
reps?: boolean;
|
||||
rpe?: boolean;
|
||||
set_type?: boolean;
|
||||
};
|
||||
format_units?: {
|
||||
weight?: 'kg' | 'lbs';
|
||||
reps?: 'count';
|
||||
rpe?: '0-10';
|
||||
set_type?: 'warmup|normal|drop|failure';
|
||||
};
|
||||
}
|
||||
|
||||
// Set types and formats
|
||||
export type SetType = 'warmup' | 'normal' | 'drop' | 'failure';
|
||||
|
||||
export interface WorkoutSet {
|
||||
id: string;
|
||||
weight?: number;
|
||||
reps?: number;
|
||||
rpe?: number;
|
||||
type: SetType;
|
||||
isCompleted: boolean;
|
||||
notes?: string;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
// Exercise with workout-specific data
|
||||
export interface WorkoutExercise extends BaseExercise {
|
||||
sets: WorkoutSet[];
|
||||
totalWeight?: number;
|
||||
notes?: string;
|
||||
restTime?: number; // Rest time in seconds
|
||||
targetSets?: number;
|
||||
targetReps?: number;
|
||||
}
|
||||
|
||||
// Exercise template specific types
|
||||
export interface ExerciseTemplate extends BaseExercise {
|
||||
defaultSets?: {
|
||||
type: SetType;
|
||||
weight?: number;
|
||||
reps?: number;
|
||||
rpe?: number;
|
||||
}[];
|
||||
recommendations?: {
|
||||
beginnerWeight?: number;
|
||||
intermediateWeight?: number;
|
||||
advancedWeight?: number;
|
||||
restTime?: number;
|
||||
tempo?: string;
|
||||
};
|
||||
variations?: string[];
|
||||
progression?: {
|
||||
type: 'linear' | 'percentage' | 'custom';
|
||||
increment?: number;
|
||||
rules?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
// Exercise history and progress tracking
|
||||
export interface ExerciseHistory {
|
||||
exerciseId: string;
|
||||
entries: Array<{
|
||||
date: number;
|
||||
workoutId: string;
|
||||
sets: WorkoutSet[];
|
||||
totalWeight: number;
|
||||
notes?: string;
|
||||
}>;
|
||||
personalBests: {
|
||||
weight?: {
|
||||
value: number;
|
||||
date: number;
|
||||
workoutId: string;
|
||||
};
|
||||
reps?: {
|
||||
value: number;
|
||||
date: number;
|
||||
workoutId: string;
|
||||
};
|
||||
volume?: {
|
||||
value: number;
|
||||
date: number;
|
||||
workoutId: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// Type guards
|
||||
export function isWorkoutExercise(exercise: any): exercise is WorkoutExercise {
|
||||
return exercise && Array.isArray(exercise.sets);
|
||||
}
|
||||
|
||||
export function isExerciseTemplate(exercise: any): exercise is ExerciseTemplate {
|
||||
return exercise && 'recommendations' in exercise;
|
||||
}
|
55
types/library.ts
Normal file
55
types/library.ts
Normal file
@ -0,0 +1,55 @@
|
||||
// types/library.ts
|
||||
interface TemplateExercise {
|
||||
title: string;
|
||||
targetSets: number;
|
||||
targetReps: number;
|
||||
}
|
||||
|
||||
export type TemplateType = 'strength' | 'circuit' | 'emom' | 'amrap';
|
||||
|
||||
export type TemplateCategory =
|
||||
| 'Full Body'
|
||||
| 'Custom'
|
||||
| 'Push/Pull/Legs'
|
||||
| 'Upper/Lower'
|
||||
| 'Cardio'
|
||||
| 'CrossFit'
|
||||
| 'Strength'
|
||||
| 'Conditioning';
|
||||
export type ContentSource = 'local' | 'powr' | 'nostr';
|
||||
|
||||
export interface Template {
|
||||
id: string;
|
||||
title: string;
|
||||
type: TemplateType; // 'strength' | 'circuit' | 'emom' | 'amrap'
|
||||
category: TemplateCategory;
|
||||
exercises: TemplateExercise[];
|
||||
description?: string;
|
||||
tags: string[];
|
||||
source: ContentSource;
|
||||
isFavorite?: boolean;
|
||||
lastUsed?: Date;
|
||||
}
|
||||
|
||||
export interface FilterOptions {
|
||||
equipment: string[];
|
||||
tags: string[];
|
||||
source: ContentSource[];
|
||||
}
|
||||
|
||||
export type ExerciseType = 'strength' | 'cardio' | 'bodyweight';
|
||||
export type ExerciseCategory = 'Push' | 'Pull' | 'Legs' | 'Core';
|
||||
export type ExerciseEquipment = 'bodyweight' | 'barbell' | 'dumbbell' | 'kettlebell' | 'machine' | 'cable' | 'other';
|
||||
|
||||
export interface Exercise {
|
||||
id: string;
|
||||
title: string;
|
||||
category: ExerciseCategory;
|
||||
type?: ExerciseType;
|
||||
equipment?: ExerciseEquipment;
|
||||
description?: string;
|
||||
tags: string[];
|
||||
source: ContentSource;
|
||||
usageCount?: number;
|
||||
lastUsed?: Date;
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user