first test
@@ -0,0 +1,192 @@
|
||||
# Trackeep Mobile App
|
||||
|
||||
React Native mobile application for Trackeep - productivity and knowledge management platform.
|
||||
|
||||
## Features
|
||||
|
||||
### ✅ Core Features Implemented
|
||||
- **🔐 Authentication**: Login with email/password and GitHub OAuth
|
||||
- **📱 Offline Support**: Full offline functionality with sync when online
|
||||
- **📝 Content Management**: Bookmarks, Tasks, Notes, and Time Tracking
|
||||
- **🔍 Search**: Unified search across all content types
|
||||
- **⏱️ Time Tracking**: Built-in timer with task association
|
||||
- **🎨 Modern UI**: Material Design with React Native Paper
|
||||
- **📊 Dashboard**: Overview with stats and recent activity
|
||||
|
||||
### ✅ Mobile-Specific Features
|
||||
- **Gesture Navigation**: Intuitive mobile navigation patterns
|
||||
- **Push Notifications**: Task reminders and updates with permission management
|
||||
- **Camera Integration**: Document scanning capability with permission handling
|
||||
- **Voice Notes**: Audio recording for quick notes with speech-to-text
|
||||
- **Background Sync**: Automatic data synchronization
|
||||
- **Responsive Design**: Optimized for various screen sizes
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **React Native** 0.72.6
|
||||
- **TypeScript** for type safety
|
||||
- **React Navigation** for navigation
|
||||
- **React Native Paper** for UI components
|
||||
- **AsyncStorage** for local data persistence
|
||||
- **Axios** for API communication
|
||||
- **Vector Icons** for iconography
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
mobile-app/
|
||||
├── src/
|
||||
│ ├── components/ # Reusable UI components
|
||||
│ ├── screens/ # Screen components
|
||||
│ │ ├── auth/ # Authentication screens
|
||||
│ │ ├── DashboardScreen.tsx
|
||||
│ │ ├── BookmarksScreen.tsx
|
||||
│ │ ├── TasksScreen.tsx
|
||||
│ │ ├── NotesScreen.tsx
|
||||
│ │ ├── TimeTrackingScreen.tsx
|
||||
│ │ ├── SearchScreen.tsx
|
||||
│ │ └── SettingsScreen.tsx
|
||||
│ ├── services/ # Business logic and API
|
||||
│ │ ├── AuthContext.tsx
|
||||
│ │ ├── OfflineContext.tsx
|
||||
│ │ └── api.ts
|
||||
│ ├── navigation/ # Navigation configuration
|
||||
│ ├── utils/ # Utility functions
|
||||
│ │ ├── storage.ts
|
||||
│ │ └── offlineSync.ts
|
||||
│ └── types/ # TypeScript type definitions
|
||||
├── android/ # Android-specific code
|
||||
├── ios/ # iOS-specific code
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 16+
|
||||
- React Native CLI
|
||||
- Android Studio (for Android development)
|
||||
- Xcode (for iOS development)
|
||||
|
||||
### Installation
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd Trackeep/mobile-app
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. For iOS, install pods:
|
||||
```bash
|
||||
cd ios && pod install && cd ..
|
||||
```
|
||||
|
||||
### Running the App
|
||||
|
||||
#### Android
|
||||
```bash
|
||||
npm run android
|
||||
```
|
||||
|
||||
#### iOS
|
||||
```bash
|
||||
npm run ios
|
||||
```
|
||||
|
||||
#### Start Metro Bundler
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Create a `.env` file in the root directory:
|
||||
|
||||
```env
|
||||
API_BASE_URL=http://localhost:8080/api
|
||||
```
|
||||
|
||||
### API Configuration
|
||||
|
||||
Update the API base URL in `src/services/api.ts` to match your backend server.
|
||||
|
||||
## Features Status
|
||||
|
||||
### ✅ Completed
|
||||
- [x] Project setup and configuration
|
||||
- [x] Authentication flow (email/password, GitHub)
|
||||
- [x] Navigation structure
|
||||
- [x] Core screens (Dashboard, Bookmarks, Tasks, Notes, Time Tracking, Search, Settings)
|
||||
- [x] Offline data storage and sync
|
||||
- [x] Modern UI with Material Design
|
||||
- [x] TypeScript integration
|
||||
- [x] API service layer
|
||||
- [x] Push notification implementation with permission management
|
||||
- [x] Camera integration for document scanning
|
||||
- [x] Voice recording for notes with speech-to-text
|
||||
- [x] Enhanced settings screen with mobile features
|
||||
|
||||
### 📋 Planned
|
||||
- [ ] Biometric authentication
|
||||
- [ ] Dark mode theme
|
||||
- [ ] Widget support
|
||||
- [ ] Apple Watch companion app
|
||||
- [ ] Advanced analytics
|
||||
|
||||
## Development
|
||||
|
||||
### Code Style
|
||||
|
||||
The project uses TypeScript and follows React Native best practices. All components are functional components with hooks.
|
||||
|
||||
### State Management
|
||||
|
||||
- **Authentication**: React Context (AuthContext)
|
||||
- **Offline Sync**: React Context (OfflineContext)
|
||||
- **Local Data**: AsyncStorage with SQLite for complex queries
|
||||
|
||||
### API Integration
|
||||
|
||||
All API calls are centralized in `src/services/api.ts` with automatic token management and error handling.
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
### Android Release Build
|
||||
```bash
|
||||
npm run build:android
|
||||
```
|
||||
|
||||
### iOS Release Build
|
||||
```bash
|
||||
npm run build:ios
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
3. Make your changes
|
||||
4. Add tests if applicable
|
||||
5. Submit a pull request
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License.
|
||||
|
||||
## Support
|
||||
|
||||
For support and questions, please open an issue in the repository.
|
||||
@@ -0,0 +1,210 @@
|
||||
# Mobile App Sync Testing Guide
|
||||
|
||||
## Overview
|
||||
This guide helps you test the bi-directional synchronization between the Trackeep mobile app and web dashboard.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **Backend Server**: Ensure your Trackeep backend is running
|
||||
2. **Web Dashboard**: Access the web dashboard at `http://localhost:3000` (or your configured URL)
|
||||
3. **Mobile App**: Run the React Native app using:
|
||||
```bash
|
||||
npm start
|
||||
npm run android # or npm run ios
|
||||
```
|
||||
|
||||
## First Launch Setup
|
||||
|
||||
1. **Server Configuration**: On first launch, the mobile app will show the server setup screen:
|
||||
- Enter your backend URL (e.g., `http://localhost:8080`)
|
||||
- Enter your credentials
|
||||
- Test connection before completing setup
|
||||
|
||||
2. **Authentication**: After setup, you'll be redirected to login with your existing credentials
|
||||
|
||||
## Testing Real-Time Sync
|
||||
|
||||
### Test 1: Create Content on Mobile, Verify on Web
|
||||
|
||||
1. **On Mobile App**:
|
||||
- Open the Dashboard
|
||||
- Tap the FAB (+) button
|
||||
- Create a new task, bookmark, or note
|
||||
- Verify it appears in the mobile dashboard
|
||||
|
||||
2. **On Web Dashboard**:
|
||||
- Navigate to the corresponding section (Tasks, Bookmarks, or Notes)
|
||||
- The new item should appear within seconds (if WebSocket is connected)
|
||||
- If not, refresh the page to see the synced item
|
||||
|
||||
### Test 2: Create Content on Web, Verify on Mobile
|
||||
|
||||
1. **On Web Dashboard**:
|
||||
- Create a new task, bookmark, or note
|
||||
- Save the item
|
||||
|
||||
2. **On Mobile App**:
|
||||
- The item should appear automatically if real-time sync is working
|
||||
- Pull to refresh on the dashboard to force sync
|
||||
- Check the specific section to verify the item appears
|
||||
|
||||
### Test 3: Offline Mode Testing
|
||||
|
||||
1. **Enable Offline Mode**:
|
||||
- Turn off internet connection on your mobile device
|
||||
- The app should show "🔴 Offline" status
|
||||
|
||||
2. **Create Content Offline**:
|
||||
- Create several tasks, bookmarks, or notes
|
||||
- Notice the pending changes counter increases
|
||||
|
||||
3. **Restore Connection**:
|
||||
- Turn internet back on
|
||||
- App should show "🟢 Connected" and auto-sync
|
||||
- Verify items appear on web dashboard
|
||||
|
||||
### Test 4: Conflict Resolution
|
||||
|
||||
1. **Simulate Conflict**:
|
||||
- Create the same item on both mobile and web while offline
|
||||
- Bring both online simultaneously
|
||||
- Verify how conflicts are resolved (last write wins or merge)
|
||||
|
||||
## Key Features to Test
|
||||
|
||||
### Real-Time Updates
|
||||
- ✅ WebSocket connection status
|
||||
- ✅ Instant updates across devices
|
||||
- ✅ Connection recovery after disconnection
|
||||
|
||||
### Offline Support
|
||||
- ✅ Offline data persistence
|
||||
- ✅ Pending changes tracking
|
||||
- ✅ Automatic sync when online
|
||||
- ✅ Manual sync button
|
||||
|
||||
### Data Integrity
|
||||
- ✅ All data types sync correctly (tasks, bookmarks, notes)
|
||||
- ✅ Timestamps preserved
|
||||
- ✅ User associations maintained
|
||||
- ✅ Tags and metadata sync
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **WebSocket Connection Failed**:
|
||||
- Check if backend WebSocket endpoint is accessible
|
||||
- Verify firewall settings
|
||||
- Check browser console for WebSocket errors
|
||||
|
||||
2. **Sync Not Working**:
|
||||
- Verify server URL in mobile app settings
|
||||
- Check authentication tokens
|
||||
- Review backend logs for sync errors
|
||||
|
||||
3. **Offline Mode Not Detected**:
|
||||
- Check network permissions on mobile device
|
||||
- Verify NetInfo plugin is working
|
||||
- Test with airplane mode
|
||||
|
||||
### Debug Tools
|
||||
|
||||
1. **Mobile App Debugging**:
|
||||
```bash
|
||||
# Enable debug mode
|
||||
npx react-native log-android
|
||||
npx react-native log-ios
|
||||
```
|
||||
|
||||
2. **Backend Logs**:
|
||||
- Monitor sync endpoint logs
|
||||
- Check WebSocket connection logs
|
||||
- Review database transaction logs
|
||||
|
||||
3. **Browser Console**:
|
||||
- Monitor WebSocket connections
|
||||
- Check for real-time update events
|
||||
- Verify API responses
|
||||
|
||||
## Performance Testing
|
||||
|
||||
### Test Scenarios
|
||||
|
||||
1. **Large Dataset Sync**:
|
||||
- Create 100+ items on one device
|
||||
- Measure sync time to other device
|
||||
- Verify no data loss
|
||||
|
||||
2. **Concurrent Updates**:
|
||||
- Multiple users updating same data
|
||||
- Test conflict resolution
|
||||
- Verify data consistency
|
||||
|
||||
3. **Network Conditions**:
|
||||
- Test on slow networks (2G/3G)
|
||||
- Test with intermittent connectivity
|
||||
- Verify sync resilience
|
||||
|
||||
## Expected Results
|
||||
|
||||
### Successful Sync Indicators
|
||||
|
||||
1. **Mobile App**:
|
||||
- Status shows "🟢 Connected"
|
||||
- Last sync time updates
|
||||
- No pending changes counter
|
||||
- Real-time updates received
|
||||
|
||||
2. **Web Dashboard**:
|
||||
- New items appear without refresh
|
||||
- WebSocket connection established
|
||||
- No sync errors in console
|
||||
|
||||
### Performance Benchmarks
|
||||
|
||||
- **Small items** (< 1KB): Should sync within 1-2 seconds
|
||||
- **Large items** (> 100KB): Should sync within 5-10 seconds
|
||||
- **Batch sync** (50+ items): Should complete within 30 seconds
|
||||
|
||||
## Automated Testing
|
||||
|
||||
For comprehensive testing, consider implementing:
|
||||
|
||||
1. **Unit Tests**:
|
||||
- Sync logic validation
|
||||
- Offline queue management
|
||||
- Conflict resolution
|
||||
|
||||
2. **Integration Tests**:
|
||||
- End-to-end sync workflows
|
||||
- WebSocket connection testing
|
||||
- API integration validation
|
||||
|
||||
3. **E2E Tests**:
|
||||
- Multi-device sync scenarios
|
||||
- Offline/online transitions
|
||||
- User interaction flows
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
When reporting sync issues, include:
|
||||
|
||||
1. Device information (OS, version)
|
||||
2. Network conditions
|
||||
3. Steps to reproduce
|
||||
4. Screenshots of error messages
|
||||
5. Backend logs (if available)
|
||||
6. Browser console errors
|
||||
|
||||
## Success Criteria
|
||||
|
||||
The sync implementation is considered successful when:
|
||||
|
||||
- ✅ All data types sync bi-directionally
|
||||
- ✅ Real-time updates work within 5 seconds
|
||||
- ✅ Offline mode functions correctly
|
||||
- ✅ No data loss during sync
|
||||
- ✅ Conflicts are handled gracefully
|
||||
- ✅ Performance meets benchmarks
|
||||
- ✅ Error recovery works reliably
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 1024 1024">
|
||||
<!-- Generator: Adobe Illustrator 29.7.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 8) -->
|
||||
<path fill="#3b82f6" d="M797.56,348.33c30.91,6.66,83.06,31.07,87.21,66.41,3.15,26.85-13.06,79.89-19.48,108.33-18.08,80.05-35.49,169.43-58.24,247.51-37.84,129.87-259.97,143.84-369.81,131-79.98-9.34-191.79-42.13-218.72-127.8l-79.09-341.68c-6.97-50.68,49.68-73.96,89.21-85.61,54.67-16.12,110.4-16.62,167.08-15.62l-45.47,16.39c-44.91,18.53-101.62,26.35-145.23,45.5-15.84,6.96-26.94,19.89-14.58,36.36,18.56,24.72,115.14,56.1,146.77,62.89,82.16,17.65,152.89,2.18,231.43-22.46,54.98-17.25,130.36-44.86,168.52-89.18,25.27-29.35,31.42-69.3,44.38-104.84l-243.09,42.99-2,3.82c-1.45,15.08-2.76,30.11-6.03,44.92-31.75,143.69-186.99,93.71-283.19,53.84l1.47-2.15c82.39-23.86,163.75-50.91,245.03-78.2,5.91-3.48,5.08-25.81,7.22-33.55,3.45-12.47,12.22-19.65,23.97-24.08l282.88-49.24c14.15,3.11,20.4,11.87,18.9,26.37l-29.13,88.06v.02ZM841.24,457.54l-19.61,14.59c-113.51,75.99-355.25,76.96-485.87,53.37-58.02-10.48-91.56-24.33-143.96-48.22-1.22-.56-3.68-1.88-4.26-.11,9.51,23.92,8.86,56.35,22.36,78.1,28.12,45.3,113.96,67.69,163.71,76.52,72.61,12.88,148.98,14.98,222.29,7.73,63.62-6.3,204.67-30.25,226.3-101.29,7.88-25.88,11.16-54.64,19.03-80.69h0ZM806.3,607.51l-30.5,18.26c-131.73,66.26-405.38,65.51-535.38-4.91-6.49-3.51-12.31-8.25-18.62-11.96-1.12-.66-2.56-2.94-3.72-.65,11.55,32.54,8.2,71.49,34.17,96.87,59.84,58.47,212.94,70.07,292.78,66.97,63.99-2.48,191.09-21.71,233.65-73.56,19.02-23.17,17.88-63.15,27.62-91.02h0ZM774.26,744.37c-13.76,6.59-26.7,15.08-40.78,21.08-119.06,50.77-329.26,50.83-447.48-2.37-12.39-5.57-23.46-13.36-35.88-18.71,6.11,30.5,22.34,54.66,47.59,72.51,97.85,69.2,342.24,69.95,436.47-5.6,21.95-17.59,34.7-39.15,40.08-66.92h0Z"/>
|
||||
<rect fill="#3b82f6" x="468.16" y="171.76" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
|
||||
<rect fill="#3b82f6" x="593.35" y="118.61" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
|
||||
<rect fill="#3b82f6" x="361.93" y="250.32" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 1024 1024">
|
||||
<!-- Generator: Adobe Illustrator 29.7.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 8) -->
|
||||
<path fill="#3b82f6" d="M797.56,348.33c30.91,6.66,83.06,31.07,87.21,66.41,3.15,26.85-13.06,79.89-19.48,108.33-18.08,80.05-35.49,169.43-58.24,247.51-37.84,129.87-259.97,143.84-369.81,131-79.98-9.34-191.79-42.13-218.72-127.8l-79.09-341.68c-6.97-50.68,49.68-73.96,89.21-85.61,54.67-16.12,110.4-16.62,167.08-15.62l-45.47,16.39c-44.91,18.53-101.62,26.35-145.23,45.5-15.84,6.96-26.94,19.89-14.58,36.36,18.56,24.72,115.14,56.1,146.77,62.89,82.16,17.65,152.89,2.18,231.43-22.46,54.98-17.25,130.36-44.86,168.52-89.18,25.27-29.35,31.42-69.3,44.38-104.84l-243.09,42.99-2,3.82c-1.45,15.08-2.76,30.11-6.03,44.92-31.75,143.69-186.99,93.71-283.19,53.84l1.47-2.15c82.39-23.86,163.75-50.91,245.03-78.2,5.91-3.48,5.08-25.81,7.22-33.55,3.45-12.47,12.22-19.65,23.97-24.08l282.88-49.24c14.15,3.11,20.4,11.87,18.9,26.37l-29.13,88.06v.02ZM841.24,457.54l-19.61,14.59c-113.51,75.99-355.25,76.96-485.87,53.37-58.02-10.48-91.56-24.33-143.96-48.22-1.22-.56-3.68-1.88-4.26-.11,9.51,23.92,8.86,56.35,22.36,78.1,28.12,45.3,113.96,67.69,163.71,76.52,72.61,12.88,148.98,14.98,222.29,7.73,63.62-6.3,204.67-30.25,226.3-101.29,7.88-25.88,11.16-54.64,19.03-80.69h0ZM806.3,607.51l-30.5,18.26c-131.73,66.26-405.38,65.51-535.38-4.91-6.49-3.51-12.31-8.25-18.62-11.96-1.12-.66-2.56-2.94-3.72-.65,11.55,32.54,8.2,71.49,34.17,96.87,59.84,58.47,212.94,70.07,292.78,66.97,63.99-2.48,191.09-21.71,233.65-73.56,19.02-23.17,17.88-63.15,27.62-91.02h0ZM774.26,744.37c-13.76,6.59-26.7,15.08-40.78,21.08-119.06,50.77-329.26,50.83-447.48-2.37-12.39-5.57-23.46-13.36-35.88-18.71,6.11,30.5,22.34,54.66,47.59,72.51,97.85,69.2,342.24,69.95,436.47-5.6,21.95-17.59,34.7-39.15,40.08-66.92h0Z"/>
|
||||
<rect fill="#3b82f6" x="468.16" y="171.76" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
|
||||
<rect fill="#3b82f6" x="593.35" y="118.61" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
|
||||
<rect fill="#3b82f6" x="361.93" y="250.32" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 1024 1024">
|
||||
<!-- Generator: Adobe Illustrator 29.7.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 8) -->
|
||||
<path fill="#3b82f6" d="M797.56,348.33c30.91,6.66,83.06,31.07,87.21,66.41,3.15,26.85-13.06,79.89-19.48,108.33-18.08,80.05-35.49,169.43-58.24,247.51-37.84,129.87-259.97,143.84-369.81,131-79.98-9.34-191.79-42.13-218.72-127.8l-79.09-341.68c-6.97-50.68,49.68-73.96,89.21-85.61,54.67-16.12,110.4-16.62,167.08-15.62l-45.47,16.39c-44.91,18.53-101.62,26.35-145.23,45.5-15.84,6.96-26.94,19.89-14.58,36.36,18.56,24.72,115.14,56.1,146.77,62.89,82.16,17.65,152.89,2.18,231.43-22.46,54.98-17.25,130.36-44.86,168.52-89.18,25.27-29.35,31.42-69.3,44.38-104.84l-243.09,42.99-2,3.82c-1.45,15.08-2.76,30.11-6.03,44.92-31.75,143.69-186.99,93.71-283.19,53.84l1.47-2.15c82.39-23.86,163.75-50.91,245.03-78.2,5.91-3.48,5.08-25.81,7.22-33.55,3.45-12.47,12.22-19.65,23.97-24.08l282.88-49.24c14.15,3.11,20.4,11.87,18.9,26.37l-29.13,88.06v.02ZM841.24,457.54l-19.61,14.59c-113.51,75.99-355.25,76.96-485.87,53.37-58.02-10.48-91.56-24.33-143.96-48.22-1.22-.56-3.68-1.88-4.26-.11,9.51,23.92,8.86,56.35,22.36,78.1,28.12,45.3,113.96,67.69,163.71,76.52,72.61,12.88,148.98,14.98,222.29,7.73,63.62-6.3,204.67-30.25,226.3-101.29,7.88-25.88,11.16-54.64,19.03-80.69h0ZM806.3,607.51l-30.5,18.26c-131.73,66.26-405.38,65.51-535.38-4.91-6.49-3.51-12.31-8.25-18.62-11.96-1.12-.66-2.56-2.94-3.72-.65,11.55,32.54,8.2,71.49,34.17,96.87,59.84,58.47,212.94,70.07,292.78,66.97,63.99-2.48,191.09-21.71,233.65-73.56,19.02-23.17,17.88-63.15,27.62-91.02h0ZM774.26,744.37c-13.76,6.59-26.7,15.08-40.78,21.08-119.06,50.77-329.26,50.83-447.48-2.37-12.39-5.57-23.46-13.36-35.88-18.71,6.11,30.5,22.34,54.66,47.59,72.51,97.85,69.2,342.24,69.95,436.47-5.6,21.95-17.59,34.7-39.15,40.08-66.92h0Z"/>
|
||||
<rect fill="#3b82f6" x="468.16" y="171.76" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
|
||||
<rect fill="#3b82f6" x="593.35" y="118.61" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
|
||||
<rect fill="#3b82f6" x="361.93" y="250.32" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 1024 1024">
|
||||
<!-- Generator: Adobe Illustrator 29.7.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 8) -->
|
||||
<path fill="#3b82f6" d="M797.56,348.33c30.91,6.66,83.06,31.07,87.21,66.41,3.15,26.85-13.06,79.89-19.48,108.33-18.08,80.05-35.49,169.43-58.24,247.51-37.84,129.87-259.97,143.84-369.81,131-79.98-9.34-191.79-42.13-218.72-127.8l-79.09-341.68c-6.97-50.68,49.68-73.96,89.21-85.61,54.67-16.12,110.4-16.62,167.08-15.62l-45.47,16.39c-44.91,18.53-101.62,26.35-145.23,45.5-15.84,6.96-26.94,19.89-14.58,36.36,18.56,24.72,115.14,56.1,146.77,62.89,82.16,17.65,152.89,2.18,231.43-22.46,54.98-17.25,130.36-44.86,168.52-89.18,25.27-29.35,31.42-69.3,44.38-104.84l-243.09,42.99-2,3.82c-1.45,15.08-2.76,30.11-6.03,44.92-31.75,143.69-186.99,93.71-283.19,53.84l1.47-2.15c82.39-23.86,163.75-50.91,245.03-78.2,5.91-3.48,5.08-25.81,7.22-33.55,3.45-12.47,12.22-19.65,23.97-24.08l282.88-49.24c14.15,3.11,20.4,11.87,18.9,26.37l-29.13,88.06v.02ZM841.24,457.54l-19.61,14.59c-113.51,75.99-355.25,76.96-485.87,53.37-58.02-10.48-91.56-24.33-143.96-48.22-1.22-.56-3.68-1.88-4.26-.11,9.51,23.92,8.86,56.35,22.36,78.1,28.12,45.3,113.96,67.69,163.71,76.52,72.61,12.88,148.98,14.98,222.29,7.73,63.62-6.3,204.67-30.25,226.3-101.29,7.88-25.88,11.16-54.64,19.03-80.69h0ZM806.3,607.51l-30.5,18.26c-131.73,66.26-405.38,65.51-535.38-4.91-6.49-3.51-12.31-8.25-18.62-11.96-1.12-.66-2.56-2.94-3.72-.65,11.55,32.54,8.2,71.49,34.17,96.87,59.84,58.47,212.94,70.07,292.78,66.97,63.99-2.48,191.09-21.71,233.65-73.56,19.02-23.17,17.88-63.15,27.62-91.02h0ZM774.26,744.37c-13.76,6.59-26.7,15.08-40.78,21.08-119.06,50.77-329.26,50.83-447.48-2.37-12.39-5.57-23.46-13.36-35.88-18.71,6.11,30.5,22.34,54.66,47.59,72.51,97.85,69.2,342.24,69.95,436.47-5.6,21.95-17.59,34.7-39.15,40.08-66.92h0Z"/>
|
||||
<rect fill="#3b82f6" x="468.16" y="171.76" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
|
||||
<rect fill="#3b82f6" x="593.35" y="118.61" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
|
||||
<rect fill="#3b82f6" x="361.93" y="250.32" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 1024 1024">
|
||||
<!-- Generator: Adobe Illustrator 29.7.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 8) -->
|
||||
<path fill="#3b82f6" d="M797.56,348.33c30.91,6.66,83.06,31.07,87.21,66.41,3.15,26.85-13.06,79.89-19.48,108.33-18.08,80.05-35.49,169.43-58.24,247.51-37.84,129.87-259.97,143.84-369.81,131-79.98-9.34-191.79-42.13-218.72-127.8l-79.09-341.68c-6.97-50.68,49.68-73.96,89.21-85.61,54.67-16.12,110.4-16.62,167.08-15.62l-45.47,16.39c-44.91,18.53-101.62,26.35-145.23,45.5-15.84,6.96-26.94,19.89-14.58,36.36,18.56,24.72,115.14,56.1,146.77,62.89,82.16,17.65,152.89,2.18,231.43-22.46,54.98-17.25,130.36-44.86,168.52-89.18,25.27-29.35,31.42-69.3,44.38-104.84l-243.09,42.99-2,3.82c-1.45,15.08-2.76,30.11-6.03,44.92-31.75,143.69-186.99,93.71-283.19,53.84l1.47-2.15c82.39-23.86,163.75-50.91,245.03-78.2,5.91-3.48,5.08-25.81,7.22-33.55,3.45-12.47,12.22-19.65,23.97-24.08l282.88-49.24c14.15,3.11,20.4,11.87,18.9,26.37l-29.13,88.06v.02ZM841.24,457.54l-19.61,14.59c-113.51,75.99-355.25,76.96-485.87,53.37-58.02-10.48-91.56-24.33-143.96-48.22-1.22-.56-3.68-1.88-4.26-.11,9.51,23.92,8.86,56.35,22.36,78.1,28.12,45.3,113.96,67.69,163.71,76.52,72.61,12.88,148.98,14.98,222.29,7.73,63.62-6.3,204.67-30.25,226.3-101.29,7.88-25.88,11.16-54.64,19.03-80.69h0ZM806.3,607.51l-30.5,18.26c-131.73,66.26-405.38,65.51-535.38-4.91-6.49-3.51-12.31-8.25-18.62-11.96-1.12-.66-2.56-2.94-3.72-.65,11.55,32.54,8.2,71.49,34.17,96.87,59.84,58.47,212.94,70.07,292.78,66.97,63.99-2.48,191.09-21.71,233.65-73.56,19.02-23.17,17.88-63.15,27.62-91.02h0ZM774.26,744.37c-13.76,6.59-26.7,15.08-40.78,21.08-119.06,50.77-329.26,50.83-447.48-2.37-12.39-5.57-23.46-13.36-35.88-18.71,6.11,30.5,22.34,54.66,47.59,72.51,97.85,69.2,342.24,69.95,436.47-5.6,21.95-17.59,34.7-39.15,40.08-66.92h0Z"/>
|
||||
<rect fill="#3b82f6" x="468.16" y="171.76" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
|
||||
<rect fill="#3b82f6" x="593.35" y="118.61" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
|
||||
<rect fill="#3b82f6" x="361.93" y="250.32" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "Trackeep",
|
||||
"displayName": "Trackeep",
|
||||
"version": "1.0.0",
|
||||
"description": "Productivity and knowledge management mobile app"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
module.exports = {
|
||||
presets: ['module:metro-react-native-babel-preset'],
|
||||
plugins: [
|
||||
[
|
||||
'module-resolver',
|
||||
{
|
||||
root: ['./src'],
|
||||
extensions: ['.ios.js', '.android.js', '.js', '.ts', '.tsx', '.json'],
|
||||
alias: {
|
||||
'@': './src',
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Trackeep Mobile App
|
||||
* React Native entry point
|
||||
*/
|
||||
import {AppRegistry} from 'react-native';
|
||||
import App from './src/App';
|
||||
import {name as appName} from './app.json';
|
||||
|
||||
AppRegistry.registerComponent(appName, () => App);
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 1024 1024">
|
||||
<!-- Generator: Adobe Illustrator 29.7.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 8) -->
|
||||
<path fill="#3b82f6" d="M797.56,348.33c30.91,6.66,83.06,31.07,87.21,66.41,3.15,26.85-13.06,79.89-19.48,108.33-18.08,80.05-35.49,169.43-58.24,247.51-37.84,129.87-259.97,143.84-369.81,131-79.98-9.34-191.79-42.13-218.72-127.8l-79.09-341.68c-6.97-50.68,49.68-73.96,89.21-85.61,54.67-16.12,110.4-16.62,167.08-15.62l-45.47,16.39c-44.91,18.53-101.62,26.35-145.23,45.5-15.84,6.96-26.94,19.89-14.58,36.36,18.56,24.72,115.14,56.1,146.77,62.89,82.16,17.65,152.89,2.18,231.43-22.46,54.98-17.25,130.36-44.86,168.52-89.18,25.27-29.35,31.42-69.3,44.38-104.84l-243.09,42.99-2,3.82c-1.45,15.08-2.76,30.11-6.03,44.92-31.75,143.69-186.99,93.71-283.19,53.84l1.47-2.15c82.39-23.86,163.75-50.91,245.03-78.2,5.91-3.48,5.08-25.81,7.22-33.55,3.45-12.47,12.22-19.65,23.97-24.08l282.88-49.24c14.15,3.11,20.4,11.87,18.9,26.37l-29.13,88.06v.02ZM841.24,457.54l-19.61,14.59c-113.51,75.99-355.25,76.96-485.87,53.37-58.02-10.48-91.56-24.33-143.96-48.22-1.22-.56-3.68-1.88-4.26-.11,9.51,23.92,8.86,56.35,22.36,78.1,28.12,45.3,113.96,67.69,163.71,76.52,72.61,12.88,148.98,14.98,222.29,7.73,63.62-6.3,204.67-30.25,226.3-101.29,7.88-25.88,11.16-54.64,19.03-80.69h0ZM806.3,607.51l-30.5,18.26c-131.73,66.26-405.38,65.51-535.38-4.91-6.49-3.51-12.31-8.25-18.62-11.96-1.12-.66-2.56-2.94-3.72-.65,11.55,32.54,8.2,71.49,34.17,96.87,59.84,58.47,212.94,70.07,292.78,66.97,63.99-2.48,191.09-21.71,233.65-73.56,19.02-23.17,17.88-63.15,27.62-91.02h0ZM774.26,744.37c-13.76,6.59-26.7,15.08-40.78,21.08-119.06,50.77-329.26,50.83-447.48-2.37-12.39-5.57-23.46-13.36-35.88-18.71,6.11,30.5,22.34,54.66,47.59,72.51,97.85,69.2,342.24,69.95,436.47-5.6,21.95-17.59,34.7-39.15,40.08-66.92h0Z"/>
|
||||
<rect fill="#3b82f6" x="468.16" y="171.76" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
|
||||
<rect fill="#3b82f6" x="593.35" y="118.61" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
|
||||
<rect fill="#3b82f6" x="361.93" y="250.32" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 1024 1024">
|
||||
<!-- Generator: Adobe Illustrator 29.7.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 8) -->
|
||||
<path fill="#3b82f6" d="M797.56,348.33c30.91,6.66,83.06,31.07,87.21,66.41,3.15,26.85-13.06,79.89-19.48,108.33-18.08,80.05-35.49,169.43-58.24,247.51-37.84,129.87-259.97,143.84-369.81,131-79.98-9.34-191.79-42.13-218.72-127.8l-79.09-341.68c-6.97-50.68,49.68-73.96,89.21-85.61,54.67-16.12,110.4-16.62,167.08-15.62l-45.47,16.39c-44.91,18.53-101.62,26.35-145.23,45.5-15.84,6.96-26.94,19.89-14.58,36.36,18.56,24.72,115.14,56.1,146.77,62.89,82.16,17.65,152.89,2.18,231.43-22.46,54.98-17.25,130.36-44.86,168.52-89.18,25.27-29.35,31.42-69.3,44.38-104.84l-243.09,42.99-2,3.82c-1.45,15.08-2.76,30.11-6.03,44.92-31.75,143.69-186.99,93.71-283.19,53.84l1.47-2.15c82.39-23.86,163.75-50.91,245.03-78.2,5.91-3.48,5.08-25.81,7.22-33.55,3.45-12.47,12.22-19.65,23.97-24.08l282.88-49.24c14.15,3.11,20.4,11.87,18.9,26.37l-29.13,88.06v.02ZM841.24,457.54l-19.61,14.59c-113.51,75.99-355.25,76.96-485.87,53.37-58.02-10.48-91.56-24.33-143.96-48.22-1.22-.56-3.68-1.88-4.26-.11,9.51,23.92,8.86,56.35,22.36,78.1,28.12,45.3,113.96,67.69,163.71,76.52,72.61,12.88,148.98,14.98,222.29,7.73,63.62-6.3,204.67-30.25,226.3-101.29,7.88-25.88,11.16-54.64,19.03-80.69h0ZM806.3,607.51l-30.5,18.26c-131.73,66.26-405.38,65.51-535.38-4.91-6.49-3.51-12.31-8.25-18.62-11.96-1.12-.66-2.56-2.94-3.72-.65,11.55,32.54,8.2,71.49,34.17,96.87,59.84,58.47,212.94,70.07,292.78,66.97,63.99-2.48,191.09-21.71,233.65-73.56,19.02-23.17,17.88-63.15,27.62-91.02h0ZM774.26,744.37c-13.76,6.59-26.7,15.08-40.78,21.08-119.06,50.77-329.26,50.83-447.48-2.37-12.39-5.57-23.46-13.36-35.88-18.71,6.11,30.5,22.34,54.66,47.59,72.51,97.85,69.2,342.24,69.95,436.47-5.6,21.95-17.59,34.7-39.15,40.08-66.92h0Z"/>
|
||||
<rect fill="#3b82f6" x="468.16" y="171.76" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
|
||||
<rect fill="#3b82f6" x="593.35" y="118.61" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
|
||||
<rect fill="#3b82f6" x="361.93" y="250.32" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 1024 1024">
|
||||
<!-- Generator: Adobe Illustrator 29.7.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 8) -->
|
||||
<path fill="#3b82f6" d="M797.56,348.33c30.91,6.66,83.06,31.07,87.21,66.41,3.15,26.85-13.06,79.89-19.48,108.33-18.08,80.05-35.49,169.43-58.24,247.51-37.84,129.87-259.97,143.84-369.81,131-79.98-9.34-191.79-42.13-218.72-127.8l-79.09-341.68c-6.97-50.68,49.68-73.96,89.21-85.61,54.67-16.12,110.4-16.62,167.08-15.62l-45.47,16.39c-44.91,18.53-101.62,26.35-145.23,45.5-15.84,6.96-26.94,19.89-14.58,36.36,18.56,24.72,115.14,56.1,146.77,62.89,82.16,17.65,152.89,2.18,231.43-22.46,54.98-17.25,130.36-44.86,168.52-89.18,25.27-29.35,31.42-69.3,44.38-104.84l-243.09,42.99-2,3.82c-1.45,15.08-2.76,30.11-6.03,44.92-31.75,143.69-186.99,93.71-283.19,53.84l1.47-2.15c82.39-23.86,163.75-50.91,245.03-78.2,5.91-3.48,5.08-25.81,7.22-33.55,3.45-12.47,12.22-19.65,23.97-24.08l282.88-49.24c14.15,3.11,20.4,11.87,18.9,26.37l-29.13,88.06v.02ZM841.24,457.54l-19.61,14.59c-113.51,75.99-355.25,76.96-485.87,53.37-58.02-10.48-91.56-24.33-143.96-48.22-1.22-.56-3.68-1.88-4.26-.11,9.51,23.92,8.86,56.35,22.36,78.1,28.12,45.3,113.96,67.69,163.71,76.52,72.61,12.88,148.98,14.98,222.29,7.73,63.62-6.3,204.67-30.25,226.3-101.29,7.88-25.88,11.16-54.64,19.03-80.69h0ZM806.3,607.51l-30.5,18.26c-131.73,66.26-405.38,65.51-535.38-4.91-6.49-3.51-12.31-8.25-18.62-11.96-1.12-.66-2.56-2.94-3.72-.65,11.55,32.54,8.2,71.49,34.17,96.87,59.84,58.47,212.94,70.07,292.78,66.97,63.99-2.48,191.09-21.71,233.65-73.56,19.02-23.17,17.88-63.15,27.62-91.02h0ZM774.26,744.37c-13.76,6.59-26.7,15.08-40.78,21.08-119.06,50.77-329.26,50.83-447.48-2.37-12.39-5.57-23.46-13.36-35.88-18.71,6.11,30.5,22.34,54.66,47.59,72.51,97.85,69.2,342.24,69.95,436.47-5.6,21.95-17.59,34.7-39.15,40.08-66.92h0Z"/>
|
||||
<rect fill="#3b82f6" x="468.16" y="171.76" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
|
||||
<rect fill="#3b82f6" x="593.35" y="118.61" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
|
||||
<rect fill="#3b82f6" x="361.93" y="250.32" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 1024 1024">
|
||||
<!-- Generator: Adobe Illustrator 29.7.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 8) -->
|
||||
<path fill="#3b82f6" d="M797.56,348.33c30.91,6.66,83.06,31.07,87.21,66.41,3.15,26.85-13.06,79.89-19.48,108.33-18.08,80.05-35.49,169.43-58.24,247.51-37.84,129.87-259.97,143.84-369.81,131-79.98-9.34-191.79-42.13-218.72-127.8l-79.09-341.68c-6.97-50.68,49.68-73.96,89.21-85.61,54.67-16.12,110.4-16.62,167.08-15.62l-45.47,16.39c-44.91,18.53-101.62,26.35-145.23,45.5-15.84,6.96-26.94,19.89-14.58,36.36,18.56,24.72,115.14,56.1,146.77,62.89,82.16,17.65,152.89,2.18,231.43-22.46,54.98-17.25,130.36-44.86,168.52-89.18,25.27-29.35,31.42-69.3,44.38-104.84l-243.09,42.99-2,3.82c-1.45,15.08-2.76,30.11-6.03,44.92-31.75,143.69-186.99,93.71-283.19,53.84l1.47-2.15c82.39-23.86,163.75-50.91,245.03-78.2,5.91-3.48,5.08-25.81,7.22-33.55,3.45-12.47,12.22-19.65,23.97-24.08l282.88-49.24c14.15,3.11,20.4,11.87,18.9,26.37l-29.13,88.06v.02ZM841.24,457.54l-19.61,14.59c-113.51,75.99-355.25,76.96-485.87,53.37-58.02-10.48-91.56-24.33-143.96-48.22-1.22-.56-3.68-1.88-4.26-.11,9.51,23.92,8.86,56.35,22.36,78.1,28.12,45.3,113.96,67.69,163.71,76.52,72.61,12.88,148.98,14.98,222.29,7.73,63.62-6.3,204.67-30.25,226.3-101.29,7.88-25.88,11.16-54.64,19.03-80.69h0ZM806.3,607.51l-30.5,18.26c-131.73,66.26-405.38,65.51-535.38-4.91-6.49-3.51-12.31-8.25-18.62-11.96-1.12-.66-2.56-2.94-3.72-.65,11.55,32.54,8.2,71.49,34.17,96.87,59.84,58.47,212.94,70.07,292.78,66.97,63.99-2.48,191.09-21.71,233.65-73.56,19.02-23.17,17.88-63.15,27.62-91.02h0ZM774.26,744.37c-13.76,6.59-26.7,15.08-40.78,21.08-119.06,50.77-329.26,50.83-447.48-2.37-12.39-5.57-23.46-13.36-35.88-18.71,6.11,30.5,22.34,54.66,47.59,72.51,97.85,69.2,342.24,69.95,436.47-5.6,21.95-17.59,34.7-39.15,40.08-66.92h0Z"/>
|
||||
<rect fill="#3b82f6" x="468.16" y="171.76" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
|
||||
<rect fill="#3b82f6" x="593.35" y="118.61" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
|
||||
<rect fill="#3b82f6" x="361.93" y="250.32" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,21 @@
|
||||
const {getDefaultConfig, mergeConfig} = require('@react-native/metro-config');
|
||||
|
||||
const defaultConfig = getDefaultConfig(__dirname);
|
||||
|
||||
const config = {
|
||||
transformer: {
|
||||
getTransformOptions: async () => ({
|
||||
transform: {
|
||||
experimentalImportSupport: false,
|
||||
inlineRequires: true,
|
||||
},
|
||||
}),
|
||||
},
|
||||
resolver: {
|
||||
alias: {
|
||||
'@': './src',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = mergeConfig(defaultConfig, config);
|
||||
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"name": "trackeep-mobile",
|
||||
"version": "1.0.0",
|
||||
"description": "Trackeep mobile app for productivity and knowledge management",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"android": "react-native run-android",
|
||||
"ios": "react-native run-ios",
|
||||
"start": "react-native start",
|
||||
"test": "jest",
|
||||
"lint": "eslint . --ext .js,.jsx,.ts,.tsx",
|
||||
"build:android": "cd android && ./gradlew assembleRelease",
|
||||
"build:ios": "react-native run-ios --configuration Release"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "^1.19.5",
|
||||
"@react-native-community/netinfo": "^11.4.1",
|
||||
"@react-navigation/bottom-tabs": "^6.5.11",
|
||||
"@react-navigation/drawer": "^6.6.6",
|
||||
"@react-navigation/native": "^6.1.9",
|
||||
"@react-navigation/native-stack": "^6.9.26",
|
||||
"@react-navigation/stack": "^6.3.20",
|
||||
"@types/react-native-push-notification": "^8.1.4",
|
||||
"@types/react-native-vector-icons": "^6.4.18",
|
||||
"axios": "^1.6.2",
|
||||
"react": "18.2.0",
|
||||
"react-native": "0.72.6",
|
||||
"react-native-background-timer": "^2.4.1",
|
||||
"react-native-camera": "^4.2.1",
|
||||
"react-native-gesture-handler": "^2.13.4",
|
||||
"react-native-keychain": "^8.1.3",
|
||||
"react-native-paper": "^5.11.1",
|
||||
"react-native-permissions": "^3.10.1",
|
||||
"react-native-push-notification": "^8.1.1",
|
||||
"react-native-reanimated": "^3.5.4",
|
||||
"react-native-safe-area-context": "^4.7.4",
|
||||
"react-native-screens": "^3.25.0",
|
||||
"react-native-sqlite-storage": "^6.0.1",
|
||||
"react-native-svg": "^13.14.0",
|
||||
"react-native-vector-icons": "^10.0.2",
|
||||
"react-native-vision-camera": "^3.3.5",
|
||||
"react-native-voice": "^0.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.20.0",
|
||||
"@babel/preset-env": "^7.20.0",
|
||||
"@babel/runtime": "^7.20.0",
|
||||
"@react-native/eslint-config": "^0.72.2",
|
||||
"@react-native/metro-config": "^0.72.11",
|
||||
"@tsconfig/react-native": "^3.0.0",
|
||||
"@types/react": "^18.0.24",
|
||||
"@types/react-test-renderer": "^18.0.0",
|
||||
"babel-jest": "^29.2.1",
|
||||
"eslint": "^8.19.0",
|
||||
"jest": "^29.2.1",
|
||||
"metro-react-native-babel-preset": "0.76.8",
|
||||
"prettier": "^2.4.1",
|
||||
"react-test-renderer": "18.2.0",
|
||||
"typescript": "4.8.4"
|
||||
},
|
||||
"jest": {
|
||||
"preset": "react-native"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
NavigationContainer,
|
||||
DefaultTheme as NavigationDefaultTheme,
|
||||
DarkTheme as NavigationDarkTheme,
|
||||
} from '@react-navigation/native';
|
||||
import {
|
||||
Provider as PaperProvider,
|
||||
DefaultTheme as PaperDefaultTheme,
|
||||
MD3DarkTheme as PaperDarkTheme,
|
||||
} from 'react-native-paper';
|
||||
import { StatusBar } from 'react-native';
|
||||
import { AuthProvider } from './services/AuthContext';
|
||||
import { OfflineProvider } from './services/OfflineContext';
|
||||
import { NotificationProvider } from './services/NotificationContext';
|
||||
import { CameraProvider } from './services/CameraContext';
|
||||
import { VoiceProvider } from './services/VoiceContext';
|
||||
import { ServerConfigProvider } from './services/ServerConfigContext';
|
||||
import { RealtimeSyncProvider } from './services/RealtimeSyncContext';
|
||||
import AppNavigator from './navigation/AppNavigator';
|
||||
import { loadTheme } from './utils/storage';
|
||||
|
||||
const CombinedDefaultTheme = {
|
||||
...NavigationDefaultTheme,
|
||||
...PaperDefaultTheme,
|
||||
colors: {
|
||||
...NavigationDefaultTheme.colors,
|
||||
...PaperDefaultTheme.colors,
|
||||
},
|
||||
};
|
||||
|
||||
const CombinedDarkTheme = {
|
||||
...NavigationDarkTheme,
|
||||
...PaperDarkTheme,
|
||||
colors: {
|
||||
...NavigationDarkTheme.colors,
|
||||
...PaperDarkTheme.colors,
|
||||
},
|
||||
};
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [isDarkTheme, setIsDarkTheme] = useState(false);
|
||||
const [isThemeLoaded, setIsThemeLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const initializeTheme = async () => {
|
||||
try {
|
||||
const savedTheme = await loadTheme();
|
||||
setIsDarkTheme(savedTheme === 'dark');
|
||||
} catch (error) {
|
||||
console.error('Error loading theme:', error);
|
||||
} finally {
|
||||
setIsThemeLoaded(true);
|
||||
}
|
||||
};
|
||||
|
||||
initializeTheme();
|
||||
}, []);
|
||||
|
||||
const theme = isDarkTheme ? CombinedDarkTheme : CombinedDefaultTheme;
|
||||
|
||||
if (!isThemeLoaded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<PaperProvider theme={theme}>
|
||||
<NavigationContainer theme={theme}>
|
||||
<StatusBar
|
||||
barStyle={isDarkTheme ? 'light-content' : 'dark-content'}
|
||||
backgroundColor={theme.colors.background}
|
||||
/>
|
||||
<ServerConfigProvider>
|
||||
<RealtimeSyncProvider>
|
||||
<AuthProvider>
|
||||
<NotificationProvider>
|
||||
<CameraProvider>
|
||||
<VoiceProvider>
|
||||
<OfflineProvider>
|
||||
<AppNavigator />
|
||||
</OfflineProvider>
|
||||
</VoiceProvider>
|
||||
</CameraProvider>
|
||||
</NotificationProvider>
|
||||
</AuthProvider>
|
||||
</RealtimeSyncProvider>
|
||||
</ServerConfigProvider>
|
||||
</NavigationContainer>
|
||||
</PaperProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import { NavigationContainer } from '@react-navigation/native';
|
||||
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||
import { useAuth } from '../services/AuthContext';
|
||||
import { useServerConfig } from '../services/ServerConfigContext';
|
||||
import AuthNavigator from './AuthNavigator';
|
||||
import TabNavigator from './TabNavigator';
|
||||
import LoadingScreen from '../screens/LoadingScreen';
|
||||
import ServerSetupScreen from '../screens/ServerSetupScreen';
|
||||
|
||||
export type RootStackParamList = {
|
||||
Auth: undefined;
|
||||
Main: undefined;
|
||||
Loading: undefined;
|
||||
ServerSetup: undefined;
|
||||
};
|
||||
|
||||
const Stack = createNativeStackNavigator<RootStackParamList>();
|
||||
|
||||
const AppNavigator: React.FC = () => {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const { isConfigured, isLoading: configLoading } = useServerConfig();
|
||||
|
||||
if (isLoading || configLoading) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack.Navigator screenOptions={{ headerShown: false }}>
|
||||
{!isConfigured ? (
|
||||
<Stack.Screen name="ServerSetup" component={ServerSetupScreen} />
|
||||
) : isAuthenticated ? (
|
||||
<Stack.Screen name="Main" component={TabNavigator} />
|
||||
) : (
|
||||
<Stack.Screen name="Auth" component={AuthNavigator} />
|
||||
)}
|
||||
</Stack.Navigator>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppNavigator;
|
||||
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||
import LoginScreen from '../screens/auth/LoginScreen';
|
||||
import RegisterScreen from '../screens/auth/RegisterScreen';
|
||||
|
||||
export type AuthStackParamList = {
|
||||
Login: undefined;
|
||||
Register: undefined;
|
||||
};
|
||||
|
||||
const Stack = createNativeStackNavigator<AuthStackParamList>();
|
||||
|
||||
const AuthNavigator: React.FC = () => {
|
||||
return (
|
||||
<Stack.Navigator
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
gestureEnabled: false,
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="Login" component={LoginScreen} />
|
||||
<Stack.Screen name="Register" component={RegisterScreen} />
|
||||
</Stack.Navigator>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthNavigator;
|
||||
@@ -0,0 +1,134 @@
|
||||
import React from 'react';
|
||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
|
||||
import { useOffline } from '../services/OfflineContext';
|
||||
import { useTheme } from 'react-native-paper';
|
||||
|
||||
import DashboardScreen from '../screens/DashboardScreen';
|
||||
import BookmarksScreen from '../screens/BookmarksScreen';
|
||||
import TasksScreen from '../screens/TasksScreen';
|
||||
import NotesScreen from '../screens/NotesScreen';
|
||||
import TimeTrackingScreen from '../screens/TimeTrackingScreen';
|
||||
import SearchScreen from '../screens/SearchScreen';
|
||||
import SettingsScreen from '../screens/SettingsScreen';
|
||||
import AIAssistantScreen from '../screens/AIAssistantScreen';
|
||||
|
||||
export type MainTabParamList = {
|
||||
Dashboard: undefined;
|
||||
Bookmarks: undefined;
|
||||
Tasks: undefined;
|
||||
Notes: undefined;
|
||||
TimeTracking: undefined;
|
||||
Search: undefined;
|
||||
AIAssistant: undefined;
|
||||
Settings: undefined;
|
||||
};
|
||||
|
||||
const Tab = createBottomTabNavigator<MainTabParamList>();
|
||||
|
||||
const TabNavigator: React.FC = () => {
|
||||
const { isOnline, pendingChanges } = useOffline();
|
||||
const theme = useTheme();
|
||||
|
||||
const getTabBarIcon = (name: string, color: string) => (
|
||||
<Icon name={name} size={24} color={color} />
|
||||
);
|
||||
|
||||
return (
|
||||
<Tab.Navigator
|
||||
screenOptions={({ route }) => ({
|
||||
tabBarIcon: ({ color, size }) => {
|
||||
let iconName: string;
|
||||
|
||||
switch (route.name) {
|
||||
case 'Dashboard':
|
||||
iconName = 'view-dashboard';
|
||||
break;
|
||||
case 'Bookmarks':
|
||||
iconName = 'bookmark';
|
||||
break;
|
||||
case 'Tasks':
|
||||
iconName = 'check-circle';
|
||||
break;
|
||||
case 'Notes':
|
||||
iconName = 'note-text';
|
||||
break;
|
||||
case 'TimeTracking':
|
||||
iconName = 'timer';
|
||||
break;
|
||||
case 'Search':
|
||||
iconName = 'magnify';
|
||||
break;
|
||||
case 'AIAssistant':
|
||||
iconName = 'robot';
|
||||
break;
|
||||
case 'Settings':
|
||||
iconName = 'cog';
|
||||
break;
|
||||
default:
|
||||
iconName = 'help-circle';
|
||||
}
|
||||
|
||||
return <Icon name={iconName} size={size} color={color} />;
|
||||
},
|
||||
tabBarActiveTintColor: theme.colors.primary,
|
||||
tabBarInactiveTintColor: 'gray',
|
||||
tabBarStyle: {
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderTopColor: theme.colors.outline,
|
||||
},
|
||||
headerStyle: {
|
||||
backgroundColor: theme.colors.surface,
|
||||
},
|
||||
headerTintColor: theme.colors.onSurface,
|
||||
})}
|
||||
>
|
||||
<Tab.Screen
|
||||
name="Dashboard"
|
||||
component={DashboardScreen}
|
||||
options={{
|
||||
title: 'Dashboard',
|
||||
tabBarBadge: pendingChanges > 0 ? pendingChanges : undefined,
|
||||
}}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="Bookmarks"
|
||||
component={BookmarksScreen}
|
||||
options={{ title: 'Bookmarks' }}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="Tasks"
|
||||
component={TasksScreen}
|
||||
options={{ title: 'Tasks' }}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="Notes"
|
||||
component={NotesScreen}
|
||||
options={{ title: 'Notes' }}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="TimeTracking"
|
||||
component={TimeTrackingScreen}
|
||||
options={{ title: 'Time' }}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="Search"
|
||||
component={SearchScreen}
|
||||
options={{ title: 'Search' }}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="AIAssistant"
|
||||
component={AIAssistantScreen}
|
||||
options={{ title: 'AI' }}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="Settings"
|
||||
component={SettingsScreen}
|
||||
options={{ title: 'Settings' }}
|
||||
/>
|
||||
</Tab.Navigator>
|
||||
);
|
||||
};
|
||||
|
||||
export default TabNavigator;
|
||||
@@ -0,0 +1,404 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import {
|
||||
Text,
|
||||
Card,
|
||||
Title,
|
||||
Paragraph,
|
||||
TextInput,
|
||||
Button,
|
||||
FAB,
|
||||
IconButton,
|
||||
Avatar,
|
||||
Chip,
|
||||
Divider,
|
||||
} from 'react-native-paper';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { useRealtimeUpdates } from '../services/RealtimeSyncContext';
|
||||
import { useServerConfig } from '../services/ServerConfigContext';
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
text: string;
|
||||
sender: 'user' | 'ai';
|
||||
timestamp: Date;
|
||||
type?: 'text' | 'recommendation' | 'analysis';
|
||||
}
|
||||
|
||||
const AIAssistantScreen: React.FC = () => {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [inputText, setInputText] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { config } = useServerConfig();
|
||||
|
||||
const [suggestions] = useState([
|
||||
'Help me organize my tasks',
|
||||
'Suggest bookmarks for learning React',
|
||||
'Analyze my productivity patterns',
|
||||
'Create a study plan',
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize with welcome message
|
||||
setMessages([
|
||||
{
|
||||
id: '1',
|
||||
text: "Hello! I'm your AI assistant. I can help you organize tasks, suggest bookmarks, analyze your productivity, and much more. How can I assist you today?",
|
||||
sender: 'ai',
|
||||
timestamp: new Date(),
|
||||
type: 'text',
|
||||
},
|
||||
]);
|
||||
}, []);
|
||||
|
||||
// Listen for real-time AI updates
|
||||
useRealtimeUpdates((data) => {
|
||||
if (data.type === 'ai_response') {
|
||||
const newMessage: Message = {
|
||||
id: Date.now().toString(),
|
||||
text: data.response,
|
||||
sender: 'ai',
|
||||
timestamp: new Date(),
|
||||
type: data.responseType,
|
||||
};
|
||||
setMessages(prev => [...prev, newMessage]);
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
const handleSendMessage = async () => {
|
||||
if (!inputText.trim()) return;
|
||||
|
||||
const userMessage: Message = {
|
||||
id: Date.now().toString(),
|
||||
text: inputText,
|
||||
sender: 'user',
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, userMessage]);
|
||||
setInputText('');
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Call LongCat AI API
|
||||
const response = await fetch(`${config?.baseUrl}/api/ai/chat`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${await getAuthToken()}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: inputText,
|
||||
context: 'trackeep_assistant',
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const aiResponse: Message = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
text: data.response,
|
||||
sender: 'ai',
|
||||
timestamp: new Date(),
|
||||
type: data.type || 'text',
|
||||
};
|
||||
setMessages(prev => [...prev, aiResponse]);
|
||||
} else {
|
||||
// Fallback to mock response
|
||||
const mockResponse: Message = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
text: generateMockResponse(inputText),
|
||||
sender: 'ai',
|
||||
timestamp: new Date(),
|
||||
type: 'text',
|
||||
};
|
||||
setMessages(prev => [...prev, mockResponse]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error calling AI API:', error);
|
||||
// Fallback to mock response
|
||||
const mockResponse: Message = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
text: generateMockResponse(inputText),
|
||||
sender: 'ai',
|
||||
timestamp: new Date(),
|
||||
type: 'text',
|
||||
};
|
||||
setMessages(prev => [...prev, mockResponse]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getAuthToken = async (): Promise<string | null> => {
|
||||
try {
|
||||
const authData = await AsyncStorage.getItem('trackeep_auth_token');
|
||||
return authData;
|
||||
} catch (error) {
|
||||
console.error('Error getting auth token:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const generateMockResponse = (userInput: string): string => {
|
||||
const input = userInput.toLowerCase();
|
||||
|
||||
if (input.includes('task') || input.includes('organize')) {
|
||||
return "I can help you organize your tasks! Based on your current tasks, I suggest prioritizing the high-priority items first. Would you like me to create a schedule for you?";
|
||||
} else if (input.includes('bookmark') || input.includes('learn')) {
|
||||
return "Great! I can suggest relevant bookmarks for your learning goals. I see you're interested in React - here are some top resources I recommend...";
|
||||
} else if (input.includes('productivity') || input.includes('analyze')) {
|
||||
return "Looking at your activity patterns, you're most productive in the morning. I suggest scheduling important tasks between 9-11 AM for better results.";
|
||||
} else if (input.includes('study') || input.includes('plan')) {
|
||||
return "I can create a personalized study plan for you! Based on your current notes and bookmarks, here's a structured learning path...";
|
||||
} else {
|
||||
return "I understand you need help with that. Let me analyze your current data and provide you with personalized recommendations.";
|
||||
}
|
||||
};
|
||||
|
||||
const handleSuggestionPress = (suggestion: string) => {
|
||||
setInputText(suggestion);
|
||||
};
|
||||
|
||||
const formatTime = (date: Date): string => {
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
|
||||
const renderMessage = (message: Message) => (
|
||||
<View key={message.id} style={[
|
||||
styles.messageContainer,
|
||||
message.sender === 'user' ? styles.userMessage : styles.aiMessage,
|
||||
]}>
|
||||
{message.sender === 'ai' && (
|
||||
<Avatar.Text
|
||||
size={32}
|
||||
label="AI"
|
||||
style={styles.avatar}
|
||||
/>
|
||||
)}
|
||||
<View style={[
|
||||
styles.messageBubble,
|
||||
message.sender === 'user' ? styles.userBubble : styles.aiBubble,
|
||||
]}>
|
||||
<Text style={[
|
||||
styles.messageText,
|
||||
message.sender === 'user' ? styles.userText : styles.aiText,
|
||||
]}>
|
||||
{message.text}
|
||||
</Text>
|
||||
<Text style={styles.timestamp}>
|
||||
{formatTime(message.timestamp)}
|
||||
</Text>
|
||||
</View>
|
||||
{message.sender === 'user' && (
|
||||
<Avatar.Text
|
||||
size={32}
|
||||
label="U"
|
||||
style={styles.avatar}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={styles.container}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<Title style={styles.title}>AI Assistant</Title>
|
||||
<Paragraph style={styles.subtitle}>
|
||||
Your personal productivity companion
|
||||
</Paragraph>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
style={styles.messagesContainer}
|
||||
contentContainerStyle={styles.messagesContent}
|
||||
>
|
||||
{messages.map(renderMessage)}
|
||||
|
||||
{isLoading && (
|
||||
<View style={[styles.messageContainer, styles.aiMessage]}>
|
||||
<Avatar.Text
|
||||
size={32}
|
||||
label="AI"
|
||||
style={styles.avatar}
|
||||
/>
|
||||
<View style={[styles.messageBubble, styles.aiBubble]}>
|
||||
<Text style={styles.aiText}>Thinking...</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{/* Suggestions */}
|
||||
{messages.length === 1 && (
|
||||
<View style={styles.suggestionsContainer}>
|
||||
<Text style={styles.suggestionsTitle}>Try asking:</Text>
|
||||
<View style={styles.suggestionsList}>
|
||||
{suggestions.map((suggestion, index) => (
|
||||
<Chip
|
||||
key={index}
|
||||
onPress={() => handleSuggestionPress(suggestion)}
|
||||
style={styles.suggestionChip}
|
||||
textStyle={styles.suggestionText}
|
||||
>
|
||||
{suggestion}
|
||||
</Chip>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Input Area */}
|
||||
<View style={styles.inputContainer}>
|
||||
<TextInput
|
||||
value={inputText}
|
||||
onChangeText={setInputText}
|
||||
placeholder="Ask me anything..."
|
||||
multiline
|
||||
maxLength={500}
|
||||
style={styles.textInput}
|
||||
right={
|
||||
<TextInput.Icon
|
||||
icon="send"
|
||||
onPress={handleSendMessage}
|
||||
disabled={!inputText.trim() || isLoading}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
mode="contained"
|
||||
onPress={handleSendMessage}
|
||||
disabled={!inputText.trim() || isLoading}
|
||||
loading={isLoading}
|
||||
style={styles.sendButton}
|
||||
>
|
||||
Send
|
||||
</Button>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
header: {
|
||||
padding: 16,
|
||||
backgroundColor: '#fff',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#e0e0e0',
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
color: '#6200ee',
|
||||
},
|
||||
subtitle: {
|
||||
color: '#666',
|
||||
marginTop: 4,
|
||||
},
|
||||
messagesContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
messagesContent: {
|
||||
padding: 16,
|
||||
},
|
||||
messageContainer: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: 16,
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
userMessage: {
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
aiMessage: {
|
||||
justifyContent: 'flex-start',
|
||||
},
|
||||
avatar: {
|
||||
marginHorizontal: 8,
|
||||
backgroundColor: '#6200ee',
|
||||
},
|
||||
messageBubble: {
|
||||
maxWidth: '70%',
|
||||
padding: 12,
|
||||
borderRadius: 16,
|
||||
minHeight: 40,
|
||||
},
|
||||
userBubble: {
|
||||
backgroundColor: '#6200ee',
|
||||
borderBottomRightRadius: 4,
|
||||
},
|
||||
aiBubble: {
|
||||
backgroundColor: '#fff',
|
||||
borderBottomLeftRadius: 4,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e0e0e0',
|
||||
},
|
||||
messageText: {
|
||||
fontSize: 16,
|
||||
lineHeight: 20,
|
||||
},
|
||||
userText: {
|
||||
color: '#fff',
|
||||
},
|
||||
aiText: {
|
||||
color: '#333',
|
||||
},
|
||||
timestamp: {
|
||||
fontSize: 11,
|
||||
color: '#999',
|
||||
marginTop: 4,
|
||||
alignSelf: 'flex-end',
|
||||
},
|
||||
suggestionsContainer: {
|
||||
padding: 16,
|
||||
backgroundColor: '#fff',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#e0e0e0',
|
||||
},
|
||||
suggestionsTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#666',
|
||||
marginBottom: 8,
|
||||
},
|
||||
suggestionsList: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
},
|
||||
suggestionChip: {
|
||||
backgroundColor: '#f0f0f0',
|
||||
},
|
||||
suggestionText: {
|
||||
fontSize: 12,
|
||||
color: '#333',
|
||||
},
|
||||
inputContainer: {
|
||||
padding: 16,
|
||||
backgroundColor: '#fff',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#e0e0e0',
|
||||
},
|
||||
textInput: {
|
||||
marginBottom: 8,
|
||||
backgroundColor: '#f8f8f8',
|
||||
},
|
||||
sendButton: {
|
||||
backgroundColor: '#6200ee',
|
||||
},
|
||||
});
|
||||
|
||||
export default AIAssistantScreen;
|
||||
@@ -0,0 +1,119 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet, FlatList } from 'react-native';
|
||||
import { Text, Card, Title, Paragraph, FAB, Searchbar } from 'react-native-paper';
|
||||
|
||||
const BookmarksScreen: React.FC = () => {
|
||||
const [searchQuery, setSearchQuery] = React.useState('');
|
||||
const [bookmarks] = React.useState([
|
||||
{
|
||||
id: '1',
|
||||
title: 'React Native Documentation',
|
||||
url: 'https://reactnative.dev',
|
||||
description: 'Official React Native documentation',
|
||||
tags: ['react', 'mobile', 'documentation'],
|
||||
isFavorite: true,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'TypeScript Handbook',
|
||||
url: 'https://www.typescriptlang.org/docs',
|
||||
description: 'Learn TypeScript from the official handbook',
|
||||
tags: ['typescript', 'programming', 'tutorial'],
|
||||
isFavorite: false,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
]);
|
||||
|
||||
const onChangeSearch = (query: string) => setSearchQuery(query);
|
||||
|
||||
const renderBookmark = ({ item }: any) => (
|
||||
<Card style={styles.card}>
|
||||
<Card.Content>
|
||||
<Title numberOfLines={1}>{item.title}</Title>
|
||||
<Paragraph numberOfLines={2}>{item.description}</Paragraph>
|
||||
<Text style={styles.url}>{item.url}</Text>
|
||||
<View style={styles.tagsContainer}>
|
||||
{item.tags.map((tag: string, index: number) => (
|
||||
<Text key={index} style={styles.tag}>
|
||||
#{tag}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Searchbar
|
||||
placeholder="Search bookmarks..."
|
||||
onChangeText={onChangeSearch}
|
||||
value={searchQuery}
|
||||
style={styles.searchBar}
|
||||
/>
|
||||
|
||||
<FlatList
|
||||
data={bookmarks}
|
||||
renderItem={renderBookmark}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={styles.list}
|
||||
showsVerticalScrollIndicator={false}
|
||||
/>
|
||||
|
||||
<FAB
|
||||
icon="bookmark-plus"
|
||||
style={styles.fab}
|
||||
onPress={() => console.log('Add bookmark')}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
searchBar: {
|
||||
margin: 16,
|
||||
marginBottom: 8,
|
||||
},
|
||||
list: {
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 80,
|
||||
},
|
||||
card: {
|
||||
marginBottom: 12,
|
||||
elevation: 2,
|
||||
},
|
||||
url: {
|
||||
color: '#6200ee',
|
||||
fontSize: 12,
|
||||
marginTop: 8,
|
||||
},
|
||||
tagsContainer: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
marginTop: 8,
|
||||
},
|
||||
tag: {
|
||||
backgroundColor: '#e3f2fd',
|
||||
color: '#1976d2',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 12,
|
||||
fontSize: 10,
|
||||
marginRight: 4,
|
||||
marginBottom: 4,
|
||||
},
|
||||
fab: {
|
||||
position: 'absolute',
|
||||
margin: 16,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: '#6200ee',
|
||||
},
|
||||
});
|
||||
|
||||
export default BookmarksScreen;
|
||||
@@ -0,0 +1,444 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View, StyleSheet, ScrollView, RefreshControl, Dimensions } from 'react-native';
|
||||
import { Text, Card, Title, Paragraph, Button, FAB, Avatar, Chip, ProgressBar } from 'react-native-paper';
|
||||
import { useAuth } from '../services/AuthContext';
|
||||
import { useOffline } from '../services/OfflineContext';
|
||||
import { useRealtimeSync, useRealtimeUpdates } from '../services/RealtimeSyncContext';
|
||||
import { bookmarksAPI, tasksAPI, notesAPI } from '../services/api';
|
||||
|
||||
interface QuickStats {
|
||||
totalBookmarks: number;
|
||||
totalTasks: number;
|
||||
totalNotes: number;
|
||||
completedTasks: number;
|
||||
recentActivity: number;
|
||||
}
|
||||
|
||||
interface RecentActivity {
|
||||
id: string;
|
||||
type: 'bookmark' | 'task' | 'note';
|
||||
action: string;
|
||||
title: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
|
||||
const DashboardScreen: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
const { isOnline, pendingChanges, syncNow } = useOffline();
|
||||
const { isSyncing, lastSyncTime } = useRealtimeSync();
|
||||
|
||||
const [stats, setStats] = useState<QuickStats>({
|
||||
totalBookmarks: 0,
|
||||
totalTasks: 0,
|
||||
totalNotes: 0,
|
||||
completedTasks: 0,
|
||||
recentActivity: 0,
|
||||
});
|
||||
|
||||
const [recentActivity, setRecentActivity] = useState<RecentActivity[]>([]);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadDashboardData();
|
||||
}, []);
|
||||
|
||||
// Listen for real-time updates
|
||||
useRealtimeUpdates((data) => {
|
||||
console.log('Dashboard received real-time update:', data);
|
||||
loadDashboardData();
|
||||
});
|
||||
|
||||
const loadDashboardData = async () => {
|
||||
try {
|
||||
const [bookmarksRes, tasksRes, notesRes] = await Promise.all([
|
||||
bookmarksAPI.getBookmarks(),
|
||||
tasksAPI.getTasks(),
|
||||
notesAPI.getNotes(),
|
||||
]);
|
||||
|
||||
if (bookmarksRes.success && tasksRes.success && notesRes.success) {
|
||||
const bookmarks = bookmarksRes.data || [];
|
||||
const tasks = tasksRes.data || [];
|
||||
const notes = notesRes.data || [];
|
||||
|
||||
const completedTasks = tasks.filter(task => (task as any).completed).length;
|
||||
|
||||
setStats({
|
||||
totalBookmarks: bookmarks.length,
|
||||
totalTasks: tasks.length,
|
||||
totalNotes: notes.length,
|
||||
completedTasks,
|
||||
recentActivity: 5, // Mock recent activity count
|
||||
});
|
||||
|
||||
// Generate mock recent activity
|
||||
const activity: RecentActivity[] = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'bookmark',
|
||||
action: 'Added',
|
||||
title: bookmarks[0]?.title || 'New bookmark',
|
||||
timestamp: '2 hours ago',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'task',
|
||||
action: 'Completed',
|
||||
title: tasks[0]?.title || 'New task',
|
||||
timestamp: '3 hours ago',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'note',
|
||||
action: 'Created',
|
||||
title: notes[0]?.title || 'New note',
|
||||
timestamp: '5 hours ago',
|
||||
},
|
||||
];
|
||||
|
||||
setRecentActivity(activity);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading dashboard data:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const onRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
await loadDashboardData();
|
||||
if (isOnline && pendingChanges > 0) {
|
||||
await syncNow();
|
||||
}
|
||||
setRefreshing(false);
|
||||
};
|
||||
|
||||
const getTaskCompletionPercentage = () => {
|
||||
if (stats.totalTasks === 0) return 0;
|
||||
return Math.round((stats.completedTasks / stats.totalTasks) * 100);
|
||||
};
|
||||
|
||||
const formatLastSync = () => {
|
||||
if (!lastSyncTime) return 'Never';
|
||||
const now = Date.now();
|
||||
const diff = now - lastSyncTime;
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
|
||||
if (minutes < 1) return 'Just now';
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
return `${Math.floor(hours / 24)}d ago`;
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
||||
}
|
||||
>
|
||||
{/* Header Section */}
|
||||
<View style={styles.header}>
|
||||
<View style={styles.userSection}>
|
||||
<Avatar.Text
|
||||
size={60}
|
||||
label={user?.name?.charAt(0).toUpperCase() || 'U'}
|
||||
style={styles.avatar}
|
||||
/>
|
||||
<View style={styles.userInfo}>
|
||||
<Title style={styles.welcomeText}>
|
||||
Welcome back, {user?.name || 'User'}!
|
||||
</Title>
|
||||
<Paragraph style={styles.subtitle}>
|
||||
{isOnline ? '🟢 Connected' : '🔴 Offline'} •
|
||||
{isSyncing ? ' Syncing...' : ` Last sync: ${formatLastSync()}`}
|
||||
</Paragraph>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Quick Stats Cards */}
|
||||
<View style={styles.statsGrid}>
|
||||
<Card style={[styles.statCard, { backgroundColor: '#e3f2fd' }]}>
|
||||
<Card.Content style={styles.statContent}>
|
||||
<Text style={[styles.statNumber, { color: '#1976d2' }]}>
|
||||
{stats.totalBookmarks}
|
||||
</Text>
|
||||
<Text style={styles.statLabel}>Bookmarks</Text>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
|
||||
<Card style={[styles.statCard, { backgroundColor: '#e8f5e8' }]}>
|
||||
<Card.Content style={styles.statContent}>
|
||||
<Text style={[styles.statNumber, { color: '#388e3c' }]}>
|
||||
{stats.totalTasks}
|
||||
</Text>
|
||||
<Text style={styles.statLabel}>Tasks</Text>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
|
||||
<Card style={[styles.statCard, { backgroundColor: '#fff3e0' }]}>
|
||||
<Card.Content style={styles.statContent}>
|
||||
<Text style={[styles.statNumber, { color: '#f57c00' }]}>
|
||||
{stats.totalNotes}
|
||||
</Text>
|
||||
<Text style={styles.statLabel}>Notes</Text>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
</View>
|
||||
|
||||
{/* Task Progress */}
|
||||
<Card style={styles.card}>
|
||||
<Card.Content>
|
||||
<Title style={styles.cardTitle}>Task Progress</Title>
|
||||
<View style={styles.progressContainer}>
|
||||
<Text style={styles.progressText}>
|
||||
{stats.completedTasks} of {stats.totalTasks} tasks completed
|
||||
</Text>
|
||||
<ProgressBar
|
||||
progress={getTaskCompletionPercentage() / 100}
|
||||
color="#4caf50"
|
||||
style={styles.progressBar}
|
||||
/>
|
||||
<Text style={styles.progressPercentage}>
|
||||
{getTaskCompletionPercentage()}%
|
||||
</Text>
|
||||
</View>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<Card style={styles.card}>
|
||||
<Card.Content>
|
||||
<Title style={styles.cardTitle}>Recent Activity</Title>
|
||||
{recentActivity.length > 0 ? (
|
||||
recentActivity.map((activity) => (
|
||||
<View key={activity.id} style={styles.activityItem}>
|
||||
<View style={styles.activityIcon}>
|
||||
<Text style={styles.activityEmoji}>
|
||||
{activity.type === 'bookmark' ? '🔖' :
|
||||
activity.type === 'task' ? '✅' : '📝'}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.activityContent}>
|
||||
<Text style={styles.activityTitle}>
|
||||
{activity.action} {activity.title}
|
||||
</Text>
|
||||
<Text style={styles.activityTime}>
|
||||
{activity.timestamp}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
))
|
||||
) : (
|
||||
<Paragraph style={styles.emptyText}>No recent activity</Paragraph>
|
||||
)}
|
||||
</Card.Content>
|
||||
</Card>
|
||||
|
||||
{/* Sync Status */}
|
||||
{!isOnline && pendingChanges > 0 && (
|
||||
<Card style={[styles.card, styles.offlineCard]}>
|
||||
<Card.Content>
|
||||
<Title style={styles.cardTitle}>Offline Mode</Title>
|
||||
<Paragraph>
|
||||
You have {pendingChanges} changes pending sync
|
||||
</Paragraph>
|
||||
<Button
|
||||
mode="outlined"
|
||||
onPress={syncNow}
|
||||
style={styles.syncButton}
|
||||
disabled={!isOnline || isSyncing}
|
||||
loading={isSyncing}
|
||||
>
|
||||
{isSyncing ? 'Syncing...' : 'Sync Now'}
|
||||
</Button>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Quick Actions */}
|
||||
<Card style={styles.card}>
|
||||
<Card.Content>
|
||||
<Title style={styles.cardTitle}>Quick Actions</Title>
|
||||
<View style={styles.quickActions}>
|
||||
<Chip
|
||||
icon="bookmark-plus"
|
||||
onPress={() => console.log('Add bookmark')}
|
||||
style={styles.actionChip}
|
||||
>
|
||||
Add Bookmark
|
||||
</Chip>
|
||||
<Chip
|
||||
icon="plus"
|
||||
onPress={() => console.log('Add task')}
|
||||
style={styles.actionChip}
|
||||
>
|
||||
Add Task
|
||||
</Chip>
|
||||
<Chip
|
||||
icon="note-plus"
|
||||
onPress={() => console.log('Add note')}
|
||||
style={styles.actionChip}
|
||||
>
|
||||
Add Note
|
||||
</Chip>
|
||||
</View>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
</ScrollView>
|
||||
|
||||
<FAB
|
||||
icon="plus"
|
||||
style={styles.fab}
|
||||
onPress={() => console.log('Add new item')}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
},
|
||||
header: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
userSection: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
avatar: {
|
||||
marginRight: 16,
|
||||
backgroundColor: '#6200ee',
|
||||
},
|
||||
userInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
welcomeText: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
subtitle: {
|
||||
color: '#666',
|
||||
marginTop: 4,
|
||||
fontSize: 14,
|
||||
},
|
||||
statsGrid: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 20,
|
||||
},
|
||||
statCard: {
|
||||
width: (width - 48) / 3,
|
||||
elevation: 2,
|
||||
},
|
||||
statContent: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: 16,
|
||||
},
|
||||
statNumber: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
marginTop: 4,
|
||||
textAlign: 'center',
|
||||
},
|
||||
card: {
|
||||
marginBottom: 16,
|
||||
elevation: 2,
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: 18,
|
||||
marginBottom: 12,
|
||||
color: '#333',
|
||||
},
|
||||
progressContainer: {
|
||||
marginTop: 8,
|
||||
},
|
||||
progressText: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
marginBottom: 8,
|
||||
},
|
||||
progressBar: {
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
marginBottom: 8,
|
||||
},
|
||||
progressPercentage: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
color: '#4caf50',
|
||||
textAlign: 'center',
|
||||
},
|
||||
activityItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 8,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#f0f0f0',
|
||||
},
|
||||
activityIcon: {
|
||||
marginRight: 12,
|
||||
},
|
||||
activityEmoji: {
|
||||
fontSize: 20,
|
||||
},
|
||||
activityContent: {
|
||||
flex: 1,
|
||||
},
|
||||
activityTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
color: '#333',
|
||||
},
|
||||
activityTime: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
marginTop: 2,
|
||||
},
|
||||
emptyText: {
|
||||
textAlign: 'center',
|
||||
color: '#666',
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
offlineCard: {
|
||||
backgroundColor: '#fff3cd',
|
||||
borderColor: '#ffeaa7',
|
||||
borderWidth: 1,
|
||||
},
|
||||
syncButton: {
|
||||
marginTop: 12,
|
||||
},
|
||||
quickActions: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
},
|
||||
actionChip: {
|
||||
marginBottom: 8,
|
||||
},
|
||||
fab: {
|
||||
position: 'absolute',
|
||||
margin: 16,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: '#6200ee',
|
||||
},
|
||||
});
|
||||
|
||||
export default DashboardScreen;
|
||||
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
import { ActivityIndicator, Text } from 'react-native-paper';
|
||||
|
||||
const LoadingScreen: React.FC = () => {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ActivityIndicator size="large" />
|
||||
<Text style={styles.text}>Loading Trackeep...</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
text: {
|
||||
marginTop: 16,
|
||||
fontSize: 16,
|
||||
color: '#666',
|
||||
},
|
||||
});
|
||||
|
||||
export default LoadingScreen;
|
||||
@@ -0,0 +1,104 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet, FlatList } from 'react-native';
|
||||
import { Text, Card, Title, Paragraph, FAB } from 'react-native-paper';
|
||||
|
||||
const NotesScreen: React.FC = () => {
|
||||
const [notes] = React.useState([
|
||||
{
|
||||
id: '1',
|
||||
title: 'Mobile App Architecture',
|
||||
content: 'React Native with TypeScript, navigation, offline support...',
|
||||
tags: ['architecture', 'mobile', 'react-native'],
|
||||
createdAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Meeting Notes - Product Review',
|
||||
content: 'Discussed new features, timeline, and user feedback...',
|
||||
tags: ['meeting', 'product', 'review'],
|
||||
createdAt: new Date(),
|
||||
},
|
||||
]);
|
||||
|
||||
const renderNote = ({ item }: any) => (
|
||||
<Card style={styles.card}>
|
||||
<Card.Content>
|
||||
<Title numberOfLines={1}>{item.title}</Title>
|
||||
<Paragraph numberOfLines={3}>{item.content}</Paragraph>
|
||||
<View style={styles.tagsContainer}>
|
||||
{item.tags.map((tag: string, index: number) => (
|
||||
<Text key={index} style={styles.tag}>
|
||||
#{tag}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
<Text style={styles.date}>
|
||||
{item.createdAt.toLocaleDateString()}
|
||||
</Text>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<FlatList
|
||||
data={notes}
|
||||
renderItem={renderNote}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={styles.list}
|
||||
showsVerticalScrollIndicator={false}
|
||||
/>
|
||||
|
||||
<FAB
|
||||
icon="plus"
|
||||
style={styles.fab}
|
||||
onPress={() => console.log('Add note')}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
list: {
|
||||
padding: 16,
|
||||
paddingBottom: 80,
|
||||
},
|
||||
card: {
|
||||
marginBottom: 12,
|
||||
elevation: 2,
|
||||
},
|
||||
tagsContainer: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
marginTop: 8,
|
||||
},
|
||||
tag: {
|
||||
backgroundColor: '#e8f5e8',
|
||||
color: '#2e7d32',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 12,
|
||||
fontSize: 10,
|
||||
marginRight: 4,
|
||||
marginBottom: 4,
|
||||
},
|
||||
date: {
|
||||
fontSize: 10,
|
||||
color: '#666',
|
||||
marginTop: 8,
|
||||
textAlign: 'right',
|
||||
},
|
||||
fab: {
|
||||
position: 'absolute',
|
||||
margin: 16,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: '#6200ee',
|
||||
},
|
||||
});
|
||||
|
||||
export default NotesScreen;
|
||||
@@ -0,0 +1,213 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, StyleSheet, FlatList } from 'react-native';
|
||||
import { Text, Card, Title, Paragraph, Searchbar, Chip } from 'react-native-paper';
|
||||
|
||||
const SearchScreen: React.FC = () => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedFilter, setSelectedFilter] = useState('all');
|
||||
|
||||
const filters = [
|
||||
{ id: 'all', label: 'All' },
|
||||
{ id: 'bookmarks', label: 'Bookmarks' },
|
||||
{ id: 'tasks', label: 'Tasks' },
|
||||
{ id: 'notes', label: 'Notes' },
|
||||
];
|
||||
|
||||
const searchResults = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'bookmark',
|
||||
title: 'React Native Documentation',
|
||||
description: 'Official React Native documentation and guides',
|
||||
url: 'https://reactnative.dev',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'task',
|
||||
title: 'Complete mobile app setup',
|
||||
description: 'Finish React Native project structure and navigation',
|
||||
status: 'in_progress',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'note',
|
||||
title: 'Mobile App Architecture',
|
||||
content: 'React Native with TypeScript, navigation patterns...',
|
||||
tags: ['architecture', 'mobile'],
|
||||
},
|
||||
];
|
||||
|
||||
const onChangeSearch = (query: string) => setSearchQuery(query);
|
||||
|
||||
const renderResult = ({ item }: any) => {
|
||||
const getTypeIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'bookmark': return '🔖';
|
||||
case 'task': return '✅';
|
||||
case 'note': return '📝';
|
||||
default: return '📄';
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'bookmark': return '#1976d2';
|
||||
case 'task': return '#f44336';
|
||||
case 'note': return '#4caf50';
|
||||
default: return '#666';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card style={styles.resultCard}>
|
||||
<Card.Content>
|
||||
<View style={styles.resultHeader}>
|
||||
<Text style={styles.typeIcon}>{getTypeIcon(item.type)}</Text>
|
||||
<Text style={[styles.typeLabel, { color: getTypeColor(item.type) }]}>
|
||||
{item.type.charAt(0).toUpperCase() + item.type.slice(1)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Title numberOfLines={1} style={styles.resultTitle}>
|
||||
{item.title}
|
||||
</Title>
|
||||
|
||||
<Paragraph numberOfLines={2} style={styles.resultDescription}>
|
||||
{item.description || item.content}
|
||||
</Paragraph>
|
||||
|
||||
{item.url && (
|
||||
<Text style={styles.resultUrl} numberOfLines={1}>
|
||||
{item.url}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{item.tags && (
|
||||
<View style={styles.tagsContainer}>
|
||||
{item.tags.map((tag: string, index: number) => (
|
||||
<Chip key={index} style={styles.tag}>
|
||||
{tag}
|
||||
</Chip>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</Card.Content>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Searchbar
|
||||
placeholder="Search everything..."
|
||||
onChangeText={onChangeSearch}
|
||||
value={searchQuery}
|
||||
style={styles.searchBar}
|
||||
/>
|
||||
|
||||
<View style={styles.filtersContainer}>
|
||||
{filters.map(filter => (
|
||||
<Chip
|
||||
key={filter.id}
|
||||
selected={selectedFilter === filter.id}
|
||||
onPress={() => setSelectedFilter(filter.id)}
|
||||
style={styles.filterChip}
|
||||
>
|
||||
{filter.label}
|
||||
</Chip>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<FlatList
|
||||
data={searchResults}
|
||||
renderItem={renderResult}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={styles.resultsList}
|
||||
showsVerticalScrollIndicator={false}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={styles.emptyText}>
|
||||
{searchQuery ? 'No results found' : 'Start typing to search'}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
searchBar: {
|
||||
margin: 16,
|
||||
marginBottom: 8,
|
||||
},
|
||||
filtersContainer: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 8,
|
||||
},
|
||||
filterChip: {
|
||||
marginRight: 8,
|
||||
},
|
||||
resultsList: {
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 16,
|
||||
},
|
||||
resultCard: {
|
||||
marginBottom: 12,
|
||||
elevation: 2,
|
||||
},
|
||||
resultHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
typeIcon: {
|
||||
fontSize: 16,
|
||||
marginRight: 8,
|
||||
},
|
||||
typeLabel: {
|
||||
fontSize: 12,
|
||||
fontWeight: 'bold',
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
resultTitle: {
|
||||
fontSize: 16,
|
||||
marginBottom: 4,
|
||||
},
|
||||
resultDescription: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
marginBottom: 8,
|
||||
},
|
||||
resultUrl: {
|
||||
fontSize: 12,
|
||||
color: '#1976d2',
|
||||
marginBottom: 8,
|
||||
},
|
||||
tagsContainer: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
tag: {
|
||||
marginRight: 4,
|
||||
marginBottom: 4,
|
||||
},
|
||||
emptyContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 60,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 16,
|
||||
color: '#666',
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
export default SearchScreen;
|
||||
@@ -0,0 +1,325 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import {
|
||||
Text,
|
||||
Card,
|
||||
Title,
|
||||
Paragraph,
|
||||
TextInput,
|
||||
Button,
|
||||
ActivityIndicator,
|
||||
HelperText,
|
||||
} from 'react-native-paper';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useServerConfig } from '../services/ServerConfigContext';
|
||||
import { updateAPIBaseURL } from '../services/api';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
|
||||
interface ServerConfig {
|
||||
baseUrl: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
const ServerSetupScreen: React.FC = () => {
|
||||
const [config, setConfig] = useState<ServerConfig>({
|
||||
baseUrl: '',
|
||||
username: '',
|
||||
password: '',
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [errors, setErrors] = useState<Partial<ServerConfig>>({});
|
||||
|
||||
const { setConfig: saveConfig } = useServerConfig();
|
||||
const navigation = useNavigation();
|
||||
|
||||
const validateConfig = (): boolean => {
|
||||
const newErrors: Partial<ServerConfig> = {};
|
||||
|
||||
if (!config.baseUrl.trim()) {
|
||||
newErrors.baseUrl = 'Server URL is required';
|
||||
} else if (!isValidUrl(config.baseUrl)) {
|
||||
newErrors.baseUrl = 'Please enter a valid URL (e.g., https://your-server.com)';
|
||||
}
|
||||
|
||||
if (!config.username.trim()) {
|
||||
newErrors.username = 'Username is required';
|
||||
}
|
||||
|
||||
if (!config.password.trim()) {
|
||||
newErrors.password = 'Password is required';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const isValidUrl = (url: string): boolean => {
|
||||
try {
|
||||
const urlObj = new URL(url.startsWith('http') ? url : `https://${url}`);
|
||||
return urlObj.protocol === 'http:' || urlObj.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const testConnection = async (): Promise<boolean> => {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
||||
|
||||
const response = await fetch(`${config.baseUrl}/api/health`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.error('Connection test failed:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
if (!config.baseUrl.trim()) {
|
||||
Alert.alert('Error', 'Please enter a server URL first');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const isConnected = await testConnection();
|
||||
if (isConnected) {
|
||||
Alert.alert('Success', 'Connection to server successful!');
|
||||
} else {
|
||||
Alert.alert(
|
||||
'Connection Failed',
|
||||
'Could not connect to the server. Please check the URL and ensure the server is running.'
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
Alert.alert('Error', 'Failed to test connection. Please try again.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetup = async () => {
|
||||
if (!validateConfig()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const isConnected = await testConnection();
|
||||
if (!isConnected) {
|
||||
Alert.alert(
|
||||
'Connection Failed',
|
||||
'Could not connect to the server. Please check the URL and try again.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Test authentication
|
||||
const authResponse = await fetch(`${config.baseUrl}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: config.username,
|
||||
password: config.password,
|
||||
}),
|
||||
});
|
||||
|
||||
if (authResponse.ok) {
|
||||
const authData = await authResponse.json();
|
||||
if (authData.token) {
|
||||
await saveConfig(config);
|
||||
updateAPIBaseURL(`${config.baseUrl}/api`);
|
||||
Alert.alert('Success', 'Server configuration completed successfully!');
|
||||
// Navigation will be handled automatically by the AppNavigator
|
||||
} else {
|
||||
Alert.alert('Authentication Failed', 'Invalid username or password.');
|
||||
}
|
||||
} else {
|
||||
Alert.alert('Authentication Failed', 'Invalid username or password.');
|
||||
}
|
||||
} catch (error) {
|
||||
Alert.alert('Setup Failed', 'An error occurred during setup. Please try again.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={styles.keyboardAvoidingView}
|
||||
>
|
||||
<View style={styles.content}>
|
||||
<Card style={styles.card}>
|
||||
<Card.Content>
|
||||
<Title style={styles.title}>Welcome to Trackeep</Title>
|
||||
<Paragraph style={styles.subtitle}>
|
||||
Connect to your Trackeep server to get started
|
||||
</Paragraph>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
|
||||
<Card style={styles.card}>
|
||||
<Card.Content>
|
||||
<Title style={styles.cardTitle}>Server Configuration</Title>
|
||||
|
||||
<TextInput
|
||||
label="Server URL"
|
||||
value={config.baseUrl}
|
||||
onChangeText={(text) => setConfig({ ...config, baseUrl: text })}
|
||||
placeholder="https://your-server.com"
|
||||
autoCapitalize="none"
|
||||
keyboardType="url"
|
||||
style={styles.input}
|
||||
error={!!errors.baseUrl}
|
||||
/>
|
||||
<HelperText type="error" visible={!!errors.baseUrl}>
|
||||
{errors.baseUrl}
|
||||
</HelperText>
|
||||
|
||||
<TextInput
|
||||
label="Username"
|
||||
value={config.username}
|
||||
onChangeText={(text) => setConfig({ ...config, username: text })}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
style={styles.input}
|
||||
error={!!errors.username}
|
||||
/>
|
||||
<HelperText type="error" visible={!!errors.username}>
|
||||
{errors.username}
|
||||
</HelperText>
|
||||
|
||||
<TextInput
|
||||
label="Password"
|
||||
value={config.password}
|
||||
onChangeText={(text) => setConfig({ ...config, password: text })}
|
||||
secureTextEntry
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
style={styles.input}
|
||||
error={!!errors.password}
|
||||
/>
|
||||
<HelperText type="error" visible={!!errors.password}>
|
||||
{errors.password}
|
||||
</HelperText>
|
||||
|
||||
<Button
|
||||
mode="outlined"
|
||||
onPress={handleTestConnection}
|
||||
disabled={isLoading || !config.baseUrl.trim()}
|
||||
style={styles.testButton}
|
||||
loading={isLoading}
|
||||
>
|
||||
Test Connection
|
||||
</Button>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
|
||||
<Card style={styles.infoCard}>
|
||||
<Card.Content>
|
||||
<Title style={styles.cardTitle}>Need Help?</Title>
|
||||
<Paragraph style={styles.infoText}>
|
||||
• Enter the full URL of your Trackeep server
|
||||
</Paragraph>
|
||||
<Paragraph style={styles.infoText}>
|
||||
• Use your existing Trackeep account credentials
|
||||
</Paragraph>
|
||||
<Paragraph style={styles.infoText}>
|
||||
• Make sure your server is accessible from this device
|
||||
</Paragraph>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
|
||||
<Button
|
||||
mode="contained"
|
||||
onPress={handleSetup}
|
||||
disabled={isLoading}
|
||||
loading={isLoading}
|
||||
style={styles.setupButton}
|
||||
contentStyle={styles.setupButtonContent}
|
||||
>
|
||||
Complete Setup
|
||||
</Button>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
keyboardAvoidingView: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
card: {
|
||||
marginBottom: 16,
|
||||
elevation: 2,
|
||||
},
|
||||
infoCard: {
|
||||
marginBottom: 24,
|
||||
backgroundColor: '#e3f2fd',
|
||||
},
|
||||
title: {
|
||||
textAlign: 'center',
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
color: '#6200ee',
|
||||
},
|
||||
subtitle: {
|
||||
textAlign: 'center',
|
||||
marginTop: 8,
|
||||
color: '#666',
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: 18,
|
||||
marginBottom: 16,
|
||||
color: '#333',
|
||||
},
|
||||
input: {
|
||||
marginBottom: 8,
|
||||
},
|
||||
testButton: {
|
||||
marginTop: 8,
|
||||
},
|
||||
infoText: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
marginBottom: 4,
|
||||
},
|
||||
setupButton: {
|
||||
backgroundColor: '#6200ee',
|
||||
},
|
||||
setupButtonContent: {
|
||||
paddingVertical: 8,
|
||||
},
|
||||
});
|
||||
|
||||
export default ServerSetupScreen;
|
||||
@@ -0,0 +1,324 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet, ScrollView, Alert } from 'react-native';
|
||||
import { List, Switch, Text, Card, Title, Button } from 'react-native-paper';
|
||||
import { useAuth } from '../services/AuthContext';
|
||||
import { useOffline } from '../services/OfflineContext';
|
||||
import { useNotifications } from '../services/NotificationContext';
|
||||
import { useCamera } from '../services/CameraContext';
|
||||
import { useVoice } from '../services/VoiceContext';
|
||||
|
||||
const SettingsScreen: React.FC = () => {
|
||||
const { user, logout } = useAuth();
|
||||
const { isOnline, syncNow } = useOffline();
|
||||
const { hasPermission: hasNotificationPermission, requestPermission: requestNotificationPermission } = useNotifications();
|
||||
const { hasPermission: hasCameraPermission, requestPermission: requestCameraPermission, scanDocument } = useCamera();
|
||||
const { hasPermission: hasVoicePermission, requestPermission: requestVoicePermission, isRecording, startRecording, stopRecording } = useVoice();
|
||||
|
||||
const [notifications, setNotifications] = React.useState(true);
|
||||
const [darkMode, setDarkMode] = React.useState(false);
|
||||
const [autoSync, setAutoSync] = React.useState(true);
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
};
|
||||
|
||||
const handleNotificationPermission = async () => {
|
||||
if (!hasNotificationPermission) {
|
||||
const granted = await requestNotificationPermission();
|
||||
if (granted) {
|
||||
Alert.alert('Success', 'Notification permission granted!');
|
||||
} else {
|
||||
Alert.alert('Permission Denied', 'Notification permission is required for reminders');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCameraPermission = async () => {
|
||||
if (!hasCameraPermission) {
|
||||
const granted = await requestCameraPermission();
|
||||
if (granted) {
|
||||
Alert.alert('Success', 'Camera permission granted!');
|
||||
} else {
|
||||
Alert.alert('Permission Denied', 'Camera permission is required for document scanning');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleVoicePermission = async () => {
|
||||
if (!hasVoicePermission) {
|
||||
const granted = await requestVoicePermission();
|
||||
if (granted) {
|
||||
Alert.alert('Success', 'Microphone permission granted!');
|
||||
} else {
|
||||
Alert.alert('Permission Denied', 'Microphone permission is required for voice recording');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestNotification = () => {
|
||||
// This would use the notification service to show a test notification
|
||||
Alert.alert('Test Notification', 'This is a test notification!');
|
||||
};
|
||||
|
||||
const handleTestCamera = async () => {
|
||||
try {
|
||||
const result = await scanDocument();
|
||||
if (result) {
|
||||
Alert.alert('Success', 'Document scanned successfully!');
|
||||
}
|
||||
} catch (error) {
|
||||
Alert.alert('Error', 'Failed to scan document');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestVoice = async () => {
|
||||
if (isRecording) {
|
||||
const recording = await stopRecording();
|
||||
if (recording) {
|
||||
Alert.alert('Success', `Voice recorded! Duration: ${recording.duration}s`);
|
||||
}
|
||||
} else {
|
||||
startRecording();
|
||||
Alert.alert('Recording', 'Voice recording started...');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ScrollView style={styles.scrollView}>
|
||||
<Card style={styles.card}>
|
||||
<Card.Content>
|
||||
<Title>Account</Title>
|
||||
<Text style={styles.userInfo}>
|
||||
{user?.name} ({user?.email})
|
||||
</Text>
|
||||
<Button
|
||||
mode="outlined"
|
||||
onPress={handleLogout}
|
||||
style={styles.logoutButton}
|
||||
>
|
||||
Sign Out
|
||||
</Button>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
|
||||
<Card style={styles.card}>
|
||||
<Card.Content>
|
||||
<Title>Preferences</Title>
|
||||
|
||||
<List.Item
|
||||
title="Push Notifications"
|
||||
description="Receive notifications for tasks and reminders"
|
||||
right={() => (
|
||||
<Switch
|
||||
value={notifications}
|
||||
onValueChange={setNotifications}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<List.Item
|
||||
title="Dark Mode"
|
||||
description="Use dark theme"
|
||||
right={() => (
|
||||
<Switch
|
||||
value={darkMode}
|
||||
onValueChange={setDarkMode}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<List.Item
|
||||
title="Auto Sync"
|
||||
description="Automatically sync when online"
|
||||
right={() => (
|
||||
<Switch
|
||||
value={autoSync}
|
||||
onValueChange={setAutoSync}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
|
||||
<Card style={styles.card}>
|
||||
<Card.Content>
|
||||
<Title>📱 Mobile Features</Title>
|
||||
|
||||
<List.Item
|
||||
title="Push Notifications"
|
||||
description={hasNotificationPermission ? "Permission granted" : "Permission required"}
|
||||
left={() => <Text style={styles.featureIcon}>🔔</Text>}
|
||||
right={() => (
|
||||
<View style={styles.featureActions}>
|
||||
{!hasNotificationPermission && (
|
||||
<Button
|
||||
mode="outlined"
|
||||
onPress={handleNotificationPermission}
|
||||
compact
|
||||
>
|
||||
Enable
|
||||
</Button>
|
||||
)}
|
||||
{hasNotificationPermission && (
|
||||
<Button
|
||||
mode="text"
|
||||
onPress={handleTestNotification}
|
||||
compact
|
||||
>
|
||||
Test
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
|
||||
<List.Item
|
||||
title="Camera & Document Scanning"
|
||||
description={hasCameraPermission ? "Permission granted" : "Permission required"}
|
||||
left={() => <Text style={styles.featureIcon}>📸</Text>}
|
||||
right={() => (
|
||||
<View style={styles.featureActions}>
|
||||
{!hasCameraPermission && (
|
||||
<Button
|
||||
mode="outlined"
|
||||
onPress={handleCameraPermission}
|
||||
compact
|
||||
>
|
||||
Enable
|
||||
</Button>
|
||||
)}
|
||||
{hasCameraPermission && (
|
||||
<Button
|
||||
mode="text"
|
||||
onPress={handleTestCamera}
|
||||
compact
|
||||
>
|
||||
Test
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
|
||||
<List.Item
|
||||
title="Voice Recording"
|
||||
description={hasVoicePermission ? "Permission granted" : "Permission required"}
|
||||
left={() => <Text style={styles.featureIcon}>🎤</Text>}
|
||||
right={() => (
|
||||
<View style={styles.featureActions}>
|
||||
{!hasVoicePermission && (
|
||||
<Button
|
||||
mode="outlined"
|
||||
onPress={handleVoicePermission}
|
||||
compact
|
||||
>
|
||||
Enable
|
||||
</Button>
|
||||
)}
|
||||
{hasVoicePermission && (
|
||||
<Button
|
||||
mode={isRecording ? "contained" : "text"}
|
||||
onPress={handleTestVoice}
|
||||
compact
|
||||
>
|
||||
{isRecording ? "Stop" : "Test"}
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
|
||||
<Card style={styles.card}>
|
||||
<Card.Content>
|
||||
<Title>Sync Status</Title>
|
||||
|
||||
<List.Item
|
||||
title="Connection"
|
||||
description={isOnline ? 'Connected' : 'Offline'}
|
||||
left={() => (
|
||||
<Text style={styles.statusIcon}>
|
||||
{isOnline ? '🟢' : '🔴'}
|
||||
</Text>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
mode="outlined"
|
||||
onPress={syncNow}
|
||||
disabled={!isOnline}
|
||||
style={styles.syncButton}
|
||||
>
|
||||
Sync Now
|
||||
</Button>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
|
||||
<Card style={styles.card}>
|
||||
<Card.Content>
|
||||
<Title>About</Title>
|
||||
|
||||
<List.Item
|
||||
title="Version"
|
||||
description="1.0.0"
|
||||
/>
|
||||
|
||||
<List.Item
|
||||
title="Build"
|
||||
description="React Native Mobile App"
|
||||
/>
|
||||
|
||||
<List.Item
|
||||
title="GitHub"
|
||||
description="View source code"
|
||||
onPress={() => console.log('Open GitHub')}
|
||||
/>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
card: {
|
||||
margin: 16,
|
||||
elevation: 2,
|
||||
},
|
||||
userInfo: {
|
||||
fontSize: 16,
|
||||
marginBottom: 16,
|
||||
color: '#666',
|
||||
},
|
||||
logoutButton: {
|
||||
marginTop: 8,
|
||||
},
|
||||
statusIcon: {
|
||||
fontSize: 16,
|
||||
width: 24,
|
||||
textAlign: 'center',
|
||||
},
|
||||
syncButton: {
|
||||
marginTop: 8,
|
||||
},
|
||||
featureIcon: {
|
||||
fontSize: 16,
|
||||
width: 24,
|
||||
textAlign: 'center',
|
||||
},
|
||||
featureActions: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
export default SettingsScreen;
|
||||
@@ -0,0 +1,132 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet, FlatList } from 'react-native';
|
||||
import { Text, Card, Title, Paragraph, FAB, Checkbox } from 'react-native-paper';
|
||||
|
||||
const TasksScreen: React.FC = () => {
|
||||
const [tasks, setTasks] = React.useState([
|
||||
{
|
||||
id: '1',
|
||||
title: 'Complete mobile app setup',
|
||||
description: 'Finish React Native project structure',
|
||||
status: 'in_progress' as const,
|
||||
priority: 'high' as const,
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Review pull requests',
|
||||
description: 'Check and merge pending PRs',
|
||||
status: 'todo' as const,
|
||||
priority: 'medium' as const,
|
||||
completed: false,
|
||||
},
|
||||
]);
|
||||
|
||||
const toggleTask = (taskId: string) => {
|
||||
setTasks(prev =>
|
||||
prev.map(task =>
|
||||
task.id === taskId ? { ...task, completed: !task.completed } : task
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'high': return '#f44336';
|
||||
case 'medium': return '#ff9800';
|
||||
case 'low': return '#4caf50';
|
||||
default: return '#666';
|
||||
}
|
||||
};
|
||||
|
||||
const renderTask = ({ item }: any) => (
|
||||
<Card style={styles.card}>
|
||||
<Card.Content>
|
||||
<View style={styles.taskHeader}>
|
||||
<Checkbox
|
||||
status={item.completed ? 'checked' : 'unchecked'}
|
||||
onPress={() => toggleTask(item.id)}
|
||||
/>
|
||||
<View style={styles.taskContent}>
|
||||
<Title style={[styles.taskTitle, item.completed && styles.completedTitle]}>
|
||||
{item.title}
|
||||
</Title>
|
||||
<Paragraph style={styles.taskDescription}>
|
||||
{item.description}
|
||||
</Paragraph>
|
||||
<Text style={[styles.priority, { color: getPriorityColor(item.priority) }]}>
|
||||
{item.priority.toUpperCase()}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<FlatList
|
||||
data={tasks}
|
||||
renderItem={renderTask}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={styles.list}
|
||||
showsVerticalScrollIndicator={false}
|
||||
/>
|
||||
|
||||
<FAB
|
||||
icon="plus"
|
||||
style={styles.fab}
|
||||
onPress={() => console.log('Add task')}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
list: {
|
||||
padding: 16,
|
||||
paddingBottom: 80,
|
||||
},
|
||||
card: {
|
||||
marginBottom: 12,
|
||||
elevation: 2,
|
||||
},
|
||||
taskHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
taskContent: {
|
||||
flex: 1,
|
||||
marginLeft: 12,
|
||||
},
|
||||
taskTitle: {
|
||||
fontSize: 16,
|
||||
},
|
||||
completedTitle: {
|
||||
textDecorationLine: 'line-through',
|
||||
color: '#666',
|
||||
},
|
||||
taskDescription: {
|
||||
marginTop: 4,
|
||||
fontSize: 14,
|
||||
},
|
||||
priority: {
|
||||
fontSize: 10,
|
||||
fontWeight: 'bold',
|
||||
marginTop: 8,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
fab: {
|
||||
position: 'absolute',
|
||||
margin: 16,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: '#6200ee',
|
||||
},
|
||||
});
|
||||
|
||||
export default TasksScreen;
|
||||
@@ -0,0 +1,194 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
import { Text, Card, Title, Paragraph, Button, FAB } from 'react-native-paper';
|
||||
|
||||
const TimeTrackingScreen: React.FC = () => {
|
||||
const [isTimerRunning, setIsTimerRunning] = useState(false);
|
||||
const [elapsedTime, setElapsedTime] = useState(0);
|
||||
const [currentTask, setCurrentTask] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let interval: NodeJS.Timeout;
|
||||
if (isTimerRunning) {
|
||||
interval = setInterval(() => {
|
||||
setElapsedTime(prev => prev + 1);
|
||||
}, 1000);
|
||||
}
|
||||
return () => clearInterval(interval);
|
||||
}, [isTimerRunning]);
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${hours.toString().padStart(2, '0')}:${minutes
|
||||
.toString()
|
||||
.padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const toggleTimer = () => {
|
||||
setIsTimerRunning(!isTimerRunning);
|
||||
};
|
||||
|
||||
const resetTimer = () => {
|
||||
setIsTimerRunning(false);
|
||||
setElapsedTime(0);
|
||||
setCurrentTask('');
|
||||
};
|
||||
|
||||
const timeEntries = [
|
||||
{
|
||||
id: '1',
|
||||
description: 'Mobile app development',
|
||||
duration: '2:30:00',
|
||||
date: 'Today',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
description: 'Code review',
|
||||
duration: '0:45:00',
|
||||
date: 'Yesterday',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Card style={styles.timerCard}>
|
||||
<Card.Content>
|
||||
<Title style={styles.timerTitle}>Time Tracker</Title>
|
||||
<Text style={styles.timeDisplay}>{formatTime(elapsedTime)}</Text>
|
||||
|
||||
{currentTask ? (
|
||||
<Paragraph style={styles.currentTask}>
|
||||
Working on: {currentTask}
|
||||
</Paragraph>
|
||||
) : (
|
||||
<Paragraph style={styles.noTask}>
|
||||
No task selected
|
||||
</Paragraph>
|
||||
)}
|
||||
|
||||
<View style={styles.timerButtons}>
|
||||
<Button
|
||||
mode={isTimerRunning ? 'outlined' : 'contained'}
|
||||
onPress={toggleTimer}
|
||||
style={styles.timerButton}
|
||||
>
|
||||
{isTimerRunning ? 'Pause' : 'Start'}
|
||||
</Button>
|
||||
<Button
|
||||
mode="outlined"
|
||||
onPress={resetTimer}
|
||||
style={styles.timerButton}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</View>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
|
||||
<Card style={styles.entriesCard}>
|
||||
<Card.Content>
|
||||
<Title>Recent Entries</Title>
|
||||
{timeEntries.map(entry => (
|
||||
<View key={entry.id} style={styles.entryItem}>
|
||||
<View style={styles.entryContent}>
|
||||
<Text style={styles.entryDescription}>
|
||||
{entry.description}
|
||||
</Text>
|
||||
<Text style={styles.entryDuration}>
|
||||
{entry.duration}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.entryDate}>{entry.date}</Text>
|
||||
</View>
|
||||
))}
|
||||
</Card.Content>
|
||||
</Card>
|
||||
|
||||
<FAB
|
||||
icon="plus"
|
||||
style={styles.fab}
|
||||
onPress={() => console.log('Add time entry')}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f5f5f5',
|
||||
padding: 16,
|
||||
},
|
||||
timerCard: {
|
||||
marginBottom: 16,
|
||||
elevation: 4,
|
||||
},
|
||||
timerTitle: {
|
||||
textAlign: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
timeDisplay: {
|
||||
fontSize: 48,
|
||||
fontWeight: 'bold',
|
||||
textAlign: 'center',
|
||||
color: '#6200ee',
|
||||
marginBottom: 16,
|
||||
},
|
||||
currentTask: {
|
||||
textAlign: 'center',
|
||||
color: '#666',
|
||||
marginBottom: 16,
|
||||
},
|
||||
noTask: {
|
||||
textAlign: 'center',
|
||||
color: '#999',
|
||||
fontStyle: 'italic',
|
||||
marginBottom: 16,
|
||||
},
|
||||
timerButtons: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-around',
|
||||
},
|
||||
timerButton: {
|
||||
flex: 1,
|
||||
marginHorizontal: 8,
|
||||
},
|
||||
entriesCard: {
|
||||
elevation: 2,
|
||||
},
|
||||
entryItem: {
|
||||
paddingVertical: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#eee',
|
||||
},
|
||||
entryContent: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
entryDescription: {
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
},
|
||||
entryDuration: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
color: '#6200ee',
|
||||
},
|
||||
entryDate: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
marginTop: 4,
|
||||
},
|
||||
fab: {
|
||||
position: 'absolute',
|
||||
margin: 16,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: '#6200ee',
|
||||
},
|
||||
});
|
||||
|
||||
export default TimeTrackingScreen;
|
||||
@@ -0,0 +1,190 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import {
|
||||
TextInput,
|
||||
Button,
|
||||
Text,
|
||||
Card,
|
||||
Title,
|
||||
Paragraph,
|
||||
} from 'react-native-paper';
|
||||
import { useAuth } from '../../services/AuthContext';
|
||||
|
||||
const LoginScreen: React.FC = ({ navigation }: any) => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const { login, loginWithGitHub } = useAuth();
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!email || !password) {
|
||||
Alert.alert('Error', 'Please fill in all fields');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const success = await login(email, password);
|
||||
if (!success) {
|
||||
Alert.alert('Error', 'Invalid email or password');
|
||||
}
|
||||
} catch (error) {
|
||||
Alert.alert('Error', 'Login failed. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGitHubLogin = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const success = await loginWithGitHub();
|
||||
if (!success) {
|
||||
Alert.alert('Error', 'GitHub login failed');
|
||||
}
|
||||
} catch (error) {
|
||||
Alert.alert('Error', 'GitHub login failed. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={styles.container}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
>
|
||||
<View style={styles.content}>
|
||||
<Card style={styles.card}>
|
||||
<Card.Content>
|
||||
<Title style={styles.title}>Welcome to Trackeep</Title>
|
||||
<Paragraph style={styles.subtitle}>
|
||||
Your productivity and knowledge management companion
|
||||
</Paragraph>
|
||||
|
||||
<TextInput
|
||||
label="Email"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
mode="outlined"
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
style={styles.input}
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Password"
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
mode="outlined"
|
||||
secureTextEntry={!showPassword}
|
||||
right={
|
||||
<TextInput.Icon
|
||||
icon={showPassword ? 'eye-off' : 'eye'}
|
||||
onPress={() => setShowPassword(!showPassword)}
|
||||
/>
|
||||
}
|
||||
style={styles.input}
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
<Button
|
||||
mode="contained"
|
||||
onPress={handleLogin}
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
style={styles.button}
|
||||
>
|
||||
Sign In
|
||||
</Button>
|
||||
|
||||
<View style={styles.divider}>
|
||||
<Text style={styles.dividerText}>OR</Text>
|
||||
</View>
|
||||
|
||||
<Button
|
||||
mode="outlined"
|
||||
onPress={handleGitHubLogin}
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
style={styles.githubButton}
|
||||
icon="github"
|
||||
>
|
||||
Continue with GitHub
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
mode="text"
|
||||
onPress={() => navigation.navigate('Register')}
|
||||
style={styles.linkButton}
|
||||
>
|
||||
Don't have an account? Sign Up
|
||||
</Button>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
card: {
|
||||
elevation: 4,
|
||||
borderRadius: 12,
|
||||
},
|
||||
title: {
|
||||
textAlign: 'center',
|
||||
marginBottom: 8,
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
subtitle: {
|
||||
textAlign: 'center',
|
||||
marginBottom: 24,
|
||||
color: '#666',
|
||||
},
|
||||
input: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
button: {
|
||||
marginBottom: 16,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
divider: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginVertical: 16,
|
||||
},
|
||||
dividerText: {
|
||||
flex: 1,
|
||||
textAlign: 'center',
|
||||
color: '#666',
|
||||
fontSize: 12,
|
||||
},
|
||||
githubButton: {
|
||||
marginBottom: 16,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
linkButton: {
|
||||
marginTop: 8,
|
||||
},
|
||||
});
|
||||
|
||||
export default LoginScreen;
|
||||
@@ -0,0 +1,192 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import {
|
||||
TextInput,
|
||||
Button,
|
||||
Text,
|
||||
Card,
|
||||
Title,
|
||||
Paragraph,
|
||||
} from 'react-native-paper';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { AuthStackParamList } from '../../navigation/AuthNavigator';
|
||||
|
||||
type RegisterScreenNavigationProp = NativeStackNavigationProp<
|
||||
AuthStackParamList,
|
||||
'Register'
|
||||
>;
|
||||
|
||||
interface Props {
|
||||
navigation: RegisterScreenNavigationProp;
|
||||
}
|
||||
|
||||
const RegisterScreen: React.FC<Props> = ({ navigation }) => {
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!name || !email || !password || !confirmPassword) {
|
||||
Alert.alert('Error', 'Please fill in all fields');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
Alert.alert('Error', 'Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
Alert.alert('Error', 'Password must be at least 6 characters');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
Alert.alert('Success', 'Registration successful! Please sign in.');
|
||||
navigation.navigate('Login');
|
||||
} catch (error) {
|
||||
Alert.alert('Error', 'Registration failed. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={styles.container}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
>
|
||||
<View style={styles.content}>
|
||||
<Card style={styles.card}>
|
||||
<Card.Content>
|
||||
<Title style={styles.title}>Create Account</Title>
|
||||
<Paragraph style={styles.subtitle}>
|
||||
Join Trackeep and boost your productivity
|
||||
</Paragraph>
|
||||
|
||||
<TextInput
|
||||
label="Full Name"
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
mode="outlined"
|
||||
autoCapitalize="words"
|
||||
style={styles.input}
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Email"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
mode="outlined"
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
style={styles.input}
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Password"
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
mode="outlined"
|
||||
secureTextEntry={!showPassword}
|
||||
right={
|
||||
<TextInput.Icon
|
||||
icon={showPassword ? 'eye-off' : 'eye'}
|
||||
onPress={() => setShowPassword(!showPassword)}
|
||||
/>
|
||||
}
|
||||
style={styles.input}
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Confirm Password"
|
||||
value={confirmPassword}
|
||||
onChangeText={setConfirmPassword}
|
||||
mode="outlined"
|
||||
secureTextEntry={!showConfirmPassword}
|
||||
right={
|
||||
<TextInput.Icon
|
||||
icon={showConfirmPassword ? 'eye-off' : 'eye'}
|
||||
onPress={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
/>
|
||||
}
|
||||
style={styles.input}
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
<Button
|
||||
mode="contained"
|
||||
onPress={handleRegister}
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
style={styles.button}
|
||||
>
|
||||
Sign Up
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
mode="text"
|
||||
onPress={() => navigation.navigate('Login')}
|
||||
style={styles.linkButton}
|
||||
>
|
||||
Already have an account? Sign In
|
||||
</Button>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
card: {
|
||||
elevation: 4,
|
||||
borderRadius: 12,
|
||||
},
|
||||
title: {
|
||||
textAlign: 'center',
|
||||
marginBottom: 8,
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
subtitle: {
|
||||
textAlign: 'center',
|
||||
marginBottom: 24,
|
||||
color: '#666',
|
||||
},
|
||||
input: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
button: {
|
||||
marginBottom: 16,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
linkButton: {
|
||||
marginTop: 8,
|
||||
},
|
||||
});
|
||||
|
||||
export default RegisterScreen;
|
||||
@@ -0,0 +1,197 @@
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import { User, NavigationState } from '../types';
|
||||
import { authAPI } from './api';
|
||||
import { storeAuthData, getStoredAuthData, clearAuthData } from '../utils/storage';
|
||||
|
||||
interface AuthContextType extends NavigationState {
|
||||
login: (email: string, password: string) => Promise<boolean>;
|
||||
loginWithGitHub: () => Promise<boolean>;
|
||||
logout: () => Promise<void>;
|
||||
updateUser: (user: Partial<User>) => Promise<boolean>;
|
||||
refreshUser: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
interface AuthProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
||||
const [state, setState] = useState<NavigationState>({
|
||||
isAuthenticated: false,
|
||||
isLoading: true,
|
||||
user: undefined,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
initializeAuth();
|
||||
}, []);
|
||||
|
||||
const initializeAuth = async () => {
|
||||
try {
|
||||
const storedAuth = await getStoredAuthData();
|
||||
if (storedAuth && storedAuth.token) {
|
||||
const userResponse = await authAPI.getCurrentUser(storedAuth.token);
|
||||
if (userResponse.success && userResponse.data) {
|
||||
setState({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
user: userResponse.data,
|
||||
});
|
||||
} else {
|
||||
await clearAuthData();
|
||||
setState({
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
user: undefined,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setState({
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
user: undefined,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth initialization error:', error);
|
||||
await clearAuthData();
|
||||
setState({
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
user: undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const login = async (email: string, password: string): Promise<boolean> => {
|
||||
try {
|
||||
setState(prev => ({ ...prev, isLoading: true }));
|
||||
|
||||
const response = await authAPI.login(email, password);
|
||||
|
||||
if (response.success && response.data) {
|
||||
await storeAuthData({
|
||||
token: response.data.token,
|
||||
user: response.data.user,
|
||||
});
|
||||
|
||||
setState({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
user: response.data.user,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
setState(prev => ({ ...prev, isLoading: false }));
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
setState(prev => ({ ...prev, isLoading: false }));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const loginWithGitHub = async (): Promise<boolean> => {
|
||||
try {
|
||||
setState(prev => ({ ...prev, isLoading: true }));
|
||||
|
||||
const response = await authAPI.loginWithGitHub();
|
||||
|
||||
if (response.success && response.data) {
|
||||
await storeAuthData({
|
||||
token: response.data.token,
|
||||
user: response.data.user,
|
||||
});
|
||||
|
||||
setState({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
user: response.data.user,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
setState(prev => ({ ...prev, isLoading: false }));
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('GitHub login error:', error);
|
||||
setState(prev => ({ ...prev, isLoading: false }));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const logout = async (): Promise<void> => {
|
||||
try {
|
||||
await clearAuthData();
|
||||
setState({
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
user: undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const updateUser = async (updates: Partial<User>): Promise<boolean> => {
|
||||
try {
|
||||
if (!state.user) return false;
|
||||
|
||||
const response = await authAPI.updateUser(state.user.id, updates);
|
||||
|
||||
if (response.success && response.data) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
user: { ...prev.user!, ...response.data },
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Update user error:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const refreshUser = async (): Promise<void> => {
|
||||
try {
|
||||
const storedAuth = await getStoredAuthData();
|
||||
if (storedAuth && storedAuth.token) {
|
||||
const userResponse = await authAPI.getCurrentUser(storedAuth.token);
|
||||
if (userResponse.success && userResponse.data) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
user: userResponse.data,
|
||||
}));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Refresh user error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const value: AuthContextType = {
|
||||
...state,
|
||||
login,
|
||||
loginWithGitHub,
|
||||
logout,
|
||||
updateUser,
|
||||
refreshUser,
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
};
|
||||
|
||||
export const useAuth = (): AuthContextType => {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -0,0 +1,136 @@
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import { View, Alert, Platform } from 'react-native';
|
||||
import { Camera, useCameraDevices } from 'react-native-vision-camera';
|
||||
import { request, PERMISSIONS, RESULTS } from 'react-native-permissions';
|
||||
|
||||
interface CameraContextType {
|
||||
hasPermission: boolean;
|
||||
devices: any;
|
||||
isActive: boolean;
|
||||
requestPermission: () => Promise<boolean>;
|
||||
startCamera: () => void;
|
||||
stopCamera: () => void;
|
||||
capturePhoto: () => Promise<string | null>;
|
||||
scanDocument: () => Promise<string | null>;
|
||||
}
|
||||
|
||||
const CameraContext = createContext<CameraContextType | undefined>(undefined);
|
||||
|
||||
interface CameraProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const CameraProvider: React.FC<CameraProviderProps> = ({ children }) => {
|
||||
const [hasPermission, setHasPermission] = useState(false);
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
const devices = useCameraDevices();
|
||||
const device = devices.find(d => d.position === 'back');
|
||||
|
||||
useEffect(() => {
|
||||
checkPermission();
|
||||
}, []);
|
||||
|
||||
const checkPermission = async () => {
|
||||
const permission = Platform.OS === 'ios'
|
||||
? PERMISSIONS.IOS.CAMERA
|
||||
: PERMISSIONS.ANDROID.CAMERA;
|
||||
|
||||
const result = await request(permission);
|
||||
setHasPermission(result === RESULTS.GRANTED);
|
||||
};
|
||||
|
||||
const requestPermission = async (): Promise<boolean> => {
|
||||
const permission = Platform.OS === 'ios'
|
||||
? PERMISSIONS.IOS.CAMERA
|
||||
: PERMISSIONS.ANDROID.CAMERA;
|
||||
|
||||
const result = await request(permission);
|
||||
const granted = result === RESULTS.GRANTED;
|
||||
setHasPermission(granted);
|
||||
return granted;
|
||||
};
|
||||
|
||||
const startCamera = () => {
|
||||
if (hasPermission && device) {
|
||||
setIsActive(true);
|
||||
} else {
|
||||
Alert.alert('Camera Error', 'Camera permission is required or no camera available');
|
||||
}
|
||||
};
|
||||
|
||||
const stopCamera = () => {
|
||||
setIsActive(false);
|
||||
};
|
||||
|
||||
const capturePhoto = async (): Promise<string | null> => {
|
||||
if (!device || !isActive) {
|
||||
Alert.alert('Camera Error', 'Camera is not active');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// This would need to be implemented with actual camera capture logic
|
||||
// For now, return a placeholder
|
||||
const photo = 'captured-photo-path';
|
||||
return photo;
|
||||
} catch (error) {
|
||||
console.error('Error capturing photo:', error);
|
||||
Alert.alert('Error', 'Failed to capture photo');
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const scanDocument = async (): Promise<string | null> => {
|
||||
if (!hasPermission) {
|
||||
const granted = await requestPermission();
|
||||
if (!granted) {
|
||||
Alert.alert('Permission Required', 'Camera access is required for document scanning');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Start camera for document scanning
|
||||
startCamera();
|
||||
|
||||
// This would integrate with a document scanning library
|
||||
// For now, return a placeholder
|
||||
const scannedDocument = 'scanned-document-path';
|
||||
|
||||
// Stop camera after scanning
|
||||
stopCamera();
|
||||
|
||||
return scannedDocument;
|
||||
} catch (error) {
|
||||
console.error('Error scanning document:', error);
|
||||
Alert.alert('Error', 'Failed to scan document');
|
||||
stopCamera();
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const value: CameraContextType = {
|
||||
hasPermission,
|
||||
devices,
|
||||
isActive,
|
||||
requestPermission,
|
||||
startCamera,
|
||||
stopCamera,
|
||||
capturePhoto,
|
||||
scanDocument,
|
||||
};
|
||||
|
||||
return (
|
||||
<CameraContext.Provider value={value}>
|
||||
{children}
|
||||
</CameraContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useCamera = (): CameraContextType => {
|
||||
const context = useContext(CameraContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useCamera must be used within a CameraProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -0,0 +1,175 @@
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import PushNotification from 'react-native-push-notification';
|
||||
import { Platform, PermissionsAndroid, Alert } from 'react-native';
|
||||
import { request, PERMISSIONS, RESULTS } from 'react-native-permissions';
|
||||
|
||||
interface Notification {
|
||||
id: string;
|
||||
title: string;
|
||||
message: string;
|
||||
date?: Date;
|
||||
userInfo?: any;
|
||||
}
|
||||
|
||||
interface NotificationContextType {
|
||||
isInitialized: boolean;
|
||||
hasPermission: boolean;
|
||||
requestPermission: () => Promise<boolean>;
|
||||
scheduleNotification: (notification: Notification) => void;
|
||||
cancelNotification: (id: string) => void;
|
||||
cancelAllNotifications: () => void;
|
||||
showLocalNotification: (title: string, message: string) => void;
|
||||
}
|
||||
|
||||
const NotificationContext = createContext<NotificationContextType | undefined>(undefined);
|
||||
|
||||
interface NotificationProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const NotificationProvider: React.FC<NotificationProviderProps> = ({ children }) => {
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [hasPermission, setHasPermission] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
initializeNotifications();
|
||||
}, []);
|
||||
|
||||
const initializeNotifications = () => {
|
||||
PushNotification.configure({
|
||||
onRegister: (token) => {
|
||||
console.log('Push notification token:', token);
|
||||
// TODO: Send token to backend for server-side notifications
|
||||
},
|
||||
|
||||
onNotification: (notification) => {
|
||||
console.log('Notification received:', notification);
|
||||
|
||||
if (notification.userInteraction) {
|
||||
// User tapped on notification
|
||||
handleNotificationPress(notification);
|
||||
}
|
||||
},
|
||||
|
||||
permissions: {
|
||||
alert: true,
|
||||
badge: true,
|
||||
sound: true,
|
||||
},
|
||||
|
||||
popInitialNotification: true,
|
||||
requestPermissions: Platform.OS === 'ios',
|
||||
});
|
||||
|
||||
PushNotification.createChannel(
|
||||
'trackeep-tasks',
|
||||
'Task Reminders',
|
||||
4,
|
||||
(created: any) => console.log('Task channel created:', created)
|
||||
);
|
||||
|
||||
PushNotification.createChannel(
|
||||
'trackeep-general',
|
||||
'General Notifications',
|
||||
3,
|
||||
(created: any) => console.log('General channel created:', created)
|
||||
);
|
||||
|
||||
checkPermission();
|
||||
setIsInitialized(true);
|
||||
};
|
||||
|
||||
const checkPermission = async () => {
|
||||
if (Platform.OS === 'ios') {
|
||||
PushNotification.checkPermissions((permissions) => {
|
||||
setHasPermission(Boolean(permissions.alert || permissions.badge || permissions.sound));
|
||||
});
|
||||
} else {
|
||||
const permission = PERMISSIONS.ANDROID.POST_NOTIFICATIONS;
|
||||
const result = await request(permission);
|
||||
setHasPermission(result === RESULTS.GRANTED);
|
||||
}
|
||||
};
|
||||
|
||||
const requestPermission = async (): Promise<boolean> => {
|
||||
return new Promise((resolve) => {
|
||||
if (Platform.OS === 'ios') {
|
||||
PushNotification.requestPermissions((permissions: any) => {
|
||||
const granted = permissions.alert || permissions.badge || permissions.sound;
|
||||
setHasPermission(granted);
|
||||
resolve(granted);
|
||||
});
|
||||
} else {
|
||||
request(PERMISSIONS.ANDROID.POST_NOTIFICATIONS).then((result) => {
|
||||
const granted = result === RESULTS.GRANTED;
|
||||
setHasPermission(granted);
|
||||
resolve(granted);
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const scheduleNotification = (notification: Notification) => {
|
||||
if (!hasPermission) {
|
||||
Alert.alert('Permission Required', 'Please enable notifications to receive reminders.');
|
||||
return;
|
||||
}
|
||||
|
||||
PushNotification.localNotificationSchedule({
|
||||
channelId: 'trackeep-tasks',
|
||||
id: parseInt(notification.id),
|
||||
title: notification.title,
|
||||
message: notification.message,
|
||||
date: notification.date || new Date(),
|
||||
allowWhileIdle: true,
|
||||
userInfo: notification.userInfo,
|
||||
actions: ['View', 'Dismiss'],
|
||||
});
|
||||
};
|
||||
|
||||
const cancelNotification = (id: string) => {
|
||||
PushNotification.cancelLocalNotifications({ id: id.toString() });
|
||||
};
|
||||
|
||||
const cancelAllNotifications = () => {
|
||||
PushNotification.cancelAllLocalNotifications();
|
||||
};
|
||||
|
||||
const showLocalNotification = (title: string, message: string) => {
|
||||
PushNotification.localNotification({
|
||||
channelId: 'trackeep-general',
|
||||
title,
|
||||
message,
|
||||
actions: ['View', 'Dismiss'],
|
||||
});
|
||||
};
|
||||
|
||||
const handleNotificationPress = (notification: any) => {
|
||||
// TODO: Navigate to relevant screen based on notification data
|
||||
console.log('Notification pressed:', notification);
|
||||
};
|
||||
|
||||
const value: NotificationContextType = {
|
||||
isInitialized,
|
||||
hasPermission,
|
||||
requestPermission,
|
||||
scheduleNotification,
|
||||
cancelNotification,
|
||||
cancelAllNotifications,
|
||||
showLocalNotification,
|
||||
};
|
||||
|
||||
return (
|
||||
<NotificationContext.Provider value={value}>
|
||||
{children}
|
||||
</NotificationContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useNotifications = (): NotificationContextType => {
|
||||
const context = useContext(NotificationContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useNotifications must be used within a NotificationProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import { OfflineState } from '../types';
|
||||
import NetInfo from '@react-native-community/netinfo';
|
||||
import { syncOfflineData, getPendingChangesCount } from '../utils/offlineSync';
|
||||
|
||||
interface OfflineContextType extends OfflineState {
|
||||
syncNow: () => Promise<void>;
|
||||
forceSync: () => Promise<void>;
|
||||
clearPendingChanges: () => Promise<void>;
|
||||
}
|
||||
|
||||
const OfflineContext = createContext<OfflineContextType | undefined>(undefined);
|
||||
|
||||
interface OfflineProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const OfflineProvider: React.FC<OfflineProviderProps> = ({ children }) => {
|
||||
const [state, setState] = useState<OfflineState>({
|
||||
isOnline: true,
|
||||
syncInProgress: false,
|
||||
pendingChanges: 0,
|
||||
lastSyncTime: undefined,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = NetInfo.addEventListener((netState: any) => {
|
||||
const isOnline = netState.isConnected ?? false;
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isOnline
|
||||
}));
|
||||
|
||||
if (isOnline && state.pendingChanges > 0) {
|
||||
syncOfflineData();
|
||||
}
|
||||
});
|
||||
|
||||
loadPendingChanges();
|
||||
|
||||
return () => unsubscribe();
|
||||
}, []);
|
||||
|
||||
const loadPendingChanges = async () => {
|
||||
try {
|
||||
const count = await getPendingChangesCount();
|
||||
setState(prev => ({ ...prev, pendingChanges: count }));
|
||||
} catch (error) {
|
||||
console.error('Error loading pending changes:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const syncNow = async () => {
|
||||
if (!state.isOnline || state.syncInProgress) return;
|
||||
|
||||
setState(prev => ({ ...prev, syncInProgress: true }));
|
||||
|
||||
try {
|
||||
await syncOfflineData();
|
||||
const count = await getPendingChangesCount();
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
syncInProgress: false,
|
||||
pendingChanges: count,
|
||||
lastSyncTime: new Date(),
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Sync error:', error);
|
||||
setState(prev => ({ ...prev, syncInProgress: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const forceSync = async () => {
|
||||
setState(prev => ({ ...prev, syncInProgress: true }));
|
||||
|
||||
try {
|
||||
await syncOfflineData();
|
||||
const count = await getPendingChangesCount();
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
syncInProgress: false,
|
||||
pendingChanges: count,
|
||||
lastSyncTime: new Date(),
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Force sync error:', error);
|
||||
setState(prev => ({ ...prev, syncInProgress: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const clearPendingChanges = async () => {
|
||||
try {
|
||||
setState(prev => ({ ...prev, pendingChanges: 0 }));
|
||||
} catch (error) {
|
||||
console.error('Error clearing pending changes:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const value: OfflineContextType = {
|
||||
...state,
|
||||
syncNow,
|
||||
forceSync,
|
||||
clearPendingChanges,
|
||||
};
|
||||
|
||||
return <OfflineContext.Provider value={value}>{children}</OfflineContext.Provider>;
|
||||
};
|
||||
|
||||
export const useOffline = (): OfflineContextType => {
|
||||
const context = useContext(OfflineContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useOffline must be used within an OfflineProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -0,0 +1,280 @@
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react';
|
||||
import { NetInfoState, useNetInfo } from '@react-native-community/netinfo';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { useServerConfig } from './ServerConfigContext';
|
||||
import { DeviceEventEmitter } from 'react-native';
|
||||
|
||||
interface SyncEvent {
|
||||
id: string;
|
||||
type: 'create' | 'update' | 'delete';
|
||||
entityType: 'bookmark' | 'task' | 'note' | 'timeEntry';
|
||||
entityId: string;
|
||||
data: any;
|
||||
timestamp: number;
|
||||
synced: boolean;
|
||||
}
|
||||
|
||||
interface RealtimeSyncContextType {
|
||||
isOnline: boolean;
|
||||
isSyncing: boolean;
|
||||
pendingEvents: SyncEvent[];
|
||||
lastSyncTime: number | null;
|
||||
syncNow: () => Promise<void>;
|
||||
addSyncEvent: (event: Omit<SyncEvent, 'id' | 'timestamp' | 'synced'>) => Promise<void>;
|
||||
clearPendingEvents: () => Promise<void>;
|
||||
}
|
||||
|
||||
const RealtimeSyncContext = createContext<RealtimeSyncContextType | undefined>(undefined);
|
||||
|
||||
const SYNC_EVENTS_KEY = 'trackeep_sync_events';
|
||||
const LAST_SYNC_KEY = 'trackeep_last_sync';
|
||||
|
||||
interface RealtimeSyncProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const RealtimeSyncProvider: React.FC<RealtimeSyncProviderProps> = ({ children }) => {
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
const [pendingEvents, setPendingEvents] = useState<SyncEvent[]>([]);
|
||||
const [lastSyncTime, setLastSyncTime] = useState<number | null>(null);
|
||||
const [websocket, setWebsocket] = useState<WebSocket | null>(null);
|
||||
|
||||
const netInfo = useNetInfo();
|
||||
const { config } = useServerConfig();
|
||||
|
||||
const isOnline = netInfo.isConnected === true;
|
||||
|
||||
useEffect(() => {
|
||||
loadSyncData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOnline && config && pendingEvents.length > 0) {
|
||||
syncPendingEvents();
|
||||
}
|
||||
}, [isOnline, config, pendingEvents.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOnline && config) {
|
||||
connectWebSocket();
|
||||
} else {
|
||||
disconnectWebSocket();
|
||||
}
|
||||
|
||||
return () => {
|
||||
disconnectWebSocket();
|
||||
};
|
||||
}, [isOnline, config]);
|
||||
|
||||
const loadSyncData = async () => {
|
||||
try {
|
||||
const storedEvents = await AsyncStorage.getItem(SYNC_EVENTS_KEY);
|
||||
const storedLastSync = await AsyncStorage.getItem(LAST_SYNC_KEY);
|
||||
|
||||
if (storedEvents) {
|
||||
const events = JSON.parse(storedEvents);
|
||||
setPendingEvents(events);
|
||||
}
|
||||
|
||||
if (storedLastSync) {
|
||||
setLastSyncTime(JSON.parse(storedLastSync));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading sync data:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const connectWebSocket = () => {
|
||||
if (!config) return;
|
||||
|
||||
try {
|
||||
const wsUrl = config.baseUrl.replace('http', 'ws') + '/ws';
|
||||
const ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('WebSocket connected');
|
||||
setWebsocket(ws);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
handleRealtimeUpdate(data);
|
||||
} catch (error) {
|
||||
console.error('Error parsing WebSocket message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('WebSocket disconnected');
|
||||
setWebsocket(null);
|
||||
// Attempt to reconnect after 5 seconds
|
||||
setTimeout(() => {
|
||||
if (isOnline && config) {
|
||||
connectWebSocket();
|
||||
}
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error connecting WebSocket:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const disconnectWebSocket = () => {
|
||||
if (websocket) {
|
||||
websocket.close();
|
||||
setWebsocket(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRealtimeUpdate = (data: any) => {
|
||||
// This will be handled by individual components through event listeners
|
||||
console.log('Received realtime update:', data);
|
||||
|
||||
// Emit a custom event that components can listen to
|
||||
DeviceEventEmitter.emit('trackeep:sync', data);
|
||||
};
|
||||
|
||||
const addSyncEvent = async (event: Omit<SyncEvent, 'id' | 'timestamp' | 'synced'>) => {
|
||||
const syncEvent: SyncEvent = {
|
||||
...event,
|
||||
id: Date.now().toString() + Math.random().toString(36).substr(2, 9),
|
||||
timestamp: Date.now(),
|
||||
synced: false,
|
||||
};
|
||||
|
||||
const updatedEvents = [...pendingEvents, syncEvent];
|
||||
setPendingEvents(updatedEvents);
|
||||
|
||||
try {
|
||||
await AsyncStorage.setItem(SYNC_EVENTS_KEY, JSON.stringify(updatedEvents));
|
||||
|
||||
// Try to sync immediately if online
|
||||
if (isOnline && config) {
|
||||
await syncPendingEvents();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving sync event:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const syncPendingEvents = async () => {
|
||||
if (!config || isSyncing || pendingEvents.length === 0) return;
|
||||
|
||||
setIsSyncing(true);
|
||||
|
||||
try {
|
||||
const unsyncedEvents = pendingEvents.filter(event => !event.synced);
|
||||
const results = await Promise.allSettled(
|
||||
unsyncedEvents.map(event => syncSingleEvent(event))
|
||||
);
|
||||
|
||||
const successfulEvents: string[] = [];
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === 'fulfilled' && result.value) {
|
||||
successfulEvents.push(unsyncedEvents[index].id);
|
||||
}
|
||||
});
|
||||
|
||||
// Update pending events to mark successful ones as synced
|
||||
const updatedEvents = pendingEvents.map(event => ({
|
||||
...event,
|
||||
synced: successfulEvents.includes(event.id),
|
||||
}));
|
||||
|
||||
// Remove synced events after a delay
|
||||
const finalEvents = updatedEvents.filter(event => !event.synced);
|
||||
setPendingEvents(finalEvents);
|
||||
|
||||
await AsyncStorage.setItem(SYNC_EVENTS_KEY, JSON.stringify(finalEvents));
|
||||
|
||||
// Update last sync time
|
||||
const now = Date.now();
|
||||
setLastSyncTime(now);
|
||||
await AsyncStorage.setItem(LAST_SYNC_KEY, JSON.stringify(now));
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error during sync:', error);
|
||||
} finally {
|
||||
setIsSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const syncSingleEvent = async (event: SyncEvent): Promise<boolean> => {
|
||||
try {
|
||||
const token = await AsyncStorage.getItem('trackeep_auth_token');
|
||||
if (!token || !config) return false;
|
||||
|
||||
const response = await fetch(`${config.baseUrl}/api/sync/${event.entityType}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: event.type,
|
||||
id: event.entityId,
|
||||
data: event.data,
|
||||
timestamp: event.timestamp,
|
||||
}),
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.error('Error syncing single event:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const syncNow = async () => {
|
||||
await syncPendingEvents();
|
||||
};
|
||||
|
||||
const clearPendingEvents = async () => {
|
||||
setPendingEvents([]);
|
||||
try {
|
||||
await AsyncStorage.removeItem(SYNC_EVENTS_KEY);
|
||||
} catch (error) {
|
||||
console.error('Error clearing pending events:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const value: RealtimeSyncContextType = {
|
||||
isOnline,
|
||||
isSyncing,
|
||||
pendingEvents,
|
||||
lastSyncTime,
|
||||
syncNow,
|
||||
addSyncEvent,
|
||||
clearPendingEvents,
|
||||
};
|
||||
|
||||
return (
|
||||
<RealtimeSyncContext.Provider value={value}>
|
||||
{children}
|
||||
</RealtimeSyncContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useRealtimeSync = (): RealtimeSyncContextType => {
|
||||
const context = useContext(RealtimeSyncContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useRealtimeSync must be used within a RealtimeSyncProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
// Hook for components to listen to realtime updates
|
||||
export const useRealtimeUpdates = (callback: (data: any) => void) => {
|
||||
useEffect(() => {
|
||||
const subscription = DeviceEventEmitter.addListener('trackeep:sync', callback);
|
||||
|
||||
return () => {
|
||||
subscription.remove();
|
||||
};
|
||||
}, [callback]);
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
interface ServerConfig {
|
||||
baseUrl: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
interface ServerConfigContextType {
|
||||
config: ServerConfig | null;
|
||||
isConfigured: boolean;
|
||||
setConfig: (config: ServerConfig) => Promise<void>;
|
||||
clearConfig: () => Promise<void>;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const ServerConfigContext = createContext<ServerConfigContextType | undefined>(undefined);
|
||||
|
||||
const SERVER_CONFIG_KEY = 'trackeep_server_config';
|
||||
|
||||
interface ServerConfigProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const ServerConfigProvider: React.FC<ServerConfigProviderProps> = ({ children }) => {
|
||||
const [config, setConfigState] = useState<ServerConfig | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig();
|
||||
}, []);
|
||||
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const storedConfig = await AsyncStorage.getItem(SERVER_CONFIG_KEY);
|
||||
if (storedConfig) {
|
||||
const parsedConfig = JSON.parse(storedConfig);
|
||||
setConfigState(parsedConfig);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading server config:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const setConfig = async (newConfig: ServerConfig) => {
|
||||
try {
|
||||
await AsyncStorage.setItem(SERVER_CONFIG_KEY, JSON.stringify(newConfig));
|
||||
setConfigState(newConfig);
|
||||
} catch (error) {
|
||||
console.error('Error saving server config:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const clearConfig = async () => {
|
||||
try {
|
||||
await AsyncStorage.removeItem(SERVER_CONFIG_KEY);
|
||||
setConfigState(null);
|
||||
} catch (error) {
|
||||
console.error('Error clearing server config:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const value: ServerConfigContextType = {
|
||||
config,
|
||||
isConfigured: !!config,
|
||||
setConfig,
|
||||
clearConfig,
|
||||
isLoading,
|
||||
};
|
||||
|
||||
return (
|
||||
<ServerConfigContext.Provider value={value}>
|
||||
{children}
|
||||
</ServerConfigContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useServerConfig = (): ServerConfigContextType => {
|
||||
const context = useContext(ServerConfigContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useServerConfig must be used within a ServerConfigProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -0,0 +1,208 @@
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import { Alert, Platform, PermissionsAndroid } from 'react-native';
|
||||
import { request, PERMISSIONS, RESULTS } from 'react-native-permissions';
|
||||
import Voice from 'react-native-voice';
|
||||
|
||||
interface VoiceRecording {
|
||||
id: string;
|
||||
path: string;
|
||||
duration: number;
|
||||
transcript?: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
interface VoiceContextType {
|
||||
isRecording: boolean;
|
||||
isProcessing: boolean;
|
||||
hasPermission: boolean;
|
||||
recordings: VoiceRecording[];
|
||||
requestPermission: () => Promise<boolean>;
|
||||
startRecording: () => void;
|
||||
stopRecording: () => Promise<VoiceRecording | null>;
|
||||
transcribeRecording: (recordingPath: string) => Promise<string | null>;
|
||||
deleteRecording: (id: string) => void;
|
||||
}
|
||||
|
||||
const VoiceContext = createContext<VoiceContextType | undefined>(undefined);
|
||||
|
||||
interface VoiceProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const VoiceProvider: React.FC<VoiceProviderProps> = ({ children }) => {
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [hasPermission, setHasPermission] = useState(false);
|
||||
const [recordings, setRecordings] = useState<VoiceRecording[]>([]);
|
||||
const [recordingStartTime, setRecordingStartTime] = useState<Date | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
initializeVoice();
|
||||
return () => {
|
||||
Voice.destroy();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const initializeVoice = async () => {
|
||||
await checkPermission();
|
||||
|
||||
Voice.onSpeechStart = onSpeechStart;
|
||||
Voice.onSpeechEnd = onSpeechEnd;
|
||||
Voice.onSpeechResults = onSpeechResults;
|
||||
Voice.onSpeechError = onSpeechError;
|
||||
};
|
||||
|
||||
const checkPermission = async () => {
|
||||
const permission = Platform.OS === 'ios'
|
||||
? PERMISSIONS.IOS.MICROPHONE
|
||||
: PERMISSIONS.ANDROID.RECORD_AUDIO;
|
||||
|
||||
const result = await request(permission);
|
||||
setHasPermission(result === RESULTS.GRANTED);
|
||||
};
|
||||
|
||||
const requestPermission = async (): Promise<boolean> => {
|
||||
const permission = Platform.OS === 'ios'
|
||||
? PERMISSIONS.IOS.MICROPHONE
|
||||
: PERMISSIONS.ANDROID.RECORD_AUDIO;
|
||||
|
||||
const result = await request(permission);
|
||||
const granted = result === RESULTS.GRANTED;
|
||||
setHasPermission(granted);
|
||||
return granted;
|
||||
};
|
||||
|
||||
const onSpeechStart = () => {
|
||||
setIsRecording(true);
|
||||
setRecordingStartTime(new Date());
|
||||
};
|
||||
|
||||
const onSpeechEnd = () => {
|
||||
setIsRecording(false);
|
||||
setRecordingStartTime(null);
|
||||
};
|
||||
|
||||
const onSpeechResults = (e: any) => {
|
||||
// Handle speech recognition results
|
||||
console.log('Speech results:', e.value);
|
||||
};
|
||||
|
||||
const onSpeechError = (e: any) => {
|
||||
console.error('Speech recognition error:', e);
|
||||
setIsRecording(false);
|
||||
setRecordingStartTime(null);
|
||||
Alert.alert('Recording Error', 'Failed to process voice recording');
|
||||
};
|
||||
|
||||
const startRecording = async () => {
|
||||
if (!hasPermission) {
|
||||
const granted = await requestPermission();
|
||||
if (!granted) {
|
||||
Alert.alert('Permission Required', 'Microphone access is required for voice recording');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
setIsProcessing(true);
|
||||
|
||||
// Start speech recognition
|
||||
await Voice.start('en-US');
|
||||
|
||||
// For actual audio recording, you would integrate with a library like react-native-audio-recorder-player
|
||||
// This is a placeholder for the recording functionality
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error starting recording:', error);
|
||||
Alert.alert('Error', 'Failed to start recording');
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const stopRecording = async (): Promise<VoiceRecording | null> => {
|
||||
if (!isRecording) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsProcessing(true);
|
||||
|
||||
// Stop speech recognition
|
||||
await Voice.stop();
|
||||
|
||||
// Calculate duration
|
||||
const duration = recordingStartTime
|
||||
? Math.floor((new Date().getTime() - recordingStartTime.getTime()) / 1000)
|
||||
: 0;
|
||||
|
||||
// Create recording object (placeholder - actual implementation would save audio file)
|
||||
const recording: VoiceRecording = {
|
||||
id: Date.now().toString(),
|
||||
path: `recording-${Date.now()}.m4a`,
|
||||
duration,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
setRecordings(prev => [...prev, recording]);
|
||||
setIsProcessing(false);
|
||||
|
||||
return recording;
|
||||
} catch (error) {
|
||||
console.error('Error stopping recording:', error);
|
||||
setIsProcessing(false);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const transcribeRecording = async (recordingPath: string): Promise<string | null> => {
|
||||
try {
|
||||
setIsProcessing(true);
|
||||
|
||||
// Start speech recognition for transcription
|
||||
await Voice.start('en-US');
|
||||
|
||||
// This would integrate with a speech-to-text service
|
||||
// For now, return a placeholder
|
||||
const transcript = "Transcribed text from audio recording";
|
||||
|
||||
await Voice.stop();
|
||||
setIsProcessing(false);
|
||||
|
||||
return transcript;
|
||||
} catch (error) {
|
||||
console.error('Error transcribing recording:', error);
|
||||
setIsProcessing(false);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteRecording = (id: string) => {
|
||||
setRecordings(prev => prev.filter(rec => rec.id !== id));
|
||||
};
|
||||
|
||||
const value: VoiceContextType = {
|
||||
isRecording,
|
||||
isProcessing,
|
||||
hasPermission,
|
||||
recordings,
|
||||
requestPermission,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
transcribeRecording,
|
||||
deleteRecording,
|
||||
};
|
||||
|
||||
return (
|
||||
<VoiceContext.Provider value={value}>
|
||||
{children}
|
||||
</VoiceContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useVoice = (): VoiceContextType => {
|
||||
const context = useContext(VoiceContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useVoice must be used within a VoiceProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -0,0 +1,322 @@
|
||||
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
|
||||
import { ApiResponse, User, Bookmark, Task, Note, TimeEntry, CalendarEvent, SearchFilters, SavedSearch } from '../types';
|
||||
import { getStoredAuthData } from '../utils/storage';
|
||||
import { useServerConfig } from './ServerConfigContext';
|
||||
|
||||
let API_BASE_URL = __DEV__
|
||||
? 'http://localhost:8080/api'
|
||||
: 'https://trackeep.app/api';
|
||||
|
||||
class APIClient {
|
||||
private client: AxiosInstance;
|
||||
|
||||
constructor() {
|
||||
this.client = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
this.setupInterceptors();
|
||||
}
|
||||
|
||||
updateBaseURL(newBaseURL: string) {
|
||||
API_BASE_URL = newBaseURL;
|
||||
this.client.defaults.baseURL = newBaseURL;
|
||||
}
|
||||
|
||||
private setupInterceptors() {
|
||||
this.client.interceptors.request.use(
|
||||
async (config) => {
|
||||
const authData = await getStoredAuthData();
|
||||
if (authData && authData.token) {
|
||||
config.headers.Authorization = `Bearer ${authData.token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
this.client.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
if (error.response?.status === 401) {
|
||||
await this.handleUnauthorized();
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private async handleUnauthorized() {
|
||||
try {
|
||||
const { clearAuthData } = await import('../utils/storage');
|
||||
await clearAuthData();
|
||||
} catch (error) {
|
||||
console.error('Error handling unauthorized:', error);
|
||||
}
|
||||
}
|
||||
|
||||
public async request<T>(config: AxiosRequestConfig): Promise<ApiResponse<T>> {
|
||||
try {
|
||||
const response = await this.client.request(config);
|
||||
return {
|
||||
success: true,
|
||||
data: response.data,
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.message || error.message || 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const apiClient = new APIClient();
|
||||
|
||||
export const updateAPIBaseURL = (newBaseURL: string) => {
|
||||
apiClient.updateBaseURL(newBaseURL);
|
||||
};
|
||||
|
||||
export const authAPI = {
|
||||
login: async (email: string, password: string): Promise<ApiResponse<{ token: string; user: User }>> => {
|
||||
return apiClient.request({
|
||||
method: 'POST',
|
||||
url: '/auth/login',
|
||||
data: { email, password },
|
||||
});
|
||||
},
|
||||
|
||||
loginWithGitHub: async (): Promise<ApiResponse<{ token: string; user: User }>> => {
|
||||
return apiClient.request({
|
||||
method: 'POST',
|
||||
url: '/auth/github',
|
||||
});
|
||||
},
|
||||
|
||||
getCurrentUser: async (token: string): Promise<ApiResponse<User>> => {
|
||||
return apiClient.request({
|
||||
method: 'GET',
|
||||
url: '/auth/me',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
},
|
||||
|
||||
updateUser: async (userId: string, updates: Partial<User>): Promise<ApiResponse<User>> => {
|
||||
return apiClient.request({
|
||||
method: 'PUT',
|
||||
url: `/users/${userId}`,
|
||||
data: updates,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const bookmarksAPI = {
|
||||
getBookmarks: async (filters?: Partial<SearchFilters>): Promise<ApiResponse<Bookmark[]>> => {
|
||||
return apiClient.request({
|
||||
method: 'GET',
|
||||
url: '/bookmarks',
|
||||
params: filters,
|
||||
});
|
||||
},
|
||||
|
||||
createBookmark: async (bookmark: Omit<Bookmark, 'id' | 'createdAt' | 'updatedAt'>): Promise<ApiResponse<Bookmark>> => {
|
||||
return apiClient.request({
|
||||
method: 'POST',
|
||||
url: '/bookmarks',
|
||||
data: bookmark,
|
||||
});
|
||||
},
|
||||
|
||||
updateBookmark: async (id: string, updates: Partial<Bookmark>): Promise<ApiResponse<Bookmark>> => {
|
||||
return apiClient.request({
|
||||
method: 'PUT',
|
||||
url: `/bookmarks/${id}`,
|
||||
data: updates,
|
||||
});
|
||||
},
|
||||
|
||||
deleteBookmark: async (id: string): Promise<ApiResponse<void>> => {
|
||||
return apiClient.request({
|
||||
method: 'DELETE',
|
||||
url: `/bookmarks/${id}`,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const tasksAPI = {
|
||||
getTasks: async (filters?: Partial<SearchFilters>): Promise<ApiResponse<Task[]>> => {
|
||||
return apiClient.request({
|
||||
method: 'GET',
|
||||
url: '/tasks',
|
||||
params: filters,
|
||||
});
|
||||
},
|
||||
|
||||
createTask: async (task: Omit<Task, 'id' | 'createdAt' | 'updatedAt'>): Promise<ApiResponse<Task>> => {
|
||||
return apiClient.request({
|
||||
method: 'POST',
|
||||
url: '/tasks',
|
||||
data: task,
|
||||
});
|
||||
},
|
||||
|
||||
updateTask: async (id: string, updates: Partial<Task>): Promise<ApiResponse<Task>> => {
|
||||
return apiClient.request({
|
||||
method: 'PUT',
|
||||
url: `/tasks/${id}`,
|
||||
data: updates,
|
||||
});
|
||||
},
|
||||
|
||||
deleteTask: async (id: string): Promise<ApiResponse<void>> => {
|
||||
return apiClient.request({
|
||||
method: 'DELETE',
|
||||
url: `/tasks/${id}`,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const notesAPI = {
|
||||
getNotes: async (filters?: Partial<SearchFilters>): Promise<ApiResponse<Note[]>> => {
|
||||
return apiClient.request({
|
||||
method: 'GET',
|
||||
url: '/notes',
|
||||
params: filters,
|
||||
});
|
||||
},
|
||||
|
||||
createNote: async (note: Omit<Note, 'id' | 'createdAt' | 'updatedAt'>): Promise<ApiResponse<Note>> => {
|
||||
return apiClient.request({
|
||||
method: 'POST',
|
||||
url: '/notes',
|
||||
data: note,
|
||||
});
|
||||
},
|
||||
|
||||
updateNote: async (id: string, updates: Partial<Note>): Promise<ApiResponse<Note>> => {
|
||||
return apiClient.request({
|
||||
method: 'PUT',
|
||||
url: `/notes/${id}`,
|
||||
data: updates,
|
||||
});
|
||||
},
|
||||
|
||||
deleteNote: async (id: string): Promise<ApiResponse<void>> => {
|
||||
return apiClient.request({
|
||||
method: 'DELETE',
|
||||
url: `/notes/${id}`,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const timeEntriesAPI = {
|
||||
getTimeEntries: async (filters?: any): Promise<ApiResponse<TimeEntry[]>> => {
|
||||
return apiClient.request({
|
||||
method: 'GET',
|
||||
url: '/time-entries',
|
||||
params: filters,
|
||||
});
|
||||
},
|
||||
|
||||
createTimeEntry: async (entry: Omit<TimeEntry, 'id' | 'createdAt'>): Promise<ApiResponse<TimeEntry>> => {
|
||||
return apiClient.request({
|
||||
method: 'POST',
|
||||
url: '/time-entries',
|
||||
data: entry,
|
||||
});
|
||||
},
|
||||
|
||||
updateTimeEntry: async (id: string, updates: Partial<TimeEntry>): Promise<ApiResponse<TimeEntry>> => {
|
||||
return apiClient.request({
|
||||
method: 'PUT',
|
||||
url: `/time-entries/${id}`,
|
||||
data: updates,
|
||||
});
|
||||
},
|
||||
|
||||
deleteTimeEntry: async (id: string): Promise<ApiResponse<void>> => {
|
||||
return apiClient.request({
|
||||
method: 'DELETE',
|
||||
url: `/time-entries/${id}`,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const searchAPI = {
|
||||
search: async (filters: SearchFilters): Promise<ApiResponse<any>> => {
|
||||
return apiClient.request({
|
||||
method: 'POST',
|
||||
url: '/search',
|
||||
data: filters,
|
||||
});
|
||||
},
|
||||
|
||||
getSavedSearches: async (): Promise<ApiResponse<SavedSearch[]>> => {
|
||||
return apiClient.request({
|
||||
method: 'GET',
|
||||
url: '/search/saved',
|
||||
});
|
||||
},
|
||||
|
||||
createSavedSearch: async (search: Omit<SavedSearch, 'id' | 'createdAt' | 'updatedAt'>): Promise<ApiResponse<SavedSearch>> => {
|
||||
return apiClient.request({
|
||||
method: 'POST',
|
||||
url: '/search/saved',
|
||||
data: search,
|
||||
});
|
||||
},
|
||||
|
||||
updateSavedSearch: async (id: string, updates: Partial<SavedSearch>): Promise<ApiResponse<SavedSearch>> => {
|
||||
return apiClient.request({
|
||||
method: 'PUT',
|
||||
url: `/search/saved/${id}`,
|
||||
data: updates,
|
||||
});
|
||||
},
|
||||
|
||||
deleteSavedSearch: async (id: string): Promise<ApiResponse<void>> => {
|
||||
return apiClient.request({
|
||||
method: 'DELETE',
|
||||
url: `/search/saved/${id}`,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const calendarAPI = {
|
||||
getEvents: async (filters?: any): Promise<ApiResponse<CalendarEvent[]>> => {
|
||||
return apiClient.request({
|
||||
method: 'GET',
|
||||
url: '/calendar/events',
|
||||
params: filters,
|
||||
});
|
||||
},
|
||||
|
||||
createEvent: async (event: Omit<CalendarEvent, 'id'>): Promise<ApiResponse<CalendarEvent>> => {
|
||||
return apiClient.request({
|
||||
method: 'POST',
|
||||
url: '/calendar/events',
|
||||
data: event,
|
||||
});
|
||||
},
|
||||
|
||||
updateEvent: async (id: string, updates: Partial<CalendarEvent>): Promise<ApiResponse<CalendarEvent>> => {
|
||||
return apiClient.request({
|
||||
method: 'PUT',
|
||||
url: `/calendar/events/${id}`,
|
||||
data: updates,
|
||||
});
|
||||
},
|
||||
|
||||
deleteEvent: async (id: string): Promise<ApiResponse<void>> => {
|
||||
return apiClient.request({
|
||||
method: 'DELETE',
|
||||
url: `/calendar/events/${id}`,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
avatar?: string;
|
||||
githubUsername?: string;
|
||||
preferences: UserPreferences;
|
||||
}
|
||||
|
||||
export interface UserPreferences {
|
||||
theme: 'light' | 'dark' | 'auto';
|
||||
notifications: boolean;
|
||||
syncEnabled: boolean;
|
||||
language: string;
|
||||
}
|
||||
|
||||
export interface Bookmark {
|
||||
id: string;
|
||||
title: string;
|
||||
url: string;
|
||||
description?: string;
|
||||
tags: string[];
|
||||
isFavorite: boolean;
|
||||
isRead: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
content?: string;
|
||||
thumbnail?: string;
|
||||
}
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
status: 'todo' | 'in_progress' | 'completed' | 'cancelled';
|
||||
priority: 'low' | 'medium' | 'high' | 'urgent';
|
||||
dueDate?: Date;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
tags: string[];
|
||||
estimatedTime?: number;
|
||||
actualTime?: number;
|
||||
}
|
||||
|
||||
export interface Note {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
tags: string[];
|
||||
isPublic: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
parentId?: string;
|
||||
children?: Note[];
|
||||
}
|
||||
|
||||
export interface TimeEntry {
|
||||
id: string;
|
||||
taskId?: string;
|
||||
bookmarkId?: string;
|
||||
noteId?: string;
|
||||
startTime: Date;
|
||||
endTime?: Date;
|
||||
duration?: number;
|
||||
description: string;
|
||||
tags: string[];
|
||||
billable: boolean;
|
||||
hourlyRate?: number;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface CalendarEvent {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
type: 'task' | 'meeting' | 'deadline' | 'reminder' | 'habit';
|
||||
priority: 'low' | 'medium' | 'high' | 'urgent';
|
||||
location?: string;
|
||||
attendees?: string[];
|
||||
recurring?: RecurrenceRule;
|
||||
source: 'trackeep' | 'google' | 'outlook' | 'manual';
|
||||
}
|
||||
|
||||
export interface RecurrenceRule {
|
||||
frequency: 'daily' | 'weekly' | 'monthly' | 'yearly';
|
||||
interval: number;
|
||||
endDate?: Date;
|
||||
daysOfWeek?: number[];
|
||||
}
|
||||
|
||||
export interface SearchFilters {
|
||||
query: string;
|
||||
contentType: 'all' | 'bookmarks' | 'tasks' | 'notes' | 'files';
|
||||
tags: string[];
|
||||
dateRange: { start: Date; end: Date };
|
||||
author: string;
|
||||
language: string;
|
||||
fileTypes: string[];
|
||||
isFavorite: boolean;
|
||||
isRead: boolean;
|
||||
searchMode: 'fulltext' | 'semantic' | 'hybrid';
|
||||
threshold: number;
|
||||
}
|
||||
|
||||
export interface SavedSearch {
|
||||
id: string;
|
||||
name: string;
|
||||
query: string;
|
||||
filters: SearchFilters;
|
||||
alert: boolean;
|
||||
lastRun?: Date;
|
||||
runCount: number;
|
||||
isPublic: boolean;
|
||||
description?: string;
|
||||
tags: string[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface NavigationState {
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
user?: User;
|
||||
}
|
||||
|
||||
export interface OfflineState {
|
||||
isOnline: boolean;
|
||||
syncInProgress: boolean;
|
||||
pendingChanges: number;
|
||||
lastSyncTime?: Date;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
declare module 'react-native-push-notification' {
|
||||
export interface PushNotificationPermissions {
|
||||
alert?: boolean;
|
||||
badge?: boolean;
|
||||
sound?: boolean;
|
||||
}
|
||||
|
||||
export interface PushNotification {
|
||||
configure(options: {
|
||||
onRegister?: (token: any) => void;
|
||||
onNotification?: (notification: any) => void;
|
||||
permissions?: PushNotificationPermissions;
|
||||
popInitialNotification?: boolean;
|
||||
requestPermissions?: boolean;
|
||||
}): void;
|
||||
|
||||
requestPermissions(callback?: (permissions: PushNotificationPermissions) => void): void;
|
||||
checkPermissions(callback?: (permissions: PushNotificationPermissions) => void): void;
|
||||
|
||||
localNotification(details: {
|
||||
channelId?: string;
|
||||
id?: number;
|
||||
title?: string;
|
||||
message?: string;
|
||||
userInfo?: any;
|
||||
actions?: string[];
|
||||
}): void;
|
||||
|
||||
localNotificationSchedule(details: {
|
||||
channelId?: string;
|
||||
id?: number;
|
||||
title?: string;
|
||||
message?: string;
|
||||
date: Date;
|
||||
userInfo?: any;
|
||||
actions?: string[];
|
||||
allowWhileIdle?: boolean;
|
||||
}): void;
|
||||
|
||||
cancelLocalNotifications(details: { id: string }): void;
|
||||
cancelAllLocalNotifications(): void;
|
||||
|
||||
createChannel(channelId: string, channelName: string, importance: number, callback?: (created: any) => void): void;
|
||||
createChannelImportance(channelId: string, channelName: string, importance: number, callback?: (created: any) => void): void;
|
||||
}
|
||||
|
||||
const PushNotification: PushNotification;
|
||||
export default PushNotification;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
declare module 'react-native-voice' {
|
||||
export interface VoiceResults {
|
||||
value?: string[];
|
||||
error?: boolean;
|
||||
isFinal?: boolean;
|
||||
}
|
||||
|
||||
export default class Voice {
|
||||
static isAvailable(): Promise<boolean>;
|
||||
static start(locale?: string): Promise<void>;
|
||||
static stop(): Promise<void>;
|
||||
static destroy(): Promise<void>;
|
||||
static onSpeechStart?: (e: any) => void;
|
||||
static onSpeechEnd?: (e: any) => void;
|
||||
static onSpeechResults?: (e: VoiceResults) => void;
|
||||
static onSpeechError?: (e: any) => void;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useNotifications } from '../services/NotificationContext';
|
||||
|
||||
export class NotificationUtils {
|
||||
private static notifications = useNotifications();
|
||||
|
||||
static scheduleTaskReminder(taskId: string, taskTitle: string, dueDate: Date) {
|
||||
const reminderTime = new Date(dueDate.getTime() - 24 * 60 * 60 * 1000); // 1 day before
|
||||
const now = new Date();
|
||||
|
||||
if (reminderTime > now) {
|
||||
this.notifications.scheduleNotification({
|
||||
id: `task-reminder-${taskId}`,
|
||||
title: 'Task Due Soon',
|
||||
message: `Task "${taskTitle}" is due tomorrow`,
|
||||
date: reminderTime,
|
||||
userInfo: { type: 'task', taskId },
|
||||
});
|
||||
}
|
||||
|
||||
// Schedule final reminder 1 hour before
|
||||
const finalReminder = new Date(dueDate.getTime() - 60 * 60 * 1000);
|
||||
if (finalReminder > now) {
|
||||
this.notifications.scheduleNotification({
|
||||
id: `task-final-${taskId}`,
|
||||
title: 'Task Due Soon',
|
||||
message: `Task "${taskTitle}" is due in 1 hour`,
|
||||
date: finalReminder,
|
||||
userInfo: { type: 'task', taskId },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static scheduleDeadlineReminder(taskId: string, taskTitle: string, deadline: Date) {
|
||||
const reminderTimes = [
|
||||
{ days: 7, message: 'due in 1 week' },
|
||||
{ days: 3, message: 'due in 3 days' },
|
||||
{ days: 1, message: 'due tomorrow' },
|
||||
{ hours: 1, message: 'due in 1 hour' },
|
||||
];
|
||||
|
||||
const now = new Date();
|
||||
|
||||
reminderTimes.forEach((reminder, index) => {
|
||||
let reminderTime: Date;
|
||||
|
||||
if (reminder.days) {
|
||||
reminderTime = new Date(deadline.getTime() - reminder.days * 24 * 60 * 60 * 1000);
|
||||
} else if (reminder.hours) {
|
||||
reminderTime = new Date(deadline.getTime() - reminder.hours * 60 * 60 * 1000);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
if (reminderTime > now) {
|
||||
this.notifications.scheduleNotification({
|
||||
id: `deadline-${taskId}-${index}`,
|
||||
title: 'Deadline Reminder',
|
||||
message: `Task "${taskTitle}" ${reminder.message}`,
|
||||
date: reminderTime,
|
||||
userInfo: { type: 'deadline', taskId },
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static scheduleStudyReminder(courseId: string, courseTitle: string, studyTime: Date) {
|
||||
this.notifications.scheduleNotification({
|
||||
id: `study-${courseId}`,
|
||||
title: 'Study Reminder',
|
||||
message: `Time to study "${courseTitle}"`,
|
||||
date: studyTime,
|
||||
userInfo: { type: 'study', courseId },
|
||||
});
|
||||
}
|
||||
|
||||
static cancelTaskNotifications(taskId: string) {
|
||||
this.notifications.cancelNotification(`task-reminder-${taskId}`);
|
||||
this.notifications.cancelNotification(`task-final-${taskId}`);
|
||||
|
||||
// Cancel deadline notifications
|
||||
for (let i = 0; i < 4; i++) {
|
||||
this.notifications.cancelNotification(`deadline-${taskId}-${i}`);
|
||||
}
|
||||
}
|
||||
|
||||
static showTaskCompletedNotification(taskTitle: string) {
|
||||
this.notifications.showLocalNotification(
|
||||
'Task Completed! 🎉',
|
||||
`Great job! You completed "${taskTitle}"`
|
||||
);
|
||||
}
|
||||
|
||||
static showTimeTrackingReminder() {
|
||||
this.notifications.showLocalNotification(
|
||||
'Time Tracking Reminder',
|
||||
'Don\'t forget to track your time on current tasks'
|
||||
);
|
||||
}
|
||||
|
||||
static showDailySummaryNotification(completedTasks: number, totalHours: number) {
|
||||
this.notifications.showLocalNotification(
|
||||
'Daily Summary 📊',
|
||||
`Completed ${completedTasks} tasks, tracked ${totalHours.toFixed(1)} hours today`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { getOfflineData, clearOfflineChanges, addOfflineChange } from './storage';
|
||||
import { authAPI, bookmarksAPI, tasksAPI, notesAPI, timeEntriesAPI } from '../services/api';
|
||||
|
||||
interface OfflineChange {
|
||||
id: string;
|
||||
type: 'create' | 'update' | 'delete';
|
||||
entityType: 'bookmark' | 'task' | 'note' | 'timeEntry';
|
||||
data: any;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export const getPendingChangesCount = async (): Promise<number> => {
|
||||
try {
|
||||
const changes = await getOfflineData('OFFLINE_CHANGES') as OfflineChange[];
|
||||
return changes.length;
|
||||
} catch (error) {
|
||||
console.error('Error getting pending changes count:', error);
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
export const syncOfflineData = async (): Promise<void> => {
|
||||
try {
|
||||
const changes = await getOfflineData('OFFLINE_CHANGES') as OfflineChange[];
|
||||
|
||||
for (const change of changes) {
|
||||
try {
|
||||
await processChange(change);
|
||||
} catch (error) {
|
||||
console.error(`Error processing change ${change.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
await clearOfflineChanges();
|
||||
} catch (error) {
|
||||
console.error('Sync error:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const processChange = async (change: OfflineChange): Promise<void> => {
|
||||
switch (change.entityType) {
|
||||
case 'bookmark':
|
||||
await processBookmarkChange(change);
|
||||
break;
|
||||
case 'task':
|
||||
await processTaskChange(change);
|
||||
break;
|
||||
case 'note':
|
||||
await processNoteChange(change);
|
||||
break;
|
||||
case 'timeEntry':
|
||||
await processTimeEntryChange(change);
|
||||
break;
|
||||
default:
|
||||
console.warn(`Unknown entity type: ${change.entityType}`);
|
||||
}
|
||||
};
|
||||
|
||||
const processBookmarkChange = async (change: OfflineChange): Promise<void> => {
|
||||
switch (change.type) {
|
||||
case 'create':
|
||||
await bookmarksAPI.createBookmark(change.data);
|
||||
break;
|
||||
case 'update':
|
||||
await bookmarksAPI.updateBookmark(change.data.id, change.data);
|
||||
break;
|
||||
case 'delete':
|
||||
await bookmarksAPI.deleteBookmark(change.data.id);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const processTaskChange = async (change: OfflineChange): Promise<void> => {
|
||||
switch (change.type) {
|
||||
case 'create':
|
||||
await tasksAPI.createTask(change.data);
|
||||
break;
|
||||
case 'update':
|
||||
await tasksAPI.updateTask(change.data.id, change.data);
|
||||
break;
|
||||
case 'delete':
|
||||
await tasksAPI.deleteTask(change.data.id);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const processNoteChange = async (change: OfflineChange): Promise<void> => {
|
||||
switch (change.type) {
|
||||
case 'create':
|
||||
await notesAPI.createNote(change.data);
|
||||
break;
|
||||
case 'update':
|
||||
await notesAPI.updateNote(change.data.id, change.data);
|
||||
break;
|
||||
case 'delete':
|
||||
await notesAPI.deleteNote(change.data.id);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const processTimeEntryChange = async (change: OfflineChange): Promise<void> => {
|
||||
switch (change.type) {
|
||||
case 'create':
|
||||
await timeEntriesAPI.createTimeEntry(change.data);
|
||||
break;
|
||||
case 'update':
|
||||
await timeEntriesAPI.updateTimeEntry(change.data.id, change.data);
|
||||
break;
|
||||
case 'delete':
|
||||
await timeEntriesAPI.deleteTimeEntry(change.data.id);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
export const queueOfflineChange = async (
|
||||
type: 'create' | 'update' | 'delete',
|
||||
entityType: 'bookmark' | 'task' | 'note' | 'timeEntry',
|
||||
data: any
|
||||
): Promise<void> => {
|
||||
await addOfflineChange({
|
||||
type,
|
||||
entityType,
|
||||
data,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,168 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { User } from '../types';
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
AUTH_TOKEN: '@trackeep_auth_token',
|
||||
USER_DATA: '@trackeep_user_data',
|
||||
THEME: '@trackeep_theme',
|
||||
BOOKMARKS: '@trackeep_bookmarks',
|
||||
TASKS: '@trackeep_tasks',
|
||||
NOTES: '@trackeep_notes',
|
||||
TIME_ENTRIES: '@trackeep_time_entries',
|
||||
OFFLINE_CHANGES: '@trackeep_offline_changes',
|
||||
SEARCH_HISTORY: '@trackeep_search_history',
|
||||
SAVED_SEARCHES: '@trackeep_saved_searches',
|
||||
} as const;
|
||||
|
||||
export interface StoredAuthData {
|
||||
token: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export const storeAuthData = async (data: StoredAuthData): Promise<void> => {
|
||||
try {
|
||||
await AsyncStorage.multiSet([
|
||||
[STORAGE_KEYS.AUTH_TOKEN, data.token],
|
||||
[STORAGE_KEYS.USER_DATA, JSON.stringify(data.user)],
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('Error storing auth data:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getStoredAuthData = async (): Promise<StoredAuthData | null> => {
|
||||
try {
|
||||
const [token, userData] = await AsyncStorage.multiGet([
|
||||
STORAGE_KEYS.AUTH_TOKEN,
|
||||
STORAGE_KEYS.USER_DATA,
|
||||
]);
|
||||
|
||||
if (token[1] && userData[1]) {
|
||||
return {
|
||||
token: token[1],
|
||||
user: JSON.parse(userData[1]),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error getting stored auth data:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const clearAuthData = async (): Promise<void> => {
|
||||
try {
|
||||
await AsyncStorage.multiRemove([
|
||||
STORAGE_KEYS.AUTH_TOKEN,
|
||||
STORAGE_KEYS.USER_DATA,
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('Error clearing auth data:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const loadTheme = async (): Promise<'light' | 'dark'> => {
|
||||
try {
|
||||
const theme = await AsyncStorage.getItem(STORAGE_KEYS.THEME);
|
||||
return theme === 'dark' ? 'dark' : 'light';
|
||||
} catch (error) {
|
||||
console.error('Error loading theme:', error);
|
||||
return 'light';
|
||||
}
|
||||
};
|
||||
|
||||
export const saveTheme = async (theme: 'light' | 'dark'): Promise<void> => {
|
||||
try {
|
||||
await AsyncStorage.setItem(STORAGE_KEYS.THEME, theme);
|
||||
} catch (error) {
|
||||
console.error('Error saving theme:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const storeOfflineData = async <T>(key: keyof typeof STORAGE_KEYS, data: T[]): Promise<void> => {
|
||||
try {
|
||||
await AsyncStorage.setItem(STORAGE_KEYS[key], JSON.stringify(data));
|
||||
} catch (error) {
|
||||
console.error(`Error storing offline data for ${key}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getOfflineData = async <T>(key: keyof typeof STORAGE_KEYS): Promise<T[]> => {
|
||||
try {
|
||||
const data = await AsyncStorage.getItem(STORAGE_KEYS[key]);
|
||||
return data ? JSON.parse(data) : [];
|
||||
} catch (error) {
|
||||
console.error(`Error getting offline data for ${key}:`, error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const addOfflineChange = async (change: any): Promise<void> => {
|
||||
try {
|
||||
const existingChanges = await getOfflineData('OFFLINE_CHANGES');
|
||||
existingChanges.push({
|
||||
...change,
|
||||
id: Date.now().toString(),
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
await storeOfflineData('OFFLINE_CHANGES', existingChanges);
|
||||
} catch (error) {
|
||||
console.error('Error adding offline change:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const clearOfflineChanges = async (): Promise<void> => {
|
||||
try {
|
||||
await AsyncStorage.removeItem(STORAGE_KEYS.OFFLINE_CHANGES);
|
||||
} catch (error) {
|
||||
console.error('Error clearing offline changes:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getPendingChangesCount = async (): Promise<number> => {
|
||||
try {
|
||||
const changes = await getOfflineData('OFFLINE_CHANGES');
|
||||
return changes.length;
|
||||
} catch (error) {
|
||||
console.error('Error getting pending changes count:', error);
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
export const storeSearchHistory = async (query: string): Promise<void> => {
|
||||
try {
|
||||
const history = await getOfflineData('SEARCH_HISTORY');
|
||||
const filteredHistory = (history as string[]).filter((item: string) => item !== query);
|
||||
filteredHistory.unshift(query);
|
||||
const limitedHistory = filteredHistory.slice(0, 50);
|
||||
await storeOfflineData('SEARCH_HISTORY', limitedHistory);
|
||||
} catch (error) {
|
||||
console.error('Error storing search history:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getSearchHistory = async (): Promise<string[]> => {
|
||||
try {
|
||||
return await getOfflineData('SEARCH_HISTORY');
|
||||
} catch (error) {
|
||||
console.error('Error getting search history:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const clearAllData = async (): Promise<void> => {
|
||||
try {
|
||||
await AsyncStorage.multiRemove(Object.values(STORAGE_KEYS));
|
||||
} catch (error) {
|
||||
console.error('Error clearing all data:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"extends": "@tsconfig/react-native/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"lib": ["es2017", "es2018", "es2019"],
|
||||
"moduleResolution": "node",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"target": "esnext",
|
||||
"baseUrl": "./src",
|
||||
"paths": {
|
||||
"@/*": ["*"]
|
||||
},
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*",
|
||||
"index.js",
|
||||
"App.tsx"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"babel.config.js",
|
||||
"metro.config.js",
|
||||
"jest.config.js"
|
||||
]
|
||||
}
|
||||