Initial commit of new POWR version

This commit is contained in:
DocNR 2025-02-09 20:38:38 -05:00
parent 08fc64a6a3
commit 87cdf3fc1c
102 changed files with 12008 additions and 78 deletions

78
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View 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
View 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.

View 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

View 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.

View 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
View File

@ -0,0 +1,2 @@
{
}

84
CHANGELOG.md Normal file
View 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
View 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
View File

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

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

View 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';

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

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

View 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,
},
});

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

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

View File

@ -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 (

View File

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

View File

@ -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
View File

@ -0,0 +1,6 @@
{
"aliases": {
"components": "@/components",
"lib": "@/lib"
}
}

19
components/Header.tsx Normal file
View 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>
);
}

View File

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

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

View 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>
</>
);
}

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

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

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

View 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
View 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;

View 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;

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

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

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

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

View File

@ -0,0 +1,5 @@
import * as AspectRatioPrimitive from '@rn-primitives/aspect-ratio';
const AspectRatio = AspectRatioPrimitive.Root;
export { AspectRatio };

View File

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

View File

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

View File

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

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

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

View 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
View 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,
};

View 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,
};

View 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
View 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
View 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
View 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,
};

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

View File

@ -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,

View 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
View 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,
};

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

View 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,
},
});

View 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,
},
});

View 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

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

View 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,
},
});

View 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';

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

View File

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

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

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

View File

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

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

View 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
View 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.

View File

@ -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
View 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
View 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]

View 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)

View File

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

View File

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

View File

@ -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
View File

@ -0,0 +1,4 @@
import { Check } from 'lucide-react-native';
import { iconWithClassName } from './iconWithClassName';
iconWithClassName(Check);
export { Check };

View File

@ -0,0 +1,4 @@
import { ChevronDown } from 'lucide-react-native';
import { iconWithClassName } from './iconWithClassName';
iconWithClassName(ChevronDown);
export { ChevronDown };

View 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
View 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
View File

@ -0,0 +1,4 @@
import { X } from 'lucide-react-native';
import { iconWithClassName } from './iconWithClassName';
iconWithClassName(X);
export { X };

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -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
View 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: [],
};

View File

@ -4,7 +4,7 @@
"strict": true,
"baseUrl": ".",
"paths": {
"~/*": [
"@/*": [
"*"
]
}

139
types/exercise.ts Normal file
View 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
View 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