Compare commits

...
36 Commits
Author SHA1 Message Date
tech08mag c4cc2712ee Merge pull request 'feat: remove /android from .gitignore to allow Gradle build in CI' (#17) from dev into main
Build APK / build (push) Failing after 1m7s
Build Desktop (deb) / build (push) Successful in 2m12s
release / release (push) Successful in 8m0s
Reviewed-on: #17
2026-08-26 20:52:22 +00:00
tech08mag 152d07f21e feat: remove /android from .gitignore to allow Gradle build in CI 2026-08-26 22:49:07 +02:00
tech08mag 49ef8ae82f Merge pull request 'fix: remove duplicate useRouter() declaration in CalendarScreen' (#16) from dev into main
Build APK / build (push) Failing after 1m10s
Build Desktop (deb) / build (push) Successful in 2m20s
release / release (push) Successful in 6m14s
Reviewed-on: #16
2026-08-26 20:37:58 +00:00
tech08mag 261a6d4d40 fix: remove duplicate useRouter() declaration in CalendarScreen 2026-08-26 22:36:17 +02:00
tech08mag 0b87f3e4ed Merge pull request 'refactor: update component styles and layouts' (#15) from dev into main
Build APK / build (push) Failing after 1m8s
Build Desktop (deb) / build (push) Successful in 2m12s
release / release (push) Successful in 7m9s
Reviewed-on: #15
2026-08-26 20:17:11 +00:00
tech08mag 7f8a764131 refactor: update component styles and layouts 2026-08-26 22:15:28 +02:00
tech08mag 9a9aff0880 Merge pull request 'Fix Android APK build pipeline' (#14) from dev into main
Build APK / build (push) Failing after 1m3s
Build Desktop (deb) / build (push) Successful in 2m19s
release / release (push) Successful in 6m20s
Reviewed-on: #14
2026-08-25 21:17:59 +00:00
tech08mag f188dde6e4 Merge branch 'dev' of https://gitea.techmos.org/tech08mag/carry-your-live into dev 2026-08-25 23:16:45 +02:00
tech08mag 4566794f44 new pipe 2026-08-25 23:15:16 +02:00
tech08mag c727142689 Merge branch 'main' into dev 2026-08-25 21:04:09 +00:00
tech08mag 4420c72965 Merge pull request 'new pipe' (#13) from dev into main
Build Desktop (deb) / build (push) Failing after 5m4s
release / release (push) Successful in 5m41s
Build APK / build (push) Failing after 49s
Reviewed-on: #13
2026-08-25 20:51:24 +00:00
tech08mag e55528e69f new pipe 2026-08-25 22:50:26 +02:00
tech08mag e86aa5e535 Merge pull request '- ./gradlew: No such file or directory - fixed by running from carry-your-live/android/' (#12) from dev into main
Build APK / build (push) Failing after 26s
Build Desktop (deb) / build (push) Failing after 5m12s
release / release (push) Successful in 7m20s
Reviewed-on: #12
2026-08-25 20:18:34 +00:00
tech08mag 59f95d8174 - ./gradlew: No such file or directory - fixed by running from carry-your-live/android/
- eas: not found - the workflow was reverted to use ./gradlew instead (since the project has native Android setup)
- local.properties hardcoded path - fixed by dynamically updating it in the workflow
2026-08-25 22:17:51 +02:00
tech08mag 159777818d Merge pull request 'updated pipe reverted back to gradle' (#11) from dev into main
Build APK / build (push) Failing after 53s
Build Desktop (deb) / build (push) Failing after 5m4s
release / release (push) Canceled after 8m10s
Reviewed-on: #11
2026-08-25 20:13:15 +00:00
tech08mag 00ac23160e updated pipe reverted back to gradle 2026-08-25 22:11:26 +02:00
tech08mag 46f9b4434e Merge pull request 'new pipe' (#10) from dev into main
Build APK / build (push) Failing after 43s
Build Desktop (deb) / build (push) Failing after 5m4s
release / release (push) Successful in 9m47s
Reviewed-on: #10
2026-08-25 20:05:45 +00:00
tech08mag d0cb43435b new pipe 2026-08-25 22:03:38 +02:00
tech08mag 34bc152817 Merge pull request 'Dev merge main' (#9) from dev-merge-main into main
Build APK / build (push) Failing after 36s
Build Desktop (deb) / build (push) Failing after 5m5s
release / release (push) Successful in 7m42s
Reviewed-on: #9
2026-08-25 19:53:03 +00:00
tech08mag fc8c5976f8 Merge rewritten server history (content unchanged) 2026-08-25 21:41:17 +02:00
tech08mag 259d21c968 custom tags 2026-08-25 16:26:10 +02:00
tech08mag 21a2cffcc0 Merge branch 'dev' 2026-08-24 22:02:11 +02:00
tech08mag 865e0b3301 gitignore: add dist/ to reduce repo size 2026-08-24 21:45:15 +02:00
tech08mag 7fad4b5eec fix: create android/app directory before generating debug keystore
The keytool command fails without the directory existing first.
Add mkdir -p before the keytool genkeypair command.
2026-08-24 19:50:01 +02:00
tech08mag 3a5b5a68e4 Merge pull request 'fix: avoid SIGPIPE when accepting Android SDK licenses' (#6) from dev into main
Build Desktop (deb) / build (push) Successful in 11m47s
release / release (push) Successful in 12m16s
Build APK / build (push) Failing after 13m22s
Reviewed-on: #6
2026-08-24 17:34:53 +00:00
tech08mag 2773255b38 Merge pull request 'fix: avoid SIGPIPE when accepting Android SDK licenses' (#6) from dev into main
Reviewed-on: #6
2026-08-24 17:34:53 +00:00
tech08mag 85ef3dc38b fix: avoid SIGPIPE when accepting Android SDK licenses
Replace 'yes | sdkmanager --licenses' with 'printf y | sdkmanager --licenses'
to prevent exit code 141 (SIGPIPE) when the pipe closes after sdkmanager exits.
2026-08-24 19:33:27 +02:00
tech08mag 87e3f59ee1 fix: avoid SIGPIPE when accepting Android SDK licenses
Replace 'yes | sdkmanager --licenses' with 'printf y | sdkmanager --licenses'
to prevent exit code 141 (SIGPIPE) when the pipe closes after sdkmanager exits.
2026-08-24 19:33:27 +02:00
tech08mag 80663f7d39 Merge pull request 'fix(ci): install android-36 platform and build-tools for release build' (#5) from fix/ci-android-sdk-version into main
Build APK / build (push) Failing after 9m35s
Build Desktop (deb) / build (push) Failing after 14m56s
release / release (push) Successful in 6m16s
Reviewed-on: #5
2026-08-24 12:52:26 +00:00
tech08mag ab4ddb9f8b Merge pull request 'fix(ci): install android-36 platform and build-tools for release build' (#5) from fix/ci-android-sdk-version into main
Reviewed-on: #5
2026-08-24 12:52:26 +00:00
tech08mag 6969e79029 fix(ci): install android-36 platform and build-tools for release build 2026-08-24 14:46:15 +02:00
tech08mag 448279d98c fix(ci): install android-36 platform and build-tools for release build 2026-08-24 14:46:15 +02:00
tech08mag 7e224301cd Merge pull request 'fix(ci): add babel-preset-expo dep and make Android SDK setup idempotent' (#4) from feat/subtask-categories-refresh into main
Reviewed-on: #4
2026-08-23 14:05:26 +00:00
tech08mag e87d31aded fix(ci): add babel-preset-expo dep and make Android SDK setup idempotent 2026-08-23 16:02:36 +02:00
tech08mag 964a4fe0f9 Merge pull request 'electron app' (#3) from feat/subtask-categories-refresh into main
Reviewed-on: #3
2026-08-23 13:39:14 +00:00
tech08mag fd1c473cb6 electron app 2026-08-23 15:26:52 +02:00
127 changed files with 5359 additions and 344795 deletions
+10 -4
View File
@@ -38,12 +38,16 @@ 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" \
"platforms;android-36" \
"build-tools;35.0.0" \ "build-tools;35.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
@@ -51,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 \
@@ -58,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
View File
@@ -1 +1,2 @@
node_modules node_modules/
dist/
+17
View File
@@ -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
+13
View File
@@ -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
}
+17
View File
@@ -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
+17
View File
@@ -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
+17
View File
@@ -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
+17
View File
@@ -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
+98
View File
@@ -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
+17
View File
@@ -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
+17
View File
@@ -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
+17
View File
@@ -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
-1
View File
@@ -41,4 +41,3 @@ yarn-error.*
# generated native folders # generated native folders
/ios /ios
/android
+157 -117
View File
@@ -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,9 +195,45 @@ 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}
rightAction={
<TouchableOpacity
onPress={() => router.back()}
style={styles.backButton}
accessible={true}
accessibilityLabel="Back"
>
<Svg width={20} height={20} viewBox="0 0 24 24">
<Path d="M15 18l-6-6 6-6" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
</Svg>
</TouchableOpacity>
}
/>
<KeyboardAvoidingView style={styles.kbAvoid} behavior="padding"> <KeyboardAvoidingView style={styles.kbAvoid} behavior="padding">
<ScrollView style={styles.scrollBody} showsVerticalScrollIndicator={false} contentContainerStyle={styles.scrollContent}> <ScrollView style={styles.scrollBody} showsVerticalScrollIndicator={false} contentContainerStyle={styles.scrollContent}>
@@ -244,78 +310,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 +380,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 +442,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 {
@@ -542,58 +624,16 @@ const createStyles = (theme: ThemeColors) => StyleSheet.create({
emptySubtext: { emptySubtext: {
fontSize: 13, fontSize: 13,
}, },
backButton: {
padding: 8,
},
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,
}, },
}); });
+68 -4
View File
@@ -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, accentOpacity, setAccentOpacity, 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);
@@ -32,6 +35,7 @@ export default function SettingsScreen() {
const [serverUrlVisible, setServerUrlVisible] = useState(false); const [serverUrlVisible, setServerUrlVisible] = useState(false);
const [customAccentVisible, setCustomAccentVisible] = useState(false); const [customAccentVisible, setCustomAccentVisible] = useState(false);
const [draftAccent, setDraftAccent] = useState(accentColor); const [draftAccent, setDraftAccent] = useState(accentColor);
const [draftAccentOpacity, setDraftAccentOpacity] = useState(accentOpacity);
const [syncSubtitle, setSyncSubtitle] = useState('Checking...'); const [syncSubtitle, setSyncSubtitle] = useState('Checking...');
const [updateSubtitle, setUpdateSubtitle] = useState('Tap to check'); const [updateSubtitle, setUpdateSubtitle] = useState('Tap to check');
@@ -95,6 +99,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 +120,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 +189,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 +226,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 +345,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"
@@ -330,18 +394,18 @@ export default function SettingsScreen() {
</Svg> </Svg>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<ColorWheel color={draftAccent} onChange={setDraftAccent} /> <ColorWheel color={draftAccent} opacity={draftAccentOpacity} onChange={(color, opacity) => { setDraftAccent(color); setDraftAccentOpacity(opacity); }} />
<View style={styles.accentModalActions}> <View style={styles.accentModalActions}>
<TouchableOpacity <TouchableOpacity
style={[styles.accentModalButton, { borderColor: theme.borderStrong }]} style={[styles.accentModalButton, { borderColor: theme.borderStrong }]}
onPress={() => { setDraftAccent(DEFAULT_ACCENT); setAccentColor(DEFAULT_ACCENT); }} onPress={() => { setDraftAccent(DEFAULT_ACCENT); setDraftAccentOpacity(DEFAULT_ACCENT_OPACITY); setAccentColor(DEFAULT_ACCENT); setAccentOpacity(DEFAULT_ACCENT_OPACITY); }}
activeOpacity={0.7} activeOpacity={0.7}
> >
<Text style={[styles.accentModalButtonText, { color: theme.textSecondary }]}>Reset</Text> <Text style={[styles.accentModalButtonText, { color: theme.textSecondary }]}>Reset</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
style={[styles.accentModalButton, styles.accentModalButtonPrimary, { backgroundColor: theme.accent }]} style={[styles.accentModalButton, styles.accentModalButtonPrimary, { backgroundColor: theme.accent }]}
onPress={() => { setAccentColor(draftAccent); setCustomAccentVisible(false); }} onPress={() => { setAccentColor(draftAccent); setAccentOpacity(draftAccentOpacity); setCustomAccentVisible(false); }}
activeOpacity={0.8} activeOpacity={0.8}
> >
<Text style={[styles.accentModalButtonText, { color: theme.accentText }]}>Apply</Text> <Text style={[styles.accentModalButtonText, { color: theme.accentText }]}>Apply</Text>
+1
View File
@@ -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}
+6 -3
View File
@@ -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 ? (
+1
View File
@@ -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,23 +33,48 @@ 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();
} }
interface ColorWheelProps { interface ColorWheelProps {
color: string; color: string;
onChange: (color: string) => void; opacity: number;
onChange: (color: string, opacity: number) => void;
} }
export default function ColorWheel({ color, onChange }: ColorWheelProps) { export default function ColorWheel({ color, opacity, onChange }: ColorWheelProps) {
const { theme } = useSettings(); const { theme } = useSettings();
const initialHsv = useMemo(() => hexToHsv(color), [color]); const initialHsv = useMemo(() => hexToHsv(color), [color]);
const [hue, setHue] = useState(initialHsv.h); const [hue, setHue] = useState(initialHsv.h);
@@ -66,8 +91,8 @@ export default function ColorWheel({ color, onChange }: ColorWheelProps) {
useEffect(() => { useEffect(() => {
const newColor = hsvToHex(hue, saturation, value); const newColor = hsvToHex(hue, saturation, value);
onChange(newColor); onChange(newColor, opacity);
}, [hue, saturation, value, onChange]); }, [hue, saturation, value, opacity, onChange]);
const updateFromWheel = (locationX: number, locationY: number) => { const updateFromWheel = (locationX: number, locationY: number) => {
const center = WHEEL_SIZE / 2; const center = WHEEL_SIZE / 2;
@@ -84,35 +109,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 +219,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,23 +32,48 @@ 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();
} }
interface ColorWheelProps { interface ColorWheelProps {
color: string; color: string;
onChange: (color: string) => void; opacity: number;
onChange: (color: string, opacity: number) => void;
} }
export default function ColorWheel({ color, onChange }: ColorWheelProps) { export default function ColorWheel({ color, opacity, onChange }: ColorWheelProps) {
const { theme } = useSettings(); const { theme } = useSettings();
const initialHsv = useMemo(() => hexToHsv(color), [color]); const initialHsv = useMemo(() => hexToHsv(color), [color]);
const [hue, setHue] = useState(initialHsv.h); const [hue, setHue] = useState(initialHsv.h);
@@ -65,8 +90,8 @@ export default function ColorWheel({ color, onChange }: ColorWheelProps) {
useEffect(() => { useEffect(() => {
const newColor = hsvToHex(hue, saturation, value); const newColor = hsvToHex(hue, saturation, value);
onChange(newColor); onChange(newColor, opacity);
}, [hue, saturation, value, onChange]); }, [hue, saturation, value, opacity, onChange]);
const handleWheelMouseDown = (e: React.MouseEvent) => { const handleWheelMouseDown = (e: React.MouseEvent) => {
const rect = e.currentTarget.getBoundingClientRect(); const rect = e.currentTarget.getBoundingClientRect();
@@ -108,15 +133,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 }]} />
+23 -4
View File
@@ -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}
+104 -26
View File
@@ -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>
+3 -1
View File
@@ -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>
+41 -61
View File
@@ -1,89 +1,69 @@
import React from 'react'; import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native'; import { View, Text, TouchableOpacity, StyleSheet, Switch } from 'react-native';
import { useSettings } from '@/theme';
import Svg, { Path } from 'react-native-svg';
interface ListItemProps { export const ListItem = ({ title, subtitle, onPress, leftIcon, rightIcon, disabled, leftElement, rightElement, showChevron }: {
title: string; title: string;
subtitle?: string; subtitle?: string | undefined;
onPress?: () => void;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
disabled?: boolean;
leftElement?: React.ReactNode; leftElement?: React.ReactNode;
rightElement?: React.ReactNode; rightElement?: React.ReactNode;
onPress?: () => void;
showChevron?: boolean; showChevron?: boolean;
} }) => {
export function ListItem({ title, subtitle, leftElement, rightElement, onPress, showChevron }: ListItemProps) {
const { theme } = useSettings();
const isInteractive = onPress || showChevron;
return ( return (
<TouchableOpacity <TouchableOpacity onPress={onPress} disabled={disabled} style={styles.item}>
style={[ <View style={styles.left}>
styles.container, {leftElement}
{ backgroundColor: theme.card, borderColor: theme.border }, {leftIcon}
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>
<View style={styles.rightContent}> <View style={styles.center}>
<Text style={styles.title}>{title}</Text>
{subtitle && <Text style={styles.subtitle}>{subtitle}</Text>}
</View>
<View style={styles.right}>
{rightElement} {rightElement}
{showChevron && !rightElement && ( {rightIcon}
<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> </View>
{showChevron && <Text style={styles.chevron}>▶</Text>}
</TouchableOpacity> </TouchableOpacity>
); );
} };
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { item: {
padding: 16,
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
paddingVertical: 14, backgroundColor: 'white',
paddingHorizontal: 16, marginVertical: 4,
borderRadius: 12, borderRadius: 8,
borderWidth: 1,
marginBottom: 8,
}, },
interactive: {}, left: {
leftElement: {
marginRight: 12,
},
leftContent: {
flex: 1, flex: 1,
gap: 2, },
center: {
flex: 3,
marginHorizontal: 12,
},
right: {
flex: 1,
textAlign: 'right',
}, },
title: { title: {
fontSize: 16, fontSize: 16,
fontWeight: '500', fontWeight: '500',
}, },
subtitle: { subtitle: {
fontSize: 13, fontSize: 12,
color: '#757575',
marginTop: 2,
}, },
rightContent: { chevron: {
flexDirection: 'row', fontSize: 14,
alignItems: 'center', color: '#757575',
gap: 12, marginLeft: 4,
}, },
}); });
+3 -1
View File
@@ -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={() => {
+10 -11
View File
@@ -66,7 +66,7 @@ export function TaskList({ categoryIds = [], showCompleted = false, onSelectionC
const itemRefs = useRef<Map<string, View>>(new Map()); const itemRefs = useRef<Map<string, View>>(new Map());
const subtaskRefs = useRef<Map<string, { ref: View; parentTaskId: string }>>(new Map()); const subtaskRefs = useRef<Map<string, { ref: View; parentTaskId: string }>>(new Map());
const dragStateRef = useRef<{ taskId: string; positions: Record<string, { top: number; bottom: number }>; subPositions: Record<string, { top: number; bottom: number }> } | null>(null); const dragStateRef = useRef<{ taskId: string; positions: Record<string, { top: number; bottom: number }>; subPositions: Record<string, { top: number; bottom: number }> } | null>(null);
const subtaskDragRef = useRef<{ subtaskId: string; parentTaskId: string; positions: Record<string, { top: number; bottom: number }>; subPositions: Record<string, { top: number; bottom: number }> } | null>(null); const subtaskDragRef = useRef<{ subtaskId: string; parentTaskId: string; parentSubtaskId: string | null; positions: Record<string, { top: number; bottom: number }>; subPositions: Record<string, { top: number; bottom: number }> } | null>(null);
const { const {
modals, modals,
openTaskDelete, openTaskDelete,
@@ -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 | null = null) => {
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 != null && target != null && 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 }) => {
+194
View File
@@ -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 };
}
+3 -1
View File
@@ -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) {
+31 -4
View File
@@ -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;
+31 -3
View File
@@ -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;
@@ -53,6 +57,7 @@ export interface ThemeColors {
} }
export const DEFAULT_ACCENT = '#EF5350'; export const DEFAULT_ACCENT = '#EF5350';
export const DEFAULT_ACCENT_OPACITY = 1;
export const ACCENT_PRESETS: string[] = [ export const ACCENT_PRESETS: string[] = [
'#EF5350', '#EF5350',
@@ -70,6 +75,10 @@ export const ACCENT_PRESETS: string[] = [
function parseHex(hex: string): [number, number, number] { function parseHex(hex: string): [number, number, number] {
const h = hex.replace(/^#/, ''); const h = hex.replace(/^#/, '');
// Handle both #RRGGBB (6 digits) and #AARRGGBB (8 digits with alpha)
if (h.length === 8) {
return [parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16), parseInt(h.slice(6, 8), 16)];
}
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]; return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
} }
@@ -97,8 +106,9 @@ function accentTextColor(accent: string): string {
return luminance > 0.5 ? '#111111' : '#FFFFFF'; return luminance > 0.5 ? '#111111' : '#FFFFFF';
} }
export function colors(accent: string = DEFAULT_ACCENT): ThemeColors { export function colors(accent: string = DEFAULT_ACCENT, opacity: number = 1): ThemeColors {
const softAccent = desaturate(accent, 0.35); const softAccent = desaturate(accent, 0.35);
const accentWithOpacity = opacity < 1 ? `${opacity.toString(16).padStart(2, '0')}${accent.slice(1)}` : accent;
return { return {
background: '#121212', background: '#121212',
card: '#1E1E1E', card: '#1E1E1E',
@@ -109,7 +119,7 @@ export function colors(accent: string = DEFAULT_ACCENT): ThemeColors {
textSecondary: '#E3E4E8', textSecondary: '#E3E4E8',
textFaint: '#C4C6CC', textFaint: '#C4C6CC',
textMuted: '#9C9FA7', textMuted: '#9C9FA7',
accent, accent: opacity < 1 ? `${accentWithOpacity}` : accent,
accentStrong: mixHex(accent, '#FFFFFF', 0.55), accentStrong: mixHex(accent, '#FFFFFF', 0.55),
accentSoft: mixHex(softAccent, '#121212', 0.18), accentSoft: mixHex(softAccent, '#121212', 0.18),
accentBorder: mixHex(desaturate(accent, 0.15), '#121212', 0.5), accentBorder: mixHex(desaturate(accent, 0.15), '#121212', 0.5),
@@ -138,6 +148,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 +158,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 +170,12 @@ 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',
accentOpacity: 'settings:accentOpacity',
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 +214,16 @@ 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 [accentOpacity, setAccentOpacity] = useStoredSetting<number>(STORAGE_KEYS.accentOpacity, DEFAULT_ACCENT_OPACITY);
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 +237,8 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
setSortBy, setSortBy,
reminderPreference, reminderPreference,
setReminderPreference, setReminderPreference,
tagReminderPreferences,
setTagReminderPreferences,
apiUrl, apiUrl,
setApiUrl, setApiUrl,
accentColor, accentColor,
@@ -222,6 +247,8 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
setTodoAheadDays, setTodoAheadDays,
showCompleted, showCompleted,
setShowCompleted, setShowCompleted,
deleteBySwipe,
setDeleteBySwipe,
theme, theme,
}), }),
[ [
@@ -233,6 +260,8 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
setSortBy, setSortBy,
reminderPreference, reminderPreference,
setReminderPreference, setReminderPreference,
tagReminderPreferences,
setTagReminderPreferences,
apiUrl, apiUrl,
setApiUrl, setApiUrl,
accentColor, accentColor,
@@ -241,7 +270,6 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
setTodoAheadDays, setTodoAheadDays,
showCompleted, showCompleted,
setShowCompleted, setShowCompleted,
theme,
] ]
); );
+156
View File
@@ -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;
+13
View File
@@ -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
-14
View File
@@ -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: []
-37
View File
@@ -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
View File
@@ -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.
File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More