Compare commits

...
Author SHA1 Message Date
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 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 df97937948 Merge pull request 'fix(ci): add babel-preset-expo dep and make Android SDK setup idempotent' (#4) from feat/subtask-categories-refresh into main
Build APK / build (push) Failing after 9m52s
Build Desktop (deb) / build (push) Failing after 14m48s
release / release (push) Successful in 6m0s
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 e07d28d6b1 Merge pull request 'electron app' (#3) from feat/subtask-categories-refresh into main
Build APK / build (push) Failing after 10m21s
Build Desktop (deb) / build (push) Failing after 11m14s
release / release (push) Successful in 7m7s
Reviewed-on: #3
2026-08-23 13:39:14 +00:00
tech08mag fd1c473cb6 electron app 2026-08-23 15:26:52 +02:00
tech08mag 56e6225dd1 Merge pull request 'Feat/subtask categories refresh' (#2) from feat/subtask-categories-refresh into main
release / release (push) Successful in 3s
Build APK / build (push) Failing after 11s
Reviewed-on: #2
2026-08-23 09:37:20 +00:00
tech08mag b78ada0865 Merge pull request 'feat: subtask categories + live refresh fixes + release automation' (#1) from feat/subtask-categories-refresh into main
Build APK / build (push) Failing after 3m41s
release / release (push) Successful in 6s
Reviewed-on: #1
2026-08-10 09:56:09 +00:00
126 changed files with 5272 additions and 344807 deletions
+8 -4
View File
@@ -38,7 +38,7 @@ jobs:
unzip -q /tmp/cmdtools.zip -d "$ANDROID_HOME/cmdline-tools"
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
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" \
"platform-tools" \
"platforms;android-35" \
@@ -46,6 +46,8 @@ jobs:
"build-tools;35.0.0" \
"build-tools;36.0.0" \
"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
working-directory: carry-your-live
@@ -53,6 +55,7 @@ jobs:
- name: Generate debug keystore (signs the release APK)
run: |
mkdir -p carry-your-live/android/app
keytool -genkeypair -v \
-keystore carry-your-live/android/app/debug.keystore \
-alias androiddebugkey -storepass android -keypass android \
@@ -60,11 +63,12 @@ jobs:
-dname "CN=Android Debug,O=Android,C=US"
- name: Build release APK
working-directory: carry-your-live/android
run: ./gradlew assembleRelease
run: |
cd carry-your-live/android
./gradlew assembleRelease
- name: Upload APK
uses: actions/upload-artifact@v4
with:
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
+138 -116
View File
@@ -8,17 +8,21 @@ import { useSubtasks } from '@/hooks/useSubtasks';
import { useTaskModals } from '@/hooks/useTaskModals';
import { useSettings } from '@/theme';
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 { OptionPickerModal } from '@/components/OptionPickerModal';
import { TaskItem } from '@/components/TaskItem';
import { SubtaskItem } from '@/components/SubtaskItem';
import Task from '@/models/Task';
import { format, addMonths, addDays, startOfMonth, isSameDay, isSameMonth, isToday } from 'date-fns';
import Svg, { Path, Circle } from 'react-native-svg';
import { desaturate } from '@/theme';
import Svg, { Path } from 'react-native-svg';
import type { ThemeColors } from '@/theme';
import type { TaskData, SubtaskData } from '@/types';
const WIDTH = Dimensions.get('window').width;
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 = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
@@ -26,9 +30,16 @@ const MONTH_NAMES = [
export default function CalendarScreen() {
const router = useRouter();
const { theme } = useSettings();
const { theme, sortBy } = useSettings();
const styles = React.useMemo(() => createStyles(theme), [theme]);
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 [visibleMonth, setVisibleMonth] = useState(() => new Date());
@@ -55,6 +66,25 @@ export default function CalendarScreen() {
const monthTasks = useTasksInMonth(visibleMonth);
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(
useCallback(() => {
refreshDayTasks();
@@ -165,6 +195,27 @@ export default function CalendarScreen() {
});
}, [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 (
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
<Header title="Calendar" showLogo={false} />
@@ -244,78 +295,41 @@ export default function CalendarScreen() {
<View style={styles.panelHeader}>
<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 }]}>
{selectedDayTasks.length} event{selectedDayTasks.length === 1 ? '' : 's'}
{sortedDayTasks.length} event{sortedDayTasks.length === 1 ? '' : 's'}
</Text>
)}
</View>
{selectedDayTasks.length === 0 ? (
{sortedDayTasks.length === 0 ? (
<View style={styles.emptyState}>
<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>
</View>
) : (
selectedDayTasks.map((task) => (
<View key={task.id} style={[styles.eventCard, { backgroundColor: theme.card, borderColor: theme.border }]}>
<View style={styles.eventRow}>
<TouchableOpacity
style={styles.checkCircle}
onPress={() => handleToggleComplete(task.id)}
activeOpacity={0.7}
>
<Svg width={30} height={30} viewBox="0 0 24 24">
{task.completed ? (
<>
<Circle cx={12} cy={12} r={10} fill={theme.accent} />
<Path d="M7 12l3 3 7-7" stroke="#FFFFFF" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
</>
) : (
<Circle cx={12} cy={12} r={10} stroke={theme.textSecondary} strokeWidth={2.5} fill="none" />
)}
</Svg>
</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>
sortedDayTasks.map((task) => {
const isExpanded = expandedTasks.has(task.id);
const itemSubtasks = subtasksByTask.get(task.id) ?? [];
return (
<View key={task.id} style={styles.eventCard}>
<CalendarTaskRow
task={task}
expanded={isExpanded}
subtasks={itemSubtasks}
categoryColor={categoryColors.get(task.categoryId)}
onToggle={() => handleToggleComplete(task.id)}
onPress={(id) =>
expandedTasks.has(id)
? router.push({ pathname: '/task-detail', params: { id } })
: toggleExpand(id)
}
onMenuOpen={(id) => openTaskEdit(id)}
onSubtaskToggle={handleSubtaskToggle}
/>
</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>
@@ -351,6 +365,60 @@ function byDayOf(byDay: Record<number, Task[]>, day: Date): Task[] {
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 {
if (tasks.length === 0) return null;
if (tasks.length === 1) return tasks[0].title;
@@ -359,8 +427,7 @@ function cellTitle(tasks: Task[]): string | null {
function cellColor(tasks: Task[], categories: { id: string; color: string }[], theme: ThemeColors): string {
if (tasks.length === 0) return theme.textMuted;
const cat = categories.find((c) => c.id === tasks[0].categoryId);
return cat ? desaturate(cat.color, 0.3) : theme.textMuted;
return theme.accent;
}
interface DayCellProps {
@@ -545,55 +612,10 @@ const createStyles = (theme: ThemeColors) => StyleSheet.create({
eventCard: {
marginHorizontal: 16,
marginTop: 6,
borderRadius: 16,
borderWidth: 1,
paddingHorizontal: 14,
paddingVertical: 12,
},
eventRow: {
flexDirection: 'row',
alignItems: 'center',
},
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,
subtaskList: {
paddingLeft: 8,
paddingRight: 4,
paddingTop: 4,
},
});
+64 -1
View File
@@ -15,6 +15,8 @@ import { useCategories } from '@/hooks/useDatabase';
import { getAuthUser, getAuthToken } from '@/services/auth';
import { checkForUpdates, getCurrentAppVersion } from '@/services/updates';
import { getLastSyncTime } from '@/database/sync';
import { rescheduleAllReminders } from '@/services/notifications';
import { collections } from '@/database';
import Category from '@/models/Category';
import Svg, { Path } from 'react-native-svg';
import ColorWheel from '@/components/ColorWheel';
@@ -22,9 +24,10 @@ import { getLastVisitedTab, tabHref } from '@/utils/tabHistory';
export default function SettingsScreen() {
const router = useRouter();
const { theme, notifications, setNotifications, defaultCategoryId, setDefaultCategoryId, sortBy, setSortBy, reminderPreference, setReminderPreference, apiUrl, accentColor, setAccentColor, todoAheadDays, setTodoAheadDays, showCompleted, setShowCompleted } = useSettings();
const { theme, notifications, setNotifications, defaultCategoryId, setDefaultCategoryId, sortBy, setSortBy, reminderPreference, setReminderPreference, tagReminderPreferences, setTagReminderPreferences, apiUrl, accentColor, setAccentColor, todoAheadDays, setTodoAheadDays, showCompleted, setShowCompleted, deleteBySwipe, setDeleteBySwipe } = useSettings();
const categories = useCategories();
const [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 [syncVisible, setSyncVisible] = useState(false);
const [friendsVisible, setFriendsVisible] = useState(false);
@@ -95,6 +98,14 @@ export default function SettingsScreen() {
const defaultCategoryLabel = defaultCategory?.name ?? 'None';
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 = () => {
setDraftAccent(accentColor);
setCustomAccentVisible(true);
@@ -108,6 +119,20 @@ export default function SettingsScreen() {
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[]) => {
setTodoAheadDays(parseInt(Array.isArray(value) ? value[0] : value, 10));
};
@@ -163,6 +188,18 @@ export default function SettingsScreen() {
onPress={() => notifications && setPicker('reminder')}
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>
<ListItem
title="Default Tag"
@@ -188,6 +225,19 @@ export default function SettingsScreen() {
/>
}
/>
<ListItem
title="Delete by Swiping"
subtitle="Swipe right to delete task"
rightElement={
<Switch
value={deleteBySwipe}
onValueChange={setDeleteBySwipe}
thumbColor="#FFFFFF"
trackColor={{ false: theme.borderStrong, true: theme.accent }}
/>
}
showChevron
/>
<ListItem
title="Show Calendar Tasks"
subtitle={todoAheadDays === 0 ? 'Only today' : `Up to ${todoAheadDays} days ahead`}
@@ -294,6 +344,19 @@ export default function SettingsScreen() {
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
visible={picker === 'todoAhead'}
title="Calendar Tasks in Todo"
+1
View File
@@ -194,6 +194,7 @@ export default function AddTaskScreen() {
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="interactive"
automaticallyAdjustContentInsets={false}
>
<CategorySelector
value={tags}
+6 -3
View File
@@ -40,11 +40,14 @@ export default function DayViewScreen() {
useFocusEffect(
useCallback(() => {
const sub = BackHandler.addEventListener('hardwareBackPress', () => false);
const sub = BackHandler.addEventListener('hardwareBackPress', () => {
router.back();
return true;
});
refreshSubtasks();
refresh();
return () => sub.remove();
}, [refreshSubtasks, refresh])
}, [router, refreshSubtasks, refresh])
);
const subtasksOnDay = useMemo(() => {
@@ -70,7 +73,7 @@ export default function DayViewScreen() {
return (
<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">
<ScrollView style={styles.scrollBody} contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
{loading ? (
+1
View File
@@ -187,6 +187,7 @@ export default function SubtaskDetailScreen() {
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="interactive"
>
<Controller
control={control}
@@ -49,9 +49,9 @@ export function AnalogClock({ value, is24Hour = false, onChange }: AnalogClockPr
let hour: number;
if (is24Hour) {
const inner = distance < INNER_NUM_RADIUS + 14;
hour = inner ? base + 12 : (base === 0 ? 12 : base);
hour = inner ? base + 12 : base === 0 ? 12 : base;
} else {
hour = isPM ? base + 12 : (base === 0 ? 0 : base);
hour = isPM ? base + 12 : base === 0 ? 0 : base;
}
emit(hour, minutes);
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) => {
const angle = (i * 30 * Math.PI) / 180;
const x = CENTER + RADIUS * 0.82 * Math.sin(angle);
@@ -181,7 +182,7 @@ export function AnalogClock({ value, is24Hour = false, onChange }: AnalogClockPr
</TouchableOpacity>
{!is24Hour && (
<View style={styles.periodGroup}>
{(['AM', 'PM'] as const).map((period) => {
{(['AM', 'PM'] as const).map(period => {
const active = isPM === (period === 'PM');
return (
<TouchableOpacity
@@ -215,15 +216,15 @@ export function AnalogClock({ value, is24Hour = false, onChange }: AnalogClockPr
style={styles.clockSurface}
onStartShouldSetResponder={() => true}
onMoveShouldSetResponder={() => true}
onResponderGrant={(e) => {
onResponderGrant={e => {
movedRef.current = false;
onLocation(e, false);
}}
onResponderMove={(e) => {
onResponderMove={e => {
movedRef.current = true;
onLocation(e, true);
}}
onResponderRelease={(e) => {
onResponderRelease={e => {
if (!movedRef.current) {
onLocation(e, false);
}
@@ -1,5 +1,15 @@
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 { useFriends } from '@/hooks/useFriends';
@@ -82,19 +92,14 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
<Text style={[styles.selectorIcon, { color: theme.accentStrong }]}>👤</Text>
<View style={styles.selectorContent}>
<Text style={[styles.selectorLabel, { color: theme.textMuted }]}>Assignee</Text>
<Text
style={[
styles.selectorValue,
{ color: value ? theme.text : theme.textMuted },
]}
>
<Text style={[styles.selectorValue, { color: value ? theme.text : theme.textMuted }]}>
{selectedFriend ? selectedFriend.username : 'Unassigned'}
</Text>
</View>
{value && !disabled && (
<TouchableOpacity
style={styles.clearButton}
onPress={(e) => {
onPress={e => {
e.stopPropagation();
handleClear();
}}
@@ -105,7 +110,12 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
)}
</TouchableOpacity>
<Modal visible={showModal} transparent animationType="fade" onRequestClose={() => setShowModal(false)}>
<Modal
visible={showModal}
transparent
animationType="fade"
onRequestClose={() => setShowModal(false)}
>
<KeyboardAvoidingView
style={[styles.modalOverlay, { backgroundColor: 'rgba(0,0,0,0.5)' }]}
behavior="padding"
@@ -113,7 +123,10 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
<View style={[styles.modalSheet, { backgroundColor: theme.sheetBg }]}>
<View style={styles.modalHeader}>
<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>
</TouchableOpacity>
</View>
@@ -122,7 +135,10 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
<TouchableOpacity
style={[
styles.optionRow,
{ backgroundColor: selectedFriend ? theme.accentSoft : 'transparent', borderColor: selectedFriend ? theme.accent : 'transparent' },
{
backgroundColor: selectedFriend ? theme.accentSoft : 'transparent',
borderColor: selectedFriend ? theme.accent : 'transparent',
},
]}
onPress={() => handleClear()}
>
@@ -143,7 +159,9 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
<ActivityIndicator color={theme.accent} size="large" />
</View>
) : 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
data={friends}
@@ -152,14 +170,19 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
<TouchableOpacity
style={[
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)}
>
<View style={styles.optionIcon}>
<Text style={styles.optionIconText}>👤</Text>
</View>
<Text style={[styles.optionText, { color: theme.text }]}>{item.username}</Text>
<Text style={[styles.optionText, { color: theme.text }]}>
{item.username}
</Text>
{value === item.id && (
<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.searchContainer}>
<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"
placeholderTextColor={theme.textMuted}
value={searchQuery}
@@ -197,15 +227,21 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
<View style={styles.optionIcon}>
<Text style={styles.optionIconText}>➕</Text>
</View>
<Text style={[styles.optionText, { color: theme.text }]}>{item.username}</Text>
<Text style={[styles.optionSubtext, { color: theme.textMuted }]}>Add as friend & assign</Text>
<Text style={[styles.optionText, { color: theme.text }]}>
{item.username}
</Text>
<Text style={[styles.optionSubtext, { color: theme.textMuted }]}>
Add as friend & assign
</Text>
</TouchableOpacity>
)}
/>
) : searchQuery.length >= 2 && searchResults.length === 0 && !searching ? (
<Text style={[styles.emptyText, { color: theme.textFaint }]}>No users found</Text>
) : 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}
</View>
</View>
@@ -1,11 +1,23 @@
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 { useSettings } from '@/theme';
import { CATEGORY_COLORS } from '@/constants';
import { createCategory, updateCategory, deleteCategory } from '@/utils/categoryActions';
import { confirmAction } from '@/utils/confirm';
import ColorPickerInput from '@/components/ColorPickerInput';
import ColorWheel from '@/components/ColorWheel';
import Svg, { Path, Circle } from 'react-native-svg';
interface CategoryEditorModalProps {
@@ -15,7 +27,12 @@ interface CategoryEditorModalProps {
onClose: () => void;
}
export function CategoryEditorModal({ visible, category, categoryCount, onClose }: CategoryEditorModalProps) {
export function CategoryEditorModal({
visible,
category,
categoryCount,
onClose,
}: CategoryEditorModalProps) {
const { theme } = useSettings();
const [name, setName] = useState('');
const [color, setColor] = useState(CATEGORY_COLORS[0]);
@@ -64,15 +81,8 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={0}
>
<Pressable
style={styles.overlay}
onPress={onClose}
accessibilityRole="none"
>
<Pressable
style={[styles.sheet, { backgroundColor: theme.sheetBg }]}
onPress={() => {}}
>
<Pressable style={styles.overlay} onPress={onClose} accessibilityRole="none">
<Pressable style={[styles.sheet, { backgroundColor: theme.sheetBg }]} onPress={() => {}}>
<ScrollView
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
@@ -82,10 +92,21 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose
<Text style={[styles.title, { color: theme.text }]}>
{category ? 'Edit Tag' : 'New Tag'}
</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">
<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>
</TouchableOpacity>
</View>
@@ -94,7 +115,11 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose
<TextInput
style={[
styles.input,
{ backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text },
{
backgroundColor: theme.inputBg,
borderColor: theme.borderStrong,
color: theme.text,
},
]}
placeholder="Tag name"
placeholderTextColor={theme.textMuted}
@@ -107,7 +132,7 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose
<Text style={[styles.label, { color: theme.textSecondary }]}>Color</Text>
<View style={styles.colorGrid}>
{CATEGORY_COLORS.map((c) => (
{CATEGORY_COLORS.map(c => (
<TouchableOpacity
key={c}
style={[
@@ -123,13 +148,20 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose
>
{color === c && (
<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>
)}
</TouchableOpacity>
))}
</View>
<ColorPickerInput value={color} onChange={setColor} />
<ColorWheel color={color} onChange={setColor} />
<View style={styles.actions}>
{category && (
@@ -139,13 +171,22 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose
disabled={!canDelete}
activeOpacity={0.7}
>
<Text style={[styles.deleteButtonText, { color: canDelete ? theme.accent : theme.textMuted }]}>
<Text
style={[
styles.deleteButtonText,
{ color: canDelete ? theme.accent : theme.textMuted },
]}
>
Delete
</Text>
</TouchableOpacity>
)}
<TouchableOpacity
style={[styles.saveButton, { backgroundColor: theme.accent }, !name.trim() && styles.saveButtonDisabled]}
style={[
styles.saveButton,
{ backgroundColor: theme.accent },
!name.trim() && styles.saveButtonDisabled,
]}
onPress={handleSave}
disabled={!name.trim()}
activeOpacity={0.8}
@@ -57,7 +57,7 @@ export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) {
isAllCategory={true}
scaleAnim={scaleAnim}
/>
{categories.map((category) => (
{categories.map(category => (
<CategoryButton
key={category.id}
id={category.id}
@@ -72,7 +72,10 @@ export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) {
/>
))}
<TouchableOpacity
style={[styles.manageButton, { borderColor: theme.borderStrong, backgroundColor: theme.cardAlt }]}
style={[
styles.manageButton,
{ borderColor: theme.borderStrong, backgroundColor: theme.cardAlt },
]}
onPress={() => router.push('/settings')}
activeOpacity={0.7}
accessibilityRole="button"
@@ -80,10 +83,22 @@ export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) {
hitSlop={{ top: 8, bottom: 8, left: 4, right: 4 }}
>
<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={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>
<Text style={[styles.manageLabel, { color: theme.textSecondary }]}>Manage</Text>
</TouchableOpacity>
@@ -104,7 +119,16 @@ interface CategoryButtonProps {
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 = {
transform: [{ scale: scaleAnim }],
shadowColor: selected ? theme.accent : '#000',
@@ -130,8 +154,24 @@ function CategoryButton({ color, selected, onPress, onPressIn, onPressOut, theme
>
{isAllCategory ? (
<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} />
<Path d="M6 12l4 4 8-8" stroke="#FFFFFF" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" opacity={0.9} />
<Circle
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>
) : null}
</TouchableOpacity>
@@ -1,5 +1,14 @@
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 { useSettings } from '@/theme';
import Svg, { Path } from 'react-native-svg';
@@ -16,7 +25,7 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
const [showModal, setShowModal] = useState(false);
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 next = new Set(selected);
@@ -33,7 +42,7 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
? 'None'
: selectedCategories
.slice(0, 2)
.map((c) => c.name)
.map(c => c.name)
.join(', ') + (selectedCategories.length > 2 ? ` +${selectedCategories.length - 2}` : '');
return (
@@ -53,10 +62,18 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
<View style={styles.selectorContent}>
{selectedCategories.length > 0 ? (
<View style={styles.chipRow}>
{selectedCategories.slice(0, 3).map((c) => (
<View key={c.id} style={[styles.chip, { backgroundColor: theme.cardAlt, borderColor: theme.border }]}>
{selectedCategories.slice(0, 3).map(c => (
<View
key={c.id}
style={[
styles.chip,
{ backgroundColor: theme.cardAlt, borderColor: theme.border },
]}
>
<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>
@@ -65,12 +82,24 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
)}
</View>
<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>
</TouchableOpacity>
{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
style={[styles.modalOverlay, { backgroundColor: 'rgba(0,0,0,0.5)' }]}
behavior="padding"
@@ -81,7 +110,10 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
<Text style={[styles.modalTitle, { color: theme.text }]}>Select Tags</Text>
<View style={styles.modalHeaderRight}>
<TouchableOpacity
onPress={() => { onChange([]); setShowModal(false); }}
onPress={() => {
onChange([]);
setShowModal(false);
}}
activeOpacity={0.7}
accessibilityRole="button"
accessibilityLabel="Clear all tags"
@@ -102,7 +134,7 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
<Text style={[styles.modalHint, { color: theme.textMuted }]}>
A task can have multiple tags. Selected: {selected.size}
</Text>
{categories.map((category) => {
{categories.map(category => {
const isSelected = selected.has(category.id);
return (
<TouchableOpacity
@@ -110,7 +142,11 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
style={[
styles.modalOption,
{ 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)}
activeOpacity={0.8}
@@ -118,17 +154,32 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
accessibilityLabel={`Tag ${category.name}`}
accessibilityState={{ checked: isSelected }}
>
<View style={[styles.colorCircle, { backgroundColor: category.color }, isSelected && styles.colorCircleSelected]} />
<Text style={[
<View
style={[
styles.colorCircle,
{ backgroundColor: category.color },
isSelected && styles.colorCircleSelected,
]}
/>
<Text
style={[
styles.categoryName,
{ color: theme.textSecondary },
isSelected && { color: theme.text, fontWeight: '600' },
]}>
]}
>
{category.name}
</Text>
{isSelected && (
<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>
)}
</TouchableOpacity>
@@ -20,15 +20,23 @@ export default function NativeColorPickerInput({ value, onChange }: ColorPickerI
const valid = isValidHex(hex);
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}`);
};
return (
<View style={styles.row}>
<View style={[styles.preview, { backgroundColor: valid ? value : theme.borderStrong, borderColor: theme.borderStrong }]} />
<View style={[styles.inputWrap, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong }]}>
<Text style={[styles.hash, { color: theme.textMuted }]}>#</Text>
<View
style={[
styles.preview,
{ backgroundColor: valid ? value : theme.accentSoft, borderColor: theme.accent },
]}
/>
<View style={styles.inputWrap}>
<Text style={styles.hash}>#{/**/}</Text>
<TextInput
style={[styles.input, { color: theme.text }]}
value={hex}
@@ -40,7 +48,7 @@ export default function NativeColorPickerInput({ value, onChange }: ColorPickerI
maxLength={6}
/>
</View>
<Text style={[styles.hint, { color: theme.textMuted }]}>Hex code</Text>
<Text style={styles.hint}>{/**/}Hex code</Text>
</View>
);
}
@@ -28,13 +28,13 @@ export default function WebColorPickerInput({ value, onChange }: ColorPickerInpu
width: 44,
height: 44,
padding: 0,
border: `1px solid ${theme.borderStrong}`,
border: `1px solid ${theme.accent}`,
borderRadius: 12,
backgroundColor: 'transparent',
cursor: 'pointer',
},
})}
<Text style={[styles.hexText, { color: theme.textSecondary }]}>{normalized}</Text>
<Text style={[styles.hexText, { color: theme.accent }]}>{normalized}</Text>
</View>
);
}
@@ -33,14 +33,38 @@ function hsvToHex(h: number, s: number, v: number) {
const c = v * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = v - c;
let r = 0, g = 0, b = 0;
if (h < 60) { r = c; g = x; b = 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');
let r = 0,
g = 0,
b = 0;
if (h < 60) {
r = c;
g = x;
b = 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();
}
@@ -84,35 +108,49 @@ export default function ColorWheel({ color, onChange }: ColorWheelProps) {
setValue(1 - distance / radius);
};
const wheelPanResponder = React.useMemo(() => PanResponder.create({
const wheelPanResponder = React.useMemo(
() =>
PanResponder.create({
onStartShouldSetPanResponder: () => true,
onMoveShouldSetPanResponder: () => true,
onPanResponderGrant: (event) => {
onPanResponderGrant: event => {
updateFromWheel(event.nativeEvent.locationX, event.nativeEvent.locationY);
},
onPanResponderMove: (event) => {
onPanResponderMove: event => {
updateFromWheel(event.nativeEvent.locationX, event.nativeEvent.locationY);
},
}), []);
}),
[]
);
const setHueFromLocation = (locationX: number) => {
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,
onMoveShouldSetPanResponder: () => true,
onPanResponderGrant: (event) => {
onPanResponderGrant: event => {
setHueFromLocation(event.nativeEvent.locationX);
},
onPanResponderMove: (event) => {
onPanResponderMove: event => {
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 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 thumbX =
((WHEEL_SIZE - THUMB_SIZE) / 2) * saturation * Math.cos(((hue - 180) * Math.PI) / 180) +
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 (
<View style={styles.container}>
@@ -180,7 +218,9 @@ export default function ColorWheel({ color, onChange }: ColorWheelProps) {
<View style={styles.previewContainer}>
<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>
);
@@ -32,14 +32,38 @@ function hsvToHex(h: number, s: number, v: number) {
const c = v * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = v - c;
let r = 0, g = 0, b = 0;
if (h < 60) { r = c; g = x; b = 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');
let r = 0,
g = 0,
b = 0;
if (h < 60) {
r = c;
g = x;
b = 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();
}
@@ -108,15 +132,25 @@ export default function ColorWheel({ color, onChange }: ColorWheelProps) {
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 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 thumbX =
((WHEEL_SIZE - THUMB_SIZE) / 2) * saturation * Math.cos(((hue - 180) * Math.PI) / 180) +
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);
return (
<View style={styles.container}>
<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>
<LinearGradient id="satGradient" x1="0%" y1="0%" x2="100%" y2="0%">
<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>
</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>
</TouchableOpacity>
</View>
@@ -49,14 +49,30 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
styles.pickerButton,
{ backgroundColor: theme.inputBg, borderColor: theme.borderStrong },
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}
activeOpacity={0.8}
>
<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" />
<Path d="M12 6v6l4 2" stroke={field.value ? theme.accent : theme.textFaint} strokeWidth={1.8} strokeLinecap="round" />
<Circle
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>
<Text
style={[
@@ -82,7 +98,12 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
>
<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>
</TouchableOpacity>
) : null}
@@ -142,7 +163,12 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
>
<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>
</TouchableOpacity>
) : null}
@@ -151,7 +177,10 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
}}
/>
<TouchableOpacity
style={[styles.calendarButton, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong }]}
style={[
styles.calendarButton,
{ backgroundColor: theme.inputBg, borderColor: theme.borderStrong },
]}
onPress={() => setPicker('date')}
activeOpacity={0.8}
accessibilityLabel="Open calendar"
@@ -184,7 +213,7 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
thumbColor={field.value ? theme.accent : theme.textMuted}
ios_backgroundColor={theme.border}
value={field.value ?? false}
onValueChange={(value) => {
onValueChange={value => {
field.onChange(value);
if (value) {
timeRef.current?.('');
@@ -203,10 +232,27 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
</View>
{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">
<Circle 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" />
<Circle
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>
<Text style={[styles.warningText, { color: theme.dangerStrong }]}>
{endTime === startTime
@@ -235,12 +281,8 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
<WheelTimePicker
visible={picker === 'time' || picker === 'endTime'}
title={picker === 'endTime' ? 'End Time' : 'Start Time'}
initialTime={
picker === 'endTime'
? endTime || startTime
: startTime
}
onConfirm={(time) => {
initialTime={picker === 'endTime' ? endTime || startTime : startTime}
onConfirm={time => {
if (picker === 'endTime') {
setEndTimeValue(time);
} else {
@@ -36,7 +36,8 @@ export function DescriptionInput({ error, ...props }: DescriptionInputProps) {
);
}
const createStyles = (theme: ThemeColors) => StyleSheet.create({
const createStyles = (theme: ThemeColors) =>
StyleSheet.create({
container: {
gap: 5,
},
@@ -47,12 +47,7 @@ export function FloatingActionButton() {
});
return (
<Animated.View
style={[
styles.container,
{ transform: [{ scale: scaleAnim }] },
]}
>
<Animated.View style={[styles.container, { transform: [{ scale: scaleAnim }] }]}>
<TouchableOpacity
style={[styles.button, { backgroundColor: theme.accent, shadowColor: theme.accent }]}
onPress={handlePress}
@@ -44,13 +44,17 @@ export function ForgotPasswordModal({ visible, onClose }: ForgotPasswordModalPro
const result = await forgotPassword(username.trim());
if (result.success) {
if (result.resetToken) {
Alert.alert('Reset Token (Dev Mode)', `Your reset token is: ${result.resetToken}\n\nEnter this token on the next screen.`, [
{ text: 'OK', onPress: () => setStep('enterResetCode') },
]);
Alert.alert(
'Reset Token (Dev Mode)',
`Your reset token is: ${result.resetToken}\n\nEnter this token on the next screen.`,
[{ text: 'OK', onPress: () => setStep('enterResetCode') }]
);
} else {
Alert.alert('Reset Email Sent', 'If the account exists, a password reset link has been sent.', [
{ text: 'OK', onPress: () => setStep('enterResetCode') },
]);
Alert.alert(
'Reset Email Sent',
'If the account exists, a password reset link has been sent.',
[{ text: 'OK', onPress: () => setStep('enterResetCode') }]
);
}
} else {
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.
</Text>
<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"
placeholderTextColor={theme.textMuted}
value={username}
@@ -130,7 +137,9 @@ export function ForgotPasswordModal({ visible, onClose }: ForgotPasswordModalPro
{busy ? (
<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>
</View>
@@ -142,7 +151,10 @@ export function ForgotPasswordModal({ visible, onClose }: ForgotPasswordModalPro
Enter the reset token and your new password.
</Text>
<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"
placeholderTextColor={theme.textMuted}
value={resetToken}
@@ -151,7 +163,10 @@ export function ForgotPasswordModal({ visible, onClose }: ForgotPasswordModalPro
autoCorrect={false}
/>
<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)"
placeholderTextColor={theme.textMuted}
value={newPassword}
@@ -159,7 +174,10 @@ export function ForgotPasswordModal({ visible, onClose }: ForgotPasswordModalPro
secureTextEntry
/>
<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"
placeholderTextColor={theme.textMuted}
value={confirmPassword}
@@ -176,7 +194,9 @@ export function ForgotPasswordModal({ visible, onClose }: ForgotPasswordModalPro
{busy ? (
<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>
</View>
@@ -184,10 +204,7 @@ export function ForgotPasswordModal({ visible, onClose }: ForgotPasswordModalPro
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={handleBack}>
<KeyboardAvoidingView
style={styles.overlay}
behavior="padding"
>
<KeyboardAvoidingView style={styles.overlay} behavior="padding">
<TouchableOpacity style={styles.backdrop} activeOpacity={1} onPress={handleBack} />
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
<View style={[styles.handle, { backgroundColor: theme.borderStrong }]} />
+23 -4
View File
@@ -13,7 +13,10 @@ interface FormButtonsProps {
export function FormButtons({ onSubmit, submitLabel = 'Submit' }: FormButtonsProps) {
const router = useRouter();
const { theme } = useSettings();
const { handleSubmit, formState: { isSubmitting } } = useFormContext();
const {
handleSubmit,
formState: { isSubmitting },
} = useFormContext();
const insets = useSafeAreaInsets();
const cancel = () => {
@@ -25,9 +28,21 @@ export function FormButtons({ onSubmit, submitLabel = 'Submit' }: FormButtonsPro
};
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
style={[styles.cancelButton, { borderColor: theme.borderStrong, backgroundColor: theme.card }]}
style={[
styles.cancelButton,
{ borderColor: theme.borderStrong, backgroundColor: theme.card },
]}
onPress={cancel}
disabled={isSubmitting}
activeOpacity={0.7}
@@ -38,7 +53,11 @@ export function FormButtons({ onSubmit, submitLabel = 'Submit' }: FormButtonsPro
<Text style={[styles.cancelButtonText, { color: theme.textSecondary }]}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.submitButton, { backgroundColor: theme.accent }, isSubmitting && styles.buttonDisabled]}
style={[
styles.submitButton,
{ backgroundColor: theme.accent },
isSubmitting && styles.buttonDisabled,
]}
onPress={handleSubmit(onSubmit)}
disabled={isSubmitting}
activeOpacity={0.8}
+104 -26
View File
@@ -21,11 +21,23 @@ interface FriendsModalProps {
export function FriendsModal({ visible, onClose }: FriendsModalProps) {
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 [searchResults, setSearchResults] = useState<{ id: string; username: string }[]>([]);
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 [prevSearchKey, setPrevSearchKey] = useState(searchKey);
@@ -41,7 +53,11 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
setSearching(true);
try {
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) {
console.error('Search failed:', err);
} finally {
@@ -71,13 +87,17 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
const handleRemove = async (friendId: string) => {
Alert.alert('Remove Friend', 'Are you sure you want to remove this friend?', [
{ text: 'Cancel', style: 'cancel' },
{ text: 'Remove', style: 'destructive', onPress: async () => {
{
text: 'Remove',
style: 'destructive',
onPress: async () => {
try {
await removeFriend(friendId);
} catch (err) {
Alert.alert('Error', err instanceof Error ? err.message : 'Failed to remove friend');
}
}},
},
},
]);
};
@@ -98,7 +118,7 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
</View>
<View style={styles.tabs}>
{(['friends', 'incoming', 'outgoing', 'add'] as const).map((tab) => (
{(['friends', 'incoming', 'outgoing', 'add'] as const).map(tab => (
<TouchableOpacity
key={tab}
style={[
@@ -116,10 +136,14 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
>
{tab.charAt(0).toUpperCase() + tab.slice(1)}
{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 && (
<Text style={[styles.badge, { color: theme.accentText }]}>{incoming.length}</Text>
<Text style={[styles.badge, { color: theme.accentText }]}>
{incoming.length}
</Text>
)}
</Text>
</TouchableOpacity>
@@ -135,7 +159,9 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
) : friends.length === 0 ? (
<View style={styles.empty}>
<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>
) : (
<FlatList
@@ -143,12 +169,19 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
keyExtractor={f => f.id}
renderItem={({ item }) => (
<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
style={[styles.removeBtn, { backgroundColor: theme.accentSoft, borderColor: theme.accent }]}
style={[
styles.removeBtn,
{ backgroundColor: theme.accentSoft, borderColor: theme.accent },
]}
onPress={() => handleRemove(item.id)}
>
<Text style={[styles.removeBtnText, { color: theme.accentStrong }]}>Remove</Text>
<Text style={[styles.removeBtnText, { color: theme.accentStrong }]}>
Remove
</Text>
</TouchableOpacity>
</View>
)}
@@ -165,7 +198,9 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
</View>
) : incoming.length === 0 ? (
<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>
) : (
<FlatList
@@ -173,19 +208,32 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
keyExtractor={r => r.requestId}
renderItem={({ item }) => (
<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}>
<TouchableOpacity
style={[styles.actionBtn, { backgroundColor: theme.accent }]}
onPress={() => handleAccept(item.requestId)}
>
<Text style={[styles.actionBtnText, { color: theme.accentText }]}>Accept</Text>
<Text style={[styles.actionBtnText, { color: theme.accentText }]}>
Accept
</Text>
</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)}
>
<Text style={[styles.actionBtnText, { color: theme.textMuted }]}>Decline</Text>
<Text style={[styles.actionBtnText, { color: theme.textMuted }]}>
Decline
</Text>
</TouchableOpacity>
</View>
</View>
@@ -203,7 +251,9 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
</View>
) : outgoing.length === 0 ? (
<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>
) : (
<FlatList
@@ -211,13 +261,24 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
keyExtractor={r => r.requestId}
renderItem={({ item }) => (
<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>
<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)}
>
<Text style={[styles.cancelBtnText, { color: theme.textMuted }]}>Cancel</Text>
<Text style={[styles.cancelBtnText, { color: theme.textMuted }]}>
Cancel
</Text>
</TouchableOpacity>
</View>
)}
@@ -230,7 +291,14 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
<View style={styles.content}>
<View style={styles.searchContainer}>
<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"
placeholderTextColor={theme.textMuted}
value={searchQuery}
@@ -253,16 +321,24 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
keyExtractor={u => u.id}
renderItem={({ item }) => (
<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
style={[styles.addBtn, { backgroundColor: theme.accent }]}
onPress={() => sendRequest(item.username)
onPress={() =>
sendRequest(item.username)
.then(() => {
setSearchQuery('');
setSearchResults([]);
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>
@@ -272,7 +348,9 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
/>
) : searchQuery.length < 2 ? (
<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>
) : null}
</View>
+3 -1
View File
@@ -20,7 +20,9 @@ export function Header({ title, showLogo, rightAction }: HeaderProps) {
<Text style={[styles.logoText, { color: theme.accentText }]}>✓</Text>
</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>
</View>
@@ -136,7 +136,12 @@ export function LegalModal({ visible, type, onClose }: LegalModalProps) {
<Text style={[styles.title, { color: theme.text }]}>{title}</Text>
<TouchableOpacity onPress={onClose} style={styles.closeButton} activeOpacity={0.7}>
<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>
</TouchableOpacity>
</View>
@@ -1,89 +0,0 @@
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import { useSettings } from '@/theme';
import Svg, { Path } from 'react-native-svg';
interface ListItemProps {
title: string;
subtitle?: string;
leftElement?: React.ReactNode;
rightElement?: React.ReactNode;
onPress?: () => void;
showChevron?: boolean;
}
export function ListItem({ title, subtitle, leftElement, rightElement, onPress, showChevron }: ListItemProps) {
const { theme } = useSettings();
const isInteractive = onPress || showChevron;
return (
<TouchableOpacity
style={[
styles.container,
{ backgroundColor: theme.card, borderColor: theme.border },
isInteractive && styles.interactive,
]}
onPress={onPress}
activeOpacity={0.7}
disabled={!isInteractive}
accessibilityRole={isInteractive ? 'button' : 'none'}
accessibilityLabel={title}
accessibilityHint={subtitle ? subtitle : undefined}
accessibilityState={{ disabled: !isInteractive }}
>
{leftElement && <View style={styles.leftElement}>{leftElement}</View>}
<View style={styles.leftContent}>
<Text style={[styles.title, { color: theme.text }]}>{title}</Text>
{subtitle && <Text style={[styles.subtitle, { color: theme.textMuted }]}>{subtitle}</Text>}
</View>
<View style={styles.rightContent}>
{rightElement}
{showChevron && !rightElement && (
<Svg width={20} height={20} viewBox="0 0 24 24">
<Path
d="M9 18l6-6-6-6"
stroke={theme.textSecondary}
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</Svg>
)}
</View>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 14,
paddingHorizontal: 16,
borderRadius: 12,
borderWidth: 1,
marginBottom: 8,
},
interactive: {},
leftElement: {
marginRight: 12,
},
leftContent: {
flex: 1,
gap: 2,
},
title: {
fontSize: 16,
fontWeight: '500',
},
subtitle: {
fontSize: 13,
},
rightContent: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
});
+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) {
const { theme } = useSettings();
const { theme, deleteBySwipe } = useSettings();
const styles = React.useMemo(() => createStyles(theme), [theme]);
const [opacityAnim] = React.useState(new Animated.Value(task.completed ? 0.5 : 1));
const [dragTranslateX] = React.useState(new Animated.Value(0));
@@ -176,7 +176,9 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
renderRightActions={renderRightActions}
renderLeftActions={renderLeftActions}
onSwipeableRightOpen={() => {
if (deleteBySwipe) {
onDelete?.();
}
swipeableRef.current?.close();
}}
onSwipeableLeftOpen={() => {
+9 -10
View File
@@ -334,8 +334,8 @@ export function TaskList({ categoryIds = [], showCompleted = false, onSelectionC
}
}, [findHoverTarget]);
const handleSubtaskDragStart = useCallback(async (subtaskId: string, parentTaskId: string) => {
subtaskDragRef.current = { subtaskId, parentTaskId, ...(await measureAll()) };
const handleSubtaskDragStart = useCallback(async (subtaskId: string, parentTaskId: string, parentSubtaskId?: string) => {
subtaskDragRef.current = { subtaskId, parentTaskId, parentSubtaskId, ...(await measureAll()) };
}, [measureAll]);
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);
if (target === state.subtaskId) return;
if (target) {
if (state.subPositions[target]) {
await setSubtaskParent(state.subtaskId, target);
} else if (target !== state.parentTaskId) {
// Only allow dropping on tasks (not subtasks) to match task behavior
// Check if target exists in main task positions, not subtask positions
const isTaskTarget = state.positions && target in state.positions;
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);
}
} else {
await convertSubtaskToTask(state.subtaskId);
}
}, [findHoverTarget]);
}, [findHoverTarget, moveSubtaskToTask]);
const renderItem = useCallback(
({ 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;
try {
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;
}
if (body && body.error && body.error.details) {
+31 -4
View File
@@ -1,9 +1,19 @@
import { Platform } from 'react-native';
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';
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[]>> {
try {
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
? parseReminders(task.reminders)
: task.reminder && task.reminder !== 'none'
@@ -62,6 +76,17 @@ function getRemindersForTask(task: any, globalPreference: ReminderPreference): R
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);
if (globalReminder !== 'none') {
return [globalReminder];
@@ -92,7 +117,8 @@ export async function scheduleTaskReminder(task: any, notificationsEnabled: bool
await writeIdMap(idMap);
// 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;
@@ -195,11 +221,12 @@ export async function rescheduleAllReminders(tasks: any[], notificationsEnabled:
// ignore
}
const idMap: Record<string, string[]> = {};
const tagPreferences = await loadTagPreferences();
for (const task of tasks) {
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;
+21 -1
View File
@@ -23,6 +23,10 @@ export const REMINDER_OPTIONS: { value: ReminderPreference; label: string }[] =
{ value: '1440', label: '1 day before' },
];
export type TagReminderPreferences = Record<string, ReminderPreference[]>;
export const TAG_REMINDER_PREFERENCES_KEY = 'settings:tagReminderPreferences';
export interface ThemeColors {
background: string;
card: string;
@@ -138,6 +142,8 @@ interface SettingsContextType {
setSortBy: (value: SortBy) => void;
reminderPreference: ReminderPreference;
setReminderPreference: (value: ReminderPreference) => void;
tagReminderPreferences: TagReminderPreferences;
setTagReminderPreferences: (value: TagReminderPreferences) => void;
apiUrl: string;
setApiUrl: (value: string) => void;
accentColor: string;
@@ -146,6 +152,8 @@ interface SettingsContextType {
setTodoAheadDays: (value: number) => void;
showCompleted: boolean;
setShowCompleted: (value: boolean) => void;
deleteBySwipe: boolean;
setDeleteBySwipe: (value: boolean) => void;
theme: ThemeColors;
}
@@ -156,9 +164,11 @@ const STORAGE_KEYS = {
defaultCategoryId: 'settings:defaultCategoryId',
sortBy: 'settings:sortBy',
reminderPreference: 'settings:reminderPreference',
tagReminderPreferences: TAG_REMINDER_PREFERENCES_KEY,
accentColor: 'settings:accentColor',
todoAheadDays: 'settings:todoAheadDays',
showCompleted: 'settings:showCompleted',
deleteBySwipe: 'settings:deleteBySwipe',
};
const SettingsContext = createContext<SettingsContextType | null>(null);
@@ -197,10 +207,15 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
STORAGE_KEYS.reminderPreference,
'15',
);
const [tagReminderPreferences, setTagReminderPreferences] = useStoredSetting<TagReminderPreferences>(
STORAGE_KEYS.tagReminderPreferences,
{},
);
const [apiUrl, setApiUrl] = useStoredSetting<string>(API_URL_KEY, DEFAULT_API_BASE_URL);
const [accentColor, setAccentColor] = useStoredSetting<string>(STORAGE_KEYS.accentColor, DEFAULT_ACCENT);
const [todoAheadDays, setTodoAheadDays] = useStoredSetting<number>(STORAGE_KEYS.todoAheadDays, DEFAULT_TODO_AHEAD_DAYS);
const [showCompleted, setShowCompleted] = useStoredSetting<boolean>(STORAGE_KEYS.showCompleted, false);
const [deleteBySwipe, setDeleteBySwipe] = useStoredSetting<boolean>(STORAGE_KEYS.deleteBySwipe, false);
const theme = useMemo(() => colors(accentColor), [accentColor]);
@@ -214,6 +229,8 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
setSortBy,
reminderPreference,
setReminderPreference,
tagReminderPreferences,
setTagReminderPreferences,
apiUrl,
setApiUrl,
accentColor,
@@ -222,6 +239,8 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
setTodoAheadDays,
showCompleted,
setShowCompleted,
deleteBySwipe,
setDeleteBySwipe,
theme,
}),
[
@@ -233,6 +252,8 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
setSortBy,
reminderPreference,
setReminderPreference,
tagReminderPreferences,
setTagReminderPreferences,
apiUrl,
setApiUrl,
accentColor,
@@ -241,7 +262,6 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
setTodoAheadDays,
showCompleted,
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 {
taskId: string;
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.
Binary file not shown.

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