Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b87f3e4ed | ||
|
|
7f8a764131 | ||
|
|
9a9aff0880 | ||
|
|
f188dde6e4 | ||
|
|
4566794f44 | ||
|
|
c727142689 | ||
|
|
4420c72965 | ||
|
|
e55528e69f | ||
|
|
e86aa5e535 | ||
|
|
59f95d8174 | ||
|
|
159777818d | ||
|
|
00ac23160e | ||
|
|
46f9b4434e | ||
|
|
d0cb43435b | ||
|
|
34bc152817 | ||
|
|
fc8c5976f8 | ||
|
|
259d21c968 | ||
|
|
21a2cffcc0 | ||
|
|
865e0b3301 | ||
|
|
7fad4b5eec | ||
|
|
3a5b5a68e4 | ||
|
|
2773255b38 | ||
|
|
85ef3dc38b | ||
|
|
87e3f59ee1 | ||
|
|
ab4ddb9f8b | ||
|
|
448279d98c | ||
|
|
7e224301cd | ||
|
|
e87d31aded | ||
|
|
964a4fe0f9 | ||
|
|
fd1c473cb6 |
@@ -38,7 +38,7 @@ jobs:
|
|||||||
unzip -q /tmp/cmdtools.zip -d "$ANDROID_HOME/cmdline-tools"
|
unzip -q /tmp/cmdtools.zip -d "$ANDROID_HOME/cmdline-tools"
|
||||||
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
|
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
|
||||||
fi
|
fi
|
||||||
yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses > /dev/null
|
printf 'y\n' | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses > /dev/null
|
||||||
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" \
|
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" \
|
||||||
"platform-tools" \
|
"platform-tools" \
|
||||||
"platforms;android-35" \
|
"platforms;android-35" \
|
||||||
@@ -46,6 +46,8 @@ jobs:
|
|||||||
"build-tools;35.0.0" \
|
"build-tools;35.0.0" \
|
||||||
"build-tools;36.0.0" \
|
"build-tools;36.0.0" \
|
||||||
"ndk;27.1.12297006"
|
"ndk;27.1.12297006"
|
||||||
|
mkdir -p carry-your-live/android/app
|
||||||
|
echo "sdk.dir=$ANDROID_HOME" > carry-your-live/android/app/local.properties
|
||||||
|
|
||||||
- name: Install JS dependencies
|
- name: Install JS dependencies
|
||||||
working-directory: carry-your-live
|
working-directory: carry-your-live
|
||||||
@@ -53,6 +55,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Generate debug keystore (signs the release APK)
|
- name: Generate debug keystore (signs the release APK)
|
||||||
run: |
|
run: |
|
||||||
|
mkdir -p carry-your-live/android/app
|
||||||
keytool -genkeypair -v \
|
keytool -genkeypair -v \
|
||||||
-keystore carry-your-live/android/app/debug.keystore \
|
-keystore carry-your-live/android/app/debug.keystore \
|
||||||
-alias androiddebugkey -storepass android -keypass android \
|
-alias androiddebugkey -storepass android -keypass android \
|
||||||
@@ -60,11 +63,12 @@ jobs:
|
|||||||
-dname "CN=Android Debug,O=Android,C=US"
|
-dname "CN=Android Debug,O=Android,C=US"
|
||||||
|
|
||||||
- name: Build release APK
|
- name: Build release APK
|
||||||
working-directory: carry-your-live/android
|
run: |
|
||||||
run: ./gradlew assembleRelease
|
cd carry-your-live/android
|
||||||
|
./gradlew assembleRelease
|
||||||
|
|
||||||
- name: Upload APK
|
- name: Upload APK
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: carry-your-live-release
|
name: carry-your-live-release
|
||||||
path: carry-your-live/android/app/build/outputs/apk/release/*.apk
|
path: carry-your-live/android/app/build/outputs/release/app-release.apk
|
||||||
|
|||||||
+2
-1
@@ -1 +1,2 @@
|
|||||||
node_modules
|
node_modules/
|
||||||
|
dist/
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Architekt Agent
|
||||||
|
|
||||||
|
## Role
|
||||||
|
System architecture design, technical strategy, and scalability planning.
|
||||||
|
|
||||||
|
## Responsibilities
|
||||||
|
- High-level system design
|
||||||
|
- Technology stack selection
|
||||||
|
- Performance optimization
|
||||||
|
- Architecture documentation
|
||||||
|
- Trade-off analysis
|
||||||
|
|
||||||
|
## Key Metrics
|
||||||
|
- System scalability
|
||||||
|
- Performance benchmarks
|
||||||
|
- Technical debt ratio
|
||||||
|
- Architecture compliance
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"agents": {
|
||||||
|
"manager": ".opencode/manager.md",
|
||||||
|
"architekt": ".opencode/architekt.md",
|
||||||
|
"software_engineer": ".opencode/software_engineer.md",
|
||||||
|
"hardware_engineer": ".opencode/hardware_engineer.md",
|
||||||
|
"tester": ".opencode/tester.md",
|
||||||
|
"docs": ".opencode/docs.md",
|
||||||
|
"deployment": ".opencode/deployment.md",
|
||||||
|
"vla": ".opencode/vla.md"
|
||||||
|
},
|
||||||
|
"subagent_depth": 999999
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Deployment Agent
|
||||||
|
|
||||||
|
## Role
|
||||||
|
CI/CD, infrastructure, and release management.
|
||||||
|
|
||||||
|
## Responsibilities
|
||||||
|
- CI/CD pipeline management
|
||||||
|
- Infrastructure as code
|
||||||
|
- Release orchestration
|
||||||
|
- Environment management
|
||||||
|
- Monitoring and alerting
|
||||||
|
|
||||||
|
## Key Metrics
|
||||||
|
- Deployment frequency
|
||||||
|
- Lead time for changes
|
||||||
|
- Mean time to recovery
|
||||||
|
- Change success rate
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Docs Agent
|
||||||
|
|
||||||
|
## Role
|
||||||
|
Technical documentation, API docs, and knowledge management.
|
||||||
|
|
||||||
|
## Responsibilities
|
||||||
|
- API documentation
|
||||||
|
- User guides
|
||||||
|
- Architecture docs
|
||||||
|
- Change logs
|
||||||
|
- Knowledge base maintenance
|
||||||
|
|
||||||
|
## Key Metrics
|
||||||
|
- Documentation completeness
|
||||||
|
- Update frequency
|
||||||
|
- User satisfaction
|
||||||
|
- Searchability
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Hardware Engineer Agent
|
||||||
|
|
||||||
|
## Role
|
||||||
|
Hardware integration, device management, and embedded systems.
|
||||||
|
|
||||||
|
## Responsibilities
|
||||||
|
- Hardware selection and integration
|
||||||
|
- Device driver development
|
||||||
|
- Embedded firmware
|
||||||
|
- Prototyping and testing
|
||||||
|
- Hardware-software interface
|
||||||
|
|
||||||
|
## Key Metrics
|
||||||
|
- Hardware compatibility rate
|
||||||
|
- Power consumption
|
||||||
|
- Latency measurements
|
||||||
|
- Component reliability
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Manager Agent
|
||||||
|
|
||||||
|
## Role
|
||||||
|
Project management, task coordination, prioritization, and team oversight.
|
||||||
|
|
||||||
|
## Responsibilities
|
||||||
|
- Task decomposition and assignment
|
||||||
|
- Progress tracking and status reporting
|
||||||
|
- Risk identification and mitigation
|
||||||
|
- Resource allocation
|
||||||
|
- Stakeholder communication
|
||||||
|
|
||||||
|
## Key Metrics
|
||||||
|
- Sprint velocity
|
||||||
|
- Task completion rate
|
||||||
|
- Blockers resolved
|
||||||
|
- Timeline adherence
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# Carry Your Live - Project Summary
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
A minimalist, offline-first task management app built with Expo, React Native, and WatermelonDB. Features three primary screens: To-Do List, Add New Task, and Calendar with bottom navigation.
|
||||||
|
|
||||||
|
## Core Features
|
||||||
|
- **Offline-First Architecture**: Full CRUD operations work offline with WatermelonDB + SQLite
|
||||||
|
- **Auto-Sync**: Background synchronization with custom Postgres API when online
|
||||||
|
- **Cross-Platform**: iOS, Android, and Web from single codebase
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
| Layer | Technology |
|
||||||
|
|-------|------------|
|
||||||
|
| Framework | Expo (React Native) |
|
||||||
|
| Language | TypeScript |
|
||||||
|
| Navigation | Expo Router (file-based) |
|
||||||
|
| Database | WatermelonDB + SQLite |
|
||||||
|
| State | WatermelonDB (reactive queries, no Redux needed) |
|
||||||
|
| Forms | React Hook Form + Zod validation |
|
||||||
|
| UI Components | React Native Paper + Custom SVG icons |
|
||||||
|
| Animations | React Native Reanimated |
|
||||||
|
| Date/Time | @react-native-community/datetimepicker + date-fns |
|
||||||
|
| Build | Gradle (local APK) |
|
||||||
|
| CI/CD | Gitea Actions |
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
```
|
||||||
|
carry-your-live/
|
||||||
|
├ app/
|
||||||
|
│ ├── _layout.tsx # Root layout + DatabaseProvider
|
||||||
|
│ ├── (tabs)/
|
||||||
|
│ │ ├── _layout.tsx # Bottom tab navigator
|
||||||
|
│ │ ├── index.tsx # Tasks screen
|
||||||
|
│ │ ├── calendar.tsx # Calendar screen
|
||||||
|
│ │ └── settings.tsx # Settings screen
|
||||||
|
│ └── add-task.tsx # Add Task screen
|
||||||
|
├ src/
|
||||||
|
│ ├── components/ # Reusable UI components
|
||||||
|
│ ├── database/
|
||||||
|
│ │ ├── schema.ts # WatermelonDB schema
|
||||||
|
│ │ ├── index.ts # Database instance + collections
|
||||||
|
│ │ └── sync.ts # Custom API sync adapter
|
||||||
|
│ ├── models/
|
||||||
|
│ │ ├── Task.ts
|
||||||
|
│ │ ├── Category.ts
|
||||||
|
│ │ └── Subtask.ts
|
||||||
|
│ ├── hooks/
|
||||||
|
│ │ ├── useDatabase.tsx # Database context & providers
|
||||||
|
│ │ └── useTasks.tsx # Task queries with reactive state
|
||||||
|
│ ├── constants/
|
||||||
|
│ │ └── index.ts # Categories, priorities, colors
|
||||||
|
│ └── types/
|
||||||
|
│ └── index.ts # TypeScript interfaces
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Models
|
||||||
|
- **Task**: id, title, description, categoryId, priority, completed, dueDate, dueTime, createdAt, updatedAt, subtasks
|
||||||
|
- **Category**: id, name, color, order
|
||||||
|
- **Subtask**: id, taskId, title, completed, order, createdAt, updatedAt
|
||||||
|
|
||||||
|
## Default Categories
|
||||||
|
| Name | Color | Order |
|
||||||
|
|------|-------|-------|
|
||||||
|
| Work | #E53935 (Red) | 0 |
|
||||||
|
| Personal | #43A047 (Green) | 1 |
|
||||||
|
| School | #1E88E5 (Blue) | 2 |
|
||||||
|
| Shopping | #FB8C00 (Orange) | 3 |
|
||||||
|
| Health | #8E24AA (Purple) | 4 |
|
||||||
|
| Finance | #FDD835 (Yellow) | 5 |
|
||||||
|
|
||||||
|
## Priority Colors
|
||||||
|
| Priority | Color |
|
||||||
|
|----------|-------|
|
||||||
|
| None | #9E9E9E |
|
||||||
|
| Low | #43A047 |
|
||||||
|
| Medium | #FB8C00 |
|
||||||
|
| High | #E53935 |
|
||||||
|
| Critical | #B71C1C |
|
||||||
|
|
||||||
|
## Sync API
|
||||||
|
Custom API at `http://localhost:3000/api` (configurable in `src/database/sync.ts`):
|
||||||
|
- **Pull**: GET /sync?since={timestamp} - returns categories, tasks, subtasks, timestamp
|
||||||
|
- **Push**: POST /sync/push - body with changes { categories, tasks, subtasks } + lastPulledAt
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npx expo start
|
||||||
|
# Platform-specific:
|
||||||
|
npx expo start --ios
|
||||||
|
npx expo start --android
|
||||||
|
npx expo start --web
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build Commands
|
||||||
|
- `cd android && ./gradlew assembleDebug` - Debug APK
|
||||||
|
- `cd android && ./gradlew assembleRelease` - Release APK
|
||||||
|
- `npm run desktop` - Electron desktop app
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Software Engineer Agent
|
||||||
|
|
||||||
|
## Role
|
||||||
|
Code implementation, feature development, and software quality.
|
||||||
|
|
||||||
|
## Responsibilities
|
||||||
|
- Feature implementation
|
||||||
|
- Code reviews
|
||||||
|
- Bug fixing
|
||||||
|
- Refactoring
|
||||||
|
- Unit testing
|
||||||
|
|
||||||
|
## Key Metrics
|
||||||
|
- Code quality score
|
||||||
|
- Test coverage
|
||||||
|
- Pull request throughput
|
||||||
|
- Bug resolution time
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Tester Agent
|
||||||
|
|
||||||
|
## Role
|
||||||
|
Quality assurance, test automation, and test strategy.
|
||||||
|
|
||||||
|
## Responsibilities
|
||||||
|
- Test case design
|
||||||
|
- Automated test execution
|
||||||
|
- Regression testing
|
||||||
|
- Performance testing
|
||||||
|
- Test coverage analysis
|
||||||
|
|
||||||
|
## Key Metrics
|
||||||
|
- Test pass rate
|
||||||
|
- Defect detection rate
|
||||||
|
- Test execution time
|
||||||
|
- Bug leakage
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# VLA Agent
|
||||||
|
|
||||||
|
## Role
|
||||||
|
Vision-Language-Action models and multimodal understanding.
|
||||||
|
|
||||||
|
## Responsibilities
|
||||||
|
- VLA model development
|
||||||
|
- Multimodal perception
|
||||||
|
- Action prediction
|
||||||
|
- Policy learning
|
||||||
|
- Sim-to-real transfer
|
||||||
|
|
||||||
|
## Key Metrics
|
||||||
|
- Task success rate
|
||||||
|
- Sample efficiency
|
||||||
|
- Generalization ability
|
||||||
|
- Simulation-to-real transfer rate
|
||||||
@@ -8,17 +8,21 @@ import { useSubtasks } from '@/hooks/useSubtasks';
|
|||||||
import { useTaskModals } from '@/hooks/useTaskModals';
|
import { useTaskModals } from '@/hooks/useTaskModals';
|
||||||
import { useSettings } from '@/theme';
|
import { useSettings } from '@/theme';
|
||||||
import { useCategories } from '@/hooks/useDatabase';
|
import { useCategories } from '@/hooks/useDatabase';
|
||||||
import { confirmAndToggleTaskComplete } from '@/utils/taskActions';
|
import { useICalImport } from '@/hooks/useICalImport';
|
||||||
|
import { confirmAndToggleTaskComplete, toggleSubtaskComplete } from '@/utils/taskActions';
|
||||||
import { QuickAddBar } from '@/components/QuickAddBar';
|
import { QuickAddBar } from '@/components/QuickAddBar';
|
||||||
import { OptionPickerModal } from '@/components/OptionPickerModal';
|
import { OptionPickerModal } from '@/components/OptionPickerModal';
|
||||||
|
import { TaskItem } from '@/components/TaskItem';
|
||||||
|
import { SubtaskItem } from '@/components/SubtaskItem';
|
||||||
import Task from '@/models/Task';
|
import Task from '@/models/Task';
|
||||||
import { format, addMonths, addDays, startOfMonth, isSameDay, isSameMonth, isToday } from 'date-fns';
|
import { format, addMonths, addDays, startOfMonth, isSameDay, isSameMonth, isToday } from 'date-fns';
|
||||||
import Svg, { Path, Circle } from 'react-native-svg';
|
import Svg, { Path } from 'react-native-svg';
|
||||||
import { desaturate } from '@/theme';
|
|
||||||
import type { ThemeColors } from '@/theme';
|
import type { ThemeColors } from '@/theme';
|
||||||
|
import type { TaskData, SubtaskData } from '@/types';
|
||||||
|
|
||||||
const WIDTH = Dimensions.get('window').width;
|
const WIDTH = Dimensions.get('window').width;
|
||||||
const GERMAN_WEEKDAYS = ['mo', 'di', 'mi', 'do', 'fr', 'sa', 'so'];
|
const GERMAN_WEEKDAYS = ['mo', 'di', 'mi', 'do', 'fr', 'sa', 'so'];
|
||||||
|
const PRIORITY_RANK: Record<string, number> = { none: 0, low: 1, medium: 2, high: 3, critical: 4 };
|
||||||
const MONTH_NAMES = [
|
const MONTH_NAMES = [
|
||||||
'January', 'February', 'March', 'April', 'May', 'June',
|
'January', 'February', 'March', 'April', 'May', 'June',
|
||||||
'July', 'August', 'September', 'October', 'November', 'December',
|
'July', 'August', 'September', 'October', 'November', 'December',
|
||||||
@@ -26,9 +30,16 @@ const MONTH_NAMES = [
|
|||||||
|
|
||||||
export default function CalendarScreen() {
|
export default function CalendarScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { theme } = useSettings();
|
const { theme, sortBy } = useSettings();
|
||||||
const styles = React.useMemo(() => createStyles(theme), [theme]);
|
const styles = React.useMemo(() => createStyles(theme), [theme]);
|
||||||
const categories = useCategories();
|
const categories = useCategories();
|
||||||
|
const categoryColors = useMemo(() => {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
for (const c of categories) {
|
||||||
|
map.set(c.id, c.color);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [categories]);
|
||||||
const { modals, openTaskEdit } = useTaskModals();
|
const { modals, openTaskEdit } = useTaskModals();
|
||||||
|
|
||||||
const [visibleMonth, setVisibleMonth] = useState(() => new Date());
|
const [visibleMonth, setVisibleMonth] = useState(() => new Date());
|
||||||
@@ -55,6 +66,25 @@ export default function CalendarScreen() {
|
|||||||
const monthTasks = useTasksInMonth(visibleMonth);
|
const monthTasks = useTasksInMonth(visibleMonth);
|
||||||
const refreshMonthTasks = monthTasks.refresh;
|
const refreshMonthTasks = monthTasks.refresh;
|
||||||
|
|
||||||
|
const sortedDayTasks = useMemo(() => {
|
||||||
|
const sorted = [...selectedDayTasks];
|
||||||
|
switch (sortBy) {
|
||||||
|
case 'date':
|
||||||
|
sorted.sort((a, b) => (a.dueDate || Number.MAX_SAFE_INTEGER) - (b.dueDate || Number.MAX_SAFE_INTEGER));
|
||||||
|
break;
|
||||||
|
case 'priority':
|
||||||
|
sorted.sort((a, b) => PRIORITY_RANK[b.priority] - PRIORITY_RANK[a.priority]);
|
||||||
|
break;
|
||||||
|
case 'alpha':
|
||||||
|
sorted.sort((a, b) => a.title.localeCompare(b.title));
|
||||||
|
break;
|
||||||
|
case 'created':
|
||||||
|
sorted.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return sorted;
|
||||||
|
}, [selectedDayTasks, sortBy]);
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
refreshDayTasks();
|
refreshDayTasks();
|
||||||
@@ -165,6 +195,27 @@ export default function CalendarScreen() {
|
|||||||
});
|
});
|
||||||
}, [refreshDayTasks, refreshMonthTasks, refreshSubtasks]);
|
}, [refreshDayTasks, refreshMonthTasks, refreshSubtasks]);
|
||||||
|
|
||||||
|
const [expandedTasks, setExpandedTasks] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const toggleExpand = useCallback((taskId: string) => {
|
||||||
|
setExpandedTasks((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(taskId)) {
|
||||||
|
next.delete(taskId);
|
||||||
|
} else {
|
||||||
|
next.add(taskId);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubtaskToggle = useCallback(async (subtaskId: string) => {
|
||||||
|
await toggleSubtaskComplete(subtaskId);
|
||||||
|
refreshDayTasks();
|
||||||
|
refreshMonthTasks();
|
||||||
|
refreshSubtasks();
|
||||||
|
}, [refreshDayTasks, refreshMonthTasks, refreshSubtasks]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
|
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
|
||||||
<Header title="Calendar" showLogo={false} />
|
<Header title="Calendar" showLogo={false} />
|
||||||
@@ -244,78 +295,41 @@ export default function CalendarScreen() {
|
|||||||
|
|
||||||
<View style={styles.panelHeader}>
|
<View style={styles.panelHeader}>
|
||||||
<Text style={[styles.panelDate, { color: theme.text }]}>{format(selectedDate, 'EEEE, MMMM d')}</Text>
|
<Text style={[styles.panelDate, { color: theme.text }]}>{format(selectedDate, 'EEEE, MMMM d')}</Text>
|
||||||
{selectedDayTasks.length > 0 && (
|
{sortedDayTasks.length > 0 && (
|
||||||
<Text style={[styles.panelCount, { color: theme.textFaint }]}>
|
<Text style={[styles.panelCount, { color: theme.textFaint }]}>
|
||||||
{selectedDayTasks.length} event{selectedDayTasks.length === 1 ? '' : 's'}
|
{sortedDayTasks.length} event{sortedDayTasks.length === 1 ? '' : 's'}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{selectedDayTasks.length === 0 ? (
|
{sortedDayTasks.length === 0 ? (
|
||||||
<View style={styles.emptyState}>
|
<View style={styles.emptyState}>
|
||||||
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>No events on this day</Text>
|
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>No events on this day</Text>
|
||||||
<Text style={[styles.emptySubtext, { color: theme.textMuted }]}>Tap a date or use the bar below</Text>
|
<Text style={[styles.emptySubtext, { color: theme.textMuted }]}>Tap a date or use the bar below</Text>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
selectedDayTasks.map((task) => (
|
sortedDayTasks.map((task) => {
|
||||||
<View key={task.id} style={[styles.eventCard, { backgroundColor: theme.card, borderColor: theme.border }]}>
|
const isExpanded = expandedTasks.has(task.id);
|
||||||
<View style={styles.eventRow}>
|
const itemSubtasks = subtasksByTask.get(task.id) ?? [];
|
||||||
<TouchableOpacity
|
return (
|
||||||
style={styles.checkCircle}
|
<View key={task.id} style={styles.eventCard}>
|
||||||
onPress={() => handleToggleComplete(task.id)}
|
<CalendarTaskRow
|
||||||
activeOpacity={0.7}
|
task={task}
|
||||||
>
|
expanded={isExpanded}
|
||||||
<Svg width={30} height={30} viewBox="0 0 24 24">
|
subtasks={itemSubtasks}
|
||||||
{task.completed ? (
|
categoryColor={categoryColors.get(task.categoryId)}
|
||||||
<>
|
onToggle={() => handleToggleComplete(task.id)}
|
||||||
<Circle cx={12} cy={12} r={10} fill={theme.accent} />
|
onPress={(id) =>
|
||||||
<Path d="M7 12l3 3 7-7" stroke="#FFFFFF" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
expandedTasks.has(id)
|
||||||
</>
|
? router.push({ pathname: '/task-detail', params: { id } })
|
||||||
) : (
|
: toggleExpand(id)
|
||||||
<Circle cx={12} cy={12} r={10} stroke={theme.textSecondary} strokeWidth={2.5} fill="none" />
|
}
|
||||||
)}
|
onMenuOpen={(id) => openTaskEdit(id)}
|
||||||
</Svg>
|
onSubtaskToggle={handleSubtaskToggle}
|
||||||
</TouchableOpacity>
|
/>
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.eventTitleTouch}
|
|
||||||
onPress={() => router.push({ pathname: '/task-detail', params: { id: task.id } })}
|
|
||||||
activeOpacity={0.7}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={[styles.eventTitle, { color: theme.text }, task.completed && styles.eventCompleted]}
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
{task.title}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<TouchableOpacity style={styles.menuButton} onPress={() => openTaskEdit(task.id)} activeOpacity={0.7} accessibilityRole="button" accessibilityLabel={`Edit ${task.title}`}>
|
|
||||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
|
||||||
<Path d="M9 6l6 6-6 6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
|
||||||
</Svg>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
</View>
|
||||||
|
);
|
||||||
{(subtasksByTask.get(task.id) ?? []).length > 0 && (
|
})
|
||||||
<View style={styles.bullets}>
|
|
||||||
{(subtasksByTask.get(task.id) ?? []).map((sub) => (
|
|
||||||
<View key={sub.id} style={styles.bulletRow}>
|
|
||||||
<Svg width={5} height={5} viewBox="0 0 6 6" style={styles.bulletDot as any}>
|
|
||||||
<Circle cx={3} cy={3} r={3} fill={theme.textMuted} />
|
|
||||||
</Svg>
|
|
||||||
<Text
|
|
||||||
style={[styles.bulletText, { color: theme.textFaint }, sub.completed && styles.bulletCompleted]}
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
{sub.title}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
@@ -351,6 +365,60 @@ function byDayOf(byDay: Record<number, Task[]>, day: Date): Task[] {
|
|||||||
return byDay[day.getDate()] ?? [];
|
return byDay[day.getDate()] ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CalendarTaskRowProps {
|
||||||
|
task: Task;
|
||||||
|
expanded: boolean;
|
||||||
|
subtasks: SubtaskData[];
|
||||||
|
categoryColor?: string;
|
||||||
|
onToggle: (taskId: string) => void | Promise<void>;
|
||||||
|
onPress: (taskId: string) => void;
|
||||||
|
onMenuOpen: (taskId: string) => void;
|
||||||
|
onSubtaskToggle: (subtaskId: string) => void | Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CalendarTaskRow = React.memo(function CalendarTaskRow({
|
||||||
|
task,
|
||||||
|
expanded,
|
||||||
|
subtasks,
|
||||||
|
categoryColor,
|
||||||
|
onToggle,
|
||||||
|
onPress,
|
||||||
|
onMenuOpen,
|
||||||
|
onSubtaskToggle,
|
||||||
|
}: CalendarTaskRowProps) {
|
||||||
|
const { theme } = useSettings();
|
||||||
|
const styles = React.useMemo(() => createStyles(theme), [theme]);
|
||||||
|
const sortedSubtasks = useMemo(
|
||||||
|
() => subtasks.slice().sort((a, b) => a.order - b.order),
|
||||||
|
[subtasks]
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<TaskItem
|
||||||
|
task={task as unknown as TaskData}
|
||||||
|
onToggle={() => onToggle(task.id)}
|
||||||
|
onPress={() => onPress(task.id)}
|
||||||
|
onMenuOpen={() => onMenuOpen(task.id)}
|
||||||
|
expanded={expanded}
|
||||||
|
categoryColor={categoryColor}
|
||||||
|
/>
|
||||||
|
{expanded && sortedSubtasks.length > 0 && (
|
||||||
|
<View style={styles.subtaskList}>
|
||||||
|
{sortedSubtasks.map((sub) => (
|
||||||
|
<SubtaskItem
|
||||||
|
key={sub.id}
|
||||||
|
subtask={sub}
|
||||||
|
depth={1}
|
||||||
|
categoryColor={categoryColor}
|
||||||
|
onToggle={() => onSubtaskToggle(sub.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
function cellTitle(tasks: Task[]): string | null {
|
function cellTitle(tasks: Task[]): string | null {
|
||||||
if (tasks.length === 0) return null;
|
if (tasks.length === 0) return null;
|
||||||
if (tasks.length === 1) return tasks[0].title;
|
if (tasks.length === 1) return tasks[0].title;
|
||||||
@@ -359,8 +427,7 @@ function cellTitle(tasks: Task[]): string | null {
|
|||||||
|
|
||||||
function cellColor(tasks: Task[], categories: { id: string; color: string }[], theme: ThemeColors): string {
|
function cellColor(tasks: Task[], categories: { id: string; color: string }[], theme: ThemeColors): string {
|
||||||
if (tasks.length === 0) return theme.textMuted;
|
if (tasks.length === 0) return theme.textMuted;
|
||||||
const cat = categories.find((c) => c.id === tasks[0].categoryId);
|
return theme.accent;
|
||||||
return cat ? desaturate(cat.color, 0.3) : theme.textMuted;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DayCellProps {
|
interface DayCellProps {
|
||||||
@@ -545,55 +612,10 @@ const createStyles = (theme: ThemeColors) => StyleSheet.create({
|
|||||||
eventCard: {
|
eventCard: {
|
||||||
marginHorizontal: 16,
|
marginHorizontal: 16,
|
||||||
marginTop: 6,
|
marginTop: 6,
|
||||||
borderRadius: 16,
|
|
||||||
borderWidth: 1,
|
|
||||||
paddingHorizontal: 14,
|
|
||||||
paddingVertical: 12,
|
|
||||||
},
|
},
|
||||||
eventRow: {
|
subtaskList: {
|
||||||
flexDirection: 'row',
|
paddingLeft: 8,
|
||||||
alignItems: 'center',
|
paddingRight: 4,
|
||||||
},
|
paddingTop: 4,
|
||||||
checkCircle: {
|
|
||||||
width: 30,
|
|
||||||
marginRight: 12,
|
|
||||||
},
|
|
||||||
eventTitleTouch: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
eventTitle: {
|
|
||||||
fontSize: 15,
|
|
||||||
fontWeight: '500',
|
|
||||||
},
|
|
||||||
eventCompleted: {
|
|
||||||
textDecorationLine: 'line-through',
|
|
||||||
color: theme.textMuted,
|
|
||||||
},
|
|
||||||
menuButton: {
|
|
||||||
padding: 4,
|
|
||||||
marginLeft: 6,
|
|
||||||
},
|
|
||||||
bullets: {
|
|
||||||
marginTop: 8,
|
|
||||||
paddingTop: 8,
|
|
||||||
borderTopWidth: 1,
|
|
||||||
borderTopColor: 'rgba(255,255,255,0.06)',
|
|
||||||
gap: 6,
|
|
||||||
},
|
|
||||||
bulletRow: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 8,
|
|
||||||
},
|
|
||||||
bulletDot: {
|
|
||||||
marginLeft: 8,
|
|
||||||
},
|
|
||||||
bulletText: {
|
|
||||||
flex: 1,
|
|
||||||
fontSize: 13,
|
|
||||||
},
|
|
||||||
bulletCompleted: {
|
|
||||||
textDecorationLine: 'line-through',
|
|
||||||
color: theme.textMuted,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -15,6 +15,8 @@ import { useCategories } from '@/hooks/useDatabase';
|
|||||||
import { getAuthUser, getAuthToken } from '@/services/auth';
|
import { getAuthUser, getAuthToken } from '@/services/auth';
|
||||||
import { checkForUpdates, getCurrentAppVersion } from '@/services/updates';
|
import { checkForUpdates, getCurrentAppVersion } from '@/services/updates';
|
||||||
import { getLastSyncTime } from '@/database/sync';
|
import { getLastSyncTime } from '@/database/sync';
|
||||||
|
import { rescheduleAllReminders } from '@/services/notifications';
|
||||||
|
import { collections } from '@/database';
|
||||||
import Category from '@/models/Category';
|
import Category from '@/models/Category';
|
||||||
import Svg, { Path } from 'react-native-svg';
|
import Svg, { Path } from 'react-native-svg';
|
||||||
import ColorWheel from '@/components/ColorWheel';
|
import ColorWheel from '@/components/ColorWheel';
|
||||||
@@ -22,9 +24,10 @@ import { getLastVisitedTab, tabHref } from '@/utils/tabHistory';
|
|||||||
|
|
||||||
export default function SettingsScreen() {
|
export default function SettingsScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { theme, notifications, setNotifications, defaultCategoryId, setDefaultCategoryId, sortBy, setSortBy, reminderPreference, setReminderPreference, apiUrl, accentColor, setAccentColor, todoAheadDays, setTodoAheadDays, showCompleted, setShowCompleted } = useSettings();
|
const { theme, notifications, setNotifications, defaultCategoryId, setDefaultCategoryId, sortBy, setSortBy, reminderPreference, setReminderPreference, tagReminderPreferences, setTagReminderPreferences, apiUrl, accentColor, setAccentColor, todoAheadDays, setTodoAheadDays, showCompleted, setShowCompleted, deleteBySwipe, setDeleteBySwipe } = useSettings();
|
||||||
const categories = useCategories();
|
const categories = useCategories();
|
||||||
const [picker, setPicker] = useState<null | 'category' | 'sort' | 'reminder' | 'todoAhead'>(null);
|
const [picker, setPicker] = useState<null | 'category' | 'sort' | 'reminder' | 'todoAhead'>(null);
|
||||||
|
const [reminderTagPicker, setReminderTagPicker] = useState<Category | null>(null);
|
||||||
const [editingCategory, setEditingCategory] = useState<Category | null | 'new'>(null);
|
const [editingCategory, setEditingCategory] = useState<Category | null | 'new'>(null);
|
||||||
const [syncVisible, setSyncVisible] = useState(false);
|
const [syncVisible, setSyncVisible] = useState(false);
|
||||||
const [friendsVisible, setFriendsVisible] = useState(false);
|
const [friendsVisible, setFriendsVisible] = useState(false);
|
||||||
@@ -95,6 +98,14 @@ export default function SettingsScreen() {
|
|||||||
const defaultCategoryLabel = defaultCategory?.name ?? 'None';
|
const defaultCategoryLabel = defaultCategory?.name ?? 'None';
|
||||||
const reminderLabel = REMINDER_OPTIONS.find((o) => o.value === reminderPreference)?.label ?? 'No reminder';
|
const reminderLabel = REMINDER_OPTIONS.find((o) => o.value === reminderPreference)?.label ?? 'No reminder';
|
||||||
|
|
||||||
|
const tagReminderLabel = (categoryId: string) => {
|
||||||
|
const selected = tagReminderPreferences[categoryId] ?? [];
|
||||||
|
if (selected.length === 0) return `Default (${reminderLabel})`;
|
||||||
|
return selected
|
||||||
|
.map((v) => REMINDER_OPTIONS.find((o) => o.value === v)?.label ?? v)
|
||||||
|
.join(', ');
|
||||||
|
};
|
||||||
|
|
||||||
const openCustomAccent = () => {
|
const openCustomAccent = () => {
|
||||||
setDraftAccent(accentColor);
|
setDraftAccent(accentColor);
|
||||||
setCustomAccentVisible(true);
|
setCustomAccentVisible(true);
|
||||||
@@ -108,6 +119,20 @@ export default function SettingsScreen() {
|
|||||||
setReminderPreference((Array.isArray(value) ? value[0] : value) as typeof reminderPreference);
|
setReminderPreference((Array.isArray(value) ? value[0] : value) as typeof reminderPreference);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleTagReminderPreference = async (categoryId: string, value: string | string[]) => {
|
||||||
|
const selected = (Array.isArray(value) ? value : [value]).filter(
|
||||||
|
(v): v is typeof reminderPreference => v !== 'none'
|
||||||
|
);
|
||||||
|
setTagReminderPreferences({ ...tagReminderPreferences, [categoryId]: selected });
|
||||||
|
if (!notifications) return;
|
||||||
|
try {
|
||||||
|
const tasks = await collections.tasks.query().fetch();
|
||||||
|
await rescheduleAllReminders(tasks, notifications, reminderPreference);
|
||||||
|
} catch {
|
||||||
|
// ignore reschedule failures
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleTodoAheadDays = (value: string | string[]) => {
|
const handleTodoAheadDays = (value: string | string[]) => {
|
||||||
setTodoAheadDays(parseInt(Array.isArray(value) ? value[0] : value, 10));
|
setTodoAheadDays(parseInt(Array.isArray(value) ? value[0] : value, 10));
|
||||||
};
|
};
|
||||||
@@ -163,6 +188,18 @@ export default function SettingsScreen() {
|
|||||||
onPress={() => notifications && setPicker('reminder')}
|
onPress={() => notifications && setPicker('reminder')}
|
||||||
showChevron
|
showChevron
|
||||||
/>
|
/>
|
||||||
|
{categories.map((category) => (
|
||||||
|
<ListItem
|
||||||
|
key={`tag-reminder-${category.id}`}
|
||||||
|
title={category.name}
|
||||||
|
subtitle={notifications ? tagReminderLabel(category.id) : 'Enable notifications first'}
|
||||||
|
leftElement={
|
||||||
|
<View style={[styles.categoryDot, { backgroundColor: category.color }]} />
|
||||||
|
}
|
||||||
|
onPress={() => notifications && setReminderTagPicker(category)}
|
||||||
|
showChevron
|
||||||
|
/>
|
||||||
|
))}
|
||||||
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>Tasks</Text>
|
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>Tasks</Text>
|
||||||
<ListItem
|
<ListItem
|
||||||
title="Default Tag"
|
title="Default Tag"
|
||||||
@@ -188,6 +225,19 @@ export default function SettingsScreen() {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<ListItem
|
||||||
|
title="Delete by Swiping"
|
||||||
|
subtitle="Swipe right to delete task"
|
||||||
|
rightElement={
|
||||||
|
<Switch
|
||||||
|
value={deleteBySwipe}
|
||||||
|
onValueChange={setDeleteBySwipe}
|
||||||
|
thumbColor="#FFFFFF"
|
||||||
|
trackColor={{ false: theme.borderStrong, true: theme.accent }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
showChevron
|
||||||
|
/>
|
||||||
<ListItem
|
<ListItem
|
||||||
title="Show Calendar Tasks"
|
title="Show Calendar Tasks"
|
||||||
subtitle={todoAheadDays === 0 ? 'Only today' : `Up to ${todoAheadDays} days ahead`}
|
subtitle={todoAheadDays === 0 ? 'Only today' : `Up to ${todoAheadDays} days ahead`}
|
||||||
@@ -294,6 +344,19 @@ export default function SettingsScreen() {
|
|||||||
onClose={() => setPicker(null)}
|
onClose={() => setPicker(null)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{categories.map((category) => (
|
||||||
|
<OptionPickerModal
|
||||||
|
key={`tag-reminder-picker-${category.id}`}
|
||||||
|
visible={reminderTagPicker?.id === category.id}
|
||||||
|
title={`Reminders · ${category.name}`}
|
||||||
|
options={REMINDER_OPTIONS.filter((o) => o.value !== 'none')}
|
||||||
|
selectedValue={tagReminderPreferences[category.id] ?? []}
|
||||||
|
multiSelect
|
||||||
|
onSelect={(value) => handleTagReminderPreference(category.id, value)}
|
||||||
|
onClose={() => setReminderTagPicker(null)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
<OptionPickerModal
|
<OptionPickerModal
|
||||||
visible={picker === 'todoAhead'}
|
visible={picker === 'todoAhead'}
|
||||||
title="Calendar Tasks in Todo"
|
title="Calendar Tasks in Todo"
|
||||||
|
|||||||
@@ -194,6 +194,7 @@ export default function AddTaskScreen() {
|
|||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
keyboardDismissMode="interactive"
|
keyboardDismissMode="interactive"
|
||||||
|
automaticallyAdjustContentInsets={false}
|
||||||
>
|
>
|
||||||
<CategorySelector
|
<CategorySelector
|
||||||
value={tags}
|
value={tags}
|
||||||
|
|||||||
@@ -40,11 +40,14 @@ export default function DayViewScreen() {
|
|||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
const sub = BackHandler.addEventListener('hardwareBackPress', () => false);
|
const sub = BackHandler.addEventListener('hardwareBackPress', () => {
|
||||||
|
router.back();
|
||||||
|
return true;
|
||||||
|
});
|
||||||
refreshSubtasks();
|
refreshSubtasks();
|
||||||
refresh();
|
refresh();
|
||||||
return () => sub.remove();
|
return () => sub.remove();
|
||||||
}, [refreshSubtasks, refresh])
|
}, [router, refreshSubtasks, refresh])
|
||||||
);
|
);
|
||||||
|
|
||||||
const subtasksOnDay = useMemo(() => {
|
const subtasksOnDay = useMemo(() => {
|
||||||
@@ -70,7 +73,7 @@ export default function DayViewScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={[styles.container, { backgroundColor: theme.background, paddingBottom: insets.bottom }]}>
|
<SafeAreaView style={[styles.container, { backgroundColor: theme.background, paddingBottom: insets.bottom }]}>
|
||||||
<Header title={format(day, 'EEEE, MMM d')} showLogo={false} />
|
<Header title={format(day, 'EEEE, MMM d')} showLogo={false} rightAction="Back" />
|
||||||
<KeyboardAvoidingView style={styles.kbAvoid} behavior="padding">
|
<KeyboardAvoidingView style={styles.kbAvoid} behavior="padding">
|
||||||
<ScrollView style={styles.scrollBody} contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
|
<ScrollView style={styles.scrollBody} contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
|
|||||||
@@ -187,6 +187,7 @@ export default function SubtaskDetailScreen() {
|
|||||||
contentContainerStyle={styles.scrollContent}
|
contentContainerStyle={styles.scrollContent}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
|
keyboardDismissMode="interactive"
|
||||||
>
|
>
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
|
|||||||
@@ -49,9 +49,9 @@ export function AnalogClock({ value, is24Hour = false, onChange }: AnalogClockPr
|
|||||||
let hour: number;
|
let hour: number;
|
||||||
if (is24Hour) {
|
if (is24Hour) {
|
||||||
const inner = distance < INNER_NUM_RADIUS + 14;
|
const inner = distance < INNER_NUM_RADIUS + 14;
|
||||||
hour = inner ? base + 12 : (base === 0 ? 12 : base);
|
hour = inner ? base + 12 : base === 0 ? 12 : base;
|
||||||
} else {
|
} else {
|
||||||
hour = isPM ? base + 12 : (base === 0 ? 0 : base);
|
hour = isPM ? base + 12 : base === 0 ? 0 : base;
|
||||||
}
|
}
|
||||||
emit(hour, minutes);
|
emit(hour, minutes);
|
||||||
setMode('minute');
|
setMode('minute');
|
||||||
@@ -135,7 +135,8 @@ export function AnalogClock({ value, is24Hour = false, onChange }: AnalogClockPr
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const minuteDots = mode === 'minute'
|
const minuteDots =
|
||||||
|
mode === 'minute'
|
||||||
? Array.from({ length: 12 }, (_, i) => {
|
? Array.from({ length: 12 }, (_, i) => {
|
||||||
const angle = (i * 30 * Math.PI) / 180;
|
const angle = (i * 30 * Math.PI) / 180;
|
||||||
const x = CENTER + RADIUS * 0.82 * Math.sin(angle);
|
const x = CENTER + RADIUS * 0.82 * Math.sin(angle);
|
||||||
@@ -181,7 +182,7 @@ export function AnalogClock({ value, is24Hour = false, onChange }: AnalogClockPr
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
{!is24Hour && (
|
{!is24Hour && (
|
||||||
<View style={styles.periodGroup}>
|
<View style={styles.periodGroup}>
|
||||||
{(['AM', 'PM'] as const).map((period) => {
|
{(['AM', 'PM'] as const).map(period => {
|
||||||
const active = isPM === (period === 'PM');
|
const active = isPM === (period === 'PM');
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -215,15 +216,15 @@ export function AnalogClock({ value, is24Hour = false, onChange }: AnalogClockPr
|
|||||||
style={styles.clockSurface}
|
style={styles.clockSurface}
|
||||||
onStartShouldSetResponder={() => true}
|
onStartShouldSetResponder={() => true}
|
||||||
onMoveShouldSetResponder={() => true}
|
onMoveShouldSetResponder={() => true}
|
||||||
onResponderGrant={(e) => {
|
onResponderGrant={e => {
|
||||||
movedRef.current = false;
|
movedRef.current = false;
|
||||||
onLocation(e, false);
|
onLocation(e, false);
|
||||||
}}
|
}}
|
||||||
onResponderMove={(e) => {
|
onResponderMove={e => {
|
||||||
movedRef.current = true;
|
movedRef.current = true;
|
||||||
onLocation(e, true);
|
onLocation(e, true);
|
||||||
}}
|
}}
|
||||||
onResponderRelease={(e) => {
|
onResponderRelease={e => {
|
||||||
if (!movedRef.current) {
|
if (!movedRef.current) {
|
||||||
onLocation(e, false);
|
onLocation(e, false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, Text, TouchableOpacity, StyleSheet, Modal, FlatList, ActivityIndicator, TextInput, KeyboardAvoidingView } from 'react-native';
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
StyleSheet,
|
||||||
|
Modal,
|
||||||
|
FlatList,
|
||||||
|
ActivityIndicator,
|
||||||
|
TextInput,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
} from 'react-native';
|
||||||
import { useSettings } from '@/theme';
|
import { useSettings } from '@/theme';
|
||||||
import { useFriends } from '@/hooks/useFriends';
|
import { useFriends } from '@/hooks/useFriends';
|
||||||
|
|
||||||
@@ -82,19 +92,14 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
|
|||||||
<Text style={[styles.selectorIcon, { color: theme.accentStrong }]}>👤</Text>
|
<Text style={[styles.selectorIcon, { color: theme.accentStrong }]}>👤</Text>
|
||||||
<View style={styles.selectorContent}>
|
<View style={styles.selectorContent}>
|
||||||
<Text style={[styles.selectorLabel, { color: theme.textMuted }]}>Assignee</Text>
|
<Text style={[styles.selectorLabel, { color: theme.textMuted }]}>Assignee</Text>
|
||||||
<Text
|
<Text style={[styles.selectorValue, { color: value ? theme.text : theme.textMuted }]}>
|
||||||
style={[
|
|
||||||
styles.selectorValue,
|
|
||||||
{ color: value ? theme.text : theme.textMuted },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{selectedFriend ? selectedFriend.username : 'Unassigned'}
|
{selectedFriend ? selectedFriend.username : 'Unassigned'}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
{value && !disabled && (
|
{value && !disabled && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.clearButton}
|
style={styles.clearButton}
|
||||||
onPress={(e) => {
|
onPress={e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleClear();
|
handleClear();
|
||||||
}}
|
}}
|
||||||
@@ -105,7 +110,12 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
|
|||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
<Modal visible={showModal} transparent animationType="fade" onRequestClose={() => setShowModal(false)}>
|
<Modal
|
||||||
|
visible={showModal}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={() => setShowModal(false)}
|
||||||
|
>
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
style={[styles.modalOverlay, { backgroundColor: 'rgba(0,0,0,0.5)' }]}
|
style={[styles.modalOverlay, { backgroundColor: 'rgba(0,0,0,0.5)' }]}
|
||||||
behavior="padding"
|
behavior="padding"
|
||||||
@@ -113,7 +123,10 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
|
|||||||
<View style={[styles.modalSheet, { backgroundColor: theme.sheetBg }]}>
|
<View style={[styles.modalSheet, { backgroundColor: theme.sheetBg }]}>
|
||||||
<View style={styles.modalHeader}>
|
<View style={styles.modalHeader}>
|
||||||
<Text style={[styles.modalTitle, { color: theme.text }]}>Assign Task</Text>
|
<Text style={[styles.modalTitle, { color: theme.text }]}>Assign Task</Text>
|
||||||
<TouchableOpacity onPress={() => setShowModal(false)} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
|
<TouchableOpacity
|
||||||
|
onPress={() => setShowModal(false)}
|
||||||
|
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
||||||
|
>
|
||||||
<Text style={[styles.closeText, { color: theme.textMuted }]}>✕</Text>
|
<Text style={[styles.closeText, { color: theme.textMuted }]}>✕</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
@@ -122,7 +135,10 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[
|
style={[
|
||||||
styles.optionRow,
|
styles.optionRow,
|
||||||
{ backgroundColor: selectedFriend ? theme.accentSoft : 'transparent', borderColor: selectedFriend ? theme.accent : 'transparent' },
|
{
|
||||||
|
backgroundColor: selectedFriend ? theme.accentSoft : 'transparent',
|
||||||
|
borderColor: selectedFriend ? theme.accent : 'transparent',
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
onPress={() => handleClear()}
|
onPress={() => handleClear()}
|
||||||
>
|
>
|
||||||
@@ -143,7 +159,9 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
|
|||||||
<ActivityIndicator color={theme.accent} size="large" />
|
<ActivityIndicator color={theme.accent} size="large" />
|
||||||
</View>
|
</View>
|
||||||
) : friends.length === 0 ? (
|
) : friends.length === 0 ? (
|
||||||
<Text style={[styles.emptyText, { color: theme.textFaint }]}>No friends yet. Add friends to assign tasks.</Text>
|
<Text style={[styles.emptyText, { color: theme.textFaint }]}>
|
||||||
|
No friends yet. Add friends to assign tasks.
|
||||||
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<FlatList
|
<FlatList
|
||||||
data={friends}
|
data={friends}
|
||||||
@@ -152,14 +170,19 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[
|
style={[
|
||||||
styles.optionRow,
|
styles.optionRow,
|
||||||
{ backgroundColor: value === item.id ? theme.accentSoft : 'transparent', borderColor: value === item.id ? theme.accent : 'transparent' },
|
{
|
||||||
|
backgroundColor: value === item.id ? theme.accentSoft : 'transparent',
|
||||||
|
borderColor: value === item.id ? theme.accent : 'transparent',
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
onPress={() => handleSelectFriend(item.id)}
|
onPress={() => handleSelectFriend(item.id)}
|
||||||
>
|
>
|
||||||
<View style={styles.optionIcon}>
|
<View style={styles.optionIcon}>
|
||||||
<Text style={styles.optionIconText}>👤</Text>
|
<Text style={styles.optionIconText}>👤</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text style={[styles.optionText, { color: theme.text }]}>{item.username}</Text>
|
<Text style={[styles.optionText, { color: theme.text }]}>
|
||||||
|
{item.username}
|
||||||
|
</Text>
|
||||||
{value === item.id && (
|
{value === item.id && (
|
||||||
<Text style={[styles.checkmark, { color: theme.accentStrong }]}>✓</Text>
|
<Text style={[styles.checkmark, { color: theme.accentStrong }]}>✓</Text>
|
||||||
)}
|
)}
|
||||||
@@ -172,7 +195,14 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
|
|||||||
<View style={styles.modalSection}>
|
<View style={styles.modalSection}>
|
||||||
<View style={styles.searchContainer}>
|
<View style={styles.searchContainer}>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={[styles.searchInput, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text }]}
|
style={[
|
||||||
|
styles.searchInput,
|
||||||
|
{
|
||||||
|
backgroundColor: theme.inputBg,
|
||||||
|
borderColor: theme.borderStrong,
|
||||||
|
color: theme.text,
|
||||||
|
},
|
||||||
|
]}
|
||||||
placeholder="Search users by username"
|
placeholder="Search users by username"
|
||||||
placeholderTextColor={theme.textMuted}
|
placeholderTextColor={theme.textMuted}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
@@ -197,15 +227,21 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
|
|||||||
<View style={styles.optionIcon}>
|
<View style={styles.optionIcon}>
|
||||||
<Text style={styles.optionIconText}>➕</Text>
|
<Text style={styles.optionIconText}>➕</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text style={[styles.optionText, { color: theme.text }]}>{item.username}</Text>
|
<Text style={[styles.optionText, { color: theme.text }]}>
|
||||||
<Text style={[styles.optionSubtext, { color: theme.textMuted }]}>Add as friend & assign</Text>
|
{item.username}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.optionSubtext, { color: theme.textMuted }]}>
|
||||||
|
Add as friend & assign
|
||||||
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
) : searchQuery.length >= 2 && searchResults.length === 0 && !searching ? (
|
) : searchQuery.length >= 2 && searchResults.length === 0 && !searching ? (
|
||||||
<Text style={[styles.emptyText, { color: theme.textFaint }]}>No users found</Text>
|
<Text style={[styles.emptyText, { color: theme.textFaint }]}>No users found</Text>
|
||||||
) : searchQuery.length < 2 ? (
|
) : searchQuery.length < 2 ? (
|
||||||
<Text style={[styles.emptyText, { color: theme.textFaint }]}>Type at least 2 characters to search</Text>
|
<Text style={[styles.emptyText, { color: theme.textFaint }]}>
|
||||||
|
Type at least 2 characters to search
|
||||||
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Modal, View, Text, StyleSheet, TextInput, TouchableOpacity, ScrollView, KeyboardAvoidingView, Platform, Pressable, useWindowDimensions } from 'react-native';
|
import {
|
||||||
|
Modal,
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
ScrollView,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
useWindowDimensions,
|
||||||
|
} from 'react-native';
|
||||||
import Category from '@/models/Category';
|
import Category from '@/models/Category';
|
||||||
import { useSettings } from '@/theme';
|
import { useSettings } from '@/theme';
|
||||||
import { CATEGORY_COLORS } from '@/constants';
|
import { CATEGORY_COLORS } from '@/constants';
|
||||||
import { createCategory, updateCategory, deleteCategory } from '@/utils/categoryActions';
|
import { createCategory, updateCategory, deleteCategory } from '@/utils/categoryActions';
|
||||||
import { confirmAction } from '@/utils/confirm';
|
import { confirmAction } from '@/utils/confirm';
|
||||||
import ColorPickerInput from '@/components/ColorPickerInput';
|
import ColorWheel from '@/components/ColorWheel';
|
||||||
import Svg, { Path, Circle } from 'react-native-svg';
|
import Svg, { Path, Circle } from 'react-native-svg';
|
||||||
|
|
||||||
interface CategoryEditorModalProps {
|
interface CategoryEditorModalProps {
|
||||||
@@ -15,7 +27,12 @@ interface CategoryEditorModalProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CategoryEditorModal({ visible, category, categoryCount, onClose }: CategoryEditorModalProps) {
|
export function CategoryEditorModal({
|
||||||
|
visible,
|
||||||
|
category,
|
||||||
|
categoryCount,
|
||||||
|
onClose,
|
||||||
|
}: CategoryEditorModalProps) {
|
||||||
const { theme } = useSettings();
|
const { theme } = useSettings();
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [color, setColor] = useState(CATEGORY_COLORS[0]);
|
const [color, setColor] = useState(CATEGORY_COLORS[0]);
|
||||||
@@ -64,15 +81,8 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose
|
|||||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||||
keyboardVerticalOffset={0}
|
keyboardVerticalOffset={0}
|
||||||
>
|
>
|
||||||
<Pressable
|
<Pressable style={styles.overlay} onPress={onClose} accessibilityRole="none">
|
||||||
style={styles.overlay}
|
<Pressable style={[styles.sheet, { backgroundColor: theme.sheetBg }]} onPress={() => {}}>
|
||||||
onPress={onClose}
|
|
||||||
accessibilityRole="none"
|
|
||||||
>
|
|
||||||
<Pressable
|
|
||||||
style={[styles.sheet, { backgroundColor: theme.sheetBg }]}
|
|
||||||
onPress={() => {}}
|
|
||||||
>
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
@@ -82,10 +92,21 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose
|
|||||||
<Text style={[styles.title, { color: theme.text }]}>
|
<Text style={[styles.title, { color: theme.text }]}>
|
||||||
{category ? 'Edit Tag' : 'New Tag'}
|
{category ? 'Edit Tag' : 'New Tag'}
|
||||||
</Text>
|
</Text>
|
||||||
<TouchableOpacity onPress={onClose} style={styles.closeButton} hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }} accessibilityRole="button" accessibilityLabel="Close tag editor">
|
<TouchableOpacity
|
||||||
|
onPress={onClose}
|
||||||
|
style={styles.closeButton}
|
||||||
|
hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Close tag editor"
|
||||||
|
>
|
||||||
<Svg width={22} height={22} viewBox="0 0 24 24">
|
<Svg width={22} height={22} viewBox="0 0 24 24">
|
||||||
<Circle cx={12} cy={12} r={10} fill={theme.cardAlt} />
|
<Circle cx={12} cy={12} r={10} fill={theme.cardAlt} />
|
||||||
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" />
|
<Path
|
||||||
|
d="M18 6L6 18M6 6l12 12"
|
||||||
|
stroke={theme.textSecondary}
|
||||||
|
strokeWidth={2.5}
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
</Svg>
|
</Svg>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
@@ -94,7 +115,11 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose
|
|||||||
<TextInput
|
<TextInput
|
||||||
style={[
|
style={[
|
||||||
styles.input,
|
styles.input,
|
||||||
{ backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text },
|
{
|
||||||
|
backgroundColor: theme.inputBg,
|
||||||
|
borderColor: theme.borderStrong,
|
||||||
|
color: theme.text,
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
placeholder="Tag name"
|
placeholder="Tag name"
|
||||||
placeholderTextColor={theme.textMuted}
|
placeholderTextColor={theme.textMuted}
|
||||||
@@ -107,7 +132,7 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose
|
|||||||
|
|
||||||
<Text style={[styles.label, { color: theme.textSecondary }]}>Color</Text>
|
<Text style={[styles.label, { color: theme.textSecondary }]}>Color</Text>
|
||||||
<View style={styles.colorGrid}>
|
<View style={styles.colorGrid}>
|
||||||
{CATEGORY_COLORS.map((c) => (
|
{CATEGORY_COLORS.map(c => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={c}
|
key={c}
|
||||||
style={[
|
style={[
|
||||||
@@ -123,13 +148,20 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose
|
|||||||
>
|
>
|
||||||
{color === c && (
|
{color === c && (
|
||||||
<Svg width={16} height={16} viewBox="0 0 24 24">
|
<Svg width={16} height={16} viewBox="0 0 24 24">
|
||||||
<Path d="M5 12l5 5 9-10" stroke="#FFFFFF" strokeWidth={3} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
<Path
|
||||||
|
d="M5 12l5 5 9-10"
|
||||||
|
stroke="#FFFFFF"
|
||||||
|
strokeWidth={3}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
</Svg>
|
</Svg>
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
<ColorPickerInput value={color} onChange={setColor} />
|
<ColorWheel color={color} onChange={setColor} />
|
||||||
|
|
||||||
<View style={styles.actions}>
|
<View style={styles.actions}>
|
||||||
{category && (
|
{category && (
|
||||||
@@ -139,13 +171,22 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose
|
|||||||
disabled={!canDelete}
|
disabled={!canDelete}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<Text style={[styles.deleteButtonText, { color: canDelete ? theme.accent : theme.textMuted }]}>
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.deleteButtonText,
|
||||||
|
{ color: canDelete ? theme.accent : theme.textMuted },
|
||||||
|
]}
|
||||||
|
>
|
||||||
Delete
|
Delete
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.saveButton, { backgroundColor: theme.accent }, !name.trim() && styles.saveButtonDisabled]}
|
style={[
|
||||||
|
styles.saveButton,
|
||||||
|
{ backgroundColor: theme.accent },
|
||||||
|
!name.trim() && styles.saveButtonDisabled,
|
||||||
|
]}
|
||||||
onPress={handleSave}
|
onPress={handleSave}
|
||||||
disabled={!name.trim()}
|
disabled={!name.trim()}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) {
|
|||||||
isAllCategory={true}
|
isAllCategory={true}
|
||||||
scaleAnim={scaleAnim}
|
scaleAnim={scaleAnim}
|
||||||
/>
|
/>
|
||||||
{categories.map((category) => (
|
{categories.map(category => (
|
||||||
<CategoryButton
|
<CategoryButton
|
||||||
key={category.id}
|
key={category.id}
|
||||||
id={category.id}
|
id={category.id}
|
||||||
@@ -72,7 +72,10 @@ export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.manageButton, { borderColor: theme.borderStrong, backgroundColor: theme.cardAlt }]}
|
style={[
|
||||||
|
styles.manageButton,
|
||||||
|
{ borderColor: theme.borderStrong, backgroundColor: theme.cardAlt },
|
||||||
|
]}
|
||||||
onPress={() => router.push('/settings')}
|
onPress={() => router.push('/settings')}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
@@ -80,10 +83,22 @@ export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) {
|
|||||||
hitSlop={{ top: 8, bottom: 8, left: 4, right: 4 }}
|
hitSlop={{ top: 8, bottom: 8, left: 4, right: 4 }}
|
||||||
>
|
>
|
||||||
<Svg width={14} height={14} viewBox="0 0 24 24">
|
<Svg width={14} height={14} viewBox="0 0 24 24">
|
||||||
<Circle cx={12} cy={12} r={9} stroke={theme.textSecondary} strokeWidth={2.2} fill="none" />
|
<Circle
|
||||||
|
cx={12}
|
||||||
|
cy={12}
|
||||||
|
r={9}
|
||||||
|
stroke={theme.textSecondary}
|
||||||
|
strokeWidth={2.2}
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
<Circle cx={12} cy={8.5} r={1.2} fill={theme.textSecondary} />
|
<Circle cx={12} cy={8.5} r={1.2} fill={theme.textSecondary} />
|
||||||
<Circle cx={12} cy={15.5} r={1.2} fill={theme.textSecondary} />
|
<Circle cx={12} cy={15.5} r={1.2} fill={theme.textSecondary} />
|
||||||
<Path d="M7.8 6.2l1 1.7M16.2 16.1l1 1.7M7.8 17.8l1-1.7M16.2 7.9l1-1.7" stroke={theme.textSecondary} strokeWidth={1.8} strokeLinecap="round" />
|
<Path
|
||||||
|
d="M7.8 6.2l1 1.7M16.2 16.1l1 1.7M7.8 17.8l1-1.7M16.2 7.9l1-1.7"
|
||||||
|
stroke={theme.textSecondary}
|
||||||
|
strokeWidth={1.8}
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
</Svg>
|
</Svg>
|
||||||
<Text style={[styles.manageLabel, { color: theme.textSecondary }]}>Manage</Text>
|
<Text style={[styles.manageLabel, { color: theme.textSecondary }]}>Manage</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -104,7 +119,16 @@ interface CategoryButtonProps {
|
|||||||
scaleAnim: Animated.Value;
|
scaleAnim: Animated.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CategoryButton({ color, selected, onPress, onPressIn, onPressOut, theme, isAllCategory, scaleAnim }: CategoryButtonProps) {
|
function CategoryButton({
|
||||||
|
color,
|
||||||
|
selected,
|
||||||
|
onPress,
|
||||||
|
onPressIn,
|
||||||
|
onPressOut,
|
||||||
|
theme,
|
||||||
|
isAllCategory,
|
||||||
|
scaleAnim,
|
||||||
|
}: CategoryButtonProps) {
|
||||||
const animatedStyle = {
|
const animatedStyle = {
|
||||||
transform: [{ scale: scaleAnim }],
|
transform: [{ scale: scaleAnim }],
|
||||||
shadowColor: selected ? theme.accent : '#000',
|
shadowColor: selected ? theme.accent : '#000',
|
||||||
@@ -130,8 +154,24 @@ function CategoryButton({ color, selected, onPress, onPressIn, onPressOut, theme
|
|||||||
>
|
>
|
||||||
{isAllCategory ? (
|
{isAllCategory ? (
|
||||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||||
<Circle cx={12} cy={12} r={9} stroke="#FFFFFF" strokeWidth={2.5} fill="none" opacity={0.9} />
|
<Circle
|
||||||
<Path d="M6 12l4 4 8-8" stroke="#FFFFFF" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" opacity={0.9} />
|
cx={12}
|
||||||
|
cy={12}
|
||||||
|
r={9}
|
||||||
|
stroke="#FFFFFF"
|
||||||
|
strokeWidth={2.5}
|
||||||
|
fill="none"
|
||||||
|
opacity={0.9}
|
||||||
|
/>
|
||||||
|
<Path
|
||||||
|
d="M6 12l4 4 8-8"
|
||||||
|
stroke="#FFFFFF"
|
||||||
|
strokeWidth={2.5}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
fill="none"
|
||||||
|
opacity={0.9}
|
||||||
|
/>
|
||||||
</Svg>
|
</Svg>
|
||||||
) : null}
|
) : null}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { View, Text, StyleSheet, TouchableOpacity, ScrollView, Modal, Pressable, KeyboardAvoidingView } from 'react-native';
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
TouchableOpacity,
|
||||||
|
ScrollView,
|
||||||
|
Modal,
|
||||||
|
Pressable,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
} from 'react-native';
|
||||||
import { useCategories } from '@/hooks/useDatabase';
|
import { useCategories } from '@/hooks/useDatabase';
|
||||||
import { useSettings } from '@/theme';
|
import { useSettings } from '@/theme';
|
||||||
import Svg, { Path } from 'react-native-svg';
|
import Svg, { Path } from 'react-native-svg';
|
||||||
@@ -16,7 +25,7 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
|
|||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
|
||||||
const selected = new Set(value);
|
const selected = new Set(value);
|
||||||
const selectedCategories = categories.filter((c) => selected.has(c.id));
|
const selectedCategories = categories.filter(c => selected.has(c.id));
|
||||||
|
|
||||||
const toggle = (id: string) => {
|
const toggle = (id: string) => {
|
||||||
const next = new Set(selected);
|
const next = new Set(selected);
|
||||||
@@ -33,7 +42,7 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
|
|||||||
? 'None'
|
? 'None'
|
||||||
: selectedCategories
|
: selectedCategories
|
||||||
.slice(0, 2)
|
.slice(0, 2)
|
||||||
.map((c) => c.name)
|
.map(c => c.name)
|
||||||
.join(', ') + (selectedCategories.length > 2 ? ` +${selectedCategories.length - 2}` : '');
|
.join(', ') + (selectedCategories.length > 2 ? ` +${selectedCategories.length - 2}` : '');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -53,10 +62,18 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
|
|||||||
<View style={styles.selectorContent}>
|
<View style={styles.selectorContent}>
|
||||||
{selectedCategories.length > 0 ? (
|
{selectedCategories.length > 0 ? (
|
||||||
<View style={styles.chipRow}>
|
<View style={styles.chipRow}>
|
||||||
{selectedCategories.slice(0, 3).map((c) => (
|
{selectedCategories.slice(0, 3).map(c => (
|
||||||
<View key={c.id} style={[styles.chip, { backgroundColor: theme.cardAlt, borderColor: theme.border }]}>
|
<View
|
||||||
|
key={c.id}
|
||||||
|
style={[
|
||||||
|
styles.chip,
|
||||||
|
{ backgroundColor: theme.cardAlt, borderColor: theme.border },
|
||||||
|
]}
|
||||||
|
>
|
||||||
<View style={[styles.colorCircle, { backgroundColor: c.color }]} />
|
<View style={[styles.colorCircle, { backgroundColor: c.color }]} />
|
||||||
<Text style={[styles.chipText, { color: theme.textSecondary }]} numberOfLines={1}>{c.name}</Text>
|
<Text style={[styles.chipText, { color: theme.textSecondary }]} numberOfLines={1}>
|
||||||
|
{c.name}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
@@ -65,12 +82,24 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||||
<Path d="M9 18l6-6-6-6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
<Path
|
||||||
|
d="M9 18l6-6-6-6"
|
||||||
|
stroke={theme.textSecondary}
|
||||||
|
strokeWidth={2.5}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
</Svg>
|
</Svg>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
{error && <Text style={[styles.errorText, { color: theme.dangerStrong }]}>{error}</Text>}
|
{error && <Text style={[styles.errorText, { color: theme.dangerStrong }]}>{error}</Text>}
|
||||||
|
|
||||||
<Modal visible={showModal} transparent animationType="fade" onRequestClose={() => setShowModal(false)}>
|
<Modal
|
||||||
|
visible={showModal}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={() => setShowModal(false)}
|
||||||
|
>
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
style={[styles.modalOverlay, { backgroundColor: 'rgba(0,0,0,0.5)' }]}
|
style={[styles.modalOverlay, { backgroundColor: 'rgba(0,0,0,0.5)' }]}
|
||||||
behavior="padding"
|
behavior="padding"
|
||||||
@@ -81,7 +110,10 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
|
|||||||
<Text style={[styles.modalTitle, { color: theme.text }]}>Select Tags</Text>
|
<Text style={[styles.modalTitle, { color: theme.text }]}>Select Tags</Text>
|
||||||
<View style={styles.modalHeaderRight}>
|
<View style={styles.modalHeaderRight}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => { onChange([]); setShowModal(false); }}
|
onPress={() => {
|
||||||
|
onChange([]);
|
||||||
|
setShowModal(false);
|
||||||
|
}}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel="Clear all tags"
|
accessibilityLabel="Clear all tags"
|
||||||
@@ -102,7 +134,7 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
|
|||||||
<Text style={[styles.modalHint, { color: theme.textMuted }]}>
|
<Text style={[styles.modalHint, { color: theme.textMuted }]}>
|
||||||
A task can have multiple tags. Selected: {selected.size}
|
A task can have multiple tags. Selected: {selected.size}
|
||||||
</Text>
|
</Text>
|
||||||
{categories.map((category) => {
|
{categories.map(category => {
|
||||||
const isSelected = selected.has(category.id);
|
const isSelected = selected.has(category.id);
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -110,7 +142,11 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
|
|||||||
style={[
|
style={[
|
||||||
styles.modalOption,
|
styles.modalOption,
|
||||||
{ backgroundColor: theme.card, borderColor: theme.borderStrong },
|
{ backgroundColor: theme.card, borderColor: theme.borderStrong },
|
||||||
isSelected && { borderColor: theme.accent, backgroundColor: theme.accentSoft, borderWidth: 2 },
|
isSelected && {
|
||||||
|
borderColor: theme.accent,
|
||||||
|
backgroundColor: theme.accentSoft,
|
||||||
|
borderWidth: 2,
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
onPress={() => toggle(category.id)}
|
onPress={() => toggle(category.id)}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
@@ -118,17 +154,32 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
|
|||||||
accessibilityLabel={`Tag ${category.name}`}
|
accessibilityLabel={`Tag ${category.name}`}
|
||||||
accessibilityState={{ checked: isSelected }}
|
accessibilityState={{ checked: isSelected }}
|
||||||
>
|
>
|
||||||
<View style={[styles.colorCircle, { backgroundColor: category.color }, isSelected && styles.colorCircleSelected]} />
|
<View
|
||||||
<Text style={[
|
style={[
|
||||||
|
styles.colorCircle,
|
||||||
|
{ backgroundColor: category.color },
|
||||||
|
isSelected && styles.colorCircleSelected,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
styles.categoryName,
|
styles.categoryName,
|
||||||
{ color: theme.textSecondary },
|
{ color: theme.textSecondary },
|
||||||
isSelected && { color: theme.text, fontWeight: '600' },
|
isSelected && { color: theme.text, fontWeight: '600' },
|
||||||
]}>
|
]}
|
||||||
|
>
|
||||||
{category.name}
|
{category.name}
|
||||||
</Text>
|
</Text>
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||||
<Path d="M5 12l5 5 9-10" stroke={theme.accent} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
<Path
|
||||||
|
d="M5 12l5 5 9-10"
|
||||||
|
stroke={theme.accent}
|
||||||
|
strokeWidth={2.5}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
</Svg>
|
</Svg>
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
@@ -20,15 +20,23 @@ export default function NativeColorPickerInput({ value, onChange }: ColorPickerI
|
|||||||
const valid = isValidHex(hex);
|
const valid = isValidHex(hex);
|
||||||
|
|
||||||
const handleChange = (raw: string) => {
|
const handleChange = (raw: string) => {
|
||||||
const cleaned = raw.replace(/[^0-9a-fA-F]/g, '').slice(0, 6).toUpperCase();
|
const cleaned = raw
|
||||||
|
.replace(/[^0-9a-fA-F]/g, '')
|
||||||
|
.slice(0, 6)
|
||||||
|
.toUpperCase();
|
||||||
onChange(`#${cleaned}`);
|
onChange(`#${cleaned}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
<View style={[styles.preview, { backgroundColor: valid ? value : theme.borderStrong, borderColor: theme.borderStrong }]} />
|
<View
|
||||||
<View style={[styles.inputWrap, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong }]}>
|
style={[
|
||||||
<Text style={[styles.hash, { color: theme.textMuted }]}>#</Text>
|
styles.preview,
|
||||||
|
{ backgroundColor: valid ? value : theme.accentSoft, borderColor: theme.accent },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<View style={styles.inputWrap}>
|
||||||
|
<Text style={styles.hash}>#{/**/}</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={[styles.input, { color: theme.text }]}
|
style={[styles.input, { color: theme.text }]}
|
||||||
value={hex}
|
value={hex}
|
||||||
@@ -40,7 +48,7 @@ export default function NativeColorPickerInput({ value, onChange }: ColorPickerI
|
|||||||
maxLength={6}
|
maxLength={6}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<Text style={[styles.hint, { color: theme.textMuted }]}>Hex code</Text>
|
<Text style={styles.hint}>{/**/}Hex code</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,13 +28,13 @@ export default function WebColorPickerInput({ value, onChange }: ColorPickerInpu
|
|||||||
width: 44,
|
width: 44,
|
||||||
height: 44,
|
height: 44,
|
||||||
padding: 0,
|
padding: 0,
|
||||||
border: `1px solid ${theme.borderStrong}`,
|
border: `1px solid ${theme.accent}`,
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
<Text style={[styles.hexText, { color: theme.textSecondary }]}>{normalized}</Text>
|
<Text style={[styles.hexText, { color: theme.accent }]}>{normalized}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,14 +33,38 @@ function hsvToHex(h: number, s: number, v: number) {
|
|||||||
const c = v * s;
|
const c = v * s;
|
||||||
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||||
const m = v - c;
|
const m = v - c;
|
||||||
let r = 0, g = 0, b = 0;
|
let r = 0,
|
||||||
if (h < 60) { r = c; g = x; b = 0; }
|
g = 0,
|
||||||
else if (h < 120) { r = x; g = c; b = 0; }
|
b = 0;
|
||||||
else if (h < 180) { r = 0; g = c; b = x; }
|
if (h < 60) {
|
||||||
else if (h < 240) { r = 0; g = x; b = c; }
|
r = c;
|
||||||
else if (h < 300) { r = x; g = 0; b = c; }
|
g = x;
|
||||||
else { r = c; g = 0; b = x; }
|
b = 0;
|
||||||
const toHex = (n: number) => Math.round((n + m) * 255).toString(16).padStart(2, '0');
|
} else if (h < 120) {
|
||||||
|
r = x;
|
||||||
|
g = c;
|
||||||
|
b = 0;
|
||||||
|
} else if (h < 180) {
|
||||||
|
r = 0;
|
||||||
|
g = c;
|
||||||
|
b = x;
|
||||||
|
} else if (h < 240) {
|
||||||
|
r = 0;
|
||||||
|
g = x;
|
||||||
|
b = c;
|
||||||
|
} else if (h < 300) {
|
||||||
|
r = x;
|
||||||
|
g = 0;
|
||||||
|
b = c;
|
||||||
|
} else {
|
||||||
|
r = c;
|
||||||
|
g = 0;
|
||||||
|
b = x;
|
||||||
|
}
|
||||||
|
const toHex = (n: number) =>
|
||||||
|
Math.round((n + m) * 255)
|
||||||
|
.toString(16)
|
||||||
|
.padStart(2, '0');
|
||||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase();
|
return `#${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,35 +108,49 @@ export default function ColorWheel({ color, onChange }: ColorWheelProps) {
|
|||||||
setValue(1 - distance / radius);
|
setValue(1 - distance / radius);
|
||||||
};
|
};
|
||||||
|
|
||||||
const wheelPanResponder = React.useMemo(() => PanResponder.create({
|
const wheelPanResponder = React.useMemo(
|
||||||
|
() =>
|
||||||
|
PanResponder.create({
|
||||||
onStartShouldSetPanResponder: () => true,
|
onStartShouldSetPanResponder: () => true,
|
||||||
onMoveShouldSetPanResponder: () => true,
|
onMoveShouldSetPanResponder: () => true,
|
||||||
onPanResponderGrant: (event) => {
|
onPanResponderGrant: event => {
|
||||||
updateFromWheel(event.nativeEvent.locationX, event.nativeEvent.locationY);
|
updateFromWheel(event.nativeEvent.locationX, event.nativeEvent.locationY);
|
||||||
},
|
},
|
||||||
onPanResponderMove: (event) => {
|
onPanResponderMove: event => {
|
||||||
updateFromWheel(event.nativeEvent.locationX, event.nativeEvent.locationY);
|
updateFromWheel(event.nativeEvent.locationX, event.nativeEvent.locationY);
|
||||||
},
|
},
|
||||||
}), []);
|
}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const setHueFromLocation = (locationX: number) => {
|
const setHueFromLocation = (locationX: number) => {
|
||||||
setHue(Math.min(360, Math.max(0, (locationX / WHEEL_SIZE) * 360)));
|
setHue(Math.min(360, Math.max(0, (locationX / WHEEL_SIZE) * 360)));
|
||||||
};
|
};
|
||||||
|
|
||||||
const huePanResponder = React.useMemo(() => PanResponder.create({
|
const huePanResponder = React.useMemo(
|
||||||
|
() =>
|
||||||
|
PanResponder.create({
|
||||||
onStartShouldSetPanResponder: () => true,
|
onStartShouldSetPanResponder: () => true,
|
||||||
onMoveShouldSetPanResponder: () => true,
|
onMoveShouldSetPanResponder: () => true,
|
||||||
onPanResponderGrant: (event) => {
|
onPanResponderGrant: event => {
|
||||||
setHueFromLocation(event.nativeEvent.locationX);
|
setHueFromLocation(event.nativeEvent.locationX);
|
||||||
},
|
},
|
||||||
onPanResponderMove: (event) => {
|
onPanResponderMove: event => {
|
||||||
setHueFromLocation(event.nativeEvent.locationX);
|
setHueFromLocation(event.nativeEvent.locationX);
|
||||||
},
|
},
|
||||||
}), []);
|
}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const thumbX = ((WHEEL_SIZE - THUMB_SIZE) / 2) * saturation * Math.cos((hue - 180) * Math.PI / 180) + (WHEEL_SIZE / 2) - (THUMB_SIZE / 2);
|
const thumbX =
|
||||||
const thumbY = ((WHEEL_SIZE - THUMB_SIZE) / 2) * saturation * Math.sin((hue - 180) * Math.PI / 180) + (WHEEL_SIZE / 2) - (THUMB_SIZE / 2);
|
((WHEEL_SIZE - THUMB_SIZE) / 2) * saturation * Math.cos(((hue - 180) * Math.PI) / 180) +
|
||||||
const hueThumbX = (hue / 360) * WHEEL_SIZE - (THUMB_SIZE / 2);
|
WHEEL_SIZE / 2 -
|
||||||
|
THUMB_SIZE / 2;
|
||||||
|
const thumbY =
|
||||||
|
((WHEEL_SIZE - THUMB_SIZE) / 2) * saturation * Math.sin(((hue - 180) * Math.PI) / 180) +
|
||||||
|
WHEEL_SIZE / 2 -
|
||||||
|
THUMB_SIZE / 2;
|
||||||
|
const hueThumbX = (hue / 360) * WHEEL_SIZE - THUMB_SIZE / 2;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
@@ -180,7 +218,9 @@ export default function ColorWheel({ color, onChange }: ColorWheelProps) {
|
|||||||
|
|
||||||
<View style={styles.previewContainer}>
|
<View style={styles.previewContainer}>
|
||||||
<View style={[styles.preview, { backgroundColor: hsvToHex(hue, saturation, value) }]} />
|
<View style={[styles.preview, { backgroundColor: hsvToHex(hue, saturation, value) }]} />
|
||||||
<RNText style={[styles.previewText, { color: theme.text }]}>{hsvToHex(hue, saturation, value)}</RNText>
|
<RNText style={[styles.previewText, { color: theme.text }]}>
|
||||||
|
{hsvToHex(hue, saturation, value)}
|
||||||
|
</RNText>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -32,14 +32,38 @@ function hsvToHex(h: number, s: number, v: number) {
|
|||||||
const c = v * s;
|
const c = v * s;
|
||||||
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||||
const m = v - c;
|
const m = v - c;
|
||||||
let r = 0, g = 0, b = 0;
|
let r = 0,
|
||||||
if (h < 60) { r = c; g = x; b = 0; }
|
g = 0,
|
||||||
else if (h < 120) { r = x; g = c; b = 0; }
|
b = 0;
|
||||||
else if (h < 180) { r = 0; g = c; b = x; }
|
if (h < 60) {
|
||||||
else if (h < 240) { r = 0; g = x; b = c; }
|
r = c;
|
||||||
else if (h < 300) { r = x; g = 0; b = c; }
|
g = x;
|
||||||
else { r = c; g = 0; b = x; }
|
b = 0;
|
||||||
const toHex = (n: number) => Math.round((n + m) * 255).toString(16).padStart(2, '0');
|
} else if (h < 120) {
|
||||||
|
r = x;
|
||||||
|
g = c;
|
||||||
|
b = 0;
|
||||||
|
} else if (h < 180) {
|
||||||
|
r = 0;
|
||||||
|
g = c;
|
||||||
|
b = x;
|
||||||
|
} else if (h < 240) {
|
||||||
|
r = 0;
|
||||||
|
g = x;
|
||||||
|
b = c;
|
||||||
|
} else if (h < 300) {
|
||||||
|
r = x;
|
||||||
|
g = 0;
|
||||||
|
b = c;
|
||||||
|
} else {
|
||||||
|
r = c;
|
||||||
|
g = 0;
|
||||||
|
b = x;
|
||||||
|
}
|
||||||
|
const toHex = (n: number) =>
|
||||||
|
Math.round((n + m) * 255)
|
||||||
|
.toString(16)
|
||||||
|
.padStart(2, '0');
|
||||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase();
|
return `#${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,15 +132,25 @@ export default function ColorWheel({ color, onChange }: ColorWheelProps) {
|
|||||||
handleMove(e.nativeEvent);
|
handleMove(e.nativeEvent);
|
||||||
};
|
};
|
||||||
|
|
||||||
const thumbX = ((WHEEL_SIZE - THUMB_SIZE) / 2) * saturation * Math.cos((hue - 180) * Math.PI / 180) + (WHEEL_SIZE / 2) - (THUMB_SIZE / 2);
|
const thumbX =
|
||||||
const thumbY = ((WHEEL_SIZE - THUMB_SIZE) / 2) * saturation * Math.sin((hue - 180) * Math.PI / 180) + (WHEEL_SIZE / 2) - (THUMB_SIZE / 2);
|
((WHEEL_SIZE - THUMB_SIZE) / 2) * saturation * Math.cos(((hue - 180) * Math.PI) / 180) +
|
||||||
const hueThumbX = (hue / 360) * WHEEL_SIZE - (THUMB_SIZE / 2);
|
WHEEL_SIZE / 2 -
|
||||||
|
THUMB_SIZE / 2;
|
||||||
|
const thumbY =
|
||||||
|
((WHEEL_SIZE - THUMB_SIZE) / 2) * saturation * Math.sin(((hue - 180) * Math.PI) / 180) +
|
||||||
|
WHEEL_SIZE / 2 -
|
||||||
|
THUMB_SIZE / 2;
|
||||||
|
const hueThumbX = (hue / 360) * WHEEL_SIZE - THUMB_SIZE / 2;
|
||||||
const currentColor = hsvToHex(hue, saturation, value);
|
const currentColor = hsvToHex(hue, saturation, value);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<View style={styles.wheelContainer}>
|
<View style={styles.wheelContainer}>
|
||||||
<Svg width={WHEEL_SIZE} height={WHEEL_SIZE} {...({ onMouseDown: handleWheelMouseDown } as object)}>
|
<Svg
|
||||||
|
width={WHEEL_SIZE}
|
||||||
|
height={WHEEL_SIZE}
|
||||||
|
{...({ onMouseDown: handleWheelMouseDown } as object)}
|
||||||
|
>
|
||||||
<DefsElement>
|
<DefsElement>
|
||||||
<LinearGradient id="satGradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
<LinearGradient id="satGradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
<Stop offset="0%" stopColor={`hsl(${hue}, 100%, 50%)`} stopOpacity={1} />
|
<Stop offset="0%" stopColor={`hsl(${hue}, 100%, 50%)`} stopOpacity={1} />
|
||||||
|
|||||||
@@ -109,7 +109,11 @@ export default function WebDateTimeInput({
|
|||||||
>
|
>
|
||||||
<Text style={[styles.clearButtonText, { color: mutedColor }]}>Clear</Text>
|
<Text style={[styles.clearButtonText, { color: mutedColor }]}>Clear</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity style={[styles.doneButton, { backgroundColor: theme.accent }]} onPress={onDismiss} activeOpacity={0.8}>
|
<TouchableOpacity
|
||||||
|
style={[styles.doneButton, { backgroundColor: theme.accent }]}
|
||||||
|
onPress={onDismiss}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
<Text style={[styles.doneButtonText, { color: theme.accentText }]}>Done</Text>
|
<Text style={[styles.doneButtonText, { color: theme.accentText }]}>Done</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -49,14 +49,30 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
|
|||||||
styles.pickerButton,
|
styles.pickerButton,
|
||||||
{ backgroundColor: theme.inputBg, borderColor: theme.borderStrong },
|
{ backgroundColor: theme.inputBg, borderColor: theme.borderStrong },
|
||||||
field.value && { borderColor: theme.accent, backgroundColor: theme.accentSoft },
|
field.value && { borderColor: theme.accent, backgroundColor: theme.accentSoft },
|
||||||
fieldName === 'endTime' && endTimeInvalid && { borderColor: theme.dangerStrong, backgroundColor: theme.accentSoft },
|
fieldName === 'endTime' &&
|
||||||
|
endTimeInvalid && {
|
||||||
|
borderColor: theme.dangerStrong,
|
||||||
|
backgroundColor: theme.accentSoft,
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||||
<Circle cx={12} cy={12} r={10} stroke={field.value ? theme.accent : theme.textFaint} strokeWidth={1.8} fill="none" />
|
<Circle
|
||||||
<Path d="M12 6v6l4 2" stroke={field.value ? theme.accent : theme.textFaint} strokeWidth={1.8} strokeLinecap="round" />
|
cx={12}
|
||||||
|
cy={12}
|
||||||
|
r={10}
|
||||||
|
stroke={field.value ? theme.accent : theme.textFaint}
|
||||||
|
strokeWidth={1.8}
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
<Path
|
||||||
|
d="M12 6v6l4 2"
|
||||||
|
stroke={field.value ? theme.accent : theme.textFaint}
|
||||||
|
strokeWidth={1.8}
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
</Svg>
|
</Svg>
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
@@ -82,7 +98,12 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
|
|||||||
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
||||||
>
|
>
|
||||||
<Svg width={14} height={14} viewBox="0 0 24 24">
|
<Svg width={14} height={14} viewBox="0 0 24 24">
|
||||||
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textSecondary} strokeWidth={2.2} strokeLinecap="round" />
|
<Path
|
||||||
|
d="M18 6L6 18M6 6l12 12"
|
||||||
|
stroke={theme.textSecondary}
|
||||||
|
strokeWidth={2.2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
</Svg>
|
</Svg>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -142,7 +163,12 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
|
|||||||
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
||||||
>
|
>
|
||||||
<Svg width={14} height={14} viewBox="0 0 24 24">
|
<Svg width={14} height={14} viewBox="0 0 24 24">
|
||||||
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textSecondary} strokeWidth={2.2} strokeLinecap="round" />
|
<Path
|
||||||
|
d="M18 6L6 18M6 6l12 12"
|
||||||
|
stroke={theme.textSecondary}
|
||||||
|
strokeWidth={2.2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
</Svg>
|
</Svg>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -151,7 +177,10 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.calendarButton, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong }]}
|
style={[
|
||||||
|
styles.calendarButton,
|
||||||
|
{ backgroundColor: theme.inputBg, borderColor: theme.borderStrong },
|
||||||
|
]}
|
||||||
onPress={() => setPicker('date')}
|
onPress={() => setPicker('date')}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
accessibilityLabel="Open calendar"
|
accessibilityLabel="Open calendar"
|
||||||
@@ -184,7 +213,7 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
|
|||||||
thumbColor={field.value ? theme.accent : theme.textMuted}
|
thumbColor={field.value ? theme.accent : theme.textMuted}
|
||||||
ios_backgroundColor={theme.border}
|
ios_backgroundColor={theme.border}
|
||||||
value={field.value ?? false}
|
value={field.value ?? false}
|
||||||
onValueChange={(value) => {
|
onValueChange={value => {
|
||||||
field.onChange(value);
|
field.onChange(value);
|
||||||
if (value) {
|
if (value) {
|
||||||
timeRef.current?.('');
|
timeRef.current?.('');
|
||||||
@@ -203,10 +232,27 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{endTimeInvalid && (
|
{endTimeInvalid && (
|
||||||
<View style={[styles.warningRow, { backgroundColor: theme.accentSoft, borderColor: theme.dangerStrong }]}>
|
<View
|
||||||
|
style={[
|
||||||
|
styles.warningRow,
|
||||||
|
{ backgroundColor: theme.accentSoft, borderColor: theme.dangerStrong },
|
||||||
|
]}
|
||||||
|
>
|
||||||
<Svg width={14} height={14} viewBox="0 0 24 24">
|
<Svg width={14} height={14} viewBox="0 0 24 24">
|
||||||
<Circle cx={12} cy={12} r={10} stroke={theme.dangerStrong} strokeWidth={1.8} fill="none" />
|
<Circle
|
||||||
<Path d="M12 8v5M12 16.5v.5" stroke={theme.dangerStrong} strokeWidth={1.8} strokeLinecap="round" />
|
cx={12}
|
||||||
|
cy={12}
|
||||||
|
r={10}
|
||||||
|
stroke={theme.dangerStrong}
|
||||||
|
strokeWidth={1.8}
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
<Path
|
||||||
|
d="M12 8v5M12 16.5v.5"
|
||||||
|
stroke={theme.dangerStrong}
|
||||||
|
strokeWidth={1.8}
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
</Svg>
|
</Svg>
|
||||||
<Text style={[styles.warningText, { color: theme.dangerStrong }]}>
|
<Text style={[styles.warningText, { color: theme.dangerStrong }]}>
|
||||||
{endTime === startTime
|
{endTime === startTime
|
||||||
@@ -235,12 +281,8 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
|
|||||||
<WheelTimePicker
|
<WheelTimePicker
|
||||||
visible={picker === 'time' || picker === 'endTime'}
|
visible={picker === 'time' || picker === 'endTime'}
|
||||||
title={picker === 'endTime' ? 'End Time' : 'Start Time'}
|
title={picker === 'endTime' ? 'End Time' : 'Start Time'}
|
||||||
initialTime={
|
initialTime={picker === 'endTime' ? endTime || startTime : startTime}
|
||||||
picker === 'endTime'
|
onConfirm={time => {
|
||||||
? endTime || startTime
|
|
||||||
: startTime
|
|
||||||
}
|
|
||||||
onConfirm={(time) => {
|
|
||||||
if (picker === 'endTime') {
|
if (picker === 'endTime') {
|
||||||
setEndTimeValue(time);
|
setEndTimeValue(time);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ export function DescriptionInput({ error, ...props }: DescriptionInputProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const createStyles = (theme: ThemeColors) => StyleSheet.create({
|
const createStyles = (theme: ThemeColors) =>
|
||||||
|
StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
gap: 5,
|
gap: 5,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -47,12 +47,7 @@ export function FloatingActionButton() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View
|
<Animated.View style={[styles.container, { transform: [{ scale: scaleAnim }] }]}>
|
||||||
style={[
|
|
||||||
styles.container,
|
|
||||||
{ transform: [{ scale: scaleAnim }] },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.button, { backgroundColor: theme.accent, shadowColor: theme.accent }]}
|
style={[styles.button, { backgroundColor: theme.accent, shadowColor: theme.accent }]}
|
||||||
onPress={handlePress}
|
onPress={handlePress}
|
||||||
|
|||||||
@@ -44,13 +44,17 @@ export function ForgotPasswordModal({ visible, onClose }: ForgotPasswordModalPro
|
|||||||
const result = await forgotPassword(username.trim());
|
const result = await forgotPassword(username.trim());
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
if (result.resetToken) {
|
if (result.resetToken) {
|
||||||
Alert.alert('Reset Token (Dev Mode)', `Your reset token is: ${result.resetToken}\n\nEnter this token on the next screen.`, [
|
Alert.alert(
|
||||||
{ text: 'OK', onPress: () => setStep('enterResetCode') },
|
'Reset Token (Dev Mode)',
|
||||||
]);
|
`Your reset token is: ${result.resetToken}\n\nEnter this token on the next screen.`,
|
||||||
|
[{ text: 'OK', onPress: () => setStep('enterResetCode') }]
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
Alert.alert('Reset Email Sent', 'If the account exists, a password reset link has been sent.', [
|
Alert.alert(
|
||||||
{ text: 'OK', onPress: () => setStep('enterResetCode') },
|
'Reset Email Sent',
|
||||||
]);
|
'If the account exists, a password reset link has been sent.',
|
||||||
|
[{ text: 'OK', onPress: () => setStep('enterResetCode') }]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setError('Failed to request password reset');
|
setError('Failed to request password reset');
|
||||||
@@ -112,7 +116,10 @@ export function ForgotPasswordModal({ visible, onClose }: ForgotPasswordModalPro
|
|||||||
Enter your username to receive a password reset link.
|
Enter your username to receive a password reset link.
|
||||||
</Text>
|
</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={[styles.input, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text }]}
|
style={[
|
||||||
|
styles.input,
|
||||||
|
{ backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text },
|
||||||
|
]}
|
||||||
placeholder="Username"
|
placeholder="Username"
|
||||||
placeholderTextColor={theme.textMuted}
|
placeholderTextColor={theme.textMuted}
|
||||||
value={username}
|
value={username}
|
||||||
@@ -130,7 +137,9 @@ export function ForgotPasswordModal({ visible, onClose }: ForgotPasswordModalPro
|
|||||||
{busy ? (
|
{busy ? (
|
||||||
<ActivityIndicator color={theme.accentText} />
|
<ActivityIndicator color={theme.accentText} />
|
||||||
) : (
|
) : (
|
||||||
<Text style={[styles.primaryButtonText, { color: theme.accentText }]}>Send Reset Link</Text>
|
<Text style={[styles.primaryButtonText, { color: theme.accentText }]}>
|
||||||
|
Send Reset Link
|
||||||
|
</Text>
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
@@ -142,7 +151,10 @@ export function ForgotPasswordModal({ visible, onClose }: ForgotPasswordModalPro
|
|||||||
Enter the reset token and your new password.
|
Enter the reset token and your new password.
|
||||||
</Text>
|
</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={[styles.input, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text }]}
|
style={[
|
||||||
|
styles.input,
|
||||||
|
{ backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text },
|
||||||
|
]}
|
||||||
placeholder="Reset Token"
|
placeholder="Reset Token"
|
||||||
placeholderTextColor={theme.textMuted}
|
placeholderTextColor={theme.textMuted}
|
||||||
value={resetToken}
|
value={resetToken}
|
||||||
@@ -151,7 +163,10 @@ export function ForgotPasswordModal({ visible, onClose }: ForgotPasswordModalPro
|
|||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={[styles.input, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text }]}
|
style={[
|
||||||
|
styles.input,
|
||||||
|
{ backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text },
|
||||||
|
]}
|
||||||
placeholder="New Password (min 8 chars)"
|
placeholder="New Password (min 8 chars)"
|
||||||
placeholderTextColor={theme.textMuted}
|
placeholderTextColor={theme.textMuted}
|
||||||
value={newPassword}
|
value={newPassword}
|
||||||
@@ -159,7 +174,10 @@ export function ForgotPasswordModal({ visible, onClose }: ForgotPasswordModalPro
|
|||||||
secureTextEntry
|
secureTextEntry
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={[styles.input, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text }]}
|
style={[
|
||||||
|
styles.input,
|
||||||
|
{ backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text },
|
||||||
|
]}
|
||||||
placeholder="Confirm New Password"
|
placeholder="Confirm New Password"
|
||||||
placeholderTextColor={theme.textMuted}
|
placeholderTextColor={theme.textMuted}
|
||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
@@ -176,7 +194,9 @@ export function ForgotPasswordModal({ visible, onClose }: ForgotPasswordModalPro
|
|||||||
{busy ? (
|
{busy ? (
|
||||||
<ActivityIndicator color={theme.accentText} />
|
<ActivityIndicator color={theme.accentText} />
|
||||||
) : (
|
) : (
|
||||||
<Text style={[styles.primaryButtonText, { color: theme.accentText }]}>Reset Password</Text>
|
<Text style={[styles.primaryButtonText, { color: theme.accentText }]}>
|
||||||
|
Reset Password
|
||||||
|
</Text>
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
@@ -184,10 +204,7 @@ export function ForgotPasswordModal({ visible, onClose }: ForgotPasswordModalPro
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={handleBack}>
|
<Modal visible={visible} transparent animationType="fade" onRequestClose={handleBack}>
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView style={styles.overlay} behavior="padding">
|
||||||
style={styles.overlay}
|
|
||||||
behavior="padding"
|
|
||||||
>
|
|
||||||
<TouchableOpacity style={styles.backdrop} activeOpacity={1} onPress={handleBack} />
|
<TouchableOpacity style={styles.backdrop} activeOpacity={1} onPress={handleBack} />
|
||||||
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
|
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
|
||||||
<View style={[styles.handle, { backgroundColor: theme.borderStrong }]} />
|
<View style={[styles.handle, { backgroundColor: theme.borderStrong }]} />
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ interface FormButtonsProps {
|
|||||||
export function FormButtons({ onSubmit, submitLabel = 'Submit' }: FormButtonsProps) {
|
export function FormButtons({ onSubmit, submitLabel = 'Submit' }: FormButtonsProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { theme } = useSettings();
|
const { theme } = useSettings();
|
||||||
const { handleSubmit, formState: { isSubmitting } } = useFormContext();
|
const {
|
||||||
|
handleSubmit,
|
||||||
|
formState: { isSubmitting },
|
||||||
|
} = useFormContext();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
const cancel = () => {
|
const cancel = () => {
|
||||||
@@ -25,9 +28,21 @@ export function FormButtons({ onSubmit, submitLabel = 'Submit' }: FormButtonsPro
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.container, { backgroundColor: theme.background, borderTopColor: theme.border, paddingBottom: insets.bottom + 12 }]}>
|
<View
|
||||||
|
style={[
|
||||||
|
styles.container,
|
||||||
|
{
|
||||||
|
backgroundColor: theme.background,
|
||||||
|
borderTopColor: theme.border,
|
||||||
|
paddingBottom: insets.bottom + 12,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.cancelButton, { borderColor: theme.borderStrong, backgroundColor: theme.card }]}
|
style={[
|
||||||
|
styles.cancelButton,
|
||||||
|
{ borderColor: theme.borderStrong, backgroundColor: theme.card },
|
||||||
|
]}
|
||||||
onPress={cancel}
|
onPress={cancel}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
@@ -38,7 +53,11 @@ export function FormButtons({ onSubmit, submitLabel = 'Submit' }: FormButtonsPro
|
|||||||
<Text style={[styles.cancelButtonText, { color: theme.textSecondary }]}>Cancel</Text>
|
<Text style={[styles.cancelButtonText, { color: theme.textSecondary }]}>Cancel</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.submitButton, { backgroundColor: theme.accent }, isSubmitting && styles.buttonDisabled]}
|
style={[
|
||||||
|
styles.submitButton,
|
||||||
|
{ backgroundColor: theme.accent },
|
||||||
|
isSubmitting && styles.buttonDisabled,
|
||||||
|
]}
|
||||||
onPress={handleSubmit(onSubmit)}
|
onPress={handleSubmit(onSubmit)}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
|
|||||||
@@ -21,11 +21,23 @@ interface FriendsModalProps {
|
|||||||
|
|
||||||
export function FriendsModal({ visible, onClose }: FriendsModalProps) {
|
export function FriendsModal({ visible, onClose }: FriendsModalProps) {
|
||||||
const { theme } = useSettings();
|
const { theme } = useSettings();
|
||||||
const { friends, incoming, outgoing, loading, sendRequest, acceptRequest, declineRequest, removeFriend, searchUsers } = useFriends();
|
const {
|
||||||
|
friends,
|
||||||
|
incoming,
|
||||||
|
outgoing,
|
||||||
|
loading,
|
||||||
|
sendRequest,
|
||||||
|
acceptRequest,
|
||||||
|
declineRequest,
|
||||||
|
removeFriend,
|
||||||
|
searchUsers,
|
||||||
|
} = useFriends();
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [searchResults, setSearchResults] = useState<{ id: string; username: string }[]>([]);
|
const [searchResults, setSearchResults] = useState<{ id: string; username: string }[]>([]);
|
||||||
const [searching, setSearching] = useState(false);
|
const [searching, setSearching] = useState(false);
|
||||||
const [selectedTab, setSelectedTab] = useState<'friends' | 'incoming' | 'outgoing' | 'add'>('friends');
|
const [selectedTab, setSelectedTab] = useState<'friends' | 'incoming' | 'outgoing' | 'add'>(
|
||||||
|
'friends'
|
||||||
|
);
|
||||||
|
|
||||||
const searchKey = `${selectedTab}:${searchQuery}`;
|
const searchKey = `${selectedTab}:${searchQuery}`;
|
||||||
const [prevSearchKey, setPrevSearchKey] = useState(searchKey);
|
const [prevSearchKey, setPrevSearchKey] = useState(searchKey);
|
||||||
@@ -41,7 +53,11 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
|
|||||||
setSearching(true);
|
setSearching(true);
|
||||||
try {
|
try {
|
||||||
const results = await searchUsers(searchQuery);
|
const results = await searchUsers(searchQuery);
|
||||||
setSearchResults(results.filter(r => !friends.some(f => f.id === r.id) && !outgoing.some(o => o.id === r.id)));
|
setSearchResults(
|
||||||
|
results.filter(
|
||||||
|
r => !friends.some(f => f.id === r.id) && !outgoing.some(o => o.id === r.id)
|
||||||
|
)
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Search failed:', err);
|
console.error('Search failed:', err);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -71,13 +87,17 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
|
|||||||
const handleRemove = async (friendId: string) => {
|
const handleRemove = async (friendId: string) => {
|
||||||
Alert.alert('Remove Friend', 'Are you sure you want to remove this friend?', [
|
Alert.alert('Remove Friend', 'Are you sure you want to remove this friend?', [
|
||||||
{ text: 'Cancel', style: 'cancel' },
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
{ text: 'Remove', style: 'destructive', onPress: async () => {
|
{
|
||||||
|
text: 'Remove',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
try {
|
try {
|
||||||
await removeFriend(friendId);
|
await removeFriend(friendId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
Alert.alert('Error', err instanceof Error ? err.message : 'Failed to remove friend');
|
Alert.alert('Error', err instanceof Error ? err.message : 'Failed to remove friend');
|
||||||
}
|
}
|
||||||
}},
|
},
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -98,7 +118,7 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.tabs}>
|
<View style={styles.tabs}>
|
||||||
{(['friends', 'incoming', 'outgoing', 'add'] as const).map((tab) => (
|
{(['friends', 'incoming', 'outgoing', 'add'] as const).map(tab => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={tab}
|
key={tab}
|
||||||
style={[
|
style={[
|
||||||
@@ -116,10 +136,14 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
|
|||||||
>
|
>
|
||||||
{tab.charAt(0).toUpperCase() + tab.slice(1)}
|
{tab.charAt(0).toUpperCase() + tab.slice(1)}
|
||||||
{tab === 'friends' && friends.length > 0 && (
|
{tab === 'friends' && friends.length > 0 && (
|
||||||
<Text style={[styles.badge, { color: theme.accentText }]}>{friends.length}</Text>
|
<Text style={[styles.badge, { color: theme.accentText }]}>
|
||||||
|
{friends.length}
|
||||||
|
</Text>
|
||||||
)}
|
)}
|
||||||
{tab === 'incoming' && incoming.length > 0 && (
|
{tab === 'incoming' && incoming.length > 0 && (
|
||||||
<Text style={[styles.badge, { color: theme.accentText }]}>{incoming.length}</Text>
|
<Text style={[styles.badge, { color: theme.accentText }]}>
|
||||||
|
{incoming.length}
|
||||||
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -135,7 +159,9 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
|
|||||||
) : friends.length === 0 ? (
|
) : friends.length === 0 ? (
|
||||||
<View style={styles.empty}>
|
<View style={styles.empty}>
|
||||||
<Text style={[styles.emptyText, { color: theme.textMuted }]}>No friends yet</Text>
|
<Text style={[styles.emptyText, { color: theme.textMuted }]}>No friends yet</Text>
|
||||||
<Text style={[styles.emptyHint, { color: theme.textFaint }]}>{'Tap "Add" to find friends by username'}</Text>
|
<Text style={[styles.emptyHint, { color: theme.textFaint }]}>
|
||||||
|
{'Tap "Add" to find friends by username'}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<FlatList
|
<FlatList
|
||||||
@@ -143,12 +169,19 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
|
|||||||
keyExtractor={f => f.id}
|
keyExtractor={f => f.id}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
<View style={[styles.friendRow, { borderBottomColor: theme.border }]}>
|
<View style={[styles.friendRow, { borderBottomColor: theme.border }]}>
|
||||||
<Text style={[styles.friendName, { color: theme.text }]}>{item.username}</Text>
|
<Text style={[styles.friendName, { color: theme.text }]}>
|
||||||
|
{item.username}
|
||||||
|
</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.removeBtn, { backgroundColor: theme.accentSoft, borderColor: theme.accent }]}
|
style={[
|
||||||
|
styles.removeBtn,
|
||||||
|
{ backgroundColor: theme.accentSoft, borderColor: theme.accent },
|
||||||
|
]}
|
||||||
onPress={() => handleRemove(item.id)}
|
onPress={() => handleRemove(item.id)}
|
||||||
>
|
>
|
||||||
<Text style={[styles.removeBtnText, { color: theme.accentStrong }]}>Remove</Text>
|
<Text style={[styles.removeBtnText, { color: theme.accentStrong }]}>
|
||||||
|
Remove
|
||||||
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -165,7 +198,9 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
|
|||||||
</View>
|
</View>
|
||||||
) : incoming.length === 0 ? (
|
) : incoming.length === 0 ? (
|
||||||
<View style={styles.empty}>
|
<View style={styles.empty}>
|
||||||
<Text style={[styles.emptyText, { color: theme.textMuted }]}>No incoming requests</Text>
|
<Text style={[styles.emptyText, { color: theme.textMuted }]}>
|
||||||
|
No incoming requests
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<FlatList
|
<FlatList
|
||||||
@@ -173,19 +208,32 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
|
|||||||
keyExtractor={r => r.requestId}
|
keyExtractor={r => r.requestId}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
<View style={[styles.requestRow, { borderBottomColor: theme.border }]}>
|
<View style={[styles.requestRow, { borderBottomColor: theme.border }]}>
|
||||||
<Text style={[styles.friendName, { color: theme.text }]}>{item.username}</Text>
|
<Text style={[styles.friendName, { color: theme.text }]}>
|
||||||
|
{item.username}
|
||||||
|
</Text>
|
||||||
<View style={styles.requestActions}>
|
<View style={styles.requestActions}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.actionBtn, { backgroundColor: theme.accent }]}
|
style={[styles.actionBtn, { backgroundColor: theme.accent }]}
|
||||||
onPress={() => handleAccept(item.requestId)}
|
onPress={() => handleAccept(item.requestId)}
|
||||||
>
|
>
|
||||||
<Text style={[styles.actionBtnText, { color: theme.accentText }]}>Accept</Text>
|
<Text style={[styles.actionBtnText, { color: theme.accentText }]}>
|
||||||
|
Accept
|
||||||
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.actionBtn, { backgroundColor: 'transparent', borderColor: theme.borderStrong, borderWidth: 1 }]}
|
style={[
|
||||||
|
styles.actionBtn,
|
||||||
|
{
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
borderColor: theme.borderStrong,
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
]}
|
||||||
onPress={() => handleDecline(item.requestId)}
|
onPress={() => handleDecline(item.requestId)}
|
||||||
>
|
>
|
||||||
<Text style={[styles.actionBtnText, { color: theme.textMuted }]}>Decline</Text>
|
<Text style={[styles.actionBtnText, { color: theme.textMuted }]}>
|
||||||
|
Decline
|
||||||
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -203,7 +251,9 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
|
|||||||
</View>
|
</View>
|
||||||
) : outgoing.length === 0 ? (
|
) : outgoing.length === 0 ? (
|
||||||
<View style={styles.empty}>
|
<View style={styles.empty}>
|
||||||
<Text style={[styles.emptyText, { color: theme.textMuted }]}>No outgoing requests</Text>
|
<Text style={[styles.emptyText, { color: theme.textMuted }]}>
|
||||||
|
No outgoing requests
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<FlatList
|
<FlatList
|
||||||
@@ -211,13 +261,24 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
|
|||||||
keyExtractor={r => r.requestId}
|
keyExtractor={r => r.requestId}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
<View style={[styles.requestRow, { borderBottomColor: theme.border }]}>
|
<View style={[styles.requestRow, { borderBottomColor: theme.border }]}>
|
||||||
<Text style={[styles.friendName, { color: theme.textMuted }]}>{item.username}</Text>
|
<Text style={[styles.friendName, { color: theme.textMuted }]}>
|
||||||
|
{item.username}
|
||||||
|
</Text>
|
||||||
<Text style={[styles.pendingText, { color: theme.textFaint }]}>Pending</Text>
|
<Text style={[styles.pendingText, { color: theme.textFaint }]}>Pending</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.cancelBtn, { backgroundColor: 'transparent', borderColor: theme.borderStrong, borderWidth: 1 }]}
|
style={[
|
||||||
|
styles.cancelBtn,
|
||||||
|
{
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
borderColor: theme.borderStrong,
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
]}
|
||||||
onPress={() => handleDecline(item.requestId)}
|
onPress={() => handleDecline(item.requestId)}
|
||||||
>
|
>
|
||||||
<Text style={[styles.cancelBtnText, { color: theme.textMuted }]}>Cancel</Text>
|
<Text style={[styles.cancelBtnText, { color: theme.textMuted }]}>
|
||||||
|
Cancel
|
||||||
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -230,7 +291,14 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
|
|||||||
<View style={styles.content}>
|
<View style={styles.content}>
|
||||||
<View style={styles.searchContainer}>
|
<View style={styles.searchContainer}>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={[styles.searchInput, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text }]}
|
style={[
|
||||||
|
styles.searchInput,
|
||||||
|
{
|
||||||
|
backgroundColor: theme.inputBg,
|
||||||
|
borderColor: theme.borderStrong,
|
||||||
|
color: theme.text,
|
||||||
|
},
|
||||||
|
]}
|
||||||
placeholder="Search by username"
|
placeholder="Search by username"
|
||||||
placeholderTextColor={theme.textMuted}
|
placeholderTextColor={theme.textMuted}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
@@ -253,16 +321,24 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
|
|||||||
keyExtractor={u => u.id}
|
keyExtractor={u => u.id}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
<View style={[styles.friendRow, { borderBottomColor: theme.border }]}>
|
<View style={[styles.friendRow, { borderBottomColor: theme.border }]}>
|
||||||
<Text style={[styles.friendName, { color: theme.text }]}>{item.username}</Text>
|
<Text style={[styles.friendName, { color: theme.text }]}>
|
||||||
|
{item.username}
|
||||||
|
</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.addBtn, { backgroundColor: theme.accent }]}
|
style={[styles.addBtn, { backgroundColor: theme.accent }]}
|
||||||
onPress={() => sendRequest(item.username)
|
onPress={() =>
|
||||||
|
sendRequest(item.username)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setSearchQuery('');
|
setSearchQuery('');
|
||||||
setSearchResults([]);
|
setSearchResults([]);
|
||||||
setSelectedTab('friends');
|
setSelectedTab('friends');
|
||||||
})
|
})
|
||||||
.catch(err => Alert.alert('Error', err instanceof Error ? err.message : 'Failed to send request'))
|
.catch(err =>
|
||||||
|
Alert.alert(
|
||||||
|
'Error',
|
||||||
|
err instanceof Error ? err.message : 'Failed to send request'
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Text style={[styles.addBtnText, { color: theme.accentText }]}>Add</Text>
|
<Text style={[styles.addBtnText, { color: theme.accentText }]}>Add</Text>
|
||||||
@@ -272,7 +348,9 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
|
|||||||
/>
|
/>
|
||||||
) : searchQuery.length < 2 ? (
|
) : searchQuery.length < 2 ? (
|
||||||
<View style={styles.empty}>
|
<View style={styles.empty}>
|
||||||
<Text style={[styles.emptyHint, { color: theme.textFaint }]}>Type at least 2 characters to search</Text>
|
<Text style={[styles.emptyHint, { color: theme.textFaint }]}>
|
||||||
|
Type at least 2 characters to search
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ export function Header({ title, showLogo, rightAction }: HeaderProps) {
|
|||||||
<Text style={[styles.logoText, { color: theme.accentText }]}>✓</Text>
|
<Text style={[styles.logoText, { color: theme.accentText }]}>✓</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
<Text style={[styles.title, { color: theme.text }]} accessibilityRole="header">{title}</Text>
|
<Text style={[styles.title, { color: theme.text }]} accessibilityRole="header">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
<View style={styles.spacer}>{rightAction}</View>
|
<View style={styles.spacer}>{rightAction}</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -136,7 +136,12 @@ export function LegalModal({ visible, type, onClose }: LegalModalProps) {
|
|||||||
<Text style={[styles.title, { color: theme.text }]}>{title}</Text>
|
<Text style={[styles.title, { color: theme.text }]}>{title}</Text>
|
||||||
<TouchableOpacity onPress={onClose} style={styles.closeButton} activeOpacity={0.7}>
|
<TouchableOpacity onPress={onClose} style={styles.closeButton} activeOpacity={0.7}>
|
||||||
<Svg width={24} height={24} viewBox="0 0 24 24">
|
<Svg width={24} height={24} viewBox="0 0 24 24">
|
||||||
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textSecondary} strokeWidth={2.2} strokeLinecap="round" />
|
<Path
|
||||||
|
d="M18 6L6 18M6 6l12 12"
|
||||||
|
stroke={theme.textSecondary}
|
||||||
|
strokeWidth={2.2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
</Svg>
|
</Svg>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
|
||||||
import { useSettings } from '@/theme';
|
|
||||||
import Svg, { Path } from 'react-native-svg';
|
|
||||||
|
|
||||||
interface ListItemProps {
|
|
||||||
title: string;
|
|
||||||
subtitle?: string;
|
|
||||||
leftElement?: React.ReactNode;
|
|
||||||
rightElement?: React.ReactNode;
|
|
||||||
onPress?: () => void;
|
|
||||||
showChevron?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ListItem({ title, subtitle, leftElement, rightElement, onPress, showChevron }: ListItemProps) {
|
|
||||||
const { theme } = useSettings();
|
|
||||||
const isInteractive = onPress || showChevron;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[
|
|
||||||
styles.container,
|
|
||||||
{ backgroundColor: theme.card, borderColor: theme.border },
|
|
||||||
isInteractive && styles.interactive,
|
|
||||||
]}
|
|
||||||
onPress={onPress}
|
|
||||||
activeOpacity={0.7}
|
|
||||||
disabled={!isInteractive}
|
|
||||||
accessibilityRole={isInteractive ? 'button' : 'none'}
|
|
||||||
accessibilityLabel={title}
|
|
||||||
accessibilityHint={subtitle ? subtitle : undefined}
|
|
||||||
accessibilityState={{ disabled: !isInteractive }}
|
|
||||||
>
|
|
||||||
{leftElement && <View style={styles.leftElement}>{leftElement}</View>}
|
|
||||||
<View style={styles.leftContent}>
|
|
||||||
<Text style={[styles.title, { color: theme.text }]}>{title}</Text>
|
|
||||||
{subtitle && <Text style={[styles.subtitle, { color: theme.textMuted }]}>{subtitle}</Text>}
|
|
||||||
</View>
|
|
||||||
<View style={styles.rightContent}>
|
|
||||||
{rightElement}
|
|
||||||
{showChevron && !rightElement && (
|
|
||||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
|
||||||
<Path
|
|
||||||
d="M9 18l6-6-6-6"
|
|
||||||
stroke={theme.textSecondary}
|
|
||||||
strokeWidth={2.5}
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
fill="none"
|
|
||||||
/>
|
|
||||||
</Svg>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
paddingVertical: 14,
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
borderRadius: 12,
|
|
||||||
borderWidth: 1,
|
|
||||||
marginBottom: 8,
|
|
||||||
},
|
|
||||||
interactive: {},
|
|
||||||
leftElement: {
|
|
||||||
marginRight: 12,
|
|
||||||
},
|
|
||||||
leftContent: {
|
|
||||||
flex: 1,
|
|
||||||
gap: 2,
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: '500',
|
|
||||||
},
|
|
||||||
subtitle: {
|
|
||||||
fontSize: 13,
|
|
||||||
},
|
|
||||||
rightContent: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 12,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -47,7 +47,7 @@ interface TaskItemProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const TaskItem = React.memo(function TaskItem({ task, onToggle, onDelete, onPress, onLongPress, onMenuOpen, selected, selectionMode, completedSection, draggable, hovered, onDragStart, onDragUpdate, onDragEnd, onReorderStart, onReorderUpdate, onReorderEnd, expanded, indented, depth = 0, categoryColor, categoryColors }: TaskItemProps) {
|
export const TaskItem = React.memo(function TaskItem({ task, onToggle, onDelete, onPress, onLongPress, onMenuOpen, selected, selectionMode, completedSection, draggable, hovered, onDragStart, onDragUpdate, onDragEnd, onReorderStart, onReorderUpdate, onReorderEnd, expanded, indented, depth = 0, categoryColor, categoryColors }: TaskItemProps) {
|
||||||
const { theme } = useSettings();
|
const { theme, deleteBySwipe } = useSettings();
|
||||||
const styles = React.useMemo(() => createStyles(theme), [theme]);
|
const styles = React.useMemo(() => createStyles(theme), [theme]);
|
||||||
const [opacityAnim] = React.useState(new Animated.Value(task.completed ? 0.5 : 1));
|
const [opacityAnim] = React.useState(new Animated.Value(task.completed ? 0.5 : 1));
|
||||||
const [dragTranslateX] = React.useState(new Animated.Value(0));
|
const [dragTranslateX] = React.useState(new Animated.Value(0));
|
||||||
@@ -176,7 +176,9 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
|
|||||||
renderRightActions={renderRightActions}
|
renderRightActions={renderRightActions}
|
||||||
renderLeftActions={renderLeftActions}
|
renderLeftActions={renderLeftActions}
|
||||||
onSwipeableRightOpen={() => {
|
onSwipeableRightOpen={() => {
|
||||||
|
if (deleteBySwipe) {
|
||||||
onDelete?.();
|
onDelete?.();
|
||||||
|
}
|
||||||
swipeableRef.current?.close();
|
swipeableRef.current?.close();
|
||||||
}}
|
}}
|
||||||
onSwipeableLeftOpen={() => {
|
onSwipeableLeftOpen={() => {
|
||||||
|
|||||||
@@ -334,8 +334,8 @@ export function TaskList({ categoryIds = [], showCompleted = false, onSelectionC
|
|||||||
}
|
}
|
||||||
}, [findHoverTarget]);
|
}, [findHoverTarget]);
|
||||||
|
|
||||||
const handleSubtaskDragStart = useCallback(async (subtaskId: string, parentTaskId: string) => {
|
const handleSubtaskDragStart = useCallback(async (subtaskId: string, parentTaskId: string, parentSubtaskId?: string) => {
|
||||||
subtaskDragRef.current = { subtaskId, parentTaskId, ...(await measureAll()) };
|
subtaskDragRef.current = { subtaskId, parentTaskId, parentSubtaskId, ...(await measureAll()) };
|
||||||
}, [measureAll]);
|
}, [measureAll]);
|
||||||
|
|
||||||
const handleSubtaskDragUpdate = useCallback((absoluteY: number) => {
|
const handleSubtaskDragUpdate = useCallback((absoluteY: number) => {
|
||||||
@@ -371,16 +371,15 @@ export function TaskList({ categoryIds = [], showCompleted = false, onSelectionC
|
|||||||
const target = findHoverTarget(absoluteY, state.subtaskId, state.positions, state.subPositions);
|
const target = findHoverTarget(absoluteY, state.subtaskId, state.positions, state.subPositions);
|
||||||
if (target === state.subtaskId) return;
|
if (target === state.subtaskId) return;
|
||||||
|
|
||||||
if (target) {
|
// Only allow dropping on tasks (not subtasks) to match task behavior
|
||||||
if (state.subPositions[target]) {
|
// Check if target exists in main task positions, not subtask positions
|
||||||
await setSubtaskParent(state.subtaskId, target);
|
const isTaskTarget = state.positions && target in state.positions;
|
||||||
} else if (target !== state.parentTaskId) {
|
if (target && isTaskTarget) {
|
||||||
|
// Move subtask to target task - makes it a subtask of that task
|
||||||
|
// If no target or target is a subtask: do nothing (matches task behavior)
|
||||||
await moveSubtaskToTask(state.subtaskId, target);
|
await moveSubtaskToTask(state.subtaskId, target);
|
||||||
}
|
}
|
||||||
} else {
|
}, [findHoverTarget, moveSubtaskToTask]);
|
||||||
await convertSubtaskToTask(state.subtaskId);
|
|
||||||
}
|
|
||||||
}, [findHoverTarget]);
|
|
||||||
|
|
||||||
const renderItem = useCallback(
|
const renderItem = useCallback(
|
||||||
({ item, index }: { item: Task; index: number }) => {
|
({ item, index }: { item: Task; index: number }) => {
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { useDatabase } from './useDatabase';
|
||||||
|
import { Q } from '@nozbe/watermelondb';
|
||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import Task from '../models/Task';
|
||||||
|
import parseICal from '../utils/icalParser';
|
||||||
|
import { startOfMonth, endOfMonth, addMonths, format } from 'date-fns';
|
||||||
|
|
||||||
|
function mapICalEventToTask(event: {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
start: Date;
|
||||||
|
end?: Date;
|
||||||
|
allDay?: boolean;
|
||||||
|
rrule?: string;
|
||||||
|
uid?: string;
|
||||||
|
priority?: number;
|
||||||
|
}): {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
dueDate: number;
|
||||||
|
dueTime: string;
|
||||||
|
endTime: string;
|
||||||
|
allDay: boolean;
|
||||||
|
repeat: string;
|
||||||
|
repeatInterval: number;
|
||||||
|
repeatDays: string;
|
||||||
|
seriesId: string;
|
||||||
|
reminder: string;
|
||||||
|
reminders: string;
|
||||||
|
categoryId: string;
|
||||||
|
tags: string;
|
||||||
|
priority: string;
|
||||||
|
completed: boolean;
|
||||||
|
completedAt: number | null;
|
||||||
|
} {
|
||||||
|
const defaultCategoryId = 'cat_default'; // will be resolved later
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
let dueDate = event.start.getTime();
|
||||||
|
let dueTime = '00:00';
|
||||||
|
let endTime = '00:00';
|
||||||
|
let allDay = event.allDay === true || !event.end;
|
||||||
|
|
||||||
|
if (event.allDay) {
|
||||||
|
dueDate = new Date(event.start.getFullYear(), event.start.getMonth(), event.start.getDate()).getTime();
|
||||||
|
dueTime = '00:00';
|
||||||
|
endTime = '23:59';
|
||||||
|
} else {
|
||||||
|
dueDate = event.start.getTime();
|
||||||
|
dueTime = `${event.start.getHours().toString().padStart(2, '0')}:${event.start.getMinutes().toString().padStart(2, '0')}`;
|
||||||
|
if (event.end) {
|
||||||
|
endTime = `${event.end.getHours().toString().padStart(2, '0')}:${event.end.getMinutes().toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let repeat = 'none';
|
||||||
|
let repeatInterval = 1;
|
||||||
|
let repeatDays = '';
|
||||||
|
let seriesId = event.uid || `${dueDate}-${Math.random().toString(36).substring(2, 9)}`;
|
||||||
|
let reminder = 'none';
|
||||||
|
let reminders = '';
|
||||||
|
|
||||||
|
if (event.rrule) {
|
||||||
|
repeat = 'custom';
|
||||||
|
// Simple RRULE parsing - extract FREQ and INTERVAL
|
||||||
|
const freqMatch = event.rrule.match(/FREQ=([^;:]+)/i);
|
||||||
|
if (freqMatch) {
|
||||||
|
const freq = freqMatch[1].toLowerCase();
|
||||||
|
if (freq === 'daily') {
|
||||||
|
repeat = 'daily';
|
||||||
|
repeatInterval = 1;
|
||||||
|
} else if (freq === 'weekly') {
|
||||||
|
repeat = 'weekly';
|
||||||
|
repeatInterval = 1;
|
||||||
|
} else if (freq === 'monthly') {
|
||||||
|
repeat = 'monthly';
|
||||||
|
repeatInterval = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Extract INTERVAL
|
||||||
|
const intervalMatch = event.rrule.match(/INTERVAL=(\d+)/i);
|
||||||
|
if (intervalMatch) {
|
||||||
|
repeatInterval = parseInt(intervalMatch[1], 10);
|
||||||
|
}
|
||||||
|
// Extract BYDAY for weekly/Monthly
|
||||||
|
const bydayMatch = event.rrule.match(/BYDAY=([^;,]+)/i);
|
||||||
|
if (bydayMatch) {
|
||||||
|
repeatDays = bydayMatch[1].replace(/,/g, ',');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map priority (iCal priority: -2=low, 0=normal, 2=high, 3-4=highest, 5=urgent)
|
||||||
|
let priorityValue: string = 'medium';
|
||||||
|
if (event.priority !== undefined) {
|
||||||
|
const p = event.priority;
|
||||||
|
if (p <= -2) priorityValue = 'low';
|
||||||
|
else if (p === 0) priorityValue = 'medium';
|
||||||
|
else if (p === 2) priorityValue = 'high';
|
||||||
|
else if (p >= 5) priorityValue = 'urgent';
|
||||||
|
else priorityValue = 'medium';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: event.title,
|
||||||
|
description: event.description || '',
|
||||||
|
dueDate,
|
||||||
|
dueTime,
|
||||||
|
endTime,
|
||||||
|
allDay,
|
||||||
|
repeat,
|
||||||
|
repeatInterval,
|
||||||
|
repeatDays,
|
||||||
|
seriesId,
|
||||||
|
reminder,
|
||||||
|
reminders,
|
||||||
|
categoryId: defaultCategoryId,
|
||||||
|
tags: '',
|
||||||
|
priority: priorityValue,
|
||||||
|
completed: false,
|
||||||
|
completedAt: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useICalImport() {
|
||||||
|
const { collections } = useDatabase();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const importICal = useCallback(async (icalContent: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const events = parseICal(icalContent);
|
||||||
|
|
||||||
|
if (events.length === 0) {
|
||||||
|
throw new Error('No events found in the iCal data');
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskData = events.map(mapICalEventToTask);
|
||||||
|
|
||||||
|
// Insert tasks into WatermelonDB
|
||||||
|
const subscription = collections.tasks
|
||||||
|
.query()
|
||||||
|
.splice(
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
...taskData.map((td) => ({
|
||||||
|
title: td.title,
|
||||||
|
description: td.description,
|
||||||
|
category_id: td.categoryId,
|
||||||
|
tags: td.tags,
|
||||||
|
priority: td.priority,
|
||||||
|
completed: td.completed,
|
||||||
|
completed_at: td.completedAt,
|
||||||
|
due_date: td.dueDate,
|
||||||
|
due_time: td.dueTime,
|
||||||
|
end_time: td.endTime,
|
||||||
|
all_day: td.allDay,
|
||||||
|
repeat: td.repeat,
|
||||||
|
repeat_interval: td.repeatInterval,
|
||||||
|
repeat_days: td.repeatDays,
|
||||||
|
color: '#9E9E9E',
|
||||||
|
series_id: td.seriesId,
|
||||||
|
order: 0,
|
||||||
|
reminder: td.reminder,
|
||||||
|
reminders: td.reminders,
|
||||||
|
assignee_id: null,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Observe the changes
|
||||||
|
subscription.subscribe({
|
||||||
|
next: (result) => {
|
||||||
|
// Tasks have been imported
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
console.error('Import error:', err);
|
||||||
|
setError(err.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('ICal import failed:', err);
|
||||||
|
setError(err.message || 'Import failed');
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [collections]);
|
||||||
|
|
||||||
|
return { importICal, loading, error };
|
||||||
|
}
|
||||||
@@ -109,7 +109,9 @@ export async function apiFetch(path: string, options: RequestInit = {}): Promise
|
|||||||
let details: any = null;
|
let details: any = null;
|
||||||
try {
|
try {
|
||||||
const body = JSON.parse(text);
|
const body = JSON.parse(text);
|
||||||
if (body && typeof body.message === 'string') {
|
if (body && typeof body.error?.message === 'string') {
|
||||||
|
message = body.error.message;
|
||||||
|
} else if (body && typeof body.message === 'string') {
|
||||||
message = body.message;
|
message = body.message;
|
||||||
}
|
}
|
||||||
if (body && body.error && body.error.details) {
|
if (body && body.error && body.error.details) {
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
import { Platform } from 'react-native';
|
import { Platform } from 'react-native';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import { Reminder, reminderToMinutes, parseReminders, ReminderPreference } from '@/types';
|
import { Reminder, reminderToMinutes, parseReminders, ReminderPreference, parseTaskTags } from '@/types';
|
||||||
|
import { TagReminderPreferences, TAG_REMINDER_PREFERENCES_KEY } from '@/theme';
|
||||||
|
|
||||||
const ID_MAP_KEY = 'notifications:taskIdToNotificationIds';
|
const ID_MAP_KEY = 'notifications:taskIdToNotificationIds';
|
||||||
|
|
||||||
|
async function loadTagPreferences(): Promise<TagReminderPreferences> {
|
||||||
|
try {
|
||||||
|
const raw = await AsyncStorage.getItem(TAG_REMINDER_PREFERENCES_KEY);
|
||||||
|
return raw ? (JSON.parse(raw) as TagReminderPreferences) : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function readIdMap(): Promise<Record<string, string[]>> {
|
async function readIdMap(): Promise<Record<string, string[]>> {
|
||||||
try {
|
try {
|
||||||
const raw = await AsyncStorage.getItem(ID_MAP_KEY);
|
const raw = await AsyncStorage.getItem(ID_MAP_KEY);
|
||||||
@@ -51,7 +61,11 @@ function reminderPreferenceToReminder(pref: ReminderPreference): Reminder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRemindersForTask(task: any, globalPreference: ReminderPreference): Reminder[] {
|
function getRemindersForTask(
|
||||||
|
task: any,
|
||||||
|
globalPreference: ReminderPreference,
|
||||||
|
tagPreferences: TagReminderPreferences = {}
|
||||||
|
): Reminder[] {
|
||||||
const taskReminders: Reminder[] = task.reminders
|
const taskReminders: Reminder[] = task.reminders
|
||||||
? parseReminders(task.reminders)
|
? parseReminders(task.reminders)
|
||||||
: task.reminder && task.reminder !== 'none'
|
: task.reminder && task.reminder !== 'none'
|
||||||
@@ -62,6 +76,17 @@ function getRemindersForTask(task: any, globalPreference: ReminderPreference): R
|
|||||||
return taskReminders;
|
return taskReminders;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tagReminders: Reminder[] = [];
|
||||||
|
for (const tagId of parseTaskTags(task.tags, task.categoryId)) {
|
||||||
|
for (const pref of tagPreferences[tagId] ?? []) {
|
||||||
|
if (pref === 'none') continue;
|
||||||
|
if (!tagReminders.includes(pref)) tagReminders.push(pref);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tagReminders.length > 0) {
|
||||||
|
return tagReminders;
|
||||||
|
}
|
||||||
|
|
||||||
const globalReminder = reminderPreferenceToReminder(globalPreference);
|
const globalReminder = reminderPreferenceToReminder(globalPreference);
|
||||||
if (globalReminder !== 'none') {
|
if (globalReminder !== 'none') {
|
||||||
return [globalReminder];
|
return [globalReminder];
|
||||||
@@ -92,7 +117,8 @@ export async function scheduleTaskReminder(task: any, notificationsEnabled: bool
|
|||||||
await writeIdMap(idMap);
|
await writeIdMap(idMap);
|
||||||
|
|
||||||
// Determine the set of reminders to schedule
|
// Determine the set of reminders to schedule
|
||||||
const reminders = getRemindersForTask(task, globalReminderPreference);
|
const tagPreferences = await loadTagPreferences();
|
||||||
|
const reminders = getRemindersForTask(task, globalReminderPreference, tagPreferences);
|
||||||
|
|
||||||
if (reminders.length === 0 || !dueDate || !task.dueTime) return;
|
if (reminders.length === 0 || !dueDate || !task.dueTime) return;
|
||||||
|
|
||||||
@@ -195,11 +221,12 @@ export async function rescheduleAllReminders(tasks: any[], notificationsEnabled:
|
|||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
const idMap: Record<string, string[]> = {};
|
const idMap: Record<string, string[]> = {};
|
||||||
|
const tagPreferences = await loadTagPreferences();
|
||||||
|
|
||||||
for (const task of tasks) {
|
for (const task of tasks) {
|
||||||
const dueDate = task.dueDate ? new Date(task.dueDate) : null;
|
const dueDate = task.dueDate ? new Date(task.dueDate) : null;
|
||||||
|
|
||||||
const reminders = getRemindersForTask(task, globalReminderPreference);
|
const reminders = getRemindersForTask(task, globalReminderPreference, tagPreferences);
|
||||||
|
|
||||||
if (reminders.length === 0 || !dueDate || !task.dueTime) continue;
|
if (reminders.length === 0 || !dueDate || !task.dueTime) continue;
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ export const REMINDER_OPTIONS: { value: ReminderPreference; label: string }[] =
|
|||||||
{ value: '1440', label: '1 day before' },
|
{ value: '1440', label: '1 day before' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export type TagReminderPreferences = Record<string, ReminderPreference[]>;
|
||||||
|
|
||||||
|
export const TAG_REMINDER_PREFERENCES_KEY = 'settings:tagReminderPreferences';
|
||||||
|
|
||||||
export interface ThemeColors {
|
export interface ThemeColors {
|
||||||
background: string;
|
background: string;
|
||||||
card: string;
|
card: string;
|
||||||
@@ -138,6 +142,8 @@ interface SettingsContextType {
|
|||||||
setSortBy: (value: SortBy) => void;
|
setSortBy: (value: SortBy) => void;
|
||||||
reminderPreference: ReminderPreference;
|
reminderPreference: ReminderPreference;
|
||||||
setReminderPreference: (value: ReminderPreference) => void;
|
setReminderPreference: (value: ReminderPreference) => void;
|
||||||
|
tagReminderPreferences: TagReminderPreferences;
|
||||||
|
setTagReminderPreferences: (value: TagReminderPreferences) => void;
|
||||||
apiUrl: string;
|
apiUrl: string;
|
||||||
setApiUrl: (value: string) => void;
|
setApiUrl: (value: string) => void;
|
||||||
accentColor: string;
|
accentColor: string;
|
||||||
@@ -146,6 +152,8 @@ interface SettingsContextType {
|
|||||||
setTodoAheadDays: (value: number) => void;
|
setTodoAheadDays: (value: number) => void;
|
||||||
showCompleted: boolean;
|
showCompleted: boolean;
|
||||||
setShowCompleted: (value: boolean) => void;
|
setShowCompleted: (value: boolean) => void;
|
||||||
|
deleteBySwipe: boolean;
|
||||||
|
setDeleteBySwipe: (value: boolean) => void;
|
||||||
theme: ThemeColors;
|
theme: ThemeColors;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,9 +164,11 @@ const STORAGE_KEYS = {
|
|||||||
defaultCategoryId: 'settings:defaultCategoryId',
|
defaultCategoryId: 'settings:defaultCategoryId',
|
||||||
sortBy: 'settings:sortBy',
|
sortBy: 'settings:sortBy',
|
||||||
reminderPreference: 'settings:reminderPreference',
|
reminderPreference: 'settings:reminderPreference',
|
||||||
|
tagReminderPreferences: TAG_REMINDER_PREFERENCES_KEY,
|
||||||
accentColor: 'settings:accentColor',
|
accentColor: 'settings:accentColor',
|
||||||
todoAheadDays: 'settings:todoAheadDays',
|
todoAheadDays: 'settings:todoAheadDays',
|
||||||
showCompleted: 'settings:showCompleted',
|
showCompleted: 'settings:showCompleted',
|
||||||
|
deleteBySwipe: 'settings:deleteBySwipe',
|
||||||
};
|
};
|
||||||
|
|
||||||
const SettingsContext = createContext<SettingsContextType | null>(null);
|
const SettingsContext = createContext<SettingsContextType | null>(null);
|
||||||
@@ -197,10 +207,15 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
|
|||||||
STORAGE_KEYS.reminderPreference,
|
STORAGE_KEYS.reminderPreference,
|
||||||
'15',
|
'15',
|
||||||
);
|
);
|
||||||
|
const [tagReminderPreferences, setTagReminderPreferences] = useStoredSetting<TagReminderPreferences>(
|
||||||
|
STORAGE_KEYS.tagReminderPreferences,
|
||||||
|
{},
|
||||||
|
);
|
||||||
const [apiUrl, setApiUrl] = useStoredSetting<string>(API_URL_KEY, DEFAULT_API_BASE_URL);
|
const [apiUrl, setApiUrl] = useStoredSetting<string>(API_URL_KEY, DEFAULT_API_BASE_URL);
|
||||||
const [accentColor, setAccentColor] = useStoredSetting<string>(STORAGE_KEYS.accentColor, DEFAULT_ACCENT);
|
const [accentColor, setAccentColor] = useStoredSetting<string>(STORAGE_KEYS.accentColor, DEFAULT_ACCENT);
|
||||||
const [todoAheadDays, setTodoAheadDays] = useStoredSetting<number>(STORAGE_KEYS.todoAheadDays, DEFAULT_TODO_AHEAD_DAYS);
|
const [todoAheadDays, setTodoAheadDays] = useStoredSetting<number>(STORAGE_KEYS.todoAheadDays, DEFAULT_TODO_AHEAD_DAYS);
|
||||||
const [showCompleted, setShowCompleted] = useStoredSetting<boolean>(STORAGE_KEYS.showCompleted, false);
|
const [showCompleted, setShowCompleted] = useStoredSetting<boolean>(STORAGE_KEYS.showCompleted, false);
|
||||||
|
const [deleteBySwipe, setDeleteBySwipe] = useStoredSetting<boolean>(STORAGE_KEYS.deleteBySwipe, false);
|
||||||
|
|
||||||
const theme = useMemo(() => colors(accentColor), [accentColor]);
|
const theme = useMemo(() => colors(accentColor), [accentColor]);
|
||||||
|
|
||||||
@@ -214,6 +229,8 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
|
|||||||
setSortBy,
|
setSortBy,
|
||||||
reminderPreference,
|
reminderPreference,
|
||||||
setReminderPreference,
|
setReminderPreference,
|
||||||
|
tagReminderPreferences,
|
||||||
|
setTagReminderPreferences,
|
||||||
apiUrl,
|
apiUrl,
|
||||||
setApiUrl,
|
setApiUrl,
|
||||||
accentColor,
|
accentColor,
|
||||||
@@ -222,6 +239,8 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
|
|||||||
setTodoAheadDays,
|
setTodoAheadDays,
|
||||||
showCompleted,
|
showCompleted,
|
||||||
setShowCompleted,
|
setShowCompleted,
|
||||||
|
deleteBySwipe,
|
||||||
|
setDeleteBySwipe,
|
||||||
theme,
|
theme,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
@@ -233,6 +252,8 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
|
|||||||
setSortBy,
|
setSortBy,
|
||||||
reminderPreference,
|
reminderPreference,
|
||||||
setReminderPreference,
|
setReminderPreference,
|
||||||
|
tagReminderPreferences,
|
||||||
|
setTagReminderPreferences,
|
||||||
apiUrl,
|
apiUrl,
|
||||||
setApiUrl,
|
setApiUrl,
|
||||||
accentColor,
|
accentColor,
|
||||||
@@ -241,7 +262,6 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
|
|||||||
setTodoAheadDays,
|
setTodoAheadDays,
|
||||||
showCompleted,
|
showCompleted,
|
||||||
setShowCompleted,
|
setShowCompleted,
|
||||||
theme,
|
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
function parseICal(content: string): Array<{
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
start: Date;
|
||||||
|
end?: Date;
|
||||||
|
allDay?: boolean;
|
||||||
|
rrule?: string;
|
||||||
|
uid?: string;
|
||||||
|
priority?: number;
|
||||||
|
}> {
|
||||||
|
const events: Array<{
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
start: Date;
|
||||||
|
end?: Date;
|
||||||
|
allDay?: boolean;
|
||||||
|
rrule?: string;
|
||||||
|
uid?: string;
|
||||||
|
priority?: number;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
const lines = content.split('\n');
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
while (i < lines.length) {
|
||||||
|
const line = lines[i].trim();
|
||||||
|
if (!line) {
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith('BEGIN:VEVENT')) {
|
||||||
|
const event: {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
start: Date;
|
||||||
|
end?: Date;
|
||||||
|
allDay?: boolean;
|
||||||
|
rrule?: string;
|
||||||
|
uid?: string;
|
||||||
|
priority?: number;
|
||||||
|
} = {
|
||||||
|
title: '',
|
||||||
|
start: new Date(0),
|
||||||
|
};
|
||||||
|
|
||||||
|
i++;
|
||||||
|
let inEvent = true;
|
||||||
|
|
||||||
|
while (i < lines.length && inEvent) {
|
||||||
|
const line = lines[i].trim();
|
||||||
|
i++;
|
||||||
|
|
||||||
|
if (line === 'END:VEVENT') {
|
||||||
|
inEvent = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith('END:')) {
|
||||||
|
inEvent = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith('SUMMARY:')) {
|
||||||
|
event.title = line.substring(8).trim();
|
||||||
|
} else if (line.startsWith('DESCRIPTION:')) {
|
||||||
|
event.description = line.substring(12).trim();
|
||||||
|
} else if (line.startsWith('DTSTART:')) {
|
||||||
|
const dateStr = line.substring(8).trim();
|
||||||
|
event.start = parseICalDateTime(dateStr);
|
||||||
|
} else if (line.startsWith('DTEND:')) {
|
||||||
|
const dateStr = line.substring(6).trim();
|
||||||
|
event.end = parseICalDateTime(dateStr);
|
||||||
|
} else if (line.startsWith('DTSTAMP:')) {
|
||||||
|
// ignore, we use start date
|
||||||
|
} else if (line.startsWith('UID:')) {
|
||||||
|
event.uid = line.substring(4).trim();
|
||||||
|
} else if (line.startsWith('PRIORITY:')) {
|
||||||
|
event.priority = parseInt(line.substring(9).trim(), 10);
|
||||||
|
} else if (line.startsWith('ALL-DAY:')) {
|
||||||
|
event.allDay = line.substring(10).trim().toLowerCase() === 'true';
|
||||||
|
} else if (line.startsWith('RRULE:')) {
|
||||||
|
event.rrule = line.substring(6).trim();
|
||||||
|
} else if (line.startsWith('BEGIN:VALARM')) {
|
||||||
|
// skip alarms
|
||||||
|
i++;
|
||||||
|
let inAlarm = true;
|
||||||
|
while (i < lines.length && inAlarm) {
|
||||||
|
const alarmLine = lines[i].trim();
|
||||||
|
i++;
|
||||||
|
if (alarmLine === 'END:VALARM') {
|
||||||
|
inAlarm = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// skip unrecognized lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.title) {
|
||||||
|
events.push(event);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseICalDateTime(dateStr: string): Date {
|
||||||
|
// Handle various iCal date/time formats
|
||||||
|
// Format 1: YYYYMMDDTHHMMSSZ (UTC)
|
||||||
|
// Format 2: YYYYMMDDTHHMMSS (local)
|
||||||
|
// Format 3: YYYYMMDD (date only)
|
||||||
|
// Format 4: TZID:... (with timezone)
|
||||||
|
|
||||||
|
let datePart = '';
|
||||||
|
let timePart = '';
|
||||||
|
let hasTime = false;
|
||||||
|
|
||||||
|
// Check for Z (UTC) indicator
|
||||||
|
const hasZ = dateStr.endsWith('Z');
|
||||||
|
const cleanStr = hasZ ? dateStr.slice(0, -1) : dateStr;
|
||||||
|
|
||||||
|
// Try to split at T
|
||||||
|
const tIndex = cleanStr.indexOf('T');
|
||||||
|
if (tIndex !== -1) {
|
||||||
|
datePart = cleanStr.substring(0, tIndex);
|
||||||
|
timePart = cleanStr.substring(tIndex + 1);
|
||||||
|
hasTime = true;
|
||||||
|
} else {
|
||||||
|
datePart = cleanStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse date (YYYYMMDD)
|
||||||
|
if (datePart.length >= 8) {
|
||||||
|
const year = parseInt(datePart.substring(0, 4), 10);
|
||||||
|
const month = parseInt(datePart.substring(4, 6), 10) - 1; // 0-indexed
|
||||||
|
const day = parseInt(datePart.substring(6, 8), 10);
|
||||||
|
const date = new Date(year, month, day);
|
||||||
|
|
||||||
|
if (hasTime && timePart) {
|
||||||
|
const hour = parseInt(timePart.substring(0, 2), 10);
|
||||||
|
const minute = parseInt(timePart.substring(2, 4), 10);
|
||||||
|
date.setHours(hour, minute, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: try Date.parse
|
||||||
|
return new Date(dateStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default parseICal;
|
||||||
@@ -804,6 +804,19 @@ export async function reorderTasks(taskIds: string[]): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function reorderSubtasks(subtaskIds: string[]): Promise<void> {
|
||||||
|
await database.write(async () => {
|
||||||
|
const now = new Date();
|
||||||
|
for (let i = 0; i < subtaskIds.length; i++) {
|
||||||
|
const subtask = await collections.subtasks.find(subtaskIds[i]);
|
||||||
|
await subtask.update((s) => {
|
||||||
|
s.order = i;
|
||||||
|
s.updatedAt = now;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreateSubtaskData {
|
export interface CreateSubtaskData {
|
||||||
taskId: string;
|
taskId: string;
|
||||||
parentSubtaskId?: string | null;
|
parentSubtaskId?: string | null;
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Vendored
-14
@@ -1,14 +0,0 @@
|
|||||||
x64:
|
|
||||||
firstOrDefaultFilePatterns:
|
|
||||||
- '**/*'
|
|
||||||
- '!**/node_modules/**'
|
|
||||||
- '!build{,/**/*}'
|
|
||||||
- '!dist{,/**/*}'
|
|
||||||
- '!**/*.{iml,hprof,orig,pyc,pyo,rbc,swp,csproj,sln,suo,xproj,cc,d.ts,mk,a,o,obj,forge-meta,pdb}'
|
|
||||||
- '!**/._*'
|
|
||||||
- '!**/electron-builder.{yaml,yml,json,json5,toml,ts}'
|
|
||||||
- '!**/{.git,.hg,.svn,CVS,RCS,SCCS,__pycache__,.DS_Store,thumbs.db,.gitignore,.gitkeep,.gitattributes,.npmignore,.idea,.vs,.flowconfig,.jshintrc,.eslintrc,.circleci,.yarn-integrity,.yarn-metadata.json,yarn-error.log,yarn.lock,package-lock.json,npm-debug.log,pnpm-lock.yaml,bun.lock,bun.lockb,appveyor.yml,.travis.yml,circle.yml,.nyc_output,.husky,.github,electron-builder.env}'
|
|
||||||
- '!.yarn{,/**/*}'
|
|
||||||
- '!.editorconfig'
|
|
||||||
- '!.yarnrc.yml'
|
|
||||||
nodeModuleFilePatterns: []
|
|
||||||
Vendored
-37
@@ -1,37 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
|
|
||||||
<title>carry-your-live</title>
|
|
||||||
<!-- The `react-native-web` recommended style reset: https://necolas.github.io/react-native-web/docs/setup/#root-element -->
|
|
||||||
<style id="expo-reset">
|
|
||||||
/* These styles make the body full-height */
|
|
||||||
html,
|
|
||||||
body {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
/* These styles disable body scrolling if you are using <ScrollView> */
|
|
||||||
body {
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
/* These styles make the root element full-height */
|
|
||||||
#root {
|
|
||||||
display: flex;
|
|
||||||
height: 100%;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<!-- Use static rendering with Expo Router to support running without JavaScript. -->
|
|
||||||
<noscript>
|
|
||||||
You need to enable JavaScript to run this app.
|
|
||||||
</noscript>
|
|
||||||
<!-- The root element for your Expo app. -->
|
|
||||||
<div id="root"></div>
|
|
||||||
<script src="/_expo/static/js/web/entry-c9e0f94bf4e223fd6879dadc2f9426cb.js" defer></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
-21
@@ -1,21 +0,0 @@
|
|||||||
Copyright (c) Electron contributors
|
|
||||||
Copyright (c) 2013-2020 GitHub Inc.
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining
|
|
||||||
a copy of this software and associated documentation files (the
|
|
||||||
"Software"), to deal in the Software without restriction, including
|
|
||||||
without limitation the rights to use, copy, modify, merge, publish,
|
|
||||||
distribute, sublicense, and/or sell copies of the Software, and to
|
|
||||||
permit persons to whom the Software is furnished to do so, subject to
|
|
||||||
the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be
|
|
||||||
included in all copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
||||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
||||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
||||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
||||||
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
||||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
||||||
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
||||||
-343352
File diff suppressed because it is too large
Load Diff
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user