fixxed big ui cavia
Build APK / build (push) Canceled after 0s

This commit is contained in:
2026-08-07 01:06:03 +02:00
parent 3d594670b0
commit ecb7fbefb1
71 changed files with 3989 additions and 1204 deletions
+49 -1
View File
@@ -76,7 +76,8 @@ A minimalist, offline-first task management app built with Expo, React Native, a
| UI Components | React Native Paper + Custom SVG icons | | UI Components | React Native Paper + Custom SVG icons |
| Animations | React Native Reanimated | | Animations | React Native Reanimated |
| Date/Time | @react-native-community/datetimepicker + date-fns | | Date/Time | @react-native-community/datetimepicker + date-fns |
| Build | Expo Dev Client / EAS Build | | Build | EAS Build / Gradle (local APK) |
| CI/CD | Gitea Actions (`.gitea/workflows/build-apk.yml`) |
## Project Structure ## Project Structure
@@ -210,6 +211,8 @@ npx expo start --web
``` ```
### Building ### Building
#### EAS Build (cloud)
```bash ```bash
# Install EAS CLI # Install EAS CLI
npm install -g eas-cli npm install -g eas-cli
@@ -222,6 +225,51 @@ eas build --platform ios
eas build --platform android eas build --platform android
eas build --platform web eas build --platform web
``` ```
Profiles are defined in `eas.json` (`development`, `preview`, `production`; production auto-increments version code).
#### Gradle (local Android APK)
The native Android project lives in `carry-your-live/android/`. Requires JDK 17+ and the Android SDK (platform 35, build-tools 35.0.0, NDK 27.1).
```bash
cd carry-your-live/android
# Debug APK (unsigned)
./gradlew assembleDebug
# Release APK (currently signed with the debug keystore)
./gradlew assembleRelease
```
Output:
```
android/app/build/outputs/apk/debug/app-debug.apk
android/app/build/outputs/apk/release/app-release.apk
```
Key notes:
- The debug keystore (`android/app/debug.keystore`) is generated by the CI pipeline and must exist for release builds — create it locally with the same command used in the pipeline (see below) if it's missing.
- Release builds use `signingConfig signingConfigs.debug` until a production keystore is configured.
- Versions are set in `android/app/build.gradle` (`versionCode`, `versionName`).
## CI/CD Pipeline (Gitea Actions)
`.gitea/workflows/build-apk.yml` builds a release APK on every push to `main` (also manually triggerable via workflow_dispatch):
1. **Checkout** and setup Node 22, JDK 17 (Temurin)
2. **Android SDK**: installs cmdline-tools, licenses, platform-tools, `platforms;android-35`, `build-tools;35.0.0`, `ndk;27.1.12297006`
3. **JS deps**: `npm ci` in `carry-your-live/`
4. **Debug keystore**: generates `carry-your-live/android/app/debug.keystore` with `keytool` (alias `androiddebugkey`, passwords `android`)
5. **Build**: `./gradlew assembleRelease` in `carry-your-live/android/`
6. **Upload**: the APK is saved as the `carry-your-live-release` artifact (downloadable from the run's artifacts page)
To run the build locally exactly as CI does:
```bash
keytool -genkeypair -v \
-keystore carry-your-live/android/app/debug.keystore \
-alias androiddebugkey -storepass android -keypass android \
-keyalg RSA -keysize 2048 -validity 10000 \
-dname "CN=Android Debug,O=Android,C=US"
cd carry-your-live/android && ./gradlew assembleRelease
```
## Sync API Specification ## Sync API Specification
+1
View File
@@ -75,6 +75,7 @@ export const subtasks = pgTable('subtasks', {
id: text('id').primaryKey(), id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
taskId: text('task_id').notNull().references(() => tasks.id, { onDelete: 'cascade' }), taskId: text('task_id').notNull().references(() => tasks.id, { onDelete: 'cascade' }),
parentSubtaskId: text('parent_subtask_id').references(() => subtasks.id, { onDelete: 'cascade' }),
title: text('title').notNull(), title: text('title').notNull(),
description: text('description').notNull().default(''), description: text('description').notNull().default(''),
priority: text('priority', { enum: ['none', 'low', 'medium', 'high', 'critical'] }).notNull().default('none'), priority: text('priority', { enum: ['none', 'low', 'medium', 'high', 'critical'] }).notNull().default('none'),
+22 -2
View File
@@ -11,6 +11,17 @@ const router = Router();
router.use(authMiddleware); router.use(authMiddleware);
// Helper to build nested subtask tree
const buildSubtaskTree = (allSubtasks: any[], parentId: string | null = null): any[] => {
return allSubtasks
.filter((s) => s.parentSubtaskId === parentId)
.sort((a, b) => a.order - b.order)
.map((s) => ({
...s,
subtasks: buildSubtaskTree(allSubtasks, s.id),
}));
};
router.get('/task/:taskId', asyncHandler(async (req: Request, res: Response) => { router.get('/task/:taskId', asyncHandler(async (req: Request, res: Response) => {
const userId = req.user!.userId; const userId = req.user!.userId;
@@ -31,7 +42,9 @@ router.get('/task/:taskId', asyncHandler(async (req: Request, res: Response) =>
.where(and(eq(subtasks.taskId, req.params.taskId), eq(subtasks.userId, userId))) .where(and(eq(subtasks.taskId, req.params.taskId), eq(subtasks.userId, userId)))
.orderBy(asc(subtasks.order)); .orderBy(asc(subtasks.order));
res.json({ subtasks: taskSubtasks }); const nestedSubtasks = buildSubtaskTree(taskSubtasks);
res.json({ subtasks: nestedSubtasks });
})); }));
router.post('/task/:taskId', asyncHandler(async (req: Request, res: Response) => { router.post('/task/:taskId', asyncHandler(async (req: Request, res: Response) => {
@@ -49,10 +62,16 @@ router.post('/task/:taskId', asyncHandler(async (req: Request, res: Response) =>
} }
const now = Date.now(); const now = Date.now();
const parentSubtaskId = data.parentSubtaskId || null;
const maxOrder = await db const maxOrder = await db
.select({ order: subtasks.order }) .select({ order: subtasks.order })
.from(subtasks) .from(subtasks)
.where(and(eq(subtasks.taskId, req.params.taskId), eq(subtasks.userId, userId))) .where(and(
eq(subtasks.taskId, req.params.taskId),
eq(subtasks.userId, userId),
parentSubtaskId ? eq(subtasks.parentSubtaskId, parentSubtaskId) : eq(subtasks.parentSubtaskId, null)
))
.orderBy(desc(subtasks.order)) .orderBy(desc(subtasks.order))
.limit(1); .limit(1);
@@ -62,6 +81,7 @@ router.post('/task/:taskId', asyncHandler(async (req: Request, res: Response) =>
id: subtaskId, id: subtaskId,
userId, userId,
taskId: req.params.taskId, taskId: req.params.taskId,
parentSubtaskId,
title: data.title, title: data.title,
description: data.description ?? '', description: data.description ?? '',
priority: data.priority ?? 'none', priority: data.priority ?? 'none',
+2
View File
@@ -69,6 +69,7 @@ export const subtaskCreateSchema = z.object({
reminders: z.string().max(100).optional(), reminders: z.string().max(100).optional(),
assigneeId: z.string().nullable().optional(), assigneeId: z.string().nullable().optional(),
order: z.number().int().min(0).optional(), order: z.number().int().min(0).optional(),
parentSubtaskId: z.string().nullable().optional(),
}); });
export const subtaskUpdateSchema = z.object({ export const subtaskUpdateSchema = z.object({
@@ -88,6 +89,7 @@ export const subtaskUpdateSchema = z.object({
reminders: z.string().max(100).optional(), reminders: z.string().max(100).optional(),
assigneeId: z.string().nullable().optional(), assigneeId: z.string().nullable().optional(),
order: z.number().int().min(0).optional(), order: z.number().int().min(0).optional(),
parentSubtaskId: z.string().nullable().optional(),
}); });
export const userSettingsSchema = z.object({ export const userSettingsSchema = z.object({
+12
View File
@@ -0,0 +1,12 @@
./gradlew assembleRelease
/home/tech08mag/Android/Sdk/platform-tools/adb push /home/tech08mag/Code/carry-your-live/carry-your-live/android/app/build/outputs/apk/release/app-release.apk /sdcard/Download/
./gradlew installDebug
./gradlew assembleRelease Build release APK (what you ran)
./gradlew bundleRelease Build release AAB (for Play Store)
./gradlew assembleDebug Build debug APK
./gradlew installRelease Build + install release APK to connected device
./gradlew installDebug Build + install debug APK
./gradlew clean Clean build outputs
./gradlew tasks List all available tasks
+8 -1
View File
@@ -31,12 +31,19 @@
"icon": "./assets/android-icon-foreground.png", "icon": "./assets/android-icon-foreground.png",
"color": "#1E88E5" "color": "#1E88E5"
} }
] ],
"./plugins/withQuickAddWidget"
], ],
"extra": { "extra": {
"eas": { "eas": {
"projectId": "93a56631-01e5-45ab-9de1-7e6fb863c0a9" "projectId": "93a56631-01e5-45ab-9de1-7e6fb863c0a9"
} }
},
"runtimeVersion": {
"policy": "appVersion"
},
"updates": {
"url": "https://u.expo.dev/93a56631-01e5-45ab-9de1-7e6fb863c0a9"
} }
} }
} }
+9
View File
@@ -45,6 +45,15 @@ export default function TabLayout() {
), ),
}} }}
/> />
<Tabs.Screen
name="stats"
options={{
title: 'Stats',
tabBarIcon: ({ focused, color }) => (
<TabBarIcon name="stats" focused={focused} color={color} />
),
}}
/>
<Tabs.Screen <Tabs.Screen
name="settings" name="settings"
options={{ options={{
+79 -49
View File
@@ -1,15 +1,17 @@
import React, { useMemo, useRef, useState } from 'react'; import React, { useMemo, useRef, useState, useCallback } from 'react';
import { View, Text, StyleSheet, SafeAreaView, ScrollView, TouchableOpacity, FlatList } from 'react-native'; import { View, Text, StyleSheet, SafeAreaView, ScrollView, TouchableOpacity, FlatList } from 'react-native';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { Header } from '@/components/Header'; import { Header } from '@/components/Header';
import { useTasksByDate } from '@/hooks/useTasks'; import { useTasksByDate } from '@/hooks/useTasks';
import { useTaskModals } from '@/hooks/useTaskModals';
import { toggleTaskComplete } from '@/utils/taskActions'; import { toggleTaskComplete } from '@/utils/taskActions';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import { TaskItem } from '@/components/TaskItem'; import { TaskItem } from '@/components/TaskItem';
import { QuickAddBar } from '@/components/QuickAddBar'; import { QuickAddBar } from '@/components/QuickAddBar';
import { TaskData } from '@/types'; import { TaskData } from '@/types';
import { format, startOfMonth, endOfMonth, eachDayOfInterval, isSameDay, isSameMonth, addMonths, isToday } from 'date-fns'; import { format, startOfMonth, endOfMonth, eachDayOfInterval, isSameDay, isSameMonth, addMonths, isToday, startOfDay } from 'date-fns';
import Svg, { Path } from 'react-native-svg'; import Svg, { Path } from 'react-native-svg';
import type { ThemeColors } from '@/theme';
const DAY_WIDTH = 44; const DAY_WIDTH = 44;
const DAY_GAP = 6; const DAY_GAP = 6;
@@ -17,6 +19,7 @@ const DAY_GAP = 6;
export default function CalendarScreen() { export default function CalendarScreen() {
const router = useRouter(); const router = useRouter();
const { theme } = useSettings(); const { theme } = useSettings();
const { modals, openTaskMenu, openTaskDelete } = useTaskModals();
const [visibleMonth, setVisibleMonth] = useState(() => new Date()); const [visibleMonth, setVisibleMonth] = useState(() => new Date());
const [selectedDate, setSelectedDate] = useState(() => new Date()); const [selectedDate, setSelectedDate] = useState(() => new Date());
const stripRef = useRef<ScrollView>(null); const stripRef = useRef<ScrollView>(null);
@@ -28,46 +31,57 @@ export default function CalendarScreen() {
const { tasks, loading } = useTasksByDate(selectedDate); const { tasks, loading } = useTasksByDate(selectedDate);
const handleDayPress = (day: Date) => { const handleDayPress = useCallback((day: Date) => {
setSelectedDate(day); setSelectedDate(day);
if (!isSameMonth(day, visibleMonth)) { if (!isSameMonth(day, visibleMonth)) {
setVisibleMonth(day); setVisibleMonth(day);
} }
}; }, [visibleMonth]);
const handlePrevMonth = () => { const handlePrevMonth = useCallback(() => {
const prev = addMonths(visibleMonth, -1); const prev = addMonths(visibleMonth, -1);
setVisibleMonth(prev); setVisibleMonth(prev);
if (!isSameMonth(selectedDate, prev)) { if (!isSameMonth(selectedDate, prev)) {
setSelectedDate(startOfMonth(prev)); setSelectedDate(startOfMonth(prev));
} }
}; }, [visibleMonth, selectedDate]);
const handleNextMonth = () => { const handleNextMonth = useCallback(() => {
const next = addMonths(visibleMonth, 1); const next = addMonths(visibleMonth, 1);
setVisibleMonth(next); setVisibleMonth(next);
if (!isSameMonth(selectedDate, next)) { if (!isSameMonth(selectedDate, next)) {
setSelectedDate(startOfMonth(next)); setSelectedDate(startOfMonth(next));
} }
}; }, [visibleMonth, selectedDate]);
const handleToggleComplete = async (taskId: string) => { const handleToggleComplete = useCallback(async (taskId: string) => {
await toggleTaskComplete(taskId); await toggleTaskComplete(taskId);
}; }, []);
const today = new Date(); const scrollToDay = useCallback((day: Date) => {
const scrollToDay = (day: Date) => {
const index = days.findIndex((d) => isSameDay(d, day)); const index = days.findIndex((d) => isSameDay(d, day));
if (index >= 0) { if (index >= 0) {
stripRef.current?.scrollTo({ x: Math.max(0, index * (DAY_WIDTH + DAY_GAP) - 24), animated: true }); stripRef.current?.scrollTo({ x: Math.max(0, index * (DAY_WIDTH + DAY_GAP) - 24), animated: true });
} }
}; }, [days]);
React.useEffect(() => { React.useEffect(() => {
scrollToDay(isSameMonth(selectedDate, visibleMonth) ? selectedDate : today); const target = isSameMonth(selectedDate, visibleMonth) ? selectedDate : startOfDay(new Date());
// eslint-disable-next-line react-hooks/exhaustive-deps scrollToDay(target);
}, [visibleMonth]); }, [visibleMonth, scrollToDay, selectedDate]);
const renderTask = useCallback(
({ item }: { item: TaskData }) => (
<TaskItem
task={item}
onToggle={() => handleToggleComplete(item.id)}
onDelete={() => openTaskDelete(item)}
onPress={() => router.push({ pathname: '/task-detail', params: { id: item.id } })}
onMenuOpen={() => openTaskMenu(item)}
/>
),
[handleToggleComplete, openTaskDelete, openTaskMenu, router]
);
return ( return (
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}> <SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
@@ -79,30 +93,16 @@ export default function CalendarScreen() {
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.dateStrip} contentContainerStyle={styles.dateStrip}
> >
{days.map((day) => { {days.map((day) => (
const isSelected = isSameDay(day, selectedDate); <DayButton
const isCurrent = isToday(day); key={day.toISOString()}
return ( day={day}
<TouchableOpacity selected={isSameDay(day, selectedDate)}
key={day.toISOString()} current={isToday(day) && !isSameDay(day, selectedDate)}
style={[ onPress={handleDayPress}
styles.dayButton, theme={theme}
{ backgroundColor: theme.card, borderColor: theme.borderStrong }, />
isCurrent && !isSelected && { borderColor: theme.accent, borderWidth: 1.5 }, ))}
isSelected && { backgroundColor: theme.accent, borderColor: theme.accent },
]}
onPress={() => handleDayPress(day)}
activeOpacity={0.7}
>
<Text style={[styles.dayWeekday, { color: theme.textMuted }, isSelected && styles.dayTextSelected]}>
{format(day, 'EEE').charAt(0)}
</Text>
<Text style={[styles.dayNumber, { color: theme.text }, isSelected && styles.dayTextSelected]}>
{format(day, 'd')}
</Text>
</TouchableOpacity>
);
})}
</ScrollView> </ScrollView>
<View style={styles.monthRow}> <View style={styles.monthRow}>
@@ -122,14 +122,8 @@ export default function CalendarScreen() {
<FlatList <FlatList
data={tasks} data={tasks}
keyExtractor={(item) => item.id} keyExtractor={(item) => item.id}
renderItem={({ item }) => ( renderItem={renderTask}
<TaskItem ItemSeparatorComponent={MemoSeparator}
task={item as TaskData}
onToggle={() => handleToggleComplete(item.id)}
onPress={() => router.push({ pathname: '/task-detail', params: { id: item.id } })}
/>
)}
ItemSeparatorComponent={() => <View style={styles.separator} />}
ListEmptyComponent={ ListEmptyComponent={
loading ? ( loading ? (
<View style={styles.emptyState}> <View style={styles.emptyState}>
@@ -149,10 +143,46 @@ export default function CalendarScreen() {
dueDate={selectedDate.getTime()} dueDate={selectedDate.getTime()}
placeholder={`Add task for ${format(selectedDate, 'MMM d')}`} placeholder={`Add task for ${format(selectedDate, 'MMM d')}`}
/> />
{modals(() => {})}
</SafeAreaView> </SafeAreaView>
); );
} }
interface DayButtonProps {
day: Date;
selected: boolean;
current: boolean;
onPress: (day: Date) => void;
theme: ThemeColors;
}
const DayButton = React.memo(function DayButton({ day, selected, current, onPress, theme }: DayButtonProps) {
return (
<TouchableOpacity
style={[
styles.dayButton,
{ backgroundColor: theme.card, borderColor: theme.borderStrong },
current && { borderColor: theme.accent, borderWidth: 1.5 },
selected && { backgroundColor: theme.accent, borderColor: theme.accent },
]}
onPress={() => onPress(day)}
activeOpacity={0.7}
>
<Text style={[styles.dayWeekday, { color: theme.textMuted }, selected && styles.dayTextSelected]}>
{format(day, 'EEE').charAt(0)}
</Text>
<Text style={[styles.dayNumber, { color: theme.text }, selected && styles.dayTextSelected]}>
{format(day, 'd')}
</Text>
</TouchableOpacity>
);
});
const MemoSeparator = React.memo(function Separator() {
return <View style={styles.separator} />;
});
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
+7 -3
View File
@@ -11,7 +11,6 @@ export default function TasksScreen() {
const { isReady } = useDatabase(); const { isReady } = useDatabase();
const { theme } = useSettings(); const { theme } = useSettings();
const [selectedCategory, setSelectedCategory] = React.useState<string>('all'); const [selectedCategory, setSelectedCategory] = React.useState<string>('all');
const [selectionActive, setSelectionActive] = React.useState(false);
if (!isReady) { if (!isReady) {
return ( return (
@@ -24,8 +23,10 @@ export default function TasksScreen() {
return ( return (
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}> <SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
<Header title="TODO" showLogo={true} /> <Header title="TODO" showLogo={true} />
<CategoryFilter selected={selectedCategory} onSelect={setSelectedCategory} /> <View style={styles.categoryFilterWrapper}>
<TaskList categoryId={selectedCategory} onSelectionChange={setSelectionActive} /> <CategoryFilter selected={selectedCategory} onSelect={setSelectedCategory} />
</View>
<TaskList categoryId={selectedCategory} />
<QuickAddBar /> <QuickAddBar />
</SafeAreaView> </SafeAreaView>
); );
@@ -35,6 +36,9 @@ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
}, },
categoryFilterWrapper: {
height: 36,
},
loadingContainer: { loadingContainer: {
flex: 1, flex: 1,
justifyContent: 'center', justifyContent: 'center',
+36 -6
View File
@@ -1,5 +1,5 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { View, Text, StyleSheet, SafeAreaView, ScrollView, Switch, TouchableOpacity } from 'react-native'; import { View, Text, StyleSheet, SafeAreaView, ScrollView, Switch, TouchableOpacity, Alert, Linking } from 'react-native';
import { Header } from '@/components/Header'; import { Header } from '@/components/Header';
import { ListItem } from '@/components/ListItem'; import { ListItem } from '@/components/ListItem';
import { OptionPickerModal } from '@/components/OptionPickerModal'; import { OptionPickerModal } from '@/components/OptionPickerModal';
@@ -12,6 +12,7 @@ import SyncStatus from '@/components/SyncStatus';
import { useSettings, SORT_OPTIONS, REMINDER_OPTIONS } from '@/theme'; import { useSettings, SORT_OPTIONS, REMINDER_OPTIONS } from '@/theme';
import { useCategories } from '@/hooks/useDatabase'; import { useCategories } from '@/hooks/useDatabase';
import { getAuthUser, getAuthToken } from '@/services/auth'; import { getAuthUser, getAuthToken } from '@/services/auth';
import { checkForUpdates, getCurrentAppVersion } from '@/services/updates';
import { getLastSyncTime } from '@/database/sync'; import { getLastSyncTime } from '@/database/sync';
import Category from '@/models/Category'; import Category from '@/models/Category';
import Svg, { Path } from 'react-native-svg'; import Svg, { Path } from 'react-native-svg';
@@ -26,6 +27,28 @@ export default function SettingsScreen() {
const [legalVisible, setLegalVisible] = useState<null | 'privacy' | 'terms'>(null); const [legalVisible, setLegalVisible] = useState<null | 'privacy' | 'terms'>(null);
const [serverUrlVisible, setServerUrlVisible] = useState(false); const [serverUrlVisible, setServerUrlVisible] = useState(false);
const [syncSubtitle, setSyncSubtitle] = useState('Checking...'); const [syncSubtitle, setSyncSubtitle] = useState('Checking...');
const [updateSubtitle, setUpdateSubtitle] = useState('Tap to check');
const handleCheckUpdates = async () => {
setUpdateSubtitle('Checking...');
const update = await checkForUpdates();
if (!update) {
setUpdateSubtitle('Up to date');
Alert.alert('Up to date', `You're running the latest version (${getCurrentAppVersion()}).`);
return;
}
setUpdateSubtitle(`${update.version} available`);
const url = update.apkUrl ?? update.releaseUrl;
Alert.alert('Update available', `Version ${update.version} is available for download.`, [
{ text: 'Later', style: 'cancel' },
{
text: 'Download',
onPress: () => {
if (url) Linking.openURL(url).catch(() => {});
},
},
]);
};
const refreshSyncStatus = async () => { const refreshSyncStatus = async () => {
const token = await getAuthToken(); const token = await getAuthToken();
@@ -45,6 +68,7 @@ export default function SettingsScreen() {
}; };
React.useEffect(() => { React.useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
refreshSyncStatus(); refreshSyncStatus();
}, []); }, []);
@@ -52,12 +76,12 @@ export default function SettingsScreen() {
const defaultCategoryLabel = defaultCategory?.name ?? (categories.length > 0 ? categories[0].name : 'None'); const defaultCategoryLabel = defaultCategory?.name ?? (categories.length > 0 ? categories[0].name : 'None');
const reminderLabel = REMINDER_OPTIONS.find((o) => o.value === reminderPreference)?.label ?? 'No reminder'; const reminderLabel = REMINDER_OPTIONS.find((o) => o.value === reminderPreference)?.label ?? 'No reminder';
const handleDefaultCategory = (value: string) => { const handleDefaultCategory = (value: string | string[]) => {
setDefaultCategoryId(value); setDefaultCategoryId(Array.isArray(value) ? value[0] : value);
}; };
const handleReminderPreference = (value: string) => { const handleReminderPreference = (value: string | string[]) => {
setReminderPreference(value as typeof reminderPreference); setReminderPreference((Array.isArray(value) ? value[0] : value) as typeof reminderPreference);
}; };
const editorVisible = editingCategory !== null; const editorVisible = editingCategory !== null;
@@ -146,9 +170,15 @@ export default function SettingsScreen() {
showChevron showChevron
/> />
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>About</Text> <Text style={[styles.sectionTitle, { color: theme.textMuted }]}>About</Text>
<ListItem
title="Check for Updates"
subtitle={updateSubtitle}
onPress={handleCheckUpdates}
showChevron
/>
<ListItem <ListItem
title="Version" title="Version"
subtitle="1.0.0" subtitle={getCurrentAppVersion()}
/> />
<ListItem <ListItem
title="Privacy Policy" title="Privacy Policy"
+201
View File
@@ -0,0 +1,201 @@
import React from 'react';
import { View, Text, StyleSheet, SafeAreaView, ScrollView } from 'react-native';
import { Header } from '@/components/Header';
import { useStats } from '@/hooks/useStats';
import { useSettings } from '@/theme';
export default function StatsScreen() {
const { theme } = useSettings();
const stats = useStats();
const maxDaily = Math.max(1, ...stats.daily.map((d) => d.count));
return (
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
<Header title="Stats" showLogo={true} />
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
<View style={[styles.card, { backgroundColor: theme.card }]}>
<Text style={[styles.cardTitle, { color: theme.textMuted }]}>Overview</Text>
<View style={styles.summaryGrid}>
<View style={styles.summaryItem}>
<Text style={[styles.summaryValue, { color: theme.accent }]}>{stats.totalCompleted}</Text>
<Text style={[styles.summaryLabel, { color: theme.textMuted }]}>Completed</Text>
</View>
<View style={styles.summaryItem}>
<Text style={[styles.summaryValue, { color: theme.text }]}>{stats.completedLast7}</Text>
<Text style={[styles.summaryLabel, { color: theme.textMuted }]}>Last 7 days</Text>
</View>
<View style={styles.summaryItem}>
<Text style={[styles.summaryValue, { color: theme.text }]}>{stats.completedLast30}</Text>
<Text style={[styles.summaryLabel, { color: theme.textMuted }]}>Last 30 days</Text>
</View>
<View style={styles.summaryItem}>
<Text style={[styles.summaryValue, { color: theme.text }]}>{stats.completionRate}%</Text>
<Text style={[styles.summaryLabel, { color: theme.textMuted }]}>Success rate</Text>
</View>
<View style={styles.summaryItem}>
<Text style={[styles.summaryValue, { color: theme.text }]}>{stats.currentStreak}</Text>
<Text style={[styles.summaryLabel, { color: theme.textMuted }]}>Day streak</Text>
</View>
<View style={styles.summaryItem}>
<Text style={[styles.summaryValue, { color: stats.overdueCount > 0 ? theme.accent : theme.text }]}>
{stats.overdueCount}
</Text>
<Text style={[styles.summaryLabel, { color: theme.textMuted }]}>Overdue</Text>
</View>
</View>
</View>
<View style={[styles.card, { backgroundColor: theme.card }]}>
<Text style={[styles.cardTitle, { color: theme.textMuted }]}>Last 7 days</Text>
<View style={styles.chartRow}>
{stats.daily.map((d, i) => {
const height = (d.count / maxDaily) * BASE_HEIGHT;
return (
<View key={i} style={styles.chartCol}>
<View style={styles.chartBarTrack}>
<View
style={[
styles.chartBar,
{ height: Math.max(4, height), backgroundColor: d.count > 0 ? theme.accent : theme.cardAlt },
]}
/>
</View>
<Text style={[styles.chartLabel, { color: theme.textFaint }]}>{d.label}</Text>
</View>
);
})}
</View>
</View>
<View style={[styles.card, { backgroundColor: theme.card }]}>
<Text style={[styles.cardTitle, { color: theme.textMuted }]}>By category</Text>
{stats.byCategory.length === 0 ? (
<Text style={[styles.emptyText, { color: theme.textFaint }]}>No completed tasks yet</Text>
) : (
stats.byCategory.map((c) => (
<View key={c.id} style={styles.row}>
<View style={[styles.dot, { backgroundColor: c.color }]} />
<Text style={[styles.rowLabel, { color: theme.text }]}>{c.name}</Text>
<Text style={[styles.rowValue, { color: theme.textMuted }]}>{c.count}</Text>
</View>
))
)}
</View>
<View style={[styles.card, { backgroundColor: theme.card }]}>
<Text style={[styles.cardTitle, { color: theme.textMuted }]}>By priority</Text>
{stats.byPriority.length === 0 ? (
<Text style={[styles.emptyText, { color: theme.textFaint }]}>No completed tasks yet</Text>
) : (
stats.byPriority.map((p) => (
<View key={p.priority} style={styles.row}>
<View style={[styles.dot, { backgroundColor: priorityColor(p.priority) }]} />
<Text style={[styles.rowLabel, { color: theme.text }]}>{p.label}</Text>
<Text style={[styles.rowValue, { color: theme.textMuted }]}>{p.count}</Text>
</View>
))
)}
</View>
</ScrollView>
</SafeAreaView>
);
}
const BASE_HEIGHT = 90;
function priorityColor(priority: string): string {
switch (priority) {
case 'high':
return '#EF5350';
case 'medium':
return '#FFA726';
case 'low':
return '#66BB6A';
default:
return '#9E9E9E';
}
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
content: {
padding: 16,
paddingBottom: 32,
gap: 12,
},
card: {
borderRadius: 16,
padding: 16,
},
cardTitle: {
fontSize: 13,
fontWeight: '600',
textTransform: 'uppercase',
letterSpacing: 0.5,
marginBottom: 14,
},
summaryGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
},
summaryItem: {
width: '33.33%',
marginBottom: 16,
},
summaryValue: {
fontSize: 26,
fontWeight: '700',
},
summaryLabel: {
fontSize: 12,
marginTop: 2,
},
chartRow: {
flexDirection: 'row',
alignItems: 'flex-end',
gap: 8,
},
chartCol: {
flex: 1,
alignItems: 'center',
},
chartBarTrack: {
height: BASE_HEIGHT,
justifyContent: 'flex-end',
width: '100%',
},
chartBar: {
width: '100%',
borderRadius: 6,
minHeight: 4,
},
chartLabel: {
fontSize: 11,
marginTop: 6,
},
row: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 8,
},
dot: {
width: 10,
height: 10,
borderRadius: 5,
marginRight: 10,
},
rowLabel: {
flex: 1,
fontSize: 15,
},
rowValue: {
fontSize: 15,
fontWeight: '600',
},
emptyText: {
fontSize: 14,
},
});
+22 -1
View File
@@ -1,11 +1,31 @@
import { Stack } from 'expo-router'; import { Stack, useRouter } from 'expo-router';
import { DatabaseProvider } from '@/hooks/useDatabase'; import { DatabaseProvider } from '@/hooks/useDatabase';
import { SettingsProvider } from '@/theme'; import { SettingsProvider } from '@/theme';
import { FriendsProvider } from '@/hooks/useFriends'; import { FriendsProvider } from '@/hooks/useFriends';
import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { StatusBar } from 'expo-status-bar'; import { StatusBar } from 'expo-status-bar';
import { UpdateNotifier } from '@/components/UpdateNotifier';
import { Linking } from 'react-native';
import { useEffect } from 'react';
import { requestQuickAddFocus } from '@/utils/quickAddFocus';
function RootNavigator() { function RootNavigator() {
const router = useRouter();
useEffect(() => {
const handleUrl = (url: string | null) => {
if (!url || !url.includes('://quick-add')) return;
router.replace('/(tabs)');
requestQuickAddFocus();
};
Linking.getInitialURL().then(handleUrl).catch(() => {});
const subscription = Linking.addEventListener('url', ({ url }) => handleUrl(url));
return () => {
subscription.remove();
};
}, [router]);
return ( return (
<GestureHandlerRootView style={{ flex: 1 }}> <GestureHandlerRootView style={{ flex: 1 }}>
<StatusBar style="light" /> <StatusBar style="light" />
@@ -23,6 +43,7 @@ export default function RootLayout() {
<SettingsProvider> <SettingsProvider>
<DatabaseProvider> <DatabaseProvider>
<FriendsProvider> <FriendsProvider>
<UpdateNotifier />
<RootNavigator /> <RootNavigator />
</FriendsProvider> </FriendsProvider>
</DatabaseProvider> </DatabaseProvider>
+9 -5
View File
@@ -35,6 +35,7 @@ const taskSchema = z.object({
repeatInterval: z.number().int().min(1).max(30).optional(), repeatInterval: z.number().int().min(1).max(30).optional(),
repeatDays: z.array(z.number().int().min(0).max(6)).optional(), repeatDays: z.array(z.number().int().min(0).max(6)).optional(),
reminder: z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']), reminder: z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']),
reminders: z.string().optional(),
assigneeId: z.string().nullable().optional(), assigneeId: z.string().nullable().optional(),
subtasks: z.array(z.object({ title: z.string(), _key: z.string().optional() })).optional(), subtasks: z.array(z.object({ title: z.string(), _key: z.string().optional() })).optional(),
}); });
@@ -69,6 +70,7 @@ export default function AddTaskScreen() {
repeatInterval: 1, repeatInterval: 1,
repeatDays: [], repeatDays: [],
reminder: 'none', reminder: 'none',
reminders: '',
assigneeId: null, assigneeId: null,
subtasks: [], subtasks: [],
}, },
@@ -88,6 +90,7 @@ export default function AddTaskScreen() {
const repeatInterval = watch('repeatInterval') ?? 1; const repeatInterval = watch('repeatInterval') ?? 1;
const repeatDays = watch('repeatDays') ?? []; const repeatDays = watch('repeatDays') ?? [];
const reminder = watch('reminder'); const reminder = watch('reminder');
const reminders = watch('reminders');
const dueDate = watch('dueDate'); const dueDate = watch('dueDate');
const assigneeId = watch('assigneeId'); const assigneeId = watch('assigneeId');
@@ -124,6 +127,7 @@ export default function AddTaskScreen() {
t.repeatDays = (data.repeatDays || []).join(','); t.repeatDays = (data.repeatDays || []).join(',');
t.seriesId = seriesId; t.seriesId = seriesId;
t.reminder = data.reminder || 'none'; t.reminder = data.reminder || 'none';
t.reminders = data.reminders || '';
t.assigneeId = data.assigneeId ?? null; t.assigneeId = data.assigneeId ?? null;
t.createdAt = now; t.createdAt = now;
t.updatedAt = now; t.updatedAt = now;
@@ -213,9 +217,9 @@ export default function AddTaskScreen() {
}} }}
/> />
<ReminderSelector <ReminderSelector
value={reminder} value={reminders}
hasDueDate={!!dueDate} hasDueDate={!!dueDate}
onChange={(value) => setValue('reminder', value)} onChange={(value) => setValue('reminders', value)}
/> />
<AssigneeSelector <AssigneeSelector
value={assigneeId} value={assigneeId}
@@ -255,8 +259,8 @@ const styles = StyleSheet.create({
}, },
scrollContent: { scrollContent: {
paddingHorizontal: 16, paddingHorizontal: 16,
paddingTop: 8, paddingTop: 12,
paddingBottom: 100, paddingBottom: 120,
gap: 24, gap: 20,
}, },
}); });
+260
View File
@@ -0,0 +1,260 @@
import React from 'react';
import { View, Text, StyleSheet, SafeAreaView, ScrollView, KeyboardAvoidingView, Platform, TouchableOpacity, Alert } from 'react-native';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { Header } from '@/components/Header';
import { TaskNameInput } from '@/components/TaskNameInput';
import { DateTimePickerComponent } from '@/components/DateTimePicker';
import { PrioritySelector } from '@/components/PrioritySelector';
import { RepeatSelector } from '@/components/RepeatSelector';
import { ReminderSelector } from '@/components/ReminderSelector';
import { DescriptionInput } from '@/components/DescriptionInput';
import { FormButtons } from '@/components/FormButtons';
import { AssigneeSelector } from '@/components/AssigneeSelector';
import { useForm, FormProvider, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useDatabase } from '@/hooks/useDatabase';
import { collections } from '@/database';
import { SubtaskFormData, Reminder, parseReminders, toRemindersString } from '@/types';
import { useSettings } from '@/theme';
import { updateSubtask, deleteSubtask } from '@/utils/taskActions';
import { useFriends } from '@/hooks/useFriends';
import Svg, { Path } from 'react-native-svg';
const subtaskSchema = z.object({
title: z.string().trim().min(1, 'Subtask name is required').max(100),
description: z.string().max(1000).optional(),
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']),
dueDate: z.date().nullable().optional(),
dueTime: z.string().optional(),
endTime: z.string().optional(),
allDay: z.boolean().optional(),
repeat: z.enum(['none', 'daily', 'weekly', 'monthly', 'custom']),
repeatInterval: z.number().int().min(1).max(30).optional(),
repeatDays: z.array(z.number().int().min(0).max(6)).optional(),
reminder: z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']),
reminders: z.string().optional(),
assigneeId: z.string().nullable().optional(),
});
export default function SubtaskDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const { isReady } = useDatabase();
const router = useRouter();
const { theme } = useSettings();
const { friends } = useFriends();
const [loaded, setLoaded] = React.useState(false);
const [notFound, setNotFound] = React.useState(false);
const methods = useForm<SubtaskFormData>({
resolver: zodResolver(subtaskSchema),
defaultValues: {
title: '',
description: '',
priority: 'none',
dueDate: null,
dueTime: '',
endTime: '',
allDay: false,
repeat: 'none',
repeatInterval: 1,
repeatDays: [],
reminder: 'none',
reminders: '',
assigneeId: null,
},
});
const { handleSubmit, watch, setValue, control, reset, formState: { errors } } = methods;
const priority = watch('priority');
const repeat = watch('repeat');
const repeatInterval = watch('repeatInterval') ?? 1;
const repeatDays = watch('repeatDays') ?? [];
const reminder = watch('reminder');
const reminders = watch('reminders');
const dueDate = watch('dueDate');
const assigneeId = watch('assigneeId');
React.useEffect(() => {
if (!id || !isReady) return;
let mounted = true;
(async () => {
try {
const subtask = await collections.subtasks.find(id);
if (!mounted) return;
reset({
title: subtask.title,
description: subtask.description,
priority: subtask.priority,
dueDate: subtask.dueDate ? new Date(subtask.dueDate) : null,
dueTime: subtask.dueTime,
endTime: subtask.endTime || '',
allDay: subtask.allDay ?? false,
repeat: subtask.repeat,
repeatInterval: subtask.repeatInterval || 1,
repeatDays: (subtask.repeatDays || '').split(',').map(Number).filter((d) => !Number.isNaN(d)),
reminder: (subtask.reminder || 'none') as Reminder,
reminders: subtask.reminders || '',
assigneeId: subtask.assigneeId ?? null,
});
setLoaded(true);
} catch {
if (mounted) setNotFound(true);
}
})();
return () => { mounted = false; };
}, [id, isReady, reset]);
const onSubmit = async (data: SubtaskFormData) => {
if (!id || !isReady) return;
await updateSubtask(id, {
title: data.title,
description: data.description || '',
priority: data.priority,
dueDate: data.dueDate ? data.dueDate.getTime() : 0,
dueTime: data.dueTime || '',
endTime: data.endTime || '',
allDay: data.allDay ?? false,
repeat: data.repeat,
repeatInterval: data.repeatInterval || 1,
repeatDays: (data.repeatDays || []).join(','),
reminder: data.reminder || 'none',
assigneeId: data.assigneeId ?? null,
});
router.back();
};
const confirmDelete = () => {
Alert.alert('Delete Subtask', 'This action cannot be undone.', [
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
if (!id) return;
await deleteSubtask(id);
router.back();
},
},
]);
};
if (!isReady || !loaded) {
return (
<View style={[styles.loadingContainer, { backgroundColor: theme.background }]}>
<Text style={{ color: theme.textFaint }}>{notFound ? 'Subtask not found' : 'Loading...'}</Text>
</View>
);
}
return (
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
<Header
title="Edit Subtask"
showLogo={true}
rightAction={
<TouchableOpacity onPress={confirmDelete} activeOpacity={0.7} style={[styles.deleteButton, { borderColor: theme.accentBorder, backgroundColor: theme.accentSoft }]}>
<Svg width={22} height={22} viewBox="0 0 24 24">
<Path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13" stroke={theme.accent} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" fill="none" />
</Svg>
</TouchableOpacity>
}
/>
<FormProvider {...methods}>
<KeyboardAvoidingView
style={styles.keyboardAvoiding}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<ScrollView
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
>
<Controller
control={control}
name="title"
render={({ field }) => (
<TaskNameInput
value={field.value}
onChangeText={field.onChange}
onBlur={field.onBlur}
error={errors.title?.message}
/>
)}
/>
<PrioritySelector
value={priority}
onChange={(value) => setValue('priority', value)}
/>
<DateTimePickerComponent control={control as any} />
<RepeatSelector
value={repeat}
interval={repeatInterval}
days={repeatDays}
onChange={(nextRepeat, nextInterval, nextDays) => {
setValue('repeat', nextRepeat);
setValue('repeatInterval', nextInterval);
setValue('repeatDays', nextDays);
}}
/>
<ReminderSelector
value={reminders}
hasDueDate={!!dueDate}
onChange={(value) => setValue('reminders', value)}
/>
<AssigneeSelector
value={assigneeId}
onChange={(value: string | null) => setValue('assigneeId', value)}
friends={friends.map((f) => f.username)}
/>
<Controller
control={control}
name="description"
render={({ field }) => (
<DescriptionInput
value={field.value ?? ''}
onChangeText={field.onChange}
onBlur={field.onBlur}
/>
)}
/>
</ScrollView>
</KeyboardAvoidingView>
<FormButtons onSubmit={handleSubmit(onSubmit)} submitLabel="Save" />
</FormProvider>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
keyboardAvoiding: {
flex: 1,
},
scrollContent: {
paddingHorizontal: 16,
paddingTop: 8,
paddingBottom: 100,
gap: 24,
},
deleteButton: {
width: 36,
height: 36,
borderRadius: 12,
borderWidth: 1,
alignItems: 'center',
justifyContent: 'center',
},
});
+144 -39
View File
@@ -22,9 +22,9 @@ import { Q } from '@nozbe/watermelondb';
import { TaskFormData } from '@/types'; import { TaskFormData } from '@/types';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import { deleteTaskOccurrences } from '@/utils/taskActions'; import { deleteTaskOccurrences } from '@/utils/taskActions';
import { scheduleTaskReminder, cancelTaskReminder } from '@/services/notifications'; import { scheduleTaskReminder } from '@/services/notifications';
import { useFriends } from '@/hooks/useFriends'; import { useFriends } from '@/hooks/useFriends';
import Svg, { Path } from 'react-native-svg'; import Svg, { Path, Circle } from 'react-native-svg';
const taskSchema = z.object({ const taskSchema = z.object({
title: z.string().trim().min(1, 'Task name is required').max(100), title: z.string().trim().min(1, 'Task name is required').max(100),
@@ -39,10 +39,38 @@ const taskSchema = z.object({
repeatInterval: z.number().int().min(1).max(30).optional(), repeatInterval: z.number().int().min(1).max(30).optional(),
repeatDays: z.array(z.number().int().min(0).max(6)).optional(), repeatDays: z.array(z.number().int().min(0).max(6)).optional(),
reminder: z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']), reminder: z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']),
reminders: z.string().optional(),
assigneeId: z.string().nullable().optional(), assigneeId: z.string().nullable().optional(),
subtasks: z.array(z.object({ title: z.string(), _key: z.string().optional() })).optional(), subtasks: z.array(z.object({ title: z.string(), _key: z.string().optional() })).optional(),
}); });
function CollapsibleSection({ title, children, defaultExpanded = false, icon }: { title: string; children: React.ReactNode; defaultExpanded?: boolean; icon: React.ReactNode }) {
const { theme } = useSettings();
const [expanded, setExpanded] = React.useState(defaultExpanded);
return (
<View style={styles.section}>
<TouchableOpacity style={[styles.sectionHeader, { backgroundColor: theme.card, borderColor: theme.border }]} onPress={() => setExpanded(!expanded)} activeOpacity={0.8}>
<View style={styles.sectionHeaderLeft}>
{icon}
<Text style={[styles.sectionTitle, { color: theme.text }]}>{title}</Text>
</View>
<Svg width={20} height={20} viewBox="0 0 24 24">
<Path
d="M6 9l6 6 6-6"
stroke={theme.textMuted}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</Svg>
</TouchableOpacity>
{expanded && <View style={styles.sectionContent}>{children}</View>}
</View>
);
}
export default function TaskDetailScreen() { export default function TaskDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>(); const { id } = useLocalSearchParams<{ id: string }>();
const { isReady } = useDatabase(); const { isReady } = useDatabase();
@@ -80,6 +108,7 @@ export default function TaskDetailScreen() {
const repeatInterval = watch('repeatInterval') ?? 1; const repeatInterval = watch('repeatInterval') ?? 1;
const repeatDays = watch('repeatDays') ?? []; const repeatDays = watch('repeatDays') ?? [];
const reminder = watch('reminder'); const reminder = watch('reminder');
const reminders = watch('reminders');
const dueDate = watch('dueDate'); const dueDate = watch('dueDate');
const assigneeId = watch('assigneeId'); const assigneeId = watch('assigneeId');
const [deleteModalVisible, setDeleteModalVisible] = React.useState(false); const [deleteModalVisible, setDeleteModalVisible] = React.useState(false);
@@ -253,38 +282,89 @@ export default function TaskDetailScreen() {
value={priority} value={priority}
onChange={(value) => setValue('priority', value)} onChange={(value) => setValue('priority', value)}
/> />
<DateTimePickerComponent control={control} />
<RepeatSelector <CollapsibleSection title="Date & Time" icon={
value={repeat} <Svg width={20} height={20} viewBox="0 0 24 24">
interval={repeatInterval} <Circle cx={12} cy={12} r={10} stroke={theme.accent} strokeWidth={1.5} fill="none" />
days={repeatDays} <Path d="M12 6v6l4 2" stroke={theme.accent} strokeWidth={1.5} strokeLinecap="round" />
onChange={(nextRepeat, nextInterval, nextDays) => { </Svg>
setValue('repeat', nextRepeat); } defaultExpanded={!!dueDate}>
setValue('repeatInterval', nextInterval); <DateTimePickerComponent control={control} />
setValue('repeatDays', nextDays); </CollapsibleSection>
}}
/> <CollapsibleSection title="Repeat" icon={
<ReminderSelector <Svg width={20} height={20} viewBox="0 0 24 24">
value={reminder} <Path
hasDueDate={!!dueDate} d="M17 2l4 4-4 4M3 11v-1a4 4 0 0 1 4-4h14M7 22l-4-4 4-4M21 13v1a4 4 0 0 1-4 4H3"
onChange={(value) => setValue('reminder', value)} stroke={theme.accent}
/> strokeWidth={1.5}
<AssigneeSelector strokeLinecap="round"
value={assigneeId} strokeLinejoin="round"
onChange={(value: string | null) => setValue('assigneeId', value)} fill="none"
friends={friends.map((f) => f.username)}
/>
<Controller
control={control}
name="description"
render={({ field }) => (
<DescriptionInput
value={field.value ?? ''}
onChangeText={field.onChange}
onBlur={field.onBlur}
/> />
)} </Svg>
/> } defaultExpanded={repeat !== 'none'}>
<RepeatSelector
value={repeat}
interval={repeatInterval}
days={repeatDays}
onChange={(nextRepeat, nextInterval, nextDays) => {
setValue('repeat', nextRepeat);
setValue('repeatInterval', nextInterval);
setValue('repeatDays', nextDays);
}}
/>
</CollapsibleSection>
<CollapsibleSection title="Reminder" icon={
<Svg width={20} height={20} viewBox="0 0 24 24">
<Path
d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9M10.3 21a1.94 1.94 0 0 0 3.4 0"
stroke={theme.accent}
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</Svg>
} defaultExpanded={!!reminders && reminders !== ''}>
<ReminderSelector
value={reminders}
hasDueDate={!!dueDate}
onChange={(value) => setValue('reminders', value)}
/>
</CollapsibleSection>
<CollapsibleSection title="Assignee" icon={
<Svg width={20} height={20} viewBox="0 0 24 24">
<Circle cx={12} cy={12} r={10} stroke={theme.accent} strokeWidth={1.5} fill="none" />
<Path d="M12 10v6M12 19v1" stroke={theme.accent} strokeWidth={1.5} strokeLinecap="round" />
</Svg>
} defaultExpanded={!!assigneeId}>
<AssigneeSelector
value={assigneeId}
onChange={(value: string | null) => setValue('assigneeId', value)}
friends={friends.map((f) => f.username)}
/>
</CollapsibleSection>
<CollapsibleSection title="Description" icon={
<Svg width={20} height={20} viewBox="0 0 24 24">
<Path d="M4 6h16M4 12h16M4 18h10" stroke={theme.accent} strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
</Svg>
} defaultExpanded={!!methods.getValues('description')}>
<Controller
control={control}
name="description"
render={({ field }) => (
<DescriptionInput
value={field.value ?? ''}
onChangeText={field.onChange}
onBlur={field.onBlur}
/>
)}
/>
</CollapsibleSection>
</ScrollView> </ScrollView>
</KeyboardAvoidingView> </KeyboardAvoidingView>
<FormButtons onSubmit={handleSubmit(onSubmit)} submitLabel="Save" /> <FormButtons onSubmit={handleSubmit(onSubmit)} submitLabel="Save" />
@@ -315,15 +395,40 @@ const styles = StyleSheet.create({
flex: 1, flex: 1,
}, },
scrollContent: { scrollContent: {
paddingHorizontal: 16, paddingHorizontal: 12,
paddingTop: 8, paddingTop: 8,
paddingBottom: 100, paddingBottom: 80,
gap: 24, gap: 12,
},
section: {
gap: 6,
},
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 10,
paddingHorizontal: 12,
borderRadius: 10,
borderWidth: 1,
},
sectionHeaderLeft: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
sectionTitle: {
fontSize: 14,
fontWeight: '600',
},
sectionContent: {
paddingHorizontal: 2,
gap: 6,
}, },
deleteButton: { deleteButton: {
width: 36, width: 32,
height: 36, height: 32,
borderRadius: 12, borderRadius: 10,
borderWidth: 1, borderWidth: 1,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
+16
View File
@@ -2,9 +2,25 @@
const { defineConfig } = require('eslint/config'); const { defineConfig } = require('eslint/config');
const expoConfig = require("eslint-config-expo/flat"); const expoConfig = require("eslint-config-expo/flat");
const platformExtensions = [];
for (const platform of ['.android', '.ios', '.web', '.native', '']) {
for (const base of ['.ts', '.tsx', '.d.ts']) {
platformExtensions.push(`${platform}${base}`);
}
}
module.exports = defineConfig([ module.exports = defineConfig([
expoConfig, expoConfig,
{ {
ignores: ["dist/*"], ignores: ["dist/*"],
},
{
settings: {
'import/resolver': {
typescript: {
extensions: platformExtensions,
},
},
},
} }
]); ]);
+219
View File
@@ -7,6 +7,7 @@
"": { "": {
"name": "carry-your-live", "name": "carry-your-live",
"version": "1.0.0", "version": "1.0.0",
"hasInstallScript": true,
"dependencies": { "dependencies": {
"@hookform/resolvers": "^3.3.4", "@hookform/resolvers": "^3.3.4",
"@nozbe/watermelondb": "^0.28.1-0", "@nozbe/watermelondb": "^0.28.1-0",
@@ -21,6 +22,7 @@
"expo-router": "~57.0.10", "expo-router": "~57.0.10",
"expo-sqlite": "~57.0.1", "expo-sqlite": "~57.0.1",
"expo-status-bar": "~57.0.1", "expo-status-bar": "~57.0.1",
"expo-updates": "~57.0.12",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"react-hook-form": "^7.51.5", "react-hook-form": "^7.51.5",
@@ -40,6 +42,7 @@
"@types/react": "~19.2.2", "@types/react": "~19.2.2",
"eslint": "^9.0.0", "eslint": "^9.0.0",
"eslint-config-expo": "~57.0.1", "eslint-config-expo": "~57.0.1",
"patch-package": "^8.0.1",
"prettier": "^3.9.6", "prettier": "^3.9.6",
"typescript": "~6.0.3" "typescript": "~6.0.3"
} }
@@ -3594,6 +3597,13 @@
"node": ">=10.0.0" "node": ">=10.0.0"
} }
}, },
"node_modules/@yarnpkg/lockfile": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz",
"integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==",
"dev": true,
"license": "BSD-2-Clause"
},
"node_modules/abort-controller": { "node_modules/abort-controller": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
@@ -5965,6 +5975,12 @@
"expo": "*" "expo": "*"
} }
}, },
"node_modules/expo-eas-client": {
"version": "57.0.1",
"resolved": "https://registry.npmjs.org/expo-eas-client/-/expo-eas-client-57.0.1.tgz",
"integrity": "sha512-4w51+zsl/ziUHQMJgLgUdgsNhRPAwHBfySpPB1hpWU21X74QS9T4SqDftaRnrDagn/DfcrXUMJvHbDQUxLPJNA==",
"license": "MIT"
},
"node_modules/expo-font": { "node_modules/expo-font": {
"version": "57.0.1", "version": "57.0.1",
"resolved": "https://registry.npmjs.org/expo-font/-/expo-font-57.0.1.tgz", "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-57.0.1.tgz",
@@ -6297,6 +6313,12 @@
"react-native": "*" "react-native": "*"
} }
}, },
"node_modules/expo-structured-headers": {
"version": "57.0.0",
"resolved": "https://registry.npmjs.org/expo-structured-headers/-/expo-structured-headers-57.0.0.tgz",
"integrity": "sha512-//t9UNPbJSEysc2x4VKJG/u7Osvv5DYJWsET5bqt/B+qcD1by/JXvSQzX3Q/YAgA96xFPontrz6OAPLbO4JKEA==",
"license": "MIT"
},
"node_modules/expo-symbols": { "node_modules/expo-symbols": {
"version": "57.0.1", "version": "57.0.1",
"resolved": "https://registry.npmjs.org/expo-symbols/-/expo-symbols-57.0.1.tgz", "resolved": "https://registry.npmjs.org/expo-symbols/-/expo-symbols-57.0.1.tgz",
@@ -6313,6 +6335,43 @@
"react-native": "*" "react-native": "*"
} }
}, },
"node_modules/expo-updates": {
"version": "57.0.12",
"resolved": "https://registry.npmjs.org/expo-updates/-/expo-updates-57.0.12.tgz",
"integrity": "sha512-ZFsW8Mi9qFrrYPSXF++1FXejjflRdgbVPzaSJJATuHelVmIIb3D98gCO9sIsaLPHO1RCQVj1ltkj51VnwVL+4g==",
"license": "MIT",
"dependencies": {
"@expo/code-signing-certificates": "^0.0.6",
"@expo/plist": "^0.8.1",
"@expo/spawn-async": "^1.8.0",
"arg": "^4.1.0",
"chalk": "^4.1.2",
"debug": "^4.3.4",
"expo-eas-client": "~57.0.1",
"expo-manifests": "~57.0.1",
"expo-structured-headers": "~57.0.0",
"expo-updates-interface": "~57.0.1",
"getenv": "^2.0.0",
"glob": "^13.0.0",
"ignore": "^5.3.1",
"nullthrows": "^1.1.1",
"resolve-from": "^5.0.0"
},
"bin": {
"expo-updates": "bin/cli.js"
},
"peerDependencies": {
"expo": "*",
"expo-dev-client": "*",
"react": "*",
"react-native": "*"
},
"peerDependenciesMeta": {
"expo-dev-client": {
"optional": true
}
}
},
"node_modules/expo-updates-interface": { "node_modules/expo-updates-interface": {
"version": "57.0.1", "version": "57.0.1",
"resolved": "https://registry.npmjs.org/expo-updates-interface/-/expo-updates-interface-57.0.1.tgz", "resolved": "https://registry.npmjs.org/expo-updates-interface/-/expo-updates-interface-57.0.1.tgz",
@@ -6322,6 +6381,12 @@
"expo": "*" "expo": "*"
} }
}, },
"node_modules/expo-updates/node_modules/arg": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
"license": "MIT"
},
"node_modules/expo/node_modules/@expo/cli": { "node_modules/expo/node_modules/@expo/cli": {
"version": "57.0.12", "version": "57.0.12",
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-57.0.12.tgz", "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-57.0.12.tgz",
@@ -6861,6 +6926,16 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/find-yarn-workspace-root": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz",
"integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"micromatch": "^4.0.2"
}
},
"node_modules/flat-cache": { "node_modules/flat-cache": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
@@ -6919,6 +6994,21 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/fs-extra": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/function-bind": { "node_modules/function-bind": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -8112,6 +8202,26 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/json-stable-stringify": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz",
"integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.8",
"call-bound": "^1.0.4",
"isarray": "^2.0.5",
"jsonify": "^0.0.1",
"object-keys": "^1.1.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/json-stable-stringify-without-jsonify": { "node_modules/json-stable-stringify-without-jsonify": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
@@ -8131,6 +8241,29 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/jsonfile": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
"integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"universalify": "^2.0.0"
},
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/jsonify": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz",
"integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==",
"dev": true,
"license": "Public Domain",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/jsx-ast-utils": { "node_modules/jsx-ast-utils": {
"version": "3.3.5", "version": "3.3.5",
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
@@ -8157,6 +8290,16 @@
"json-buffer": "3.0.1" "json-buffer": "3.0.1"
} }
}, },
"node_modules/klaw-sync": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz",
"integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.1.11"
}
},
"node_modules/kleur": { "node_modules/kleur": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
@@ -9628,6 +9771,52 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/patch-package": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz",
"integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@yarnpkg/lockfile": "^1.1.0",
"chalk": "^4.1.2",
"ci-info": "^3.7.0",
"cross-spawn": "^7.0.3",
"find-yarn-workspace-root": "^2.0.0",
"fs-extra": "^10.0.0",
"json-stable-stringify": "^1.0.2",
"klaw-sync": "^6.0.0",
"minimist": "^1.2.6",
"open": "^7.4.2",
"semver": "^7.5.3",
"slash": "^2.0.0",
"tmp": "^0.2.4",
"yaml": "^2.2.2"
},
"bin": {
"patch-package": "index.js"
},
"engines": {
"node": ">=14",
"npm": ">5"
}
},
"node_modules/patch-package/node_modules/ci-info": {
"version": "3.9.0",
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz",
"integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/sibiraj-s"
}
],
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-exists": { "node_modules/path-exists": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -10965,6 +11154,16 @@
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/slash": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz",
"integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/slugify": { "node_modules/slugify": {
"version": "1.6.9", "version": "1.6.9",
"resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.9.tgz", "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.9.tgz",
@@ -11398,6 +11597,16 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/tmp": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
"integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.14"
}
},
"node_modules/tmpl": { "node_modules/tmpl": {
"version": "1.0.5", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
@@ -11687,6 +11896,16 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/unpipe": { "node_modules/unpipe": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+4 -4
View File
@@ -16,18 +16,16 @@
"expo-router": "~57.0.10", "expo-router": "~57.0.10",
"expo-sqlite": "~57.0.1", "expo-sqlite": "~57.0.1",
"expo-status-bar": "~57.0.1", "expo-status-bar": "~57.0.1",
"expo-updates": "~57.0.12",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"react-hook-form": "^7.51.5", "react-hook-form": "^7.51.5",
"react-native": "0.86.2", "react-native": "0.86.2",
"react-native-gesture-handler": "2.32.0", "react-native-gesture-handler": "2.32.0",
"react-native-paper": "^5.12.3",
"react-native-reanimated": "4.5.1",
"react-native-safe-area-context": "5.7.0", "react-native-safe-area-context": "5.7.0",
"react-native-screens": "4.26.0", "react-native-screens": "4.26.0",
"react-native-svg": "^15.15.4", "react-native-svg": "^15.15.4",
"react-native-web": "^0.21.2", "react-native-web": "^0.21.2",
"react-native-worklets": "0.10.1",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
@@ -35,6 +33,7 @@
"@types/react": "~19.2.2", "@types/react": "~19.2.2",
"eslint": "^9.0.0", "eslint": "^9.0.0",
"eslint-config-expo": "~57.0.1", "eslint-config-expo": "~57.0.1",
"patch-package": "^8.0.1",
"prettier": "^3.9.6", "prettier": "^3.9.6",
"typescript": "~6.0.3" "typescript": "~6.0.3"
}, },
@@ -50,7 +49,8 @@
"build:android": "eas build --platform android", "build:android": "eas build --platform android",
"build:web": "eas build --platform web", "build:web": "eas build --platform web",
"submit:ios": "eas submit --platform ios", "submit:ios": "eas submit --platform ios",
"submit:android": "eas submit --platform android" "submit:android": "eas submit --platform android",
"postinstall": "patch-package"
}, },
"private": true "private": true
} }
@@ -0,0 +1,78 @@
diff --git a/node_modules/react-native/src/private/webapis/dom/events/Event.js b/node_modules/react-native/src/private/webapis/dom/events/Event.js
index f918f97..5deab46 100644
--- a/node_modules/react-native/src/private/webapis/dom/events/Event.js
+++ b/node_modules/react-native/src/private/webapis/dom/events/Event.js
@@ -70,7 +70,7 @@ export default class Event {
[CURRENT_TARGET_KEY]: EventTarget | null = null;
// $FlowExpectedError[unsupported-syntax]
- [EVENT_PHASE_KEY]: boolean = Event.NONE;
+ [EVENT_PHASE_KEY]: boolean = 0;
// $FlowExpectedError[unsupported-syntax]
[IN_PASSIVE_LISTENER_FLAG_KEY]: boolean = false;
@@ -193,48 +193,64 @@ export default class Event {
// $FlowExpectedError[cannot-write]
Object.defineProperty(Event, 'NONE', {
+ writable: true,
+ configurable: true,
enumerable: true,
value: 0,
});
// $FlowExpectedError[cannot-write]
Object.defineProperty(Event.prototype, 'NONE', {
+ writable: true,
+ configurable: true,
enumerable: true,
value: 0,
});
// $FlowExpectedError[cannot-write]
Object.defineProperty(Event, 'CAPTURING_PHASE', {
+ writable: true,
+ configurable: true,
enumerable: true,
value: 1,
});
// $FlowExpectedError[cannot-write]
Object.defineProperty(Event.prototype, 'CAPTURING_PHASE', {
+ writable: true,
+ configurable: true,
enumerable: true,
value: 1,
});
// $FlowExpectedError[cannot-write]
Object.defineProperty(Event, 'AT_TARGET', {
+ writable: true,
+ configurable: true,
enumerable: true,
value: 2,
});
// $FlowExpectedError[cannot-write]
Object.defineProperty(Event.prototype, 'AT_TARGET', {
+ writable: true,
+ configurable: true,
enumerable: true,
value: 2,
});
// $FlowExpectedError[cannot-write]
Object.defineProperty(Event, 'BUBBLING_PHASE', {
+ writable: true,
+ configurable: true,
enumerable: true,
value: 3,
});
// $FlowExpectedError[cannot-write]
Object.defineProperty(Event.prototype, 'BUBBLING_PHASE', {
+ writable: true,
+ configurable: true,
enumerable: true,
value: 3,
});
@@ -0,0 +1,213 @@
const {
withAndroidManifest,
withDangerousMod,
withStringsXml,
} = require('expo/config-plugins');
const fs = require('fs');
const path = require('path');
const RECEIVER_NAME = '.QuickAddWidgetProvider';
const WIDGET_PROVIDER_XML = `<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="40dp"
android:targetCellWidth="4"
android:targetCellHeight="1"
android:updatePeriodMillis="0"
android:initialLayout="@layout/widget_quick_add"
android:resizeMode="horizontal"
android:widgetCategory="home_screen"
android:description="@string/widget_quick_add_description" />
`;
const WIDGET_LAYOUT_XML = `<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="@drawable/widget_quick_add_bg"
android:padding="14dp">
<TextView
android:id="@+id/widget_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="carry your live"
android:textColor="#8E8E8E"
android:textSize="12sp"
android:textStyle="bold" />
<TextView
android:id="@+id/quick_add_button"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_marginTop="8dp"
android:gravity="center"
android:background="@drawable/widget_quick_add_button_bg"
android:text="+ Add task"
android:textColor="#FFFFFF"
android:textSize="16sp"
android:textStyle="bold" />
</LinearLayout>
`;
const WIDGET_BG_XML = `<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#1E1E1E" />
<corners android:radius="16dp" />
<stroke android:width="1dp" android:color="#2A2A2A" />
</shape>
`;
const WIDGET_BUTTON_BG_XML = `<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#EF5350" />
<corners android:radius="12dp" />
</shape>
`;
function kotlinProvider(packageName) {
return `package ${packageName}
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.widget.RemoteViews
class QuickAddWidgetProvider : AppWidgetProvider() {
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray
) {
for (appWidgetId in appWidgetIds) {
updateWidget(context, appWidgetManager, appWidgetId)
}
}
private fun updateWidget(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetId: Int
) {
val views = RemoteViews(context.packageName, R.layout.widget_quick_add)
val openAppIntent = Intent(context, MainActivity::class.java).apply {
action = Intent.ACTION_VIEW
data = Uri.parse("exp+carry-your-live://quick-add")
}
val pendingIntent = PendingIntent.getActivity(
context,
0,
openAppIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
views.setOnClickPendingIntent(R.id.quick_add_button, pendingIntent)
appWidgetManager.updateAppWidget(appWidgetId, views)
}
}
`;
}
function withQuickAddWidget(config) {
const packageName = config.android?.package ?? 'com.anonymous.carryyourlive';
config = withStringsXml(config, (config) => {
const strings = config.modResults;
if (!strings.resources.string) {
strings.resources.string = [];
}
const existing = (strings.resources.string || []).find(
(s) => s && s['$'] && s['$'].name === 'widget_quick_add_description'
);
if (!existing) {
strings.resources.string.push({
$: { name: 'widget_quick_add_description' },
_: 'Quickly add a task',
});
}
return config;
});
config = withAndroidManifest(config, (config) => {
const manifest = config.modResults;
const application = manifest.manifest.application?.[0];
if (!application) return config;
const receivers = application.receiver || [];
const exists = receivers.some((r) => r && r['$'] && r['$']['android:name'] === RECEIVER_NAME);
if (!exists) {
application.receiver = [
...receivers,
{
$: {
'android:name': RECEIVER_NAME,
'android:exported': 'false',
'android:label': 'Quick Add Task',
},
'intent-filter': [
{
action: [{ $: { 'android:name': 'android.appwidget.action.APPWIDGET_UPDATE' } }],
},
],
'meta-data': [
{
$: {
'android:name': 'android.appwidget.provider',
'android:resource': '@xml/quick_add_widget',
},
},
],
},
];
}
return config;
});
config = withDangerousMod(config, [
'android',
async (config) => {
const projectRoot = config.modRequest.projectRoot;
const resDir = path.join(projectRoot, 'android', 'app', 'src', 'main', 'res');
const javaDir = path.join(
projectRoot,
'android',
'app',
'src',
'main',
'java',
...packageName.split('.')
);
fs.mkdirSync(path.join(resDir, 'xml'), { recursive: true });
fs.mkdirSync(path.join(resDir, 'layout'), { recursive: true });
fs.mkdirSync(path.join(resDir, 'drawable'), { recursive: true });
fs.mkdirSync(javaDir, { recursive: true });
fs.writeFileSync(path.join(resDir, 'xml', 'quick_add_widget.xml'), WIDGET_PROVIDER_XML);
fs.writeFileSync(path.join(resDir, 'layout', 'widget_quick_add.xml'), WIDGET_LAYOUT_XML);
fs.writeFileSync(path.join(resDir, 'drawable', 'widget_quick_add_bg.xml'), WIDGET_BG_XML);
fs.writeFileSync(
path.join(resDir, 'drawable', 'widget_quick_add_button_bg.xml'),
WIDGET_BUTTON_BG_XML
);
fs.writeFileSync(
path.join(javaDir, 'QuickAddWidgetProvider.kt'),
kotlinProvider(packageName)
);
return config;
},
]);
return config;
}
module.exports = withQuickAddWidget;
@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { View, Text, TouchableOpacity, StyleSheet, Modal, FlatList, ActivityIndicator, TextInput } from 'react-native'; import { View, Text, TouchableOpacity, StyleSheet, Modal, FlatList, ActivityIndicator, TextInput, KeyboardAvoidingView } from 'react-native';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import { useFriends } from '@/hooks/useFriends'; import { useFriends } from '@/hooks/useFriends';
@@ -15,11 +15,19 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
const { friends, searchUsers, loading: friendsLoading } = useFriends(); const { friends, searchUsers, loading: friendsLoading } = useFriends();
const [showModal, setShowModal] = React.useState(false); const [showModal, setShowModal] = React.useState(false);
const [searchQuery, setSearchQuery] = React.useState(''); const [searchQuery, setSearchQuery] = React.useState('');
const [searchResults, setSearchResults] = React.useState<Array<{ id: string; username: string }>>([]); const [searchResults, setSearchResults] = React.useState<{ id: string; username: string }[]>([]);
const [searching, setSearching] = React.useState(false); const [searching, setSearching] = React.useState(false);
const selectedFriend = value ? friends.find(f => f.id === value) : null; const selectedFriend = value ? friends.find(f => f.id === value) : null;
const searchKey = `${showModal}:${searchQuery}`;
const [prevSearchKey, setPrevSearchKey] = React.useState(searchKey);
if (prevSearchKey !== searchKey && (!showModal || searchQuery.length < 2)) {
setPrevSearchKey(searchKey);
setSearchResults([]);
}
React.useEffect(() => { React.useEffect(() => {
if (showModal && searchQuery.length >= 2) { if (showModal && searchQuery.length >= 2) {
const timeout = setTimeout(async () => { const timeout = setTimeout(async () => {
@@ -34,8 +42,6 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
} }
}, 300); }, 300);
return () => clearTimeout(timeout); return () => clearTimeout(timeout);
} else {
setSearchResults([]);
} }
}, [searchQuery, showModal, friends, searchUsers]); }, [searchQuery, showModal, friends, searchUsers]);
@@ -97,7 +103,10 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
</TouchableOpacity> </TouchableOpacity>
<Modal visible={showModal} transparent animationType="fade" onRequestClose={() => setShowModal(false)}> <Modal visible={showModal} transparent animationType="fade" onRequestClose={() => setShowModal(false)}>
<View style={[styles.modalOverlay, { backgroundColor: 'rgba(0,0,0,0.5)' }]}> <KeyboardAvoidingView
style={[styles.modalOverlay, { backgroundColor: 'rgba(0,0,0,0.5)' }]}
behavior="padding"
>
<View style={[styles.modalSheet, { backgroundColor: theme.sheetBg }]}> <View style={[styles.modalSheet, { backgroundColor: theme.sheetBg }]}>
<View style={styles.modalHeader}> <View style={styles.modalHeader}>
<Text style={[styles.modalTitle, { color: theme.text }]}>Assign Task</Text> <Text style={[styles.modalTitle, { color: theme.text }]}>Assign Task</Text>
@@ -197,7 +206,7 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
) : null} ) : null}
</View> </View>
</View> </View>
</View> </KeyboardAvoidingView>
</Modal> </Modal>
</View> </View>
); );
@@ -210,48 +219,48 @@ const styles = StyleSheet.create({
selectorButton: { selectorButton: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: 10, gap: 8,
paddingVertical: 14, paddingVertical: 12,
paddingHorizontal: 12, paddingHorizontal: 10,
borderRadius: 12, borderRadius: 10,
borderWidth: 1, borderWidth: 1,
minHeight: 52, minHeight: 48,
}, },
selectorIcon: { selectorIcon: {
fontSize: 20, fontSize: 18,
}, },
selectorContent: { selectorContent: {
flex: 1, flex: 1,
justifyContent: 'center', justifyContent: 'center',
}, },
selectorLabel: { selectorLabel: {
fontSize: 11, fontSize: 10,
fontWeight: '600', fontWeight: '600',
textTransform: 'uppercase', textTransform: 'uppercase',
letterSpacing: 0.5, letterSpacing: 0.5,
marginBottom: 2, marginBottom: 1,
}, },
selectorValue: { selectorValue: {
fontSize: 15, fontSize: 14,
fontWeight: '500', fontWeight: '500',
}, },
clearButton: { clearButton: {
padding: 4, padding: 3,
}, },
clearText: { clearText: {
fontSize: 18, fontSize: 16,
fontWeight: '300', fontWeight: '300',
}, },
modalOverlay: { modalOverlay: {
flex: 1, flex: 1,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
padding: 24, padding: 20,
}, },
modalSheet: { modalSheet: {
width: '100%', width: '100%',
maxWidth: 400, maxWidth: 360,
borderRadius: 20, borderRadius: 16,
overflow: 'hidden', overflow: 'hidden',
maxHeight: '85%', maxHeight: '85%',
}, },
@@ -259,81 +268,81 @@ const styles = StyleSheet.create({
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
padding: 20, padding: 16,
borderBottomWidth: 1, borderBottomWidth: 1,
}, },
modalTitle: { modalTitle: {
fontSize: 18, fontSize: 16,
fontWeight: '700', fontWeight: '700',
}, },
closeText: { closeText: {
fontSize: 22, fontSize: 20,
fontWeight: '300', fontWeight: '300',
}, },
modalSection: { modalSection: {
padding: 12, padding: 10,
paddingBottom: 20, paddingBottom: 16,
borderBottomWidth: 1, borderBottomWidth: 1,
}, },
sectionTitle: { sectionTitle: {
fontSize: 12, fontSize: 11,
fontWeight: '600', fontWeight: '600',
textTransform: 'uppercase', textTransform: 'uppercase',
letterSpacing: 0.5, letterSpacing: 0.5,
marginBottom: 8, marginBottom: 6,
paddingHorizontal: 8, paddingHorizontal: 6,
}, },
optionRow: { optionRow: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: 12, gap: 10,
paddingVertical: 12, paddingVertical: 10,
paddingHorizontal: 16, paddingHorizontal: 14,
borderRadius: 10, borderRadius: 9,
borderWidth: 1, borderWidth: 1,
}, },
optionIcon: { optionIcon: {
width: 36, width: 32,
height: 36, height: 32,
borderRadius: 18, borderRadius: 16,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
backgroundColor: 'rgba(0,0,0,0.05)', backgroundColor: 'rgba(0,0,0,0.05)',
}, },
optionIconText: { optionIconText: {
fontSize: 16, fontSize: 15,
}, },
optionText: { optionText: {
fontSize: 16, fontSize: 15,
fontWeight: '500', fontWeight: '500',
flex: 1, flex: 1,
}, },
optionSubtext: { optionSubtext: {
fontSize: 12, fontSize: 11,
marginTop: 2, marginTop: 1,
}, },
checkmark: { checkmark: {
fontSize: 18, fontSize: 16,
fontWeight: '700', fontWeight: '700',
}, },
loading: { loading: {
padding: 20, padding: 16,
alignItems: 'center', alignItems: 'center',
}, },
emptyText: { emptyText: {
fontSize: 14, fontSize: 13,
textAlign: 'center', textAlign: 'center',
paddingHorizontal: 20, paddingHorizontal: 16,
}, },
searchContainer: { searchContainer: {
padding: 12, padding: 10,
paddingBottom: 8, paddingBottom: 6,
}, },
searchInput: { searchInput: {
fontSize: 16, fontSize: 15,
paddingVertical: 12, paddingVertical: 10,
paddingHorizontal: 16, paddingHorizontal: 14,
borderRadius: 12, borderRadius: 10,
borderWidth: 1, borderWidth: 1,
}, },
}); });
@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react'; import React, { useState } from 'react';
import { Modal, View, Text, StyleSheet, TextInput, TouchableOpacity, ScrollView, KeyboardAvoidingView, Platform } from 'react-native'; import { Modal, View, Text, StyleSheet, TextInput, TouchableOpacity, ScrollView, KeyboardAvoidingView } from 'react-native';
import Category from '@/models/Category'; import Category from '@/models/Category';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import { CATEGORY_COLORS } from '@/constants'; import { CATEGORY_COLORS } from '@/constants';
@@ -19,13 +19,15 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose
const { theme } = useSettings(); const { theme } = useSettings();
const [name, setName] = useState(''); const [name, setName] = useState('');
const [color, setColor] = useState(CATEGORY_COLORS[0]); const [color, setColor] = useState(CATEGORY_COLORS[0]);
const [prevVisible, setPrevVisible] = useState(visible);
useEffect(() => { if (prevVisible !== visible) {
setPrevVisible(visible);
if (visible) { if (visible) {
setName(category?.name ?? ''); setName(category?.name ?? '');
setColor(category?.color ?? CATEGORY_COLORS[0]); setColor(category?.color ?? CATEGORY_COLORS[0]);
} }
}, [visible, category]); }
const canDelete = category !== null && categoryCount > 1; const canDelete = category !== null && categoryCount > 1;
@@ -58,7 +60,7 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}> <Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<KeyboardAvoidingView <KeyboardAvoidingView
style={styles.overlay} style={styles.overlay}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'} behavior="padding"
> >
<ScrollView contentContainerStyle={styles.overlay} keyboardShouldPersistTaps="handled"> <ScrollView contentContainerStyle={styles.overlay} keyboardShouldPersistTaps="handled">
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}> <View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
@@ -133,6 +135,7 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose
<Text style={styles.saveButtonText}>Save</Text> <Text style={styles.saveButtonText}>Save</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View>
</ScrollView> </ScrollView>
</KeyboardAvoidingView> </KeyboardAvoidingView>
</Modal> </Modal>
@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, Animated, Easing } from 'react-native'; import { View, Text, StyleSheet, ScrollView, TouchableOpacity, Animated, Easing } from 'react-native';
import { useCategories } from '@/hooks/useDatabase'; import { useUniqueCategories } from '@/hooks/useDatabase';
import { useSettings, ThemeColors } from '@/theme'; import { useSettings, ThemeColors } from '@/theme';
import Category from '@/models/Category'; import Category from '@/models/Category';
@@ -10,7 +10,7 @@ interface CategoryFilterProps {
} }
export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) { export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) {
const categories = useCategories(); const categories = useUniqueCategories();
const { theme } = useSettings(); const { theme } = useSettings();
if (categories.length === 0) { if (categories.length === 0) {
@@ -91,9 +91,9 @@ interface AnimatedCategoryButtonProps {
} }
function AnimatedCategoryButton({ category, selected, onPress, theme }: AnimatedCategoryButtonProps) { function AnimatedCategoryButton({ category, selected, onPress, theme }: AnimatedCategoryButtonProps) {
const scaleAnim = React.useRef(new Animated.Value(selected ? 1.05 : 1)).current; const [scaleAnim] = React.useState(() => new Animated.Value(selected ? 1.05 : 1));
const borderWidthAnim = React.useRef(new Animated.Value(selected ? 2 : 1)).current; const [borderWidthAnim] = React.useState(() => new Animated.Value(selected ? 2 : 1));
const shadowOpacityAnim = React.useRef(new Animated.Value(selected ? 0.15 : 0)).current; const [shadowOpacityAnim] = React.useState(() => new Animated.Value(selected ? 0.15 : 0));
React.useEffect(() => { React.useEffect(() => {
Animated.timing(scaleAnim, { Animated.timing(scaleAnim, {
@@ -151,28 +151,30 @@ function AnimatedCategoryButton({ category, selected, onPress, theme }: Animated
const styles = StyleSheet.create({ const styles = StyleSheet.create({
scrollView: { scrollView: {
paddingVertical: 0, paddingVertical: 0,
marginBottom: 0,
}, },
container: { container: {
paddingHorizontal: 16, paddingHorizontal: 12,
paddingBottom: 4, paddingTop: 0,
gap: 8, paddingBottom: 0,
alignItems: 'center', gap: 6,
alignItems: 'flex-start',
}, },
button: { button: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
paddingHorizontal: 14, paddingHorizontal: 12,
paddingVertical: 8, paddingVertical: 6,
borderRadius: 20, borderRadius: 16,
borderWidth: 1, borderWidth: 1,
minWidth: 72, minWidth: 64,
justifyContent: 'center', justifyContent: 'center',
}, },
animatedButton: { animatedButton: {
shadowColor: '#000', shadowColor: '#000',
shadowOffset: { width: 0, height: 2 }, shadowOffset: { width: 0, height: 1 },
shadowRadius: 8, shadowRadius: 4,
elevation: 3, elevation: 2,
}, },
buttonInner: { buttonInner: {
flexDirection: 'row', flexDirection: 'row',
@@ -180,17 +182,17 @@ const styles = StyleSheet.create({
gap: 6, gap: 6,
}, },
colorDot: { colorDot: {
width: 8,
height: 8,
borderRadius: 4,
},
colorDotSelected: {
width: 10, width: 10,
height: 10, height: 10,
borderRadius: 5, borderRadius: 5,
}, },
colorDotSelected: {
width: 12,
height: 12,
borderRadius: 6,
},
buttonText: { buttonText: {
fontSize: 13, fontSize: 12,
fontWeight: '500', fontWeight: '500',
}, },
}); });
@@ -1,5 +1,5 @@
import React from 'react'; import React, { useState } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, ScrollView } from 'react-native'; import { View, Text, StyleSheet, TouchableOpacity, ScrollView, Modal, Pressable, KeyboardAvoidingView } from 'react-native';
import { useCategories } from '@/hooks/useDatabase'; import { useCategories } from '@/hooks/useDatabase';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
@@ -12,82 +12,109 @@ interface CategorySelectorProps {
export function CategorySelector({ value, onChange, error }: CategorySelectorProps) { export function CategorySelector({ value, onChange, error }: CategorySelectorProps) {
const categories = useCategories(); const categories = useCategories();
const { theme } = useSettings(); const { theme } = useSettings();
const [showModal, setShowModal] = useState(false);
const selectedCategory = categories.find(c => c.id === value);
return ( return (
<View style={styles.container}> <View style={styles.container}>
<Text style={[styles.label, { color: theme.text }]}>Category</Text> <Text style={[styles.label, { color: theme.text }]}>Category</Text>
<View style={styles.requiredIndicator} /> <TouchableOpacity
<ScrollView style={[
horizontal styles.selectorButton,
showsHorizontalScrollIndicator={false} { backgroundColor: theme.inputBg, borderColor: theme.borderStrong },
contentContainerStyle={styles.scrollContent} ]}
style={styles.scrollView} onPress={() => setShowModal(true)}
activeOpacity={0.8}
> >
{categories.map((category) => ( <View style={styles.selectorContent}>
<TouchableOpacity <View style={styles.selectorRow}>
key={category.id} <View style={[styles.colorCircle, { backgroundColor: selectedCategory?.color || '#9E9E9E' }]} />
style={[ <Text style={[styles.selectorValue, { color: theme.text }]}>{selectedCategory?.name || 'Select category'}</Text>
styles.categoryButton, </View>
{ backgroundColor: theme.card, borderColor: theme.borderStrong }, </View>
value === category.id && { borderColor: theme.accent, backgroundColor: theme.accentSoft, borderWidth: 2 }, <Svg width={20} height={20} viewBox="0 0 24 24">
]} <Path d="M9 18l6-6-6-6" stroke={theme.textMuted} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" fill="none" />
onPress={() => onChange(category.id)} </Svg>
activeOpacity={0.8} </TouchableOpacity>
> {error && <Text style={[styles.errorText, { color: '#E53935' }]}>{error}</Text>}
<View
style={[ <Modal visible={showModal} transparent animationType="fade" onRequestClose={() => setShowModal(false)}>
styles.colorCircle, <KeyboardAvoidingView
{ backgroundColor: category.color }, style={[styles.modalOverlay, { backgroundColor: 'rgba(0,0,0,0.5)' }]}
value === category.id && styles.colorCircleSelected, behavior="padding"
]} >
/> <Pressable style={styles.modalOverlay} onPress={() => setShowModal(false)}>
<Text style={[ <Pressable style={[styles.modalSheet, { backgroundColor: theme.sheetBg }]}>
styles.categoryName, <View style={styles.modalHeader}>
{ color: theme.textSecondary }, <Text style={[styles.modalTitle, { color: theme.text }]}>Select Category</Text>
value === category.id && { color: theme.accent, fontWeight: '600' }, <TouchableOpacity onPress={() => setShowModal(false)} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
]}> <Text style={[styles.closeText, { color: theme.textMuted }]}></Text>
{category.name} </TouchableOpacity>
</Text> </View>
</TouchableOpacity> <ScrollView contentContainerStyle={styles.modalContent}>
))} {categories.map((category) => (
</ScrollView> <TouchableOpacity
{error && <Text style={styles.errorText}>{error}</Text>} key={category.id}
style={[
styles.modalOption,
{ backgroundColor: theme.card, borderColor: theme.borderStrong },
value === category.id && { borderColor: theme.accent, backgroundColor: theme.accentSoft, borderWidth: 2 },
]}
onPress={() => { onChange(category.id); setShowModal(false); }}
activeOpacity={0.8}
>
<View style={[styles.colorCircle, { backgroundColor: category.color }, value === category.id && styles.colorCircleSelected]} />
<Text style={[
styles.categoryName,
{ color: theme.textSecondary },
value === category.id && { color: theme.accent, fontWeight: '600' },
]}>
{category.name}
</Text>
{value === category.id && (
<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" />
</Svg>
)}
</TouchableOpacity>
))}
</ScrollView>
</Pressable>
</Pressable>
</KeyboardAvoidingView>
</Modal>
</View> </View>
); );
} }
import Svg, { Path } from 'react-native-svg';
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
gap: 8, gap: 6,
}, },
label: { label: {
fontSize: 14, fontSize: 14,
fontWeight: '600', fontWeight: '600',
}, },
requiredIndicator: { selectorButton: {
position: 'absolute',
top: 0,
right: 0,
color: '#E53935',
fontSize: 14,
},
scrollView: {
paddingVertical: 4,
},
scrollContent: {
paddingHorizontal: 16,
gap: 10,
},
categoryButton: {
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 24,
borderWidth: 1,
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: 8, justifyContent: 'space-between',
minWidth: 90, paddingVertical: 14,
justifyContent: 'center', paddingHorizontal: 12,
borderRadius: 12,
borderWidth: 1,
minHeight: 52,
},
selectorContent: {
flex: 1,
},
selectorRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
}, },
colorCircle: { colorCircle: {
width: 12, width: 12,
@@ -99,13 +126,59 @@ const styles = StyleSheet.create({
height: 14, height: 14,
borderRadius: 7, borderRadius: 7,
}, },
categoryName: { selectorValue: {
fontSize: 13, fontSize: 15,
fontWeight: '500', fontWeight: '500',
}, },
errorText: { errorText: {
fontSize: 12, fontSize: 12,
color: '#E53935', marginLeft: 4,
marginLeft: 16, },
modalOverlay: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 24,
},
modalSheet: {
width: '100%',
maxWidth: 400,
borderRadius: 20,
overflow: 'hidden',
maxHeight: '80%',
},
modalHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: 20,
borderBottomWidth: 1,
},
modalTitle: {
fontSize: 18,
fontWeight: '700',
},
closeText: {
fontSize: 22,
fontWeight: '300',
},
modalContent: {
padding: 12,
gap: 8,
},
modalOption: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
paddingVertical: 14,
paddingHorizontal: 16,
borderRadius: 12,
borderWidth: 1,
},
categoryName: {
fontSize: 15,
fontWeight: '500',
flex: 1,
}, },
}); });
@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { View, StyleSheet } from 'react-native'; import { StyleSheet } from 'react-native';
import DateTimePicker, { DateTimePickerEvent } from '@react-native-community/datetimepicker'; import DateTimePicker, { DateTimePickerEvent } from '@react-native-community/datetimepicker';
interface NativeDateTimeInputProps { interface NativeDateTimeInputProps {
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'; import React, { useState } from 'react';
import { View, Text, TouchableOpacity, StyleSheet, Modal } from 'react-native'; import { View, Text, TouchableOpacity, StyleSheet, Modal } from 'react-native';
import { DateTimePickerEvent } from '@react-native-community/datetimepicker'; import { DateTimePickerEvent } from '@react-native-community/datetimepicker';
@@ -33,10 +33,12 @@ export default function WebDateTimeInput({
is24Hour, is24Hour,
}: WebDateTimeInputProps) { }: WebDateTimeInputProps) {
const [inputValue, setInputValue] = useState(() => dateInputValue(value)); const [inputValue, setInputValue] = useState(() => dateInputValue(value));
const [prevValue, setPrevValue] = useState(value);
useEffect(() => { if (value !== prevValue) {
setPrevValue(value);
setInputValue(dateInputValue(value)); setInputValue(dateInputValue(value));
}, [value]); }
const emit = (raw: string) => { const emit = (raw: string) => {
if (!raw) return; if (!raw) return;
@@ -15,14 +15,12 @@ interface DateTimePickerComponentProps {
export function DateTimePickerComponent({ control }: DateTimePickerComponentProps) { export function DateTimePickerComponent({ control }: DateTimePickerComponentProps) {
const { theme } = useSettings(); const { theme } = useSettings();
const [picker, setPicker] = React.useState<'date' | 'time' | 'endTime' | null>(null); const [picker, setPicker] = React.useState<'date' | 'time' | 'endTime' | null>(null);
const dateValueRef = useRef<Date | null>(null);
const timeValueRef = useRef<string>('');
const endTimeValueRef = useRef<string>('');
const dateRef = useRef<((value: Date | null) => void) | null>(null); const dateRef = useRef<((value: Date | null) => void) | null>(null);
const timeRef = useRef<((value: string) => void) | null>(null); const timeRef = useRef<((value: string) => void) | null>(null);
const endTimeRef = useRef<((value: string) => void) | null>(null); const endTimeRef = useRef<((value: string) => void) | null>(null);
const allDay = useWatch({ control, name: 'allDay' }) ?? false; const allDay = useWatch({ control, name: 'allDay' }) ?? false;
const dueDate = useWatch({ control, name: 'dueDate' }) ?? null;
const startTime = useWatch({ control, name: 'dueTime' }) ?? ''; const startTime = useWatch({ control, name: 'dueTime' }) ?? '';
const endTime = useWatch({ control, name: 'endTime' }) ?? ''; const endTime = useWatch({ control, name: 'endTime' }) ?? '';
@@ -37,7 +35,6 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
const renderTimeButton = ( const renderTimeButton = (
fieldName: 'dueTime' | 'endTime', fieldName: 'dueTime' | 'endTime',
ref: React.MutableRefObject<((value: string) => void) | null>, ref: React.MutableRefObject<((value: string) => void) | null>,
valueRef: React.MutableRefObject<string>,
placeholder: string, placeholder: string,
onPress: () => void onPress: () => void
) => ( ) => (
@@ -46,7 +43,6 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
name={fieldName} name={fieldName}
render={({ field }) => { render={({ field }) => {
ref.current = field.onChange; ref.current = field.onChange;
valueRef.current = field.value ?? '';
return ( return (
<TouchableOpacity <TouchableOpacity
style={[ style={[
@@ -105,7 +101,6 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
name="dueDate" name="dueDate"
render={({ field }) => { render={({ field }) => {
dateRef.current = field.onChange; dateRef.current = field.onChange;
dateValueRef.current = field.value;
return ( return (
<TouchableOpacity <TouchableOpacity
style={[ style={[
@@ -181,8 +176,8 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
{!allDay && ( {!allDay && (
<> <>
<View style={styles.timeRow}> <View style={styles.timeRow}>
{renderTimeButton('dueTime', timeRef, timeValueRef, 'Start Time', () => setPicker('time'))} {renderTimeButton('dueTime', timeRef, 'Start Time', () => setPicker('time'))}
{renderTimeButton('endTime', endTimeRef, endTimeValueRef, 'End Time', () => setPicker('endTime'))} {renderTimeButton('endTime', endTimeRef, 'End Time', () => setPicker('endTime'))}
</View> </View>
{endTimeInvalid && ( {endTimeInvalid && (
@@ -203,7 +198,7 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
<DateTimeInput <DateTimeInput
testID="date-picker" testID="date-picker"
value={dateValueRef.current ?? new Date()} value={dueDate ?? new Date()}
mode="date" mode="date"
is24Hour={false} is24Hour={false}
isVisible={picker === 'date'} isVisible={picker === 'date'}
@@ -220,8 +215,8 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
title={picker === 'endTime' ? 'End Time' : 'Start Time'} title={picker === 'endTime' ? 'End Time' : 'Start Time'}
initialTime={ initialTime={
picker === 'endTime' picker === 'endTime'
? endTimeValueRef.current || timeValueRef.current ? endTime || startTime
: timeValueRef.current : startTime
} }
onConfirm={(time) => { onConfirm={(time) => {
if (picker === 'endTime') { if (picker === 'endTime') {
@@ -261,7 +256,7 @@ const styles = StyleSheet.create({
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
paddingVertical: 12, paddingVertical: 14,
}, },
allDayLabel: { allDayLabel: {
fontSize: 15, fontSize: 15,
@@ -293,7 +288,7 @@ const styles = StyleSheet.create({
fontWeight: '500', fontWeight: '500',
}, },
clearButton: { clearButton: {
padding: 2, padding: 4,
}, },
warningRow: { warningRow: {
flexDirection: 'row', flexDirection: 'row',
@@ -1,6 +1,5 @@
import React from 'react'; import React from 'react';
import { View, Text, StyleSheet, TextInput } from 'react-native'; import { View, Text, StyleSheet, TextInput , TextInputProps } from 'react-native';
import { TextInputProps } from 'react-native';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
interface DescriptionInputProps extends TextInputProps { interface DescriptionInputProps extends TextInputProps {
@@ -38,30 +37,30 @@ export function DescriptionInput({ error, ...props }: DescriptionInputProps) {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
gap: 6, gap: 5,
}, },
label: { label: {
fontSize: 14, fontSize: 13,
fontWeight: '600', fontWeight: '600',
}, },
input: { input: {
minHeight: 100, minHeight: 88,
padding: 16, padding: 14,
borderRadius: 12, borderRadius: 10,
borderWidth: 1, borderWidth: 1,
fontSize: 15, fontSize: 14,
}, },
inputError: { inputError: {
borderColor: '#E53935', borderColor: '#E53935',
borderWidth: 1.5, borderWidth: 1.5,
}, },
charCount: { charCount: {
fontSize: 11, fontSize: 10,
textAlign: 'right', textAlign: 'right',
marginTop: -4, marginTop: -3,
}, },
errorText: { errorText: {
fontSize: 12, fontSize: 11,
color: '#E53935', color: '#E53935',
marginLeft: 4, marginLeft: 4,
}, },
@@ -1,5 +1,5 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { View, StyleSheet, TouchableOpacity, Animated, Easing } from 'react-native'; import { StyleSheet, TouchableOpacity, Animated, Easing } from 'react-native';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import Svg, { Path } from 'react-native-svg'; import Svg, { Path } from 'react-native-svg';
+15 -13
View File
@@ -3,6 +3,7 @@ import { View, StyleSheet, TouchableOpacity, Text } from 'react-native';
import { useFormContext } from 'react-hook-form'; import { useFormContext } from 'react-hook-form';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
interface FormButtonsProps { interface FormButtonsProps {
onSubmit: (data: any) => void; onSubmit: (data: any) => void;
@@ -13,6 +14,7 @@ export function FormButtons({ onSubmit, submitLabel = 'Submit' }: FormButtonsPro
const router = useRouter(); const router = useRouter();
const { theme } = useSettings(); const { theme } = useSettings();
const { handleSubmit, formState: { isSubmitting } } = useFormContext(); const { handleSubmit, formState: { isSubmitting } } = useFormContext();
const insets = useSafeAreaInsets();
const cancel = () => { const cancel = () => {
if (router.canGoBack()) { if (router.canGoBack()) {
@@ -23,7 +25,7 @@ export function FormButtons({ onSubmit, submitLabel = 'Submit' }: FormButtonsPro
}; };
return ( return (
<View style={[styles.container, { backgroundColor: theme.background, borderTopColor: theme.border }]}> <View style={[styles.container, { backgroundColor: theme.background, borderTopColor: theme.border, paddingBottom: insets.bottom + 12 }]}>
<TouchableOpacity <TouchableOpacity
style={[styles.cancelButton, { borderColor: theme.borderStrong, backgroundColor: theme.card }]} style={[styles.cancelButton, { borderColor: theme.borderStrong, backgroundColor: theme.card }]}
onPress={cancel} onPress={cancel}
@@ -52,45 +54,45 @@ const styles = StyleSheet.create({
bottom: 0, bottom: 0,
left: 0, left: 0,
right: 0, right: 0,
paddingHorizontal: 16, paddingHorizontal: 12,
paddingVertical: 16, paddingVertical: 12,
borderTopWidth: 1, borderTopWidth: 1,
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-between', justifyContent: 'space-between',
gap: 12, gap: 10,
shadowColor: '#000', shadowColor: '#000',
shadowOffset: { width: 0, height: -2 }, shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.05, shadowOpacity: 0.05,
shadowRadius: 8, shadowRadius: 6,
elevation: 4, elevation: 3,
}, },
cancelButton: { cancelButton: {
flex: 1, flex: 1,
paddingVertical: 14, paddingVertical: 12,
borderRadius: 12, borderRadius: 10,
borderWidth: 1, borderWidth: 1,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
}, },
cancelButtonText: { cancelButtonText: {
fontSize: 16, fontSize: 15,
fontWeight: '600', fontWeight: '600',
}, },
submitButton: { submitButton: {
flex: 1, flex: 1,
paddingVertical: 14, paddingVertical: 12,
borderRadius: 12, borderRadius: 10,
backgroundColor: '#E53935', backgroundColor: '#E53935',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
shadowColor: '#E53935', shadowColor: '#E53935',
shadowOffset: { width: 0, height: 2 }, shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.3, shadowOpacity: 0.3,
shadowRadius: 8, shadowRadius: 6,
elevation: 3, elevation: 3,
}, },
submitButtonText: { submitButtonText: {
fontSize: 16, fontSize: 15,
fontWeight: '600', fontWeight: '600',
color: '#FFFFFF', color: '#FFFFFF',
}, },
+17 -26
View File
@@ -10,7 +10,6 @@ import {
ActivityIndicator, ActivityIndicator,
Alert, Alert,
KeyboardAvoidingView, KeyboardAvoidingView,
Platform,
} from 'react-native'; } from 'react-native';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import { useFriends } from '@/hooks/useFriends'; import { useFriends } from '@/hooks/useFriends';
@@ -24,10 +23,18 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
const { theme } = useSettings(); const { theme } = useSettings();
const { friends, incoming, outgoing, loading, sendRequest, acceptRequest, declineRequest, removeFriend, searchUsers } = useFriends(); const { friends, incoming, outgoing, loading, sendRequest, acceptRequest, declineRequest, removeFriend, searchUsers } = useFriends();
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<Array<{ id: string; username: string }>>([]); const [searchResults, setSearchResults] = useState<{ id: string; username: string }[]>([]);
const [searching, setSearching] = useState(false); const [searching, setSearching] = useState(false);
const [selectedTab, setSelectedTab] = useState<'friends' | 'incoming' | 'outgoing' | 'add'>('friends'); const [selectedTab, setSelectedTab] = useState<'friends' | 'incoming' | 'outgoing' | 'add'>('friends');
const searchKey = `${selectedTab}:${searchQuery}`;
const [prevSearchKey, setPrevSearchKey] = useState(searchKey);
if (prevSearchKey !== searchKey && (selectedTab !== 'add' || searchQuery.length < 2)) {
setPrevSearchKey(searchKey);
setSearchResults([]);
}
useEffect(() => { useEffect(() => {
if (selectedTab === 'add' && searchQuery.length >= 2) { if (selectedTab === 'add' && searchQuery.length >= 2) {
const timeout = setTimeout(async () => { const timeout = setTimeout(async () => {
@@ -42,23 +49,9 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
} }
}, 300); }, 300);
return () => clearTimeout(timeout); return () => clearTimeout(timeout);
} else {
setSearchResults([]);
} }
}, [searchQuery, selectedTab, friends, outgoing, searchUsers]); }, [searchQuery, selectedTab, friends, outgoing, searchUsers]);
const handleAddFriend = async () => {
if (!searchQuery.trim()) return;
try {
await sendRequest(searchQuery.trim());
setSearchQuery('');
setSearchResults([]);
setSelectedTab('friends');
} catch (err) {
Alert.alert('Error', err instanceof Error ? err.message : 'Failed to send request');
}
};
const handleAccept = async (requestId: string) => { const handleAccept = async (requestId: string) => {
try { try {
await acceptRequest(requestId); await acceptRequest(requestId);
@@ -94,7 +87,7 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}> <Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<KeyboardAvoidingView <KeyboardAvoidingView
style={[styles.overlay, { backgroundColor: 'rgba(0,0,0,0.5)' }]} style={[styles.overlay, { backgroundColor: 'rgba(0,0,0,0.5)' }]}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'} behavior="padding"
> >
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}> <View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
<View style={styles.header}> <View style={styles.header}>
@@ -142,7 +135,7 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
) : friends.length === 0 ? ( ) : friends.length === 0 ? (
<View style={styles.empty}> <View style={styles.empty}>
<Text style={[styles.emptyText, { color: theme.textMuted }]}>No friends yet</Text> <Text style={[styles.emptyText, { color: theme.textMuted }]}>No friends yet</Text>
<Text style={[styles.emptyHint, { color: theme.textFaint }]}>Tap "Add" to find friends by username</Text> <Text style={[styles.emptyHint, { color: theme.textFaint }]}>{'Tap "Add" to find friends by username'}</Text>
</View> </View>
) : ( ) : (
<FlatList <FlatList
@@ -263,16 +256,14 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
<Text style={[styles.friendName, { color: theme.text }]}>{item.username}</Text> <Text style={[styles.friendName, { color: theme.text }]}>{item.username}</Text>
<TouchableOpacity <TouchableOpacity
style={[styles.addBtn, { backgroundColor: theme.accent }]} style={[styles.addBtn, { backgroundColor: theme.accent }]}
onPress={async () => { onPress={() => sendRequest(item.username)
try { .then(() => {
await sendRequest(item.username);
setSearchQuery(''); setSearchQuery('');
setSearchResults([]); setSearchResults([]);
setSelectedTab('friends'); setSelectedTab('friends');
} catch (err) { })
Alert.alert('Error', err instanceof Error ? err.message : 'Failed to send request'); .catch(err => Alert.alert('Error', err instanceof Error ? err.message : 'Failed to send request'))
} }
}}
> >
<Text style={styles.addBtnText}>Add</Text> <Text style={styles.addBtnText}>Add</Text>
</TouchableOpacity> </TouchableOpacity>
@@ -287,7 +278,7 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
</View> </View>
)} )}
</View> </View>
</View> </KeyboardAvoidingView>
</Modal> </Modal>
); );
} }
+10 -17
View File
@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { View, Text, StyleSheet } from 'react-native'; import { View, Text, StyleSheet, StatusBar, Platform } from 'react-native';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
interface HeaderProps { interface HeaderProps {
@@ -10,9 +10,10 @@ interface HeaderProps {
export function Header({ title, showLogo, rightAction }: HeaderProps) { export function Header({ title, showLogo, rightAction }: HeaderProps) {
const { theme } = useSettings(); const { theme } = useSettings();
const topInset = Platform.OS === 'android' ? (StatusBar.currentHeight ?? 24) : 0;
return ( return (
<View style={[styles.header, { backgroundColor: theme.background }]}> <View style={[styles.header, { backgroundColor: theme.background, paddingTop: topInset }]}>
<View style={styles.headerContent}> <View style={styles.headerContent}>
{showLogo && ( {showLogo && (
<View style={styles.logoContainer}> <View style={styles.logoContainer}>
@@ -28,22 +29,20 @@ export function Header({ title, showLogo, rightAction }: HeaderProps) {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
header: { header: {
borderBottomLeftRadius: 24, borderBottomLeftRadius: 16,
borderBottomRightRadius: 24, borderBottomRightRadius: 16,
shadowColor: '#000', shadowColor: '#000',
shadowOffset: { width: 0, height: 2 }, shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05, shadowOpacity: 0.04,
shadowRadius: 8, shadowRadius: 4,
elevation: 2, elevation: 1,
}, },
headerContent: { headerContent: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
paddingHorizontal: 20, paddingHorizontal: 20,
paddingTop: 2, height: 48,
paddingBottom: 6,
height: 44,
}, },
logoContainer: { logoContainer: {
width: 32, width: 32,
@@ -69,10 +68,4 @@ const styles = StyleSheet.create({
width: 32, width: 32,
alignItems: 'flex-end', alignItems: 'flex-end',
}, },
bottomRounded: {
height: 16,
borderBottomLeftRadius: 16,
borderBottomRightRadius: 16,
marginTop: -16,
},
}); });
@@ -123,12 +123,12 @@ For questions about these Terms, contact us through the app's feedback channel.
`; `;
export function LegalModal({ visible, type, onClose }: LegalModalProps) { export function LegalModal({ visible, type, onClose }: LegalModalProps) {
if (!visible || !type) return null;
const { theme } = useSettings(); const { theme } = useSettings();
const content = type === 'privacy' ? PRIVACY_POLICY : TERMS_OF_SERVICE; const content = type === 'privacy' ? PRIVACY_POLICY : TERMS_OF_SERVICE;
const title = type === 'privacy' ? 'Privacy Policy' : 'Terms of Service'; const title = type === 'privacy' ? 'Privacy Policy' : 'Terms of Service';
if (!visible || !type) return null;
return ( return (
<SafeAreaView style={[styles.overlay, { backgroundColor: theme.overlay }]}> <SafeAreaView style={[styles.overlay, { backgroundColor: theme.overlay }]}>
<View style={[styles.modal, { backgroundColor: theme.card }]}> <View style={[styles.modal, { backgroundColor: theme.card }]}>
@@ -192,6 +192,5 @@ const styles = StyleSheet.create({
body: { body: {
fontSize: 14, fontSize: 14,
lineHeight: 22, lineHeight: 22,
whiteSpace: 'pre-wrap' as const,
}, },
}); });
@@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { View, Text, StyleSheet, Modal, TouchableOpacity, FlatList } from 'react-native'; import { View, Text, StyleSheet, Modal, TouchableOpacity, FlatList } from 'react-native';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import Svg, { Path } from 'react-native-svg'; import Svg, { Path, Circle } from 'react-native-svg';
export interface PickerOption { export interface PickerOption {
value: string; value: string;
@@ -13,14 +13,17 @@ interface OptionPickerModalProps {
visible: boolean; visible: boolean;
title: string; title: string;
options: PickerOption[]; options: PickerOption[];
selectedValue?: string; selectedValue?: string | string[];
onSelect: (value: string) => void; onSelect: (value: string | string[]) => void;
onClose: () => void; onClose: () => void;
multiSelect?: boolean;
} }
export function OptionPickerModal({ visible, title, options, selectedValue, onSelect, onClose }: OptionPickerModalProps) { export function OptionPickerModal({ visible, title, options, selectedValue, onSelect, onClose, multiSelect = false }: OptionPickerModalProps) {
const { theme } = useSettings(); const { theme } = useSettings();
const selectedValues = Array.isArray(selectedValue) ? selectedValue : selectedValue ? [selectedValue] : [];
return ( return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}> <Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<View style={[styles.overlay, { backgroundColor: theme.overlay }]}> <View style={[styles.overlay, { backgroundColor: theme.overlay }]}>
@@ -30,7 +33,7 @@ export function OptionPickerModal({ visible, title, options, selectedValue, onSe
data={options} data={options}
keyExtractor={(item) => item.value} keyExtractor={(item) => item.value}
renderItem={({ item }) => { renderItem={({ item }) => {
const selected = item.value === selectedValue; const selected = selectedValues.includes(item.value);
return ( return (
<TouchableOpacity <TouchableOpacity
style={[ style={[
@@ -39,8 +42,15 @@ export function OptionPickerModal({ visible, title, options, selectedValue, onSe
selected && { backgroundColor: theme.accentSoft }, selected && { backgroundColor: theme.accentSoft },
]} ]}
onPress={() => { onPress={() => {
onSelect(item.value); if (multiSelect) {
onClose(); const newValues = selected
? selectedValues.filter((v) => v !== item.value)
: [...selectedValues, item.value];
onSelect(newValues);
} else {
onSelect(item.value);
onClose();
}
}} }}
activeOpacity={0.7} activeOpacity={0.7}
> >
@@ -50,7 +60,14 @@ export function OptionPickerModal({ visible, title, options, selectedValue, onSe
<Text style={[styles.optionText, { color: selected ? theme.accent : theme.textSecondary }]}> <Text style={[styles.optionText, { color: selected ? theme.accent : theme.textSecondary }]}>
{item.label} {item.label}
</Text> </Text>
{selected && ( {multiSelect ? (
<Svg width={22} height={22} viewBox="0 0 24 24">
<Circle cx={12} cy={12} r={10} stroke={selected ? theme.accent : theme.borderStrong} strokeWidth={2} fill={selected ? theme.accent : 'transparent'} />
{selected && (
<Path d="M8 12l3 3 6-6" stroke="#FFFFFF" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
)}
</Svg>
) : selected && (
<Svg width={18} height={18} viewBox="0 0 24 24"> <Svg width={18} height={18} viewBox="0 0 24 24">
<Path d="M5 12l5 5 9-10" stroke={theme.accent} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" /> <Path d="M5 12l5 5 9-10" stroke={theme.accent} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
</Svg> </Svg>
@@ -3,14 +3,13 @@ import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import { PRIORITY_COLORS } from '@/constants'; import { PRIORITY_COLORS } from '@/constants';
import { Priority } from '@/types'; import { Priority } from '@/types';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import Svg, { Circle } from 'react-native-svg';
interface PrioritySelectorProps { interface PrioritySelectorProps {
value: Priority; value: Priority;
onChange: (value: Priority) => void; onChange: (value: Priority) => void;
} }
const priorities: Array<{ value: Priority; label: string }> = [ const priorities: { value: Priority; label: string }[] = [
{ value: 'none', label: 'None' }, { value: 'none', label: 'None' },
{ value: 'low', label: 'Low' }, { value: 'low', label: 'Low' },
{ value: 'medium', label: 'Medium' }, { value: 'medium', label: 'Medium' },
@@ -57,15 +56,15 @@ export function PrioritySelector({ value, onChange }: PrioritySelectorProps) {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
gap: 10, gap: 6,
}, },
label: { label: {
fontSize: 14, fontSize: 13,
fontWeight: '600', fontWeight: '600',
}, },
options: { options: {
flexDirection: 'row', flexDirection: 'row',
gap: 8, gap: 6,
}, },
option: { option: {
flex: 1, flex: 1,
@@ -73,16 +72,17 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
gap: 6, gap: 6,
paddingVertical: 12, paddingVertical: 10,
paddingHorizontal: 16, paddingHorizontal: 8,
borderRadius: 12, borderRadius: 10,
borderWidth: 1, borderWidth: 1,
minHeight: 40,
}, },
colorIndicator: { colorIndicator: {
width: 10, width: 10,
height: 10, height: 10,
borderRadius: 5, borderRadius: 5,
opacity: 0.5, opacity: 0.6,
}, },
colorIndicatorSelected: { colorIndicatorSelected: {
opacity: 1, opacity: 1,
+71 -35
View File
@@ -1,11 +1,12 @@
import React, { useEffect, useState } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { View, Text, StyleSheet, TextInput, TouchableOpacity, KeyboardAvoidingView, Platform } from 'react-native'; import { View, StyleSheet, TextInput, TouchableOpacity, Platform, Keyboard, Animated, Easing } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { database, collections } from '@/database'; import { database, collections } from '@/database';
import { useCategories } from '@/hooks/useDatabase'; import { useCategories } from '@/hooks/useDatabase';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import { OptionPickerModal } from '@/components/OptionPickerModal'; import { OptionPickerModal } from '@/components/OptionPickerModal';
import Svg, { Path } from 'react-native-svg'; import Svg, { Path } from 'react-native-svg';
import { subscribeToQuickAdd } from '@/utils/quickAddFocus';
interface QuickAddBarProps { interface QuickAddBarProps {
dueDate?: number; dueDate?: number;
@@ -17,17 +18,46 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const categories = useCategories(); const categories = useCategories();
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [categoryId, setCategoryId] = useState(defaultCategoryId || categories[0]?.id || ''); const [categoryId, setCategoryId] = useState(() => defaultCategoryId || categories[0]?.id || '');
const [categoryPickerVisible, setCategoryPickerVisible] = useState(false); const [categoryPickerVisible, setCategoryPickerVisible] = useState(false);
const inputRef = useRef<TextInput>(null);
const keyboardHeight = useRef(new Animated.Value(0)).current;
const categoryColor = categories.find((c) => c.id === categoryId)?.color ?? '#9E9E9E'; const categoryColor = categories.find((c) => c.id === categoryId)?.color ?? '#9E9E9E';
useEffect(() => { useEffect(() => {
if (!categoryId) { return subscribeToQuickAdd(() => {
setCategoryId(defaultCategoryId || categories[0]?.id || ''); setTimeout(() => {
} inputRef.current?.focus();
// eslint-disable-next-line react-hooks/exhaustive-deps }, 100);
}, [categories, defaultCategoryId]); });
}, []);
useEffect(() => {
const showListener = Keyboard.addListener('keyboardDidShow', (e) => {
const height = e.endCoordinates.height;
Animated.timing(keyboardHeight, {
toValue: height,
duration: 250,
easing: Easing.out(Easing.cubic),
useNativeDriver: false,
}).start();
});
const hideListener = Keyboard.addListener('keyboardDidHide', () => {
Animated.timing(keyboardHeight, {
toValue: 0,
duration: 250,
easing: Easing.out(Easing.cubic),
useNativeDriver: false,
}).start();
});
return () => {
showListener.remove();
hideListener.remove();
};
}, []);
const handleAdd = async () => { const handleAdd = async () => {
const trimmed = title.trim(); const trimmed = title.trim();
@@ -56,11 +86,14 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
setTitle(''); setTitle('');
}; };
const animatedBottom = keyboardHeight.interpolate({
inputRange: [0, 500],
outputRange: [insets.bottom + 0, insets.bottom + 0 + 500],
extrapolate: 'clamp',
});
return ( return (
<KeyboardAvoidingView <Animated.View style={[styles.wrapper, { bottom: animatedBottom }]}>
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={[styles.wrapper, { bottom: insets.bottom + 16 }]}
>
<View style={[styles.bar, { backgroundColor: theme.card, borderColor: theme.border }]}> <View style={[styles.bar, { backgroundColor: theme.card, borderColor: theme.border }]}>
<TouchableOpacity <TouchableOpacity
style={[styles.categoryButton, { borderColor: theme.borderStrong, backgroundColor: theme.cardAlt }]} style={[styles.categoryButton, { borderColor: theme.borderStrong, backgroundColor: theme.cardAlt }]}
@@ -74,6 +107,7 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
</Svg> </Svg>
</TouchableOpacity> </TouchableOpacity>
<TextInput <TextInput
ref={inputRef}
style={[styles.input, { color: theme.text }]} style={[styles.input, { color: theme.text }]}
placeholder={placeholder ?? 'Add a task'} placeholder={placeholder ?? 'Add a task'}
placeholderTextColor={theme.textMuted} placeholderTextColor={theme.textMuted}
@@ -83,8 +117,8 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
returnKeyType="done" returnKeyType="done"
/> />
<TouchableOpacity <TouchableOpacity
style={[styles.submit, { backgroundColor: theme.accent }, !title.trim() && styles.submitDisabled]} style={[styles.submit, { backgroundColor: theme.accent }, title.trim() ? {} : styles.submitDisabled]}
onPress={handleAdd} onPress={title.trim() ? handleAdd : undefined}
disabled={!title.trim()} disabled={!title.trim()}
activeOpacity={0.8} activeOpacity={0.8}
accessibilityLabel="Add task" accessibilityLabel="Add task"
@@ -106,57 +140,59 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
title="Select Category" title="Select Category"
options={categories.map((c) => ({ value: c.id, label: c.name, color: c.color }))} options={categories.map((c) => ({ value: c.id, label: c.name, color: c.color }))}
selectedValue={categoryId} selectedValue={categoryId}
onSelect={(value) => setCategoryId(value)} onSelect={(value) => setCategoryId(Array.isArray(value) ? value[0] : value)}
onClose={() => setCategoryPickerVisible(false)} onClose={() => setCategoryPickerVisible(false)}
/> />
</KeyboardAvoidingView> </Animated.View>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
wrapper: { wrapper: {
position: 'absolute', position: 'absolute',
left: 16, left: 8,
right: 16, right: 8,
bottom: 24, bottom: 12,
}, },
bar: { bar: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: 10, gap: 6,
paddingHorizontal: 10, paddingHorizontal: 8,
paddingVertical: 10, paddingVertical: 8,
borderRadius: 16, borderRadius: 14,
borderWidth: 1, borderWidth: 1,
shadowColor: '#000', shadowColor: '#000',
shadowOffset: { width: 0, height: 4 }, shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.12, shadowOpacity: 0.08,
shadowRadius: 12, shadowRadius: 6,
elevation: 8, elevation: 4,
}, },
categoryButton: { categoryButton: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: 6, gap: 3,
paddingVertical: 10, paddingVertical: 6,
paddingHorizontal: 12, paddingHorizontal: 6,
borderRadius: 12, borderRadius: 8,
borderWidth: 1, borderWidth: 1,
height: 40,
}, },
categoryButtonDot: { categoryButtonDot: {
width: 12, width: 8,
height: 12, height: 8,
borderRadius: 6, borderRadius: 4,
}, },
input: { input: {
flex: 1, flex: 1,
fontSize: 15, fontSize: 15,
paddingVertical: 10, paddingVertical: 10,
minHeight: 40,
}, },
submit: { submit: {
width: 40, width: 40,
height: 40, height: 40,
borderRadius: 12, borderRadius: 8,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
}, },
@@ -1,13 +1,13 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native'; import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import { Reminder, REMINDER_OPTIONS } from '@/types'; import { Reminder, REMINDER_OPTIONS, parseReminders, toRemindersString } from '@/types';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import { OptionPickerModal } from '@/components/OptionPickerModal'; import { OptionPickerModal } from '@/components/OptionPickerModal';
import Svg, { Path, Circle } from 'react-native-svg'; import Svg, { Path, Circle, Rect } from 'react-native-svg';
interface ReminderSelectorProps { interface ReminderSelectorProps {
value: Reminder; value: string;
onChange: (value: Reminder) => void; onChange: (value: string) => void;
hasDueDate: boolean; hasDueDate: boolean;
} }
@@ -15,17 +15,21 @@ export function ReminderSelector({ value, onChange, hasDueDate }: ReminderSelect
const { theme } = useSettings(); const { theme } = useSettings();
const [showPicker, setShowPicker] = useState(false); const [showPicker, setShowPicker] = useState(false);
const selected = REMINDER_OPTIONS.find((o) => o.value === value) ?? REMINDER_OPTIONS[0]; const selectedReminders = parseReminders(value);
const disabled = !hasDueDate; const disabled = !hasDueDate;
const reminderLabels = selectedReminders.length > 0
? selectedReminders.map(r => REMINDER_OPTIONS.find(o => o.value === r)?.label).filter(Boolean).join(', ')
: 'No reminder';
return ( return (
<View style={styles.container}> <View style={styles.container}>
<Text style={[styles.label, { color: theme.text }]}>Reminder</Text> <Text style={[styles.label, { color: theme.text }]}>Reminders</Text>
<TouchableOpacity <TouchableOpacity
style={[ style={[
styles.row, styles.row,
{ backgroundColor: theme.card, borderColor: theme.borderStrong }, { backgroundColor: theme.card, borderColor: theme.borderStrong },
value !== 'none' && { borderColor: theme.accent, backgroundColor: theme.accentSoft }, selectedReminders.length > 0 && { borderColor: theme.accent, backgroundColor: theme.accentSoft },
disabled && { opacity: 0.5 }, disabled && { opacity: 0.5 },
]} ]}
onPress={() => setShowPicker(true)} onPress={() => setShowPicker(true)}
@@ -35,22 +39,22 @@ export function ReminderSelector({ value, onChange, hasDueDate }: ReminderSelect
<Svg width={20} height={20} viewBox="0 0 24 24"> <Svg width={20} height={20} viewBox="0 0 24 24">
<Path <Path
d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9M10.3 21a1.94 1.94 0 0 0 3.4 0" d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9M10.3 21a1.94 1.94 0 0 0 3.4 0"
stroke={value !== 'none' ? theme.accent : theme.textMuted} stroke={selectedReminders.length > 0 ? theme.accent : theme.textMuted}
strokeWidth={1.5} strokeWidth={1.5}
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
fill="none" fill="none"
/> />
<Circle cx={18.5} cy={5.5} r={3.5} fill={value !== 'none' ? theme.accent : 'transparent'} stroke={value !== 'none' ? theme.accent : theme.textMuted} strokeWidth={1.5} /> <Circle cx={18.5} cy={5.5} r={3.5} fill={selectedReminders.length > 0 ? theme.accent : 'transparent'} stroke={selectedReminders.length > 0 ? theme.accent : theme.textMuted} strokeWidth={1.5} />
</Svg> </Svg>
<Text <Text
style={[ style={[
styles.valueText, styles.valueText,
{ color: value !== 'none' ? theme.text : theme.textMuted }, { color: selectedReminders.length > 0 ? theme.text : theme.textMuted },
value !== 'none' && styles.valueTextFilled, selectedReminders.length > 0 && styles.valueTextFilled,
]} ]}
> >
{value !== 'none' ? selected.label : 'No reminder'} {reminderLabels}
</Text> </Text>
<View style={styles.chevron}> <View style={styles.chevron}>
<Svg width={16} height={16} viewBox="0 0 24 24"> <Svg width={16} height={16} viewBox="0 0 24 24">
@@ -59,16 +63,17 @@ export function ReminderSelector({ value, onChange, hasDueDate }: ReminderSelect
</View> </View>
</TouchableOpacity> </TouchableOpacity>
{!hasDueDate && ( {!hasDueDate && (
<Text style={[styles.hint, { color: theme.textMuted }]}>Set a due date to add a reminder.</Text> <Text style={[styles.hint, { color: theme.textMuted }]}>Set a due date to add reminders.</Text>
)} )}
<OptionPickerModal <OptionPickerModal
visible={showPicker} visible={showPicker}
title="Reminder" title="Reminders"
options={REMINDER_OPTIONS.map((o) => ({ value: o.value, label: o.label }))} options={REMINDER_OPTIONS.filter((o) => o.value !== 'none').map((o) => ({ value: o.value, label: o.label }))}
selectedValue={value} selectedValue={selectedReminders}
onSelect={(v) => onChange(v as Reminder)} onSelect={(v) => onChange(toRemindersString(v as Reminder[]))}
onClose={() => setShowPicker(false)} onClose={() => setShowPicker(false)}
multiSelect
/> />
</View> </View>
); );
@@ -76,10 +81,10 @@ export function ReminderSelector({ value, onChange, hasDueDate }: ReminderSelect
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
gap: 8, gap: 6,
}, },
label: { label: {
fontSize: 14, fontSize: 13,
fontWeight: '600', fontWeight: '600',
}, },
row: { row: {
@@ -93,7 +98,7 @@ const styles = StyleSheet.create({
}, },
valueText: { valueText: {
flex: 1, flex: 1,
fontSize: 15, fontSize: 14,
}, },
valueTextFilled: { valueTextFilled: {
fontWeight: '500', fontWeight: '500',
@@ -102,7 +107,7 @@ const styles = StyleSheet.create({
transform: [{ rotate: '-90deg' }], transform: [{ rotate: '-90deg' }],
}, },
hint: { hint: {
fontSize: 12, fontSize: 11,
marginLeft: 4, marginLeft: 4,
}, },
}); });
@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Modal, TextInput, Pressable } from 'react-native'; import { View, Text, StyleSheet, TouchableOpacity, Modal, TextInput, Pressable, KeyboardAvoidingView } from 'react-native';
import { Repeat, REPEAT_OPTIONS, WEEKDAY_LABELS, repeatDaysFromString } from '@/types'; import { Repeat, REPEAT_OPTIONS, WEEKDAY_LABELS, repeatDaysFromString } from '@/types';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import { useRepeatProfiles } from '@/hooks/useDatabase'; import { useRepeatProfiles } from '@/hooks/useDatabase';
@@ -246,43 +246,48 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
animationType="fade" animationType="fade"
onRequestClose={() => setSaveModalVisible(false)} onRequestClose={() => setSaveModalVisible(false)}
> >
<Pressable style={styles.modalBackdrop} onPress={() => setSaveModalVisible(false)}> <KeyboardAvoidingView
<Pressable style={[styles.modalCard, { backgroundColor: theme.card, borderColor: theme.border }]}> style={styles.modalBackdrop}
<Text style={[styles.modalTitle, { color: theme.text }]}>Save repeat profile</Text> behavior="padding"
<Text style={[styles.modalHint, { color: theme.textFaint }]}> >
{`${REPEAT_OPTIONS.find((o) => o.value === value)?.label.replace('No Repeat', 'None')}, every ${interval} ${unitLabel(value)}${interval > 1 ? 's' : ''}`} <Pressable style={styles.modalBackdrop} onPress={() => setSaveModalVisible(false)}>
{isDaysBased(value) && days.length > 0 ? ` · ${days.map((d) => WEEKDAY_LABELS[d]).join(' ')}` : ''} <Pressable style={[styles.modalCard, { backgroundColor: theme.card, borderColor: theme.border }]}>
</Text> <Text style={[styles.modalTitle, { color: theme.text }]}>Save repeat profile</Text>
<TextInput <Text style={[styles.modalHint, { color: theme.textFaint }]}>
style={[styles.modalInput, { backgroundColor: theme.cardAlt, borderColor: theme.borderStrong, color: theme.text }]} {`${REPEAT_OPTIONS.find((o) => o.value === value)?.label.replace('No Repeat', 'None')}, every ${interval} ${unitLabel(value)}${interval > 1 ? 's' : ''}`}
value={profileName} {isDaysBased(value) && days.length > 0 ? ` · ${days.map((d) => WEEKDAY_LABELS[d]).join(' ')}` : ''}
onChangeText={setProfileName} </Text>
placeholder="Profile name (e.g. Every weekday)" <TextInput
placeholderTextColor={theme.textFaint} style={[styles.modalInput, { backgroundColor: theme.cardAlt, borderColor: theme.borderStrong, color: theme.text }]}
autoFocus value={profileName}
returnKeyType="done" onChangeText={setProfileName}
onSubmitEditing={handleSaveProfile} placeholder="Profile name (e.g. Every weekday)"
maxLength={50} placeholderTextColor={theme.textFaint}
/> autoFocus
<View style={styles.modalButtons}> returnKeyType="done"
<TouchableOpacity onSubmitEditing={handleSaveProfile}
style={[styles.modalButton, { borderColor: theme.borderStrong }]} maxLength={50}
onPress={() => setSaveModalVisible(false)} />
activeOpacity={0.8} <View style={styles.modalButtons}>
> <TouchableOpacity
<Text style={[styles.modalButtonText, { color: theme.textSecondary }]}>Cancel</Text> style={[styles.modalButton, { borderColor: theme.borderStrong }]}
</TouchableOpacity> onPress={() => setSaveModalVisible(false)}
<TouchableOpacity activeOpacity={0.8}
style={[styles.modalButton, styles.modalButtonPrimary, { backgroundColor: theme.accent }]} >
onPress={handleSaveProfile} <Text style={[styles.modalButtonText, { color: theme.textSecondary }]}>Cancel</Text>
disabled={!profileName.trim()} </TouchableOpacity>
activeOpacity={0.8} <TouchableOpacity
> style={[styles.modalButton, styles.modalButtonPrimary, { backgroundColor: theme.accent }]}
<Text style={[styles.modalButtonText, { color: '#FFFFFF', fontWeight: '600' }]}>Save</Text> onPress={handleSaveProfile}
</TouchableOpacity> disabled={!profileName.trim()}
</View> activeOpacity={0.8}
>
<Text style={[styles.modalButtonText, { color: '#FFFFFF', fontWeight: '600' }]}>Save</Text>
</TouchableOpacity>
</View>
</Pressable>
</Pressable> </Pressable>
</Pressable> </KeyboardAvoidingView>
</Modal> </Modal>
</View> </View>
); );
@@ -290,115 +295,115 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
gap: 10, gap: 8,
}, },
label: { label: {
fontSize: 14, fontSize: 13,
fontWeight: '600', fontWeight: '600',
}, },
chipRow: { chipRow: {
flexDirection: 'row', flexDirection: 'row',
flexWrap: 'wrap', flexWrap: 'wrap',
gap: 8, gap: 6,
}, },
chip: { chip: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: 6, gap: 5,
paddingVertical: 10, paddingVertical: 8,
paddingHorizontal: 12, paddingHorizontal: 10,
borderRadius: 20, borderRadius: 18,
borderWidth: 1, borderWidth: 1,
}, },
chipText: { chipText: {
fontSize: 13, fontSize: 12,
fontWeight: '500', fontWeight: '500',
}, },
settings: { settings: {
gap: 10, gap: 8,
}, },
intervalRow: { intervalRow: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: 10, gap: 8,
paddingHorizontal: 14, paddingHorizontal: 12,
paddingVertical: 10, paddingVertical: 8,
borderRadius: 12, borderRadius: 10,
borderWidth: 1, borderWidth: 1,
}, },
intervalLabel: { intervalLabel: {
fontSize: 14, fontSize: 13,
fontWeight: '500', fontWeight: '500',
}, },
intervalValue: { intervalValue: {
fontSize: 16, fontSize: 15,
fontWeight: '700', fontWeight: '700',
minWidth: 24, minWidth: 22,
textAlign: 'center', textAlign: 'center',
}, },
stepButton: { stepButton: {
width: 32, width: 28,
height: 32, height: 28,
borderRadius: 8, borderRadius: 7,
borderWidth: 1, borderWidth: 1,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
}, },
stepButtonText: { stepButtonText: {
fontSize: 18, fontSize: 16,
fontWeight: '600', fontWeight: '600',
lineHeight: 20, lineHeight: 18,
}, },
dayRow: { dayRow: {
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-between', justifyContent: 'space-between',
gap: 6, gap: 5,
}, },
dayChip: { dayChip: {
flex: 1, flex: 1,
height: 40, height: 36,
borderRadius: 10, borderRadius: 9,
borderWidth: 1, borderWidth: 1,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
}, },
dayChipLast: {}, dayChipLast: {},
dayChipText: { dayChipText: {
fontSize: 13, fontSize: 12,
fontWeight: '600', fontWeight: '600',
}, },
profileRow: { profileRow: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'flex-start', alignItems: 'flex-start',
gap: 8, gap: 6,
}, },
profileLabel: { profileLabel: {
fontSize: 12, fontSize: 11,
fontWeight: '600', fontWeight: '600',
paddingTop: 8, paddingTop: 6,
}, },
profileChips: { profileChips: {
flex: 1, flex: 1,
flexDirection: 'row', flexDirection: 'row',
flexWrap: 'wrap', flexWrap: 'wrap',
gap: 8, gap: 6,
}, },
profileChip: { profileChip: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: 6, gap: 5,
paddingVertical: 6, paddingVertical: 5,
paddingHorizontal: 10, paddingHorizontal: 8,
borderRadius: 12, borderRadius: 10,
borderWidth: 1, borderWidth: 1,
}, },
profileChipText: { profileChipText: {
fontSize: 13, fontSize: 12,
fontWeight: '500', fontWeight: '500',
}, },
profileChipX: { profileChipX: {
fontSize: 14, fontSize: 13,
lineHeight: 16, lineHeight: 15,
fontWeight: '700', fontWeight: '700',
paddingHorizontal: 2, paddingHorizontal: 2,
}, },
@@ -406,14 +411,14 @@ const styles = StyleSheet.create({
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
gap: 6, gap: 5,
paddingVertical: 10, paddingVertical: 8,
borderRadius: 12, borderRadius: 10,
borderWidth: 1, borderWidth: 1,
borderStyle: 'dashed', borderStyle: 'dashed',
}, },
saveProfileText: { saveProfileText: {
fontSize: 13, fontSize: 12,
fontWeight: '600', fontWeight: '600',
}, },
modalBackdrop: { modalBackdrop: {
@@ -421,45 +426,45 @@ const styles = StyleSheet.create({
backgroundColor: 'rgba(0,0,0,0.5)', backgroundColor: 'rgba(0,0,0,0.5)',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
padding: 24, padding: 20,
}, },
modalCard: { modalCard: {
width: '100%', width: '100%',
maxWidth: 400, maxWidth: 360,
borderRadius: 16, borderRadius: 14,
borderWidth: 1, borderWidth: 1,
padding: 20, padding: 16,
gap: 12, gap: 10,
}, },
modalTitle: { modalTitle: {
fontSize: 16, fontSize: 15,
fontWeight: '700', fontWeight: '700',
}, },
modalHint: { modalHint: {
fontSize: 13, fontSize: 12,
}, },
modalInput: { modalInput: {
borderRadius: 10, borderRadius: 9,
borderWidth: 1, borderWidth: 1,
paddingHorizontal: 12, paddingHorizontal: 10,
paddingVertical: 10, paddingVertical: 8,
fontSize: 14, fontSize: 13,
}, },
modalButtons: { modalButtons: {
flexDirection: 'row', flexDirection: 'row',
gap: 10, gap: 8,
}, },
modalButton: { modalButton: {
flex: 1, flex: 1,
alignItems: 'center', alignItems: 'center',
paddingVertical: 11, paddingVertical: 10,
borderRadius: 10, borderRadius: 9,
borderWidth: 1, borderWidth: 1,
}, },
modalButtonPrimary: { modalButtonPrimary: {
borderWidth: 0, borderWidth: 0,
}, },
modalButtonText: { modalButtonText: {
fontSize: 14, fontSize: 13,
}, },
}); });
@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react'; import React, { useState } from 'react';
import { Modal, View, Text, StyleSheet, TextInput, TouchableOpacity, KeyboardAvoidingView, Platform } from 'react-native'; import { Modal, View, Text, StyleSheet, TextInput, TouchableOpacity, KeyboardAvoidingView } from 'react-native';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import { DEFAULT_API_BASE_URL } from '@/services/auth'; import { DEFAULT_API_BASE_URL } from '@/services/auth';
import Svg, { Path } from 'react-native-svg'; import Svg, { Path } from 'react-native-svg';
@@ -12,12 +12,14 @@ interface ServerUrlModalProps {
export function ServerUrlModal({ visible, onClose }: ServerUrlModalProps) { export function ServerUrlModal({ visible, onClose }: ServerUrlModalProps) {
const { theme, apiUrl, setApiUrl } = useSettings(); const { theme, apiUrl, setApiUrl } = useSettings();
const [value, setValue] = useState(apiUrl); const [value, setValue] = useState(apiUrl);
const [prevVisible, setPrevVisible] = useState(visible);
useEffect(() => { if (prevVisible !== visible) {
setPrevVisible(visible);
if (visible) { if (visible) {
setValue(apiUrl); setValue(apiUrl);
} }
}, [visible, apiUrl]); }
const handleSave = () => { const handleSave = () => {
const trimmed = value.trim().replace(/\/+$/, ''); const trimmed = value.trim().replace(/\/+$/, '');
@@ -33,7 +35,7 @@ export function ServerUrlModal({ visible, onClose }: ServerUrlModalProps) {
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}> <Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<KeyboardAvoidingView <KeyboardAvoidingView
style={styles.overlay} style={styles.overlay}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'} behavior="padding"
> >
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}> <View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
<View style={styles.header}> <View style={styles.header}>
+77 -126
View File
@@ -1,145 +1,96 @@
import React from 'react'; import React, { useState } from 'react';
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native'; import { View, StyleSheet } from 'react-native';
import { SubtaskData } from '@/types'; import { SubtaskData } from '@/types';
import { PRIORITY_COLORS } from '@/constants'; import { TaskItem } from './TaskItem';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import Svg, { Path } from 'react-native-svg';
interface SubtaskItemProps { interface SubtaskItemProps {
subtask: SubtaskData; subtask: SubtaskData;
onToggle: () => void; onToggle: () => void | Promise<void>;
onDelete?: () => void;
onPress?: () => void;
onLongPress?: () => void;
onMenuOpen?: () => void;
selected?: boolean;
selectionMode?: boolean;
draggable?: boolean;
onDragStart?: () => void;
onDragUpdate?: (absoluteY: number) => void;
onDragEnd?: (absoluteY: number) => void;
depth?: number;
} }
export function SubtaskItem({ subtask, onToggle }: SubtaskItemProps) { export const SubtaskItem = React.memo(function SubtaskItem({
subtask,
onToggle,
onDelete,
onPress,
onLongPress,
onMenuOpen,
selected,
selectionMode,
draggable,
onDragStart,
onDragUpdate,
onDragEnd,
depth = 1
}: SubtaskItemProps) {
const { theme } = useSettings(); const { theme } = useSettings();
const [expanded, setExpanded] = useState(true);
const hasChildren = subtask.subtasks && subtask.subtasks.length > 0;
const handleExpand = () => setExpanded(!expanded);
return ( return (
<View style={[styles.container, { backgroundColor: theme.cardAlt }]}> <View>
<TouchableOpacity <TaskItem
style={styles.checkCircle} task={subtask}
onPress={onToggle} indented
activeOpacity={0.7} depth={depth}
accessibilityLabel={subtask.completed ? 'Mark incomplete' : 'Mark complete'} onToggle={onToggle}
> onDelete={onDelete}
<Svg width={20} height={20} viewBox="0 0 24 24"> onPress={onPress ?? (() => {})}
{subtask.completed ? ( onLongPress={onLongPress}
<> onMenuOpen={onMenuOpen}
<Path selected={selected}
d="M20 6L9 17l-5-5" selectionMode={selectionMode}
stroke={theme.accent} completedSection={subtask.completed}
strokeWidth={2.5} draggable={draggable}
strokeLinecap="round" onDragStart={onDragStart}
strokeLinejoin="round" onDragUpdate={onDragUpdate}
fill="none" onDragEnd={onDragEnd}
/>
{hasChildren && expanded && (
<View style={[styles.nestedSubtasks, { marginLeft: depth * 12 }]}>
{subtask.subtasks
.slice()
.sort((a, b) => a.order - b.order)
.map((child) => (
<SubtaskItem
key={child.id}
subtask={child}
onToggle={() => {}}
onDelete={onDelete}
onPress={onPress}
onLongPress={onLongPress}
onMenuOpen={onMenuOpen}
selected={selected}
selectionMode={selectionMode}
draggable={draggable}
onDragStart={onDragStart}
onDragUpdate={onDragUpdate}
onDragEnd={onDragEnd}
depth={depth + 1}
/> />
</> ))}
) : (
<Path
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2"
stroke={theme.borderStrong}
strokeWidth={2}
fill="none"
/>
)}
</Svg>
</TouchableOpacity>
<Text
style={[
styles.title,
{ color: theme.text },
subtask.completed && styles.titleCompleted,
]}
numberOfLines={1}
>
{subtask.title}
</Text>
{subtask.priority && subtask.priority !== 'none' && (
<View style={[styles.priorityBadge, { backgroundColor: PRIORITY_COLORS[subtask.priority] }]}>
<Text style={styles.priorityText}>{subtask.priority.charAt(0).toUpperCase()}</Text>
</View> </View>
)} )}
{subtask.dueDate && subtask.dueDate > 0 && (
<Text style={[styles.dueText, { color: theme.textFaint }]} numberOfLines={1}>
{formatDueDate(subtask.dueDate, subtask.dueTime, subtask.endTime || '')}
</Text>
)}
</View> </View>
); );
} });
function formatDueDate(dueDate: number, dueTime: string, endTime?: string): string {
const date = new Date(dueDate);
const today = new Date();
today.setHours(0, 0, 0, 0);
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
let dateStr = '';
if (date.toDateString() === today.toDateString()) {
dateStr = 'Today';
} else if (date.toDateString() === tomorrow.toDateString()) {
dateStr = 'Tomorrow';
} else {
dateStr = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
if (dueTime && endTime) {
return `${dateStr} · ${formatTime12(dueTime)} ${formatTime12(endTime)}`;
}
return dueTime ? `${dateStr} at ${formatTime12(dueTime)}` : dateStr;
}
function formatTime12(time: string): string {
const [hours, minutes] = time.split(':').map(Number);
if (Number.isNaN(hours) || Number.isNaN(minutes)) return time;
const period = hours >= 12 ? 'PM' : 'AM';
const h = hours % 12 === 0 ? 12 : hours % 12;
return `${h}:${String(minutes).padStart(2, '0')} ${period}`;
}
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { nestedSubtasks: {
flexDirection: 'row', marginTop: 4,
alignItems: 'center',
gap: 8,
paddingHorizontal: 12,
paddingVertical: 10,
borderRadius: 10,
marginLeft: 32,
marginBottom: 4,
},
checkCircle: {
width: 20,
height: 20,
borderRadius: 10,
alignItems: 'center',
justifyContent: 'center',
},
title: {
fontSize: 14,
flex: 1,
},
titleCompleted: {
textDecorationLine: 'line-through',
color: '#9E9E9E',
},
priorityBadge: {
paddingHorizontal: 5,
paddingVertical: 1,
borderRadius: 6,
minWidth: 18,
alignItems: 'center',
},
priorityText: {
fontSize: 8,
fontWeight: '700',
color: '#FFFFFF',
},
dueText: {
fontSize: 12,
maxWidth: '30%',
}, },
}); });
@@ -159,35 +159,35 @@ function SubtaskItem({ index, value, onChange, onRemove }: SubtaskItemProps) {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
gap: 8, gap: 6,
}, },
header: { header: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
paddingVertical: 4, paddingVertical: 2,
}, },
headerLeft: { headerLeft: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: 8, gap: 6,
}, },
label: { label: {
fontSize: 14, fontSize: 13,
fontWeight: '600', fontWeight: '600',
}, },
badge: { badge: {
paddingHorizontal: 8, paddingHorizontal: 6,
paddingVertical: 2, paddingVertical: 1,
borderRadius: 10, borderRadius: 8,
}, },
badgeText: { badgeText: {
fontSize: 12, fontSize: 11,
fontWeight: '700', fontWeight: '700',
}, },
chevron: { chevron: {
width: 16, width: 14,
height: 16, height: 14,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
}, },
@@ -195,43 +195,43 @@ const styles = StyleSheet.create({
overflow: 'hidden', overflow: 'hidden',
}, },
list: { list: {
gap: 8, gap: 6,
paddingBottom: 8, paddingBottom: 6,
}, },
item: { item: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: 10, gap: 8,
paddingHorizontal: 12, paddingHorizontal: 10,
paddingVertical: 10, paddingVertical: 8,
borderRadius: 12, borderRadius: 10,
borderWidth: 1, borderWidth: 1,
}, },
checkbox: { checkbox: {
width: 22, width: 20,
height: 22, height: 20,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
}, },
input: { input: {
flex: 1, flex: 1,
fontSize: 15, fontSize: 14,
paddingVertical: 4, paddingVertical: 2,
}, },
removeButton: { removeButton: {
padding: 4, padding: 3,
}, },
addButton: { addButton: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
gap: 6, gap: 5,
paddingVertical: 10, paddingVertical: 8,
borderRadius: 12, borderRadius: 10,
borderWidth: 1, borderWidth: 1,
}, },
addButtonText: { addButtonText: {
fontSize: 13, fontSize: 12,
fontWeight: '500', fontWeight: '500',
}, },
}); });
+20 -11
View File
@@ -7,13 +7,11 @@ import {
TextInput, TextInput,
TouchableOpacity, TouchableOpacity,
KeyboardAvoidingView, KeyboardAvoidingView,
Platform,
ActivityIndicator, ActivityIndicator,
} from 'react-native'; } from 'react-native';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import { AuthUser, getAuthToken, getAuthUser, login, register, signOutAuth } from '@/services/auth'; import { AuthUser, getAuthToken, getAuthUser, login, register, signOutAuth } from '@/services/auth';
import { runSync, getLastSyncTime, SyncResult } from '@/database/sync'; import { runSyncGuarded, startAutoSync, stopAutoSync, getLastSyncTime, SyncResult } from '@/database/sync';
import Svg, { Path } from 'react-native-svg';
interface SyncModalProps { interface SyncModalProps {
visible: boolean; visible: boolean;
@@ -35,14 +33,21 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
const [status, setStatus] = useState<Status>('idle'); const [status, setStatus] = useState<Status>('idle');
const [lastSync, setLastSync] = useState<number | null>(null); const [lastSync, setLastSync] = useState<number | null>(null);
const [result, setResult] = useState<SyncResult | null>(null); const [result, setResult] = useState<SyncResult | null>(null);
const [prevVisible, setPrevVisible] = useState(visible);
if (prevVisible !== visible) {
setPrevVisible(visible);
if (visible) {
setChecking(true);
setError(null);
setStatus('idle');
setResult(null);
}
}
useEffect(() => { useEffect(() => {
if (!visible) return; if (!visible) return;
let mounted = true; let mounted = true;
setChecking(true);
setError(null);
setStatus('idle');
setResult(null);
(async () => { (async () => {
const token = await getAuthToken(); const token = await getAuthToken();
@@ -77,6 +82,7 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
? await register(username.trim(), password) ? await register(username.trim(), password)
: await login(username.trim(), password); : await login(username.trim(), password);
setUser(authedUser); setUser(authedUser);
startAutoSync();
} catch (err: any) { } catch (err: any) {
setError(err?.message ?? 'Sign in failed'); setError(err?.message ?? 'Sign in failed');
} finally { } finally {
@@ -89,13 +95,15 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
setError(null); setError(null);
setStatus('syncing'); setStatus('syncing');
try { try {
const syncResult = await runSync(); const syncResult = await runSyncGuarded();
setResult(syncResult); setResult(syncResult);
setStatus('success'); setStatus('success');
setLastSync(Date.now()); setLastSync(Date.now());
} catch (err: any) { } catch (err: any) {
setStatus('error'); setStatus('idle');
if (err?.message === 'NOT_SIGNED_IN') { if (err?.message === 'SYNC_IN_FLIGHT') {
setError('Sync already in progress');
} else if (err?.message === 'NOT_SIGNED_IN') {
setUser(null); setUser(null);
setError('Not signed in. Sign in to sync.'); setError('Not signed in. Sign in to sync.');
} else { } else {
@@ -106,6 +114,7 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
const handleSignOut = async () => { const handleSignOut = async () => {
await signOutAuth(); await signOutAuth();
stopAutoSync();
setUser(null); setUser(null);
setError(null); setError(null);
setStatus('idle'); setStatus('idle');
@@ -116,7 +125,7 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}> <Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<KeyboardAvoidingView <KeyboardAvoidingView
style={styles.overlay} style={styles.overlay}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'} behavior="padding"
> >
<TouchableOpacity style={styles.backdrop} activeOpacity={1} onPress={onClose} /> <TouchableOpacity style={styles.backdrop} activeOpacity={1} onPress={onClose} />
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}> <View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native'; import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import Svg, { Path, Circle } from 'react-native-svg'; import Svg, { Path, Circle } from 'react-native-svg';
import { runSync, getLastSyncTime } from '@/database/sync'; import { runSyncGuarded, getLastSyncTime } from '@/database/sync';
interface SyncStatusProps { interface SyncStatusProps {
compact?: boolean; compact?: boolean;
@@ -13,21 +13,23 @@ export default function SyncStatus({ compact = false }: SyncStatusProps) {
const [status, setStatus] = useState<'idle' | 'syncing' | 'success' | 'error'>('idle'); const [status, setStatus] = useState<'idle' | 'syncing' | 'success' | 'error'>('idle');
const [lastSync, setLastSync] = useState<number | null>(null); const [lastSync, setLastSync] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [now] = useState(() => Date.now());
useEffect(() => {
loadLastSync();
}, []);
const loadLastSync = async () => { const loadLastSync = async () => {
const time = await getLastSyncTime(); const time = await getLastSyncTime();
setLastSync(time); setLastSync(time);
}; };
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
loadLastSync();
}, []);
const handleSync = async () => { const handleSync = async () => {
setStatus('syncing'); setStatus('syncing');
setError(null); setError(null);
try { try {
await runSync(); await runSyncGuarded();
setStatus('success'); setStatus('success');
await loadLastSync(); await loadLastSync();
setTimeout(() => setStatus('idle'), 3000); setTimeout(() => setStatus('idle'), 3000);
@@ -40,7 +42,7 @@ export default function SyncStatus({ compact = false }: SyncStatusProps) {
const formatTime = (timestamp: number | null): string => { const formatTime = (timestamp: number | null): string => {
if (!timestamp) return 'Never'; if (!timestamp) return 'Never';
const diff = Date.now() - timestamp; const diff = now - timestamp;
if (diff < 60000) return 'Just now'; if (diff < 60000) return 'Just now';
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`; if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`; if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
+15 -1
View File
@@ -3,7 +3,7 @@ import { ColorValue } from 'react-native';
import Svg, { Path, Circle } from 'react-native-svg'; import Svg, { Path, Circle } from 'react-native-svg';
interface TabBarIconProps { interface TabBarIconProps {
name: 'checklist' | 'calendar' | 'gear'; name: 'checklist' | 'calendar' | 'gear' | 'stats';
focused: boolean; focused: boolean;
color: ColorValue; color: ColorValue;
size?: number; size?: number;
@@ -51,6 +51,20 @@ export function TabBarIcon({ name, focused, color, size = 24 }: TabBarIconProps)
fill="none" fill="none"
/> />
)} )}
{name === 'stats' && (
<>
<Path
d="M4 20V10M10 20V4M16 20v-7M21 20H3"
stroke={color}
strokeWidth={focused ? 2.5 : 2}
strokeLinecap="round"
strokeLinejoin="round"
/>
{focused && (
<Circle cx={4} cy={10} r={2} fill={color} />
)}
</>
)}
</Svg> </Svg>
); );
} }
@@ -9,16 +9,17 @@ interface TaskDeleteModalProps {
taskId: string | null; taskId: string | null;
taskTitle?: string; taskTitle?: string;
isRepeating?: boolean; isRepeating?: boolean;
subtask?: boolean;
onClose: () => void; onClose: () => void;
onDelete: (scope: TaskDeleteScope) => void; onDelete: (scope: TaskDeleteScope) => void;
} }
export function TaskDeleteModal({ visible, taskId, taskTitle, isRepeating, onClose, onDelete }: TaskDeleteModalProps) { export function TaskDeleteModal({ visible, taskId, taskTitle, isRepeating, subtask, onClose, onDelete }: TaskDeleteModalProps) {
const { theme } = useSettings(); const { theme } = useSettings();
const [counts, setCounts] = useState({ future: 1, all: 1 }); const [counts, setCounts] = useState({ future: 1, all: 1 });
useEffect(() => { useEffect(() => {
if (!visible || !taskId) return; if (!visible || !taskId || subtask) return;
let mounted = true; let mounted = true;
getSeriesOccurrenceCounts(taskId) getSeriesOccurrenceCounts(taskId)
.then((c) => { .then((c) => {
@@ -26,11 +27,11 @@ export function TaskDeleteModal({ visible, taskId, taskTitle, isRepeating, onClo
}) })
.catch(() => {}); .catch(() => {});
return () => { mounted = false; }; return () => { mounted = false; };
}, [visible, taskId]); }, [visible, taskId, subtask]);
const options: Array<{ scope: TaskDeleteScope; label: string; hint?: string }> = [ const options: { scope: TaskDeleteScope; label: string; hint?: string }[] = subtask
{ scope: 'this', label: 'This task only' }, ? [{ scope: 'this', label: 'This subtask only' }]
]; : [{ scope: 'this', label: 'This task only' }];
if (isRepeating) { if (isRepeating) {
options.push({ scope: 'future', label: 'This and future tasks', hint: counts.future > 1 ? `${counts.future} occurrences` : undefined }); options.push({ scope: 'future', label: 'This and future tasks', hint: counts.future > 1 ? `${counts.future} occurrences` : undefined });
@@ -42,7 +43,7 @@ export function TaskDeleteModal({ visible, taskId, taskTitle, isRepeating, onClo
<View style={styles.overlay}> <View style={styles.overlay}>
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}> <View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
<View style={styles.header}> <View style={styles.header}>
<Text style={[styles.title, { color: theme.text }]}>Delete Task</Text> <Text style={[styles.title, { color: theme.text }]}>{subtask ? 'Delete Subtask' : 'Delete Task'}</Text>
<TouchableOpacity onPress={onClose} style={styles.closeButton} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}> <TouchableOpacity onPress={onClose} style={styles.closeButton} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
<Svg width={18} height={18} viewBox="0 0 24 24"> <Svg width={18} height={18} viewBox="0 0 24 24">
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textMuted} strokeWidth={2} strokeLinecap="round" /> <Path d="M18 6L6 18M6 6l12 12" stroke={theme.textMuted} strokeWidth={2} strokeLinecap="round" />
+122 -28
View File
@@ -1,13 +1,27 @@
import React from 'react'; import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Animated } from 'react-native'; import { View, Text, StyleSheet, TouchableOpacity, Animated, Platform, Dimensions } from 'react-native';
import { Swipeable, Gesture, GestureDetector } from 'react-native-gesture-handler'; import { Swipeable, Gesture, GestureDetector, PanGestureHandler } from 'react-native-gesture-handler';
import { TaskData } from '@/types'; import { TaskData, SubtaskData, Priority, Repeat, Reminder } from '@/types';
import { PRIORITY_COLORS } from '@/constants'; import { PRIORITY_COLORS } from '@/constants';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import Svg, { Path, Circle } from 'react-native-svg'; import Svg, { Path, Circle } from 'react-native-svg';
export type TaskLike = {
id: string;
title: string;
priority: Priority;
assigneeId: string | null;
repeat: Repeat;
allDay: boolean;
completed: boolean;
dueDate: number;
dueTime: string;
endTime: string;
reminder: Reminder;
};
interface TaskItemProps { interface TaskItemProps {
task: TaskData; task: TaskData | SubtaskData | TaskLike;
onToggle: () => void | Promise<void>; onToggle: () => void | Promise<void>;
onDelete?: () => void; onDelete?: () => void;
onPress: () => void; onPress: () => void;
@@ -20,17 +34,22 @@ interface TaskItemProps {
hovered?: boolean; hovered?: boolean;
onDragStart?: () => void; onDragStart?: () => void;
onDragUpdate?: (absoluteY: number) => void; onDragUpdate?: (absoluteY: number) => void;
onDragEnd?: (absoluteY: number) => void; onDragEnd?: (absoluteY: number) => void;
assigneeUsername?: string; onReorderStart?: () => void;
expanded?: boolean; onReorderUpdate?: (translationY: number) => void;
onReorderEnd?: (translationY: number) => void;
expanded?: boolean;
indented?: boolean;
depth?: number;
} }
export function TaskItem({ task, onToggle, onDelete, onPress, onLongPress, onMenuOpen, selected, selectionMode, completedSection, draggable, hovered, onDragStart, onDragUpdate, onDragEnd, expanded }: 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 }: TaskItemProps) {
const { theme } = useSettings(); const { theme } = useSettings();
const [opacityAnim] = React.useState(new Animated.Value(task.completed ? 0.5 : 1)); const [opacityAnim] = React.useState(new Animated.Value(task.completed ? 0.5 : 1));
const [dragTranslateX] = React.useState(new Animated.Value(0)); const [dragTranslateX] = React.useState(new Animated.Value(0));
const [dragTranslateY] = React.useState(new Animated.Value(0)); const [dragTranslateY] = React.useState(new Animated.Value(0));
const [dragging, setDragging] = React.useState(false); const [dragging, setDragging] = React.useState(false);
const [dragStartY, setDragStartY] = React.useState(0);
const swipeableRef = React.useRef<Swipeable>(null); const swipeableRef = React.useRef<Swipeable>(null);
const [rotateAnim] = React.useState(new Animated.Value(0)); const [rotateAnim] = React.useState(new Animated.Value(0));
@@ -44,13 +63,40 @@ export function TaskItem({ task, onToggle, onDelete, onPress, onLongPress, onMen
const rotate = rotateAnim.interpolate({ inputRange: [0, 1], outputRange: ['0deg', '180deg'] }); const rotate = rotateAnim.interpolate({ inputRange: [0, 1], outputRange: ['0deg', '180deg'] });
const reorderGesture = React.useMemo(
() =>
Gesture.Pan()
.activateAfterLongPress(0)
.minDistance(5)
.runOnJS(true)
.onStart((e) => {
setDragging(true);
setDragStartY(e.absoluteY);
onReorderStart?.();
})
.onUpdate((e) => {
dragTranslateX.setValue(e.translationX);
dragTranslateY.setValue(e.translationY);
onReorderUpdate?.(e.translationY);
})
.onEnd((e) => {
onReorderEnd?.(e.translationY);
})
.onFinalize(() => {
setDragging(false);
dragTranslateX.setValue(0);
dragTranslateY.setValue(0);
}),
[onReorderStart, onReorderUpdate, onReorderEnd, dragTranslateX, dragTranslateY]
);
const dragGesture = React.useMemo( const dragGesture = React.useMemo(
() => () =>
Gesture.Pan() Gesture.Pan()
.activateAfterLongPress(400) .activateAfterLongPress(400)
.minDistance(2) .minDistance(2)
.runOnJS(true) .runOnJS(true)
. onStart(() => { .onStart(() => {
setDragging(true); setDragging(true);
onDragStart?.(); onDragStart?.();
}) })
@@ -78,12 +124,24 @@ export function TaskItem({ task, onToggle, onDelete, onPress, onLongPress, onMen
}).start(); }).start();
}, [task.completed, opacityAnim]); }, [task.completed, opacityAnim]);
const startOfToday = new Date(); const dueInfo = React.useMemo(() => {
startOfToday.setHours(0, 0, 0, 0); const startOfToday = new Date();
const hasDueDate = task.dueDate > 0; startOfToday.setHours(0, 0, 0, 0);
const isOverdue = hasDueDate && !task.completed && task.dueDate < startOfToday.getTime(); const endOfToday = new Date();
const isDueToday = hasDueDate && !task.completed && task.dueDate >= startOfToday.getTime() && task.dueDate < new Date().setHours(23, 59, 59, 999); endOfToday.setHours(23, 59, 59, 999);
const canComplete = !hasDueDate || isOverdue || isDueToday; const hasDueDate = task.dueDate > 0;
const isOverdue = hasDueDate && !task.completed && task.dueDate < startOfToday.getTime();
const isDueToday = hasDueDate && !task.completed && task.dueDate >= startOfToday.getTime() && task.dueDate <= endOfToday.getTime();
const canComplete = !hasDueDate || isOverdue || isDueToday;
return { hasDueDate, isOverdue, isDueToday, canComplete };
}, [task.dueDate, task.completed]);
const { hasDueDate, isOverdue, isDueToday, canComplete } = dueInfo;
const formattedDueDate = React.useMemo(
() => formatDueDate(task.dueDate, task.dueTime, task.endTime || ''),
[task.dueDate, task.dueTime, task.endTime]
);
const renderRightActions = (progress: Animated.AnimatedInterpolation<number>) => { const renderRightActions = (progress: Animated.AnimatedInterpolation<number>) => {
const translateX = progress.interpolate({ inputRange: [0, 1], outputRange: [80, 0] }); const translateX = progress.interpolate({ inputRange: [0, 1], outputRange: [80, 0] });
@@ -138,19 +196,20 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
overshootRight={false} overshootRight={false}
overshootLeft={false} overshootLeft={false}
> >
<GestureDetector gesture={draggable ? dragGesture : Gesture.Native()}> <GestureDetector gesture={draggable ? (onReorderStart ? reorderGesture : dragGesture) : Gesture.Native()}>
<Animated.View <Animated.View
style={[ style={[
styles.container, styles.container,
{ backgroundColor: theme.card, borderColor: theme.border }, { backgroundColor: theme.card, borderColor: theme.border },
(indented || depth > 0) && { marginLeft: depth > 0 ? depth * 12 : 32, marginBottom: 4 },
task.completed && styles.taskCompleted, task.completed && styles.taskCompleted,
isOverdue && styles.taskOverdue, isOverdue && styles.taskOverdue,
isDueToday && styles.taskDueToday, isDueToday && styles.taskDueToday,
selectionMode && styles.taskSelected, selectionMode && styles.taskSelected,
selected && { borderColor: theme.accent, borderWidth: 2 }, selected && { borderColor: theme.accent, borderWidth: 2 },
hovered && { borderColor: theme.accent, borderWidth: 2, backgroundColor: theme.accentSoft },
completedSection && { backgroundColor: theme.cardAlt }, completedSection && { backgroundColor: theme.cardAlt },
dragging && styles.dragLifted, dragging && styles.dragLifted,
hovered && styles.taskHovered,
{ transform: [{ translateX: dragTranslateX }, { translateY: dragTranslateY }] }, { transform: [{ translateX: dragTranslateX }, { translateY: dragTranslateY }] },
]} ]}
pointerEvents={dragging ? 'none' : 'auto'} pointerEvents={dragging ? 'none' : 'auto'}
@@ -164,6 +223,24 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
> >
<View style={styles.content}> <View style={styles.content}>
<View style={styles.titleRow}> <View style={styles.titleRow}>
<TouchableOpacity
style={[styles.dragHandle, { opacity: draggable ? 1 : 0 }]}
accessible={false}
onPressIn={() => {}}
onPressOut={() => {}}
>
<Svg width={20} height={20} viewBox="0 0 24 24">
<Circle cx="6" cy="6" r="2" fill={theme.textFaint} />
<Circle cx="6" cy="12" r="2" fill={theme.textFaint} />
<Circle cx="6" cy="18" r="2" fill={theme.textFaint} />
<Circle cx="12" cy="6" r="2" fill={theme.textFaint} />
<Circle cx="12" cy="12" r="2" fill={theme.textFaint} />
<Circle cx="12" cy="18" r="2" fill={theme.textFaint} />
<Circle cx="18" cy="6" r="2" fill={theme.textFaint} />
<Circle cx="18" cy="12" r="2" fill={theme.textFaint} />
<Circle cx="18" cy="18" r="2" fill={theme.textFaint} />
</Svg>
</TouchableOpacity>
<TouchableOpacity <TouchableOpacity
style={[styles.checkCircle, !canComplete && !task.completed && styles.checkCircleDisabled]} style={[styles.checkCircle, !canComplete && !task.completed && styles.checkCircleDisabled]}
onPress={canComplete || task.completed ? onToggle : undefined} onPress={canComplete || task.completed ? onToggle : undefined}
@@ -252,7 +329,7 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
</Svg> </Svg>
</Animated.View> </Animated.View>
</View> </View>
{task.dueDate && ( {hasDueDate && (
<View style={styles.dueRow}> <View style={styles.dueRow}>
<Svg width={14} height={14} viewBox="0 0 24 24"> <Svg width={14} height={14} viewBox="0 0 24 24">
<Circle cx={12} cy={12} r={10} stroke={theme.textMuted} strokeWidth={1.5} fill="none" /> <Circle cx={12} cy={12} r={10} stroke={theme.textMuted} strokeWidth={1.5} fill="none" />
@@ -266,7 +343,7 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
isDueToday && styles.dueTextDueToday, isDueToday && styles.dueTextDueToday,
]} ]}
> >
{formatDueDate(task.dueDate, task.dueTime, task.endTime || '')} {formattedDueDate}
</Animated.Text> </Animated.Text>
{task.reminder && task.reminder !== 'none' && ( {task.reminder && task.reminder !== 'none' && (
<View style={styles.reminderIcon}> <View style={styles.reminderIcon}>
@@ -302,7 +379,7 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
</GestureDetector> </GestureDetector>
</Swipeable> </Swipeable>
); );
} });
function formatDueDate(dueDate: number, dueTime: string, endTime?: string): string { function formatDueDate(dueDate: number, dueTime: string, endTime?: string): string {
const date = new Date(dueDate); const date = new Date(dueDate);
@@ -338,19 +415,24 @@ const styles = StyleSheet.create({
container: { container: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
paddingHorizontal: 16, paddingHorizontal: 12,
paddingVertical: 14, paddingVertical: 14,
borderRadius: 16, borderRadius: 16,
borderWidth: 1, borderWidth: 1,
marginVertical: 4,
shadowColor: '#000', shadowColor: '#000',
shadowOffset: { width: 0, height: 1 }, shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.04, shadowOpacity: 0.05,
shadowRadius: 4, shadowRadius: 6,
elevation: 1, elevation: 1,
}, },
taskCompleted: { taskCompleted: {
opacity: 0.5, opacity: 0.5,
}, },
indented: {
marginLeft: 32,
marginBottom: 4,
},
taskOverdue: { taskOverdue: {
borderColor: '#4A2B2B', borderColor: '#4A2B2B',
}, },
@@ -363,6 +445,10 @@ const styles = StyleSheet.create({
shadowRadius: 6, shadowRadius: 6,
elevation: 2, elevation: 2,
}, },
taskHovered: {
borderColor: '#1E88E5',
backgroundColor: 'rgba(30, 136, 229, 0.05)',
},
dragLifted: { dragLifted: {
zIndex: 100, zIndex: 100,
elevation: 12, elevation: 12,
@@ -375,18 +461,26 @@ const styles = StyleSheet.create({
flex: 1, flex: 1,
}, },
content: { content: {
gap: 4, gap: 12,
}, },
titleRow: { titleRow: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
}, },
dragHandle: {
width: 28,
height: 28,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
marginRight: 8,
},
title: { title: {
fontSize: 16, fontSize: 17,
fontWeight: '500', fontWeight: '500',
flex: 1, flex: 1,
marginRight: 8, marginRight: 16,
}, },
titleCompleted: { titleCompleted: {
textDecorationLine: 'line-through', textDecorationLine: 'line-through',
@@ -444,7 +538,7 @@ const styles = StyleSheet.create({
dueRow: { dueRow: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: 4, gap: 8,
}, },
dueText: { dueText: {
fontSize: 13, fontSize: 13,
+472 -202
View File
@@ -1,30 +1,25 @@
import React, { useState, useCallback, useMemo, useRef, useEffect } from 'react'; import React, { useState, useCallback, useMemo, useRef } from 'react';
import { View, Text, StyleSheet, FlatList, TouchableOpacity, Animated, RefreshControl, Alert } from 'react-native'; import { View, Text, StyleSheet, Animated, RefreshControl, Alert, TouchableOpacity } from 'react-native';
import { useRouter } from 'expo-router';
import { useTasks } from '@/hooks/useTasks'; import { useTasks } from '@/hooks/useTasks';
import { useTaskModals } from '@/hooks/useTaskModals';
import { TaskItem } from './TaskItem'; import { TaskItem } from './TaskItem';
import { SubtaskItem } from './SubtaskItem'; import { SubtaskItem } from './SubtaskItem';
import { TaskData, SubtaskData } from '@/types'; import { TaskData, SubtaskData } from '@/types';
import { useCategories, useDatabase } from '@/hooks/useDatabase'; import Task from '@/models/Task';
import { useDatabase } from '@/hooks/useDatabase';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import { OptionPickerModal } from './OptionPickerModal';
import { TaskOverflowMenu } from './TaskOverflowMenu';
import { TaskDeleteModal } from './TaskDeleteModal';
import { import {
toggleTaskComplete, toggleTaskComplete,
deleteTask, deleteTask,
duplicateTask,
setTaskCategory,
setTaskPriority,
setTaskCompleted, setTaskCompleted,
deleteTaskOccurrences,
convertTaskToSubtask, convertTaskToSubtask,
convertSubtaskToTask,
moveSubtaskToTask,
toggleSubtaskComplete, toggleSubtaskComplete,
TaskDeleteScope, reorderTasks,
} from '@/utils/taskActions'; } from '@/utils/taskActions';
import { PRIORITY_LABELS } from '@/constants';
import { Q } from '@nozbe/watermelondb'; import { Q } from '@nozbe/watermelondb';
import Svg, { Path } from 'react-native-svg'; import Svg, { Path, Rect } from 'react-native-svg';
interface TaskListProps { interface TaskListProps {
categoryId?: string; categoryId?: string;
@@ -33,11 +28,20 @@ interface TaskListProps {
const PRIORITY_RANK: Record<string, number> = { none: 0, low: 1, medium: 2, high: 3, critical: 4 }; const PRIORITY_RANK: Record<string, number> = { none: 0, low: 1, medium: 2, high: 3, critical: 4 };
const SEPARATOR = () => <View style={styles.separator} />;
const MemoSeparator = React.memo(SEPARATOR);
const DropIndicator = ({ theme }: { theme: any }) => (
<View style={styles.dropIndicatorContainer}>
<View style={[styles.dropIndicator, { backgroundColor: theme.accent }]} />
<View style={[styles.dropIndicatorDot, { backgroundColor: theme.accent }]} />
<View style={[styles.dropIndicatorDot, { backgroundColor: theme.accent }]} />
</View>
);
export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProps) { export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProps) {
const router = useRouter();
const { theme, sortBy } = useSettings(); const { theme, sortBy } = useSettings();
const { collections } = useDatabase(); const { collections } = useDatabase();
const categories = useCategories();
const { tasks, loading } = useTasks(categoryId, false); const { tasks, loading } = useTasks(categoryId, false);
const { tasks: completedTasks } = useTasks(categoryId, true); const { tasks: completedTasks } = useTasks(categoryId, true);
@@ -45,16 +49,30 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [selectionMode, setSelectionMode] = useState(false); const [selectionMode, setSelectionMode] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()); const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [menuTaskId, setMenuTaskId] = useState<string | null>(null);
const [deleteTaskId, setDeleteTaskId] = useState<string | null>(null);
const [picker, setPicker] = useState<null | { type: 'category' | 'priority'; taskId?: string }>(null);
const [hoverTaskId, setHoverTaskId] = useState<string | null>(null); const [hoverTaskId, setHoverTaskId] = useState<string | null>(null);
const [expandedTasks, setExpandedTasks] = useState<Set<string>>(new Set()); const [expandedTasks, setExpandedTasks] = useState<Set<string>>(new Set());
const [subtasksMap, setSubtasksMap] = useState<Map<string, SubtaskData[]>>(new Map()); const [subtasksMap, setSubtasksMap] = useState<Map<string, SubtaskData[]>>(new Map());
const [reorderState, setReorderState] = useState<{ draggedId: string; draggedIndex: number; targetIndex: number | null; positions: Record<string, { top: number; bottom: number }> } | null>(null);
const [dropIndicator, setDropIndicator] = useState<{ targetId: string | null; position: 'above' | 'below' } | null>(null);
const itemRefs = useRef<Map<string, View>>(new Map()); const itemRefs = useRef<Map<string, View>>(new Map());
const dragStateRef = useRef<{ taskId: string; positions: Record<string, { top: number; bottom: number }> } | null>(null); const dragStateRef = useRef<{ taskId: string; positions: Record<string, { top: number; bottom: number }> } | null>(null);
const subtaskDragRef = useRef<{ subtaskId: string; parentTaskId: string; positions: Record<string, { top: number; bottom: number }> } | null>(null);
const {
modals,
openTaskMenu,
openTaskDelete,
openSubtaskMenu,
openSubtaskDelete,
openSubtaskEdit,
} = useTaskModals();
const allTasks = useMemo(() => [...tasks, ...completedTasks], [tasks, completedTasks]); const registerRef = useCallback((taskId: string, ref: View | null) => {
if (ref) {
itemRefs.current.set(taskId, ref);
} else {
itemRefs.current.delete(taskId);
}
}, []);
const sortedTasks = useMemo(() => { const sortedTasks = useMemo(() => {
const sorted = [...tasks]; const sorted = [...tasks];
@@ -75,16 +93,6 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
return sorted; return sorted;
}, [tasks, sortBy]); }, [tasks, sortBy]);
const menuTask = useMemo(
() => allTasks.find((t) => t.id === menuTaskId) ?? null,
[allTasks, menuTaskId]
);
const deleteTarget = useMemo(
() => allTasks.find((t) => t.id === deleteTaskId) ?? null,
[allTasks, deleteTaskId]
);
const onRefresh = useCallback(() => { const onRefresh = useCallback(() => {
setRefreshing(true); setRefreshing(true);
setTimeout(() => setRefreshing(false), 600); setTimeout(() => setRefreshing(false), 600);
@@ -118,22 +126,12 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
}); });
}, [onSelectionChange]); }, [onSelectionChange]);
const refreshAll = useCallback(() => {}, []);
const handleEdit = useCallback((taskId: string) => {
router.push({ pathname: '/task-detail', params: { id: taskId } });
}, [router]);
const handleToggle = useCallback(async (taskId: string) => {
await toggleTaskComplete(taskId);
refreshAll();
}, [refreshAll]);
const fetchSubtasks = useCallback(async (taskId: string) => { const fetchSubtasks = useCallback(async (taskId: string) => {
const subs = await collections.subtasks.query(Q.where('task_id', taskId)).fetch(); const subs = await collections.subtasks.query(Q.where('task_id', taskId), Q.where('parent_subtask_id', null)).fetch();
const mapped: SubtaskData[] = subs.map((s: any) => ({ const mapped: SubtaskData[] = subs.map((s: any) => ({
id: s.id, id: s.id,
taskId: s.taskId, taskId: s.taskId,
parentSubtaskId: s.parentSubtaskId || null,
title: s.title, title: s.title,
description: s.description || '', description: s.description || '',
priority: (s.priority || 'none') as SubtaskData['priority'], priority: (s.priority || 'none') as SubtaskData['priority'],
@@ -149,11 +147,62 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
reminder: (s.reminder || 'none') as SubtaskData['reminder'], reminder: (s.reminder || 'none') as SubtaskData['reminder'],
assigneeId: s.assigneeId ?? null, assigneeId: s.assigneeId ?? null,
order: s.order, order: s.order,
subtasks: [],
})); }));
setSubtasksMap((prev) => new Map(prev).set(taskId, mapped));
return mapped; // Fetch nested subtasks for each subtask
const fetchNested = async (subtaskId: string): Promise<SubtaskData[]> => {
const nested = await collections.subtasks.query(Q.where('parent_subtask_id', subtaskId)).fetch();
return nested.map((s: any) => ({
id: s.id,
taskId: s.taskId,
parentSubtaskId: s.parentSubtaskId || null,
title: s.title,
description: s.description || '',
priority: (s.priority || 'none') as SubtaskData['priority'],
completed: s.completed,
dueDate: s.dueDate || 0,
dueTime: s.dueTime || '',
endTime: s.endTime || '',
allDay: s.allDay ?? false,
repeat: (s.repeat || 'none') as SubtaskData['repeat'],
repeatInterval: s.repeatInterval ?? 1,
repeatDays: s.repeatDays || '',
seriesId: s.seriesId || '',
reminder: (s.reminder || 'none') as SubtaskData['reminder'],
assigneeId: s.assigneeId ?? null,
order: s.order,
subtasks: [],
}));
};
// Recursively fetch all nested subtasks
const buildNested = async (subtasks: SubtaskData[]): Promise<SubtaskData[]> => {
for (const sub of subtasks) {
const children = await fetchNested(sub.id);
if (children.length > 0) {
sub.subtasks = await buildNested(children);
}
}
return subtasks;
};
const withNested = await buildNested(mapped);
setSubtasksMap((prev) => new Map(prev).set(taskId, withNested));
return withNested;
}, [collections.subtasks]); }, [collections.subtasks]);
const refreshAll = useCallback(() => {
for (const taskId of expandedTasks) {
fetchSubtasks(taskId);
}
}, [expandedTasks, fetchSubtasks]);
const handleToggle = useCallback(async (taskId: string) => {
await toggleTaskComplete(taskId);
refreshAll();
}, [refreshAll]);
const toggleExpand = useCallback(async (taskId: string) => { const toggleExpand = useCallback(async (taskId: string) => {
setExpandedTasks((prev) => { setExpandedTasks((prev) => {
const next = new Set(prev); const next = new Set(prev);
@@ -182,23 +231,6 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
await fetchSubtasks(taskId); await fetchSubtasks(taskId);
}, [fetchSubtasks]); }, [fetchSubtasks]);
const handleDeleteOne = useCallback((taskId: string) => {
setDeleteTaskId(taskId);
}, []);
const handleDeleteScope = useCallback(async (scope: TaskDeleteScope) => {
const taskId = deleteTaskId;
if (!taskId) return;
setDeleteTaskId(null);
await deleteTaskOccurrences(taskId, scope);
refreshAll();
}, [deleteTaskId, refreshAll]);
const handleDuplicate = useCallback(async (taskId: string) => {
await duplicateTask(taskId);
refreshAll();
}, [refreshAll]);
const handleBulkDelete = useCallback(() => { const handleBulkDelete = useCallback(() => {
Alert.alert(`Delete ${selectedIds.size} task${selectedIds.size > 1 ? 's' : ''}?`, 'This cannot be undone.', [ Alert.alert(`Delete ${selectedIds.size} task${selectedIds.size > 1 ? 's' : ''}?`, 'This cannot be undone.', [
{ text: 'Cancel', style: 'cancel' }, { text: 'Cancel', style: 'cancel' },
@@ -206,9 +238,7 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
text: 'Delete', text: 'Delete',
style: 'destructive', style: 'destructive',
onPress: async () => { onPress: async () => {
for (const taskId of selectedIds) { await Promise.all(Array.from(selectedIds).map(taskId => deleteTask(taskId)));
await deleteTask(taskId);
}
exitSelection(); exitSelection();
refreshAll(); refreshAll();
}, },
@@ -217,27 +247,11 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
}, [selectedIds, exitSelection, refreshAll]); }, [selectedIds, exitSelection, refreshAll]);
const handleBulkComplete = useCallback(async () => { const handleBulkComplete = useCallback(async () => {
for (const taskId of selectedIds) { await Promise.all(Array.from(selectedIds).map(taskId => setTaskCompleted(taskId, true)));
await setTaskCompleted(taskId, true);
}
exitSelection(); exitSelection();
refreshAll(); refreshAll();
}, [selectedIds, exitSelection, refreshAll]); }, [selectedIds, exitSelection, refreshAll]);
const handleSingleCategory = useCallback(async (value: string) => {
if (picker?.taskId) {
await setTaskCategory(picker.taskId, value);
refreshAll();
}
}, [picker, refreshAll]);
const handleSinglePriority = useCallback(async (value: string) => {
if (picker?.taskId) {
await setTaskPriority(picker.taskId, value as TaskData['priority']);
refreshAll();
}
}, [picker, refreshAll]);
const measureItems = useCallback(async () => { const measureItems = useCallback(async () => {
const positions: Record<string, { top: number; bottom: number }> = {}; const positions: Record<string, { top: number; bottom: number }> = {};
const entries = Array.from(itemRefs.current.entries()); const entries = Array.from(itemRefs.current.entries());
@@ -262,6 +276,13 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
return null; return null;
}, []); }, []);
const calculateDropPosition = useCallback((absoluteY: number, targetId: string, positions: Record<string, { top: number; bottom: number }>) => {
const target = positions[targetId];
if (!target) return 'below' as const;
const middle = (target.top + target.bottom) / 2;
return absoluteY < middle ? 'above' : 'below';
}, []);
const handleDragStart = useCallback(async (taskId: string) => { const handleDragStart = useCallback(async (taskId: string) => {
dragStateRef.current = { taskId, positions: await measureItems() }; dragStateRef.current = { taskId, positions: await measureItems() };
}, [measureItems]); }, [measureItems]);
@@ -271,12 +292,26 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
if (!state) return; if (!state) return;
const target = findHoverTarget(absoluteY, state.taskId, state.positions); const target = findHoverTarget(absoluteY, state.taskId, state.positions);
setHoverTaskId((prev) => (prev === target ? prev : target)); setHoverTaskId((prev) => (prev === target ? prev : target));
}, [findHoverTarget]); if (target) {
const position = calculateDropPosition(absoluteY, target, state.positions);
setDropIndicator({ targetId: target, position });
} else {
// Check if below last item
const positions = state.positions;
const lastItem = Object.values(positions).reduce((max, p) => p.bottom > max.bottom ? p : max, { bottom: 0 });
if (absoluteY > lastItem.bottom) {
setDropIndicator({ targetId: null, position: 'below' });
} else {
setDropIndicator(null);
}
}
}, [findHoverTarget, calculateDropPosition]);
const handleDragEnd = useCallback((absoluteY: number) => { const handleDragEnd = useCallback((absoluteY: number) => {
const state = dragStateRef.current; const state = dragStateRef.current;
dragStateRef.current = null; dragStateRef.current = null;
setHoverTaskId(null); setHoverTaskId(null);
setDropIndicator(null);
if (!state) return; if (!state) return;
const target = findHoverTarget(absoluteY, state.taskId, state.positions); const target = findHoverTarget(absoluteY, state.taskId, state.positions);
@@ -288,6 +323,222 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
} }
}, [findHoverTarget, refreshAll]); }, [findHoverTarget, refreshAll]);
const measureReorderItems = useCallback(async () => {
const positions: Record<string, { top: number; bottom: number }> = {};
const entries = Array.from(itemRefs.current.entries());
await Promise.all(entries.map(([id, ref]) => {
return new Promise<void>((resolve) => {
ref?.measureInWindow((_x, y, _w, h) => {
positions[id] = { top: y, bottom: y + h };
resolve();
});
});
}));
return positions;
}, []);
const findReorderTarget = useCallback((absoluteY: number, draggedId: string, positions: Record<string, { top: number; bottom: number }>) => {
for (const [id, p] of Object.entries(positions)) {
if (id === draggedId) continue;
if (absoluteY >= p.top && absoluteY <= p.bottom) {
return id;
}
}
return null;
}, []);
const calculateReorderDropPosition = useCallback((absoluteY: number, targetId: string, positions: Record<string, { top: number; bottom: number }>) => {
const target = positions[targetId];
if (!target) return 'below' as const;
const middle = (target.top + target.bottom) / 2;
return absoluteY < middle ? 'above' : 'below';
}, []);
const handleReorderStart = useCallback(async (taskId: string) => {
const positions = await measureReorderItems();
const draggedIndex = sortedTasks.findIndex(t => t.id === taskId);
if (draggedIndex === -1) return;
setReorderState({ draggedId: taskId, draggedIndex, targetIndex: null, positions });
}, [measureReorderItems, sortedTasks]);
const handleReorderUpdate = useCallback((absoluteY: number) => {
const state = reorderState;
if (!state) return;
const targetId = findReorderTarget(absoluteY, state.draggedId, state.positions);
let targetIndex = null;
if (targetId) {
targetIndex = sortedTasks.findIndex(t => t.id === targetId);
const position = calculateReorderDropPosition(absoluteY, targetId, state.positions);
setDropIndicator({ targetId, position });
} else {
// Check if below last item
const positions = state.positions;
const lastItem = Object.values(positions).reduce((max, p) => p.bottom > max.bottom ? p : max, { bottom: 0 });
if (absoluteY > lastItem.bottom) {
setDropIndicator({ targetId: null, position: 'below' });
targetIndex = sortedTasks.length; // Insert at end
} else {
setDropIndicator(null);
}
}
setReorderState(prev => prev ? { ...prev, targetIndex } : null);
setHoverTaskId(targetId);
}, [findReorderTarget, calculateReorderDropPosition, reorderState, sortedTasks]);
const handleReorderEnd = useCallback(async (translationY: number) => {
const state = reorderState;
setReorderState(null);
setHoverTaskId(null);
setDropIndicator(null);
if (!state) return;
if (state.targetIndex !== null && state.targetIndex !== state.draggedIndex) {
const newOrder = [...sortedTasks];
const [removed] = newOrder.splice(state.draggedIndex, 1);
newOrder.splice(state.targetIndex, 0, removed);
const newTaskIds = newOrder.map(t => t.id);
await reorderTasks(newTaskIds);
refreshAll();
}
}, [reorderState, sortedTasks, reorderTasks, refreshAll]);
const handleSubtaskDragStart = useCallback(async (subtaskId: string, parentTaskId: string) => {
subtaskDragRef.current = { subtaskId, parentTaskId, positions: await measureItems() };
}, [measureItems]);
const handleSubtaskDragEnd = useCallback(async (absoluteY: number) => {
const state = subtaskDragRef.current;
subtaskDragRef.current = null;
setHoverTaskId(null);
setDropIndicator(null);
if (!state) return;
const target = findHoverTarget(absoluteY, state.subtaskId, state.positions);
if (target === state.parentTaskId) return;
if (target) {
await moveSubtaskToTask(state.subtaskId, target);
await fetchSubtasks(target);
} else {
await convertSubtaskToTask(state.subtaskId);
}
await fetchSubtasks(state.parentTaskId);
refreshAll();
}, [findHoverTarget, fetchSubtasks, refreshAll]);
const renderItem = useCallback(
({ item, index }: { item: Task; index: number }) => {
const isExpanded = expandedTasks.has(item.id);
const itemSubtasks = subtasksMap.get(item.id) ?? [];
const showDropAbove = dropIndicator?.targetId === item.id && dropIndicator?.position === 'above';
const showDropBelow = dropIndicator?.targetId === item.id && dropIndicator?.position === 'below';
return (
<View>
{showDropAbove && <DropIndicator theme={theme} />}
<TaskRow
task={item}
expanded={isExpanded}
subtasks={itemSubtasks}
selected={selectedIds.has(item.id)}
selectionMode={selectionMode}
hovered={hoverTaskId === item.id}
registerRef={registerRef}
onToggle={handleToggle}
onDelete={openTaskDelete}
onExpand={toggleExpand}
onSelect={toggleSelect}
onEnterSelection={enterSelection}
onMenuOpen={openTaskMenu}
onSubtaskToggle={handleSubtaskToggle}
onSubtaskDelete={openSubtaskDelete}
onSubtaskEdit={openSubtaskEdit}
onSubtaskMenuOpen={openSubtaskMenu}
onDragStart={handleDragStart}
onDragUpdate={handleDragUpdate}
onDragEnd={handleDragEnd}
onSubtaskDragStart={handleSubtaskDragStart}
onSubtaskDragEnd={handleSubtaskDragEnd}
onReorderStart={handleDragStart}
onReorderUpdate={handleDragUpdate}
onReorderEnd={handleDragEnd}
selectedIds={selectedIds}
/>
{showDropBelow && <DropIndicator theme={theme} />}
</View>
);
},
[
expandedTasks,
subtasksMap,
selectedIds,
selectionMode,
hoverTaskId,
dropIndicator,
theme,
registerRef,
handleToggle,
openTaskDelete,
toggleExpand,
toggleSelect,
enterSelection,
openTaskMenu,
handleSubtaskToggle,
openSubtaskDelete,
openSubtaskEdit,
openSubtaskMenu,
handleDragStart,
handleDragUpdate,
handleDragEnd,
handleSubtaskDragStart,
handleSubtaskDragEnd,
]
);
const listHeader = useMemo(() => {
if (sortedTasks.length > 0 || completedTasks.length > 0) return null;
return (
<View style={styles.emptyState}>
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>No tasks yet</Text>
<Text style={[styles.emptySubtext, { color: theme.textMuted }]}>Tap + to add your first task</Text>
</View>
);
}, [sortedTasks.length, completedTasks.length, theme.textSecondary, theme.textMuted]);
const listFooter = useMemo(() => {
const footerContent = completedTasks.length === 0 ? null : (
<CompletedSection
tasks={completedTasks}
onToggle={(task) => handleToggle(task.id)}
onDelete={openTaskDelete}
onMenuOpen={openTaskMenu}
onLongPress={(task) => enterSelection(task.id)}
selectionMode={selectionMode}
selectedIds={selectedIds}
onSelect={toggleSelect}
/>
);
const showDropAtEnd = dropIndicator && dropIndicator.targetId === null;
return (
<View>
{footerContent}
{showDropAtEnd && <DropIndicator theme={theme} />}
</View>
);
}, [
completedTasks,
handleToggle,
openTaskDelete,
openTaskMenu,
enterSelection,
selectionMode,
selectedIds,
toggleSelect,
dropIndicator,
theme,
]);
if (loading && !refreshing) { if (loading && !refreshing) {
return ( return (
<View style={styles.loadingContainer}> <View style={styles.loadingContainer}>
@@ -296,87 +547,16 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
); );
} }
const priorityOptions = Object.entries(PRIORITY_LABELS).map(([value, label]) => ({ value, label }));
return ( return (
<View style={styles.container}> <View style={styles.container}>
<Animated.FlatList <Animated.FlatList
data={sortedTasks} data={sortedTasks}
keyExtractor={(item) => item.id} keyExtractor={(item) => item.id}
renderItem={({ item }) => { renderItem={renderItem}
const isExpanded = expandedTasks.has(item.id); extraData={{ expandedTasks, subtasksMap, selectedIds, selectionMode, hoverTaskId, dropIndicator }}
const itemSubtasks = subtasksMap.get(item.id) ?? []; ItemSeparatorComponent={MemoSeparator}
return ( ListHeaderComponent={listHeader}
<View ListFooterComponent={listFooter}
ref={(ref) => {
if (ref) {
itemRefs.current.set(item.id, ref);
} else {
itemRefs.current.delete(item.id);
}
}}
>
<TaskItem
task={item as TaskData}
onToggle={() => handleToggle(item.id)}
onDelete={() => handleDeleteOne(item.id)}
onPress={() => selectionMode ? toggleSelect(item.id) : toggleExpand(item.id)}
onLongPress={selectionMode ? undefined : () => enterSelection(item.id)}
onMenuOpen={() => setMenuTaskId(item.id)}
selected={selectedIds.has(item.id)}
selectionMode={selectionMode}
expanded={isExpanded}
draggable
hovered={hoverTaskId === item.id}
onDragStart={() => handleDragStart(item.id)}
onDragUpdate={handleDragUpdate}
onDragEnd={handleDragEnd}
/>
{isExpanded && itemSubtasks.length > 0 && (
<View style={styles.subtaskList}>
{itemSubtasks
.slice()
.sort((a, b) => a.order - b.order)
.map((sub) => (
<SubtaskItem
key={sub.id}
subtask={sub}
onToggle={() => handleSubtaskToggle(sub.id, item.id)}
/>
))}
</View>
)}
</View>
);
}}
ItemSeparatorComponent={() => <View style={styles.separator} />}
ListHeaderComponent={
sortedTasks.length === 0 && completedTasks.length === 0 ? (
<View style={styles.emptyState}>
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>No tasks yet</Text>
<Text style={[styles.emptySubtext, { color: theme.textMuted }]}>Tap + to add your first task</Text>
</View>
) : sortedTasks.length === 0 ? (
<View style={styles.emptyState}>
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>All caught up!</Text>
<Text style={[styles.emptySubtext, { color: theme.textMuted }]}>No pending tasks</Text>
</View>
) : null
}
ListFooterComponent={
completedTasks.length > 0 ? (
<CompletedSection
tasks={completedTasks}
onToggle={(task) => handleToggle(task.id)}
onDelete={(task) => handleDeleteOne(task.id)}
onMenuOpen={(task) => setMenuTaskId(task.id)}
onLongPress={(task) => enterSelection(task.id)}
selectionMode={selectionMode}
selectedIds={selectedIds}
onSelect={(taskId) => toggleSelect(taskId)}
/>
) : null
}
refreshControl={ refreshControl={
<RefreshControl <RefreshControl
refreshing={refreshing} refreshing={refreshing}
@@ -389,44 +569,7 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
contentContainerStyle={styles.listContent} contentContainerStyle={styles.listContent}
/> />
<TaskDeleteModal {modals(refreshAll)}
visible={deleteTarget !== null}
taskId={deleteTarget?.id ?? null}
taskTitle={deleteTarget?.title}
isRepeating={deleteTarget ? deleteTarget.repeat !== 'none' : false}
onClose={() => setDeleteTaskId(null)}
onDelete={handleDeleteScope}
/>
<TaskOverflowMenu
visible={menuTask !== null}
task={menuTask}
onClose={() => setMenuTaskId(null)}
onEdit={() => menuTask && handleEdit(menuTask.id)}
onDelete={() => menuTask && handleDeleteOne(menuTask.id)}
onDuplicate={() => menuTask && handleDuplicate(menuTask.id)}
onToggleComplete={() => menuTask && handleToggle(menuTask.id)}
onChangeCategory={() => menuTask && setPicker({ type: 'category', taskId: menuTask.id })}
onChangePriority={() => menuTask && setPicker({ type: 'priority', taskId: menuTask.id })}
/>
<OptionPickerModal
visible={picker?.type === 'category'}
title="Change Category"
options={categories.map((c) => ({ value: c.id, label: c.name, color: c.color }))}
selectedValue={menuTask?.categoryId}
onSelect={handleSingleCategory}
onClose={() => setPicker(null)}
/>
<OptionPickerModal
visible={picker?.type === 'priority'}
title="Change Priority"
options={priorityOptions}
selectedValue={menuTask?.priority}
onSelect={handleSinglePriority}
onClose={() => setPicker(null)}
/>
{selectionMode && ( {selectionMode && (
<View style={[styles.selectionBar, { backgroundColor: theme.sheetBg, borderTopColor: theme.border }]}> <View style={[styles.selectionBar, { backgroundColor: theme.sheetBg, borderTopColor: theme.border }]}>
@@ -447,8 +590,7 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
); );
} }
function SelectionButton({ label, color, onPress }: { label: string; color: string; onPress: () => void }) { const SelectionButton = React.memo(function SelectionButton({ label, color, onPress }: { label: string; color: string; onPress: () => void }) {
const { theme } = useSettings();
return ( return (
<TouchableOpacity <TouchableOpacity
style={[styles.selectionButton, { backgroundColor: color }]} style={[styles.selectionButton, { backgroundColor: color }]}
@@ -458,8 +600,117 @@ function SelectionButton({ label, color, onPress }: { label: string; color: stri
<Text style={styles.selectionButtonText}>{label}</Text> <Text style={styles.selectionButtonText}>{label}</Text>
</TouchableOpacity> </TouchableOpacity>
); );
});
interface TaskRowProps {
task: Task;
expanded: boolean;
subtasks: SubtaskData[];
selected: boolean;
selectionMode: boolean;
hovered: boolean;
registerRef: (taskId: string, ref: View | null) => void;
onToggle: (taskId: string) => void | Promise<void>;
onDelete: (task: Task) => void;
onExpand: (taskId: string) => void | Promise<void>;
onSelect: (taskId: string) => void;
onEnterSelection: (taskId: string) => void;
onMenuOpen: (task: Task) => void;
onSubtaskToggle: (subtaskId: string, taskId: string) => void;
onSubtaskDelete: (subtask: SubtaskData) => void;
onSubtaskEdit: (subtaskId: string) => void;
onSubtaskMenuOpen: (subtask: SubtaskData) => void;
onDragStart: (taskId: string) => void;
onDragUpdate: (absoluteY: number) => void;
onDragEnd: (absoluteY: number) => void;
onSubtaskDragStart: (subtaskId: string, parentTaskId: string) => void;
onSubtaskDragEnd: (absoluteY: number) => void;
onReorderStart: (taskId: string) => void;
onReorderUpdate: (absoluteY: number) => void;
onReorderEnd: (translationY: number) => void;
selectedIds: Set<string>;
} }
const TaskRow = React.memo(function TaskRow({
task,
expanded,
subtasks,
selected,
selectionMode,
hovered,
registerRef,
onToggle,
onDelete,
onExpand,
onSelect,
onEnterSelection,
onMenuOpen,
onSubtaskToggle,
onSubtaskDelete,
onSubtaskEdit,
onSubtaskMenuOpen,
onDragStart,
onDragUpdate,
onDragEnd,
onSubtaskDragStart,
onSubtaskDragEnd,
onReorderStart,
onReorderUpdate,
onReorderEnd,
selectedIds,
}: TaskRowProps) {
const sortedSubtasks = useMemo(
() => subtasks.slice().sort((a, b) => a.order - b.order),
[subtasks]
);
return (
<View
ref={(ref) => registerRef(task.id, ref)}
style={styles.dragContainer}
>
<TaskItem
task={task as unknown as TaskData}
onToggle={() => onToggle(task.id)}
onDelete={() => onDelete(task)}
onPress={() => (selectionMode ? onSelect(task.id) : onExpand(task.id))}
onLongPress={selectionMode ? undefined : () => onEnterSelection(task.id)}
onMenuOpen={() => onMenuOpen(task)}
selected={selected}
selectionMode={selectionMode}
expanded={expanded}
draggable
hovered={hovered}
onDragStart={() => onDragStart(task.id)}
onDragUpdate={onDragUpdate}
onDragEnd={onDragEnd}
onReorderStart={() => onReorderStart(task.id)}
onReorderUpdate={onReorderUpdate}
onReorderEnd={onReorderEnd}
/>
{expanded && subtasks.length > 0 && (
<View style={styles.subtaskList}>
{sortedSubtasks.map((sub) => (
<SubtaskItem
key={sub.id}
subtask={sub}
onToggle={() => onSubtaskToggle(sub.id, task.id)}
onDelete={() => onSubtaskDelete(sub)}
onPress={() => onSubtaskEdit(sub.id)}
onMenuOpen={() => onSubtaskMenuOpen(sub)}
selected={selectedIds.has(sub.id)}
selectionMode={selectionMode}
draggable
onDragStart={() => onSubtaskDragStart(sub.id, task.id)}
onDragUpdate={onDragUpdate}
onDragEnd={onSubtaskDragEnd}
/>
))}
</View>
)}
</View>
);
});
interface CompletedSectionProps { interface CompletedSectionProps {
tasks: TaskData[]; tasks: TaskData[];
onToggle: (task: TaskData) => void; onToggle: (task: TaskData) => void;
@@ -471,7 +722,7 @@ interface CompletedSectionProps {
onSelect: (taskId: string) => void; onSelect: (taskId: string) => void;
} }
function CompletedSection({ tasks, onToggle, onDelete, onMenuOpen, onLongPress, selectionMode, selectedIds, onSelect }: CompletedSectionProps) { const CompletedSection = React.memo(function CompletedSection({ tasks, onToggle, onDelete, onMenuOpen, onLongPress, selectionMode, selectedIds, onSelect }: CompletedSectionProps) {
const { theme } = useSettings(); const { theme } = useSettings();
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
@@ -508,7 +759,7 @@ function CompletedSection({ tasks, onToggle, onDelete, onMenuOpen, onLongPress,
)} )}
</View> </View>
); );
} });
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
@@ -516,8 +767,8 @@ const styles = StyleSheet.create({
}, },
listContent: { listContent: {
paddingHorizontal: 16, paddingHorizontal: 16,
paddingTop: 2, paddingTop: 8,
paddingBottom: 100, paddingBottom: 120,
}, },
loadingContainer: { loadingContainer: {
flex: 1, flex: 1,
@@ -533,6 +784,25 @@ const styles = StyleSheet.create({
subtaskList: { subtaskList: {
paddingLeft: 8, paddingLeft: 8,
paddingRight: 4, paddingRight: 4,
paddingTop: 8,
},
dragContainer: {
},
dropIndicatorContainer: {
height: 8,
justifyContent: 'center',
alignItems: 'center',
},
dropIndicator: {
width: '80%',
height: 2,
borderRadius: 1,
},
dropIndicatorDot: {
width: 8,
height: 8,
borderRadius: 4,
position: 'absolute',
}, },
emptyState: { emptyState: {
alignItems: 'center', alignItems: 'center',
@@ -1,6 +1,5 @@
import React from 'react'; import React from 'react';
import { View, Text, StyleSheet, TextInput } from 'react-native'; import { View, Text, StyleSheet, TextInput , TextInputProps } from 'react-native';
import { TextInputProps } from 'react-native';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
interface TaskNameInputProps extends TextInputProps { interface TaskNameInputProps extends TextInputProps {
@@ -35,34 +34,34 @@ export function TaskNameInput({ error, ...props }: TaskNameInputProps) {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
gap: 6, gap: 5,
}, },
labelRow: { labelRow: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
}, },
label: { label: {
fontSize: 14, fontSize: 13,
fontWeight: '600', fontWeight: '600',
}, },
required: { required: {
color: '#E53935', color: '#E53935',
fontSize: 14, fontSize: 13,
fontWeight: '600', fontWeight: '600',
}, },
input: { input: {
height: 52, height: 48,
paddingHorizontal: 16, paddingHorizontal: 14,
borderRadius: 12, borderRadius: 10,
borderWidth: 1, borderWidth: 1,
fontSize: 16, fontSize: 15,
}, },
inputError: { inputError: {
borderColor: '#E53935', borderColor: '#E53935',
borderWidth: 1.5, borderWidth: 1.5,
}, },
errorText: { errorText: {
fontSize: 12, fontSize: 11,
color: '#E53935', color: '#E53935',
marginLeft: 4, marginLeft: 4,
}, },
@@ -1,19 +1,21 @@
import React from 'react'; import React from 'react';
import { View, Text, StyleSheet, Modal, TouchableOpacity } from 'react-native'; import { View, Text, StyleSheet, Modal, TouchableOpacity } from 'react-native';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import { TaskData } from '@/types'; import { TaskData, SubtaskData } from '@/types';
import Svg, { Path } from 'react-native-svg'; import Svg, { Path } from 'react-native-svg';
interface TaskOverflowMenuProps { interface TaskOverflowMenuProps {
visible: boolean; visible: boolean;
task: TaskData | null; task: TaskData | SubtaskData | null;
subtask?: boolean;
onClose: () => void; onClose: () => void;
onEdit: () => void; onEdit: () => void;
onDelete: () => void; onDelete: () => void;
onDuplicate: () => void; onDuplicate: () => void;
onToggleComplete: () => void; onToggleComplete: () => void;
onChangeCategory: () => void; onChangeCategory?: () => void;
onChangePriority: () => void; onChangePriority: () => void;
onAddSubtask?: () => void;
} }
interface MenuAction { interface MenuAction {
@@ -27,6 +29,7 @@ interface MenuAction {
export function TaskOverflowMenu({ export function TaskOverflowMenu({
visible, visible,
task, task,
subtask,
onClose, onClose,
onEdit, onEdit,
onDelete, onDelete,
@@ -34,6 +37,7 @@ export function TaskOverflowMenu({
onToggleComplete, onToggleComplete,
onChangeCategory, onChangeCategory,
onChangePriority, onChangePriority,
onAddSubtask,
}: TaskOverflowMenuProps) { }: TaskOverflowMenuProps) {
const { theme } = useSettings(); const { theme } = useSettings();
@@ -58,12 +62,18 @@ export function TaskOverflowMenu({
icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Path d="M8 8h12v12H8zM4 16V4h12" stroke={theme.textSecondary} strokeWidth={1.8} fill="none" /></Svg>, icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Path d="M8 8h12v12H8zM4 16V4h12" stroke={theme.textSecondary} strokeWidth={1.8} fill="none" /></Svg>,
onPress: onDuplicate, onPress: onDuplicate,
}, },
{ ];
if (!subtask) {
actions.push({
key: 'category', key: 'category',
label: 'Change Category', label: 'Change Category',
icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Circle2 /></Svg>, icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Circle2 /></Svg>,
onPress: onChangeCategory, onPress: onChangeCategory ?? (() => {}),
}, });
}
actions.push(
{ {
key: 'priority', key: 'priority',
label: 'Change Priority', label: 'Change Priority',
@@ -77,7 +87,16 @@ export function TaskOverflowMenu({
icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13" stroke="#E53935" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" fill="none" /></Svg>, icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13" stroke="#E53935" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" fill="none" /></Svg>,
onPress: onDelete, onPress: onDelete,
}, },
]; );
if (subtask && onAddSubtask) {
actions.push({
key: 'addSubtask',
label: 'Add Subtask',
icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Path d="M12 5v14M5 12h14" stroke={theme.textSecondary} strokeWidth={2} strokeLinecap="round" /></Svg>,
onPress: onAddSubtask,
});
}
return ( return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}> <Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
@@ -0,0 +1,30 @@
import { useEffect } from 'react';
import { Alert, Linking } from 'react-native';
import { checkForUpdates, wasPromptedFor, markPrompted } from '@/services/updates';
export function UpdateNotifier() {
useEffect(() => {
const run = async () => {
const update = await checkForUpdates();
if (!update || (await wasPromptedFor(update.tagName))) return;
await markPrompted(update.tagName);
const url = update.apkUrl ?? update.releaseUrl;
Alert.alert(
'Update available',
`A new version (${update.version}) is available for download.`,
[
{ text: 'Later', style: 'cancel' },
{
text: 'Download',
onPress: () => {
if (url) Linking.openURL(url).catch(() => {});
},
},
],
);
};
run();
}, []);
return null;
}
@@ -110,12 +110,6 @@ function WheelColumn({
[data.length, onIndexChange] [data.length, onIndexChange]
); );
const snapToNearest = useCallback(() => {
const current = listRef.current;
if (!current) return;
current.scrollToOffset({ offset: indexRef.current * ITEM_HEIGHT - (ITEM_HEIGHT * VISIBLE_ITEMS - ITEM_HEIGHT) / 2, animated: true });
}, []);
const renderItem = useCallback( const renderItem = useCallback(
({ item, index }: ListRenderItemInfo<number>) => ( ({ item, index }: ListRenderItemInfo<number>) => (
<WheelRow <WheelRow
@@ -143,10 +137,12 @@ function WheelColumn({
); );
const handleScrollEnd = useCallback(() => { const handleScrollEnd = useCallback(() => {
// Rest the wheel exactly on the snapped row so the selection band // Let FlatList's native snap handle the alignment
// and the highlighted text always line up (FlatList doesn't snap on web). // Just update the indexRef from the scroll position
snapToNearest(); const current = listRef.current;
}, [snapToNearest]); if (!current) return;
// The native snap will handle positioning, we just sync the index
}, []);
return ( return (
<FlatList <FlatList
@@ -161,7 +157,7 @@ function WheelColumn({
})} })}
initialScrollIndex={initialIndex} initialScrollIndex={initialIndex}
snapToOffsets={data.map((_, i) => i * ITEM_HEIGHT)} snapToOffsets={data.map((_, i) => i * ITEM_HEIGHT)}
decelerationRate="fast" decelerationRate="normal"
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
onScroll={handleScroll} onScroll={handleScroll}
onScrollEndDrag={handleScrollEnd} onScrollEndDrag={handleScrollEnd}
@@ -169,6 +165,8 @@ function WheelColumn({
scrollEventThrottle={16} scrollEventThrottle={16}
style={[styles.column, { width }]} style={[styles.column, { width }]}
contentContainerStyle={{ paddingBottom: (VISIBLE_ITEMS - 1) * ITEM_HEIGHT, alignItems: 'stretch' }} contentContainerStyle={{ paddingBottom: (VISIBLE_ITEMS - 1) * ITEM_HEIGHT, alignItems: 'stretch' }}
snapToAlignment="center"
snapToInterval={ITEM_HEIGHT}
/> />
); );
} }
+44 -1
View File
@@ -1,4 +1,4 @@
import { schemaMigrations, addColumns, createTable } from '@nozbe/watermelondb/Schema/migrations'; import { schemaMigrations, addColumns, createTable, unsafeExecuteSql } from '@nozbe/watermelondb/Schema/migrations';
export const migrations = schemaMigrations({ export const migrations = schemaMigrations({
migrations: [ migrations: [
@@ -137,5 +137,48 @@ export const migrations = schemaMigrations({
}), }),
], ],
}, },
{
toVersion: 13,
steps: [
addColumns({
table: 'tasks',
columns: [{ name: 'completed_at', type: 'number', isOptional: true }],
}),
],
},
{
toVersion: 14,
steps: [
unsafeExecuteSql('CREATE INDEX IF NOT EXISTS idx_tasks_updated_at ON tasks(updated_at);'),
unsafeExecuteSql('CREATE INDEX IF NOT EXISTS idx_tasks_created_at ON tasks(created_at);'),
unsafeExecuteSql('CREATE INDEX IF NOT EXISTS idx_tasks_completed ON tasks(completed);'),
unsafeExecuteSql('CREATE INDEX IF NOT EXISTS idx_subtasks_updated_at ON subtasks(updated_at);'),
unsafeExecuteSql('CREATE INDEX IF NOT EXISTS idx_subtasks_created_at ON subtasks(created_at);'),
unsafeExecuteSql('CREATE INDEX IF NOT EXISTS idx_categories_updated_at ON categories(updated_at);'),
unsafeExecuteSql('CREATE INDEX IF NOT EXISTS idx_repeat_profiles_updated_at ON repeat_profiles(updated_at);'),
unsafeExecuteSql('CREATE INDEX IF NOT EXISTS idx_friendships_updated_at ON friendships(updated_at);'),
unsafeExecuteSql('CREATE INDEX IF NOT EXISTS idx_tasks_series_id ON tasks(series_id);'),
],
},
{
toVersion: 15,
steps: [
addColumns({
table: 'subtasks',
columns: [
{ name: 'parent_subtask_id', type: 'string', isIndexed: true, isOptional: true },
],
}),
],
},
{
toVersion: 16,
steps: [
addColumns({
table: 'tasks',
columns: [{ name: 'order', type: 'number', isOptional: true }],
}),
],
},
], ],
}); });
+11 -9
View File
@@ -1,7 +1,7 @@
import { appSchema, tableSchema } from '@nozbe/watermelondb'; import { appSchema, tableSchema } from '@nozbe/watermelondb';
export const schema = appSchema({ export const schema = appSchema({
version: 12, version: 15,
tables: [ tables: [
tableSchema({ tableSchema({
name: 'categories', name: 'categories',
@@ -10,7 +10,7 @@ export const schema = appSchema({
{ name: 'color', type: 'string' }, { name: 'color', type: 'string' },
{ name: 'order', type: 'number' }, { name: 'order', type: 'number' },
{ name: 'created_at', type: 'number' }, { name: 'created_at', type: 'number' },
{ name: 'updated_at', type: 'number' }, { name: 'updated_at', type: 'number', isIndexed: true },
], ],
}), }),
tableSchema({ tableSchema({
@@ -20,7 +20,8 @@ export const schema = appSchema({
{ name: 'description', type: 'string' }, { name: 'description', type: 'string' },
{ name: 'category_id', type: 'string', isIndexed: true }, { name: 'category_id', type: 'string', isIndexed: true },
{ name: 'priority', type: 'string' }, { name: 'priority', type: 'string' },
{ name: 'completed', type: 'boolean' }, { name: 'completed', type: 'boolean', isIndexed: true },
{ name: 'completed_at', type: 'number', isOptional: true },
{ name: 'due_date', type: 'number', isIndexed: true }, { name: 'due_date', type: 'number', isIndexed: true },
{ name: 'due_time', type: 'string' }, { name: 'due_time', type: 'string' },
{ name: 'end_time', type: 'string' }, { name: 'end_time', type: 'string' },
@@ -33,14 +34,15 @@ export const schema = appSchema({
{ name: 'reminder', type: 'string' }, { name: 'reminder', type: 'string' },
{ name: 'reminders', type: 'string' }, { name: 'reminders', type: 'string' },
{ name: 'assignee_id', type: 'string', isOptional: true }, { name: 'assignee_id', type: 'string', isOptional: true },
{ name: 'created_at', type: 'number' }, { name: 'created_at', type: 'number', isIndexed: true },
{ name: 'updated_at', type: 'number' }, { name: 'updated_at', type: 'number', isIndexed: true },
], ],
}), }),
tableSchema({ tableSchema({
name: 'subtasks', name: 'subtasks',
columns: [ columns: [
{ name: 'task_id', type: 'string', isIndexed: true }, { name: 'task_id', type: 'string', isIndexed: true },
{ name: 'parent_subtask_id', type: 'string', isIndexed: true, isOptional: true },
{ name: 'title', type: 'string' }, { name: 'title', type: 'string' },
{ name: 'description', type: 'string', isOptional: true }, { name: 'description', type: 'string', isOptional: true },
{ name: 'priority', type: 'string', isOptional: true }, { name: 'priority', type: 'string', isOptional: true },
@@ -57,8 +59,8 @@ export const schema = appSchema({
{ name: 'reminders', type: 'string', isOptional: true }, { name: 'reminders', type: 'string', isOptional: true },
{ name: 'assignee_id', type: 'string', isOptional: true }, { name: 'assignee_id', type: 'string', isOptional: true },
{ name: 'order', type: 'number' }, { name: 'order', type: 'number' },
{ name: 'created_at', type: 'number' }, { name: 'created_at', type: 'number', isIndexed: true },
{ name: 'updated_at', type: 'number' }, { name: 'updated_at', type: 'number', isIndexed: true },
], ],
}), }),
tableSchema({ tableSchema({
@@ -69,7 +71,7 @@ export const schema = appSchema({
{ name: 'repeat_interval', type: 'number' }, { name: 'repeat_interval', type: 'number' },
{ name: 'repeat_days', type: 'string' }, { name: 'repeat_days', type: 'string' },
{ name: 'created_at', type: 'number' }, { name: 'created_at', type: 'number' },
{ name: 'updated_at', type: 'number' }, { name: 'updated_at', type: 'number', isIndexed: true },
], ],
}), }),
tableSchema({ tableSchema({
@@ -79,7 +81,7 @@ export const schema = appSchema({
{ name: 'friend_id', type: 'string', isIndexed: true }, { name: 'friend_id', type: 'string', isIndexed: true },
{ name: 'status', type: 'string' }, { name: 'status', type: 'string' },
{ name: 'created_at', type: 'number' }, { name: 'created_at', type: 'number' },
{ name: 'updated_at', type: 'number' }, { name: 'updated_at', type: 'number', isIndexed: true },
], ],
}), }),
], ],
+116 -88
View File
@@ -1,6 +1,7 @@
import { AppState, AppStateStatus } from 'react-native'; import { AppState, AppStateStatus } from 'react-native';
import { database, collections } from './index'; import { database, collections } from './index';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import { Q } from '@nozbe/watermelondb';
import { apiFetch, getAuthToken } from '@/services/auth'; import { apiFetch, getAuthToken } from '@/services/auth';
import Category from '@/models/Category'; import Category from '@/models/Category';
import Task from '@/models/Task'; import Task from '@/models/Task';
@@ -8,15 +9,6 @@ import Subtask from '@/models/Subtask';
import RepeatProfile from '@/models/RepeatProfile'; import RepeatProfile from '@/models/RepeatProfile';
import Friendship from '@/models/Friendship'; import Friendship from '@/models/Friendship';
interface FriendshipRow {
id: string;
userId: string;
friendId: string;
status: string;
createdAt: number;
updatedAt: number;
}
const LAST_PULLED_AT_KEY = 'sync:lastPulledAt'; const LAST_PULLED_AT_KEY = 'sync:lastPulledAt';
const LAST_RUN_AT_KEY = 'sync:lastRunAt'; const LAST_RUN_AT_KEY = 'sync:lastRunAt';
@@ -108,17 +100,13 @@ export async function runSync(): Promise<SyncResult> {
async function pushChanges(): Promise<PushConflict[]> { async function pushChanges(): Promise<PushConflict[]> {
const lastPulledAt = await getLastPulledAt(); const lastPulledAt = await getLastPulledAt();
const [categories, tasks, subtasks, repeatProfiles, friendships] = await Promise.all([ const [tasks, subtasks, repeatProfiles, friendships] = await Promise.all([
collections.categories.query().fetch(), collections.tasks.query(Q.where('updated_at', Q.gt(lastPulledAt))).fetch(),
collections.tasks.query().fetch(), collections.subtasks.query(Q.where('updated_at', Q.gt(lastPulledAt))).fetch(),
collections.subtasks.query().fetch(), collections.repeatProfiles.query(Q.where('updated_at', Q.gt(lastPulledAt))).fetch(),
collections.repeatProfiles.query().fetch(), collections.friendships.query(Q.where('updated_at', Q.gt(lastPulledAt))).fetch(),
collections.friendships.query().fetch(),
]); ]);
const localTaskIds = new Set(tasks.map((t) => t.id));
const taskById = new Map(tasks.map((t) => [t.id, t]));
const taskPayload = (t: Task) => ({ const taskPayload = (t: Task) => ({
id: t.id, id: t.id,
title: t.title, title: t.title,
@@ -141,81 +129,97 @@ async function pushChanges(): Promise<PushConflict[]> {
updatedAt: t.updatedAt.getTime(), updatedAt: t.updatedAt.getTime(),
}); });
const changedTasks = tasks const changedTasks: ReturnType<typeof taskPayload>[] = [];
.filter((t) => t.updatedAt.getTime() > lastPulledAt) const includedTaskIds = new Set<string>();
.map(taskPayload); const missingTaskIds = new Set<string>();
const referencedCategoryIds = new Set<string>();
const changedSubtasks = subtasks for (const t of tasks) {
.filter((s) => localTaskIds.has(s.taskId) && s.updatedAt.getTime() > lastPulledAt) changedTasks.push(taskPayload(t));
.map((s) => ({ includedTaskIds.add(t.id);
id: s.id, if (t.categoryId) referencedCategoryIds.add(t.categoryId);
taskId: s.taskId,
title: s.title,
description: s.description ?? '',
priority: s.priority ?? 'none',
completed: s.completed,
dueDate: s.dueDate ?? 0,
dueTime: s.dueTime ?? '',
endTime: s.endTime ?? '',
allDay: s.allDay ?? false,
repeat: s.repeat ?? 'none',
repeatInterval: s.repeatInterval ?? 1,
repeatDays: s.repeatDays ?? '',
seriesId: s.seriesId ?? '',
reminder: s.reminder ?? 'none',
reminders: s.reminders ?? '',
assigneeId: s.assigneeId ?? null,
order: s.order,
createdAt: s.createdAt.getTime(),
updatedAt: s.updatedAt.getTime(),
}));
const referencedCategoryIds = new Set(
changedTasks.map((t) => t.categoryId).filter((id) => id && id.length > 0)
);
const includedTaskIds = new Set(changedTasks.map((t) => t.id));
for (const sub of changedSubtasks) {
if (includedTaskIds.has(sub.taskId) || !taskById.has(sub.taskId)) continue;
const task = taskById.get(sub.taskId)!;
includedTaskIds.add(sub.taskId);
changedTasks.push(taskPayload(task));
if (task.categoryId) referencedCategoryIds.add(task.categoryId);
} }
const changedCategories = categories const changedSubtasks = subtasks.map((s) => ({
.filter((c) => c.updatedAt.getTime() > lastPulledAt || referencedCategoryIds.has(c.id)) id: s.id,
.map((c) => ({ taskId: s.taskId,
id: c.id, title: s.title,
name: c.name, description: s.description ?? '',
color: c.color, priority: s.priority ?? 'none',
order: c.order, completed: s.completed,
createdAt: c.createdAt.getTime(), dueDate: s.dueDate ?? 0,
updatedAt: c.updatedAt.getTime(), dueTime: s.dueTime ?? '',
})); endTime: s.endTime ?? '',
allDay: s.allDay ?? false,
repeat: s.repeat ?? 'none',
repeatInterval: s.repeatInterval ?? 1,
repeatDays: s.repeatDays ?? '',
seriesId: s.seriesId ?? '',
reminder: s.reminder ?? 'none',
reminders: s.reminders ?? '',
assigneeId: s.assigneeId ?? null,
order: s.order,
createdAt: s.createdAt.getTime(),
updatedAt: s.updatedAt.getTime(),
}));
const changedRepeatProfiles = repeatProfiles for (const sub of changedSubtasks) {
.filter((p) => p.updatedAt.getTime() > lastPulledAt) if (!includedTaskIds.has(sub.taskId)) {
.map((p) => ({ missingTaskIds.add(sub.taskId);
id: p.id, }
name: p.name, }
repeat: p.repeat,
repeatInterval: p.repeatInterval,
repeatDays: p.repeatDays,
createdAt: p.createdAt.getTime(),
updatedAt: p.updatedAt.getTime(),
}));
const changedFriendships = friendships if (missingTaskIds.size > 0) {
.filter((f) => f.updatedAt.getTime() > lastPulledAt) const parentTasks = await collections.tasks
.map((f) => ({ .query(Q.where('id', Q.oneOf(Array.from(missingTaskIds))))
id: f.id, .fetch();
userId: f.userId, for (const t of parentTasks) {
friendId: f.friendId, includedTaskIds.add(t.id);
status: f.status, changedTasks.push(taskPayload(t));
createdAt: f.createdAt.getTime(), if (t.categoryId) referencedCategoryIds.add(t.categoryId);
updatedAt: f.updatedAt.getTime(), }
})); }
let categoryQuery = collections.categories.query();
if (referencedCategoryIds.size > 0) {
categoryQuery = collections.categories.query(
Q.or(
Q.where('updated_at', Q.gt(lastPulledAt)),
Q.where('id', Q.oneOf(Array.from(referencedCategoryIds)))
)
);
} else {
categoryQuery = collections.categories.query(Q.where('updated_at', Q.gt(lastPulledAt)));
}
const categories = await categoryQuery.fetch();
const changedCategories = categories.map((c) => ({
id: c.id,
name: c.name,
color: c.color,
order: c.order,
createdAt: c.createdAt.getTime(),
updatedAt: c.updatedAt.getTime(),
}));
const changedRepeatProfiles = repeatProfiles.map((p) => ({
id: p.id,
name: p.name,
repeat: p.repeat,
repeatInterval: p.repeatInterval,
repeatDays: p.repeatDays,
createdAt: p.createdAt.getTime(),
updatedAt: p.updatedAt.getTime(),
}));
const changedFriendships = friendships.map((f) => ({
id: f.id,
userId: f.userId,
friendId: f.friendId,
status: f.status,
createdAt: f.createdAt.getTime(),
updatedAt: f.updatedAt.getTime(),
}));
if ( if (
changedCategories.length === 0 && changedCategories.length === 0 &&
@@ -542,6 +546,30 @@ export function stopWatchingForUpdates(): void {
watcherStop?.(); watcherStop?.();
} }
export function startAutoSync(intervalMs: number = DEFAULT_WATCH_INTERVAL_MS): UpdateWatcher {
return watchForUpdates(intervalMs);
}
export function stopAutoSync(): void {
stopWatchingForUpdates();
}
export async function runSyncGuarded(): Promise<SyncResult> {
if (syncInFlight) {
throw new Error('SYNC_IN_FLIGHT');
}
syncInFlight = true;
try {
return await runSync();
} finally {
syncInFlight = false;
}
}
export function isSyncInFlight(): boolean {
return syncInFlight;
}
export function isWatchingForUpdates(): boolean { export function isWatchingForUpdates(): boolean {
return watcherActive; return watcherActive;
} }
+56 -13
View File
@@ -1,11 +1,12 @@
import React, { createContext, useContext, useEffect, useState, ReactNode } from 'react'; import React, { createContext, useContext, useEffect, useState, ReactNode, useMemo } from 'react';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { database, collections } from '../database'; import { database, collections } from '../database';
import { runSync, watchForUpdates, stopWatchingForUpdates } from '../database/sync'; import { runSyncGuarded, startAutoSync, stopAutoSync } from '../database/sync';
import { getAuthToken } from '../services/auth'; import { getAuthToken } from '../services/auth';
import { DEFAULT_CATEGORIES } from '../constants'; import { DEFAULT_CATEGORIES } from '../constants';
import Category from '../models/Category'; import Category from '../models/Category';
import RepeatProfile from '../models/RepeatProfile'; import RepeatProfile from '../models/RepeatProfile';
import { Q } from '@nozbe/watermelondb';
interface DatabaseContextType { interface DatabaseContextType {
database: typeof database; database: typeof database;
@@ -23,7 +24,43 @@ export function DatabaseProvider({ children }: { children: ReactNode }) {
try { try {
const existingCategories = await collections.categories.query().fetch(); const existingCategories = await collections.categories.query().fetch();
if (existingCategories.length === 0) { // Merge categories with the same spelling (case-insensitive)
const categoriesByName = new Map<string, typeof existingCategories[0]>();
const duplicates: typeof existingCategories = [];
for (const cat of existingCategories) {
const key = cat.name.toLowerCase();
if (categoriesByName.has(key)) {
duplicates.push(cat);
} else {
categoriesByName.set(key, cat);
}
}
if (duplicates.length > 0) {
await database.write(async () => {
for (const dup of duplicates) {
const keep = categoriesByName.get(dup.name.toLowerCase())!;
// Reassign tasks from duplicate to kept category
const tasksToUpdate = await collections.tasks.query(Q.where('category_id', dup.id)).fetch();
for (const task of tasksToUpdate) {
await task.update((t) => { t.categoryId = keep.id; });
}
// Reassign subtasks too (they're linked via task_id, so we update their parent tasks)
const subtasksToUpdate = await collections.subtasks.query(Q.where('task_id', dup.id)).fetch();
for (const sub of subtasksToUpdate) {
const parentTask = await collections.tasks.find(sub.taskId);
await parentTask.update((t) => { t.categoryId = keep.id; });
}
// Delete duplicate category
await dup.destroyPermanently();
}
});
}
const freshCategories = await collections.categories.query().fetch();
if (freshCategories.length === 0) {
await database.write(async () => { await database.write(async () => {
for (const cat of DEFAULT_CATEGORIES) { for (const cat of DEFAULT_CATEGORIES) {
await collections.categories.create((c) => { await collections.categories.create((c) => {
@@ -41,13 +78,10 @@ export function DatabaseProvider({ children }: { children: ReactNode }) {
const token = await getAuthToken(); const token = await getAuthToken();
if (token) { if (token) {
runSync() runSyncGuarded().catch(() => {});
.catch(() => {}) startAutoSync();
.finally(() => {
watchForUpdates();
});
} else { } else {
stopWatchingForUpdates(); stopAutoSync();
} }
} catch (error) { } catch (error) {
console.error('Failed to initialize database:', error); console.error('Failed to initialize database:', error);
@@ -56,9 +90,10 @@ export function DatabaseProvider({ children }: { children: ReactNode }) {
}; };
useEffect(() => { useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
initializeDatabase(); initializeDatabase();
return () => { return () => {
stopWatchingForUpdates(); stopAutoSync();
}; };
}, []); }, []);
@@ -103,9 +138,17 @@ export function useCategories(): Category[] {
return categories; return categories;
} }
export function useCategory(categoryId: string) { export function useUniqueCategories(): Category[] {
const { collections } = useDatabase(); const categories = useCategories();
return collections.categories.find(categoryId); return useMemo(() => {
const seen = new Map<string, Category>();
for (const cat of categories) {
if (!seen.has(cat.name)) {
seen.set(cat.name, cat);
}
}
return Array.from(seen.values()).sort((a, b) => a.order - b.order);
}, [categories]);
} }
export function useRepeatProfiles(): RepeatProfile[] { export function useRepeatProfiles(): RepeatProfile[] {
+1
View File
@@ -40,6 +40,7 @@ export function FriendsProvider({ children }: { children: React.ReactNode }) {
}, []); }, []);
useEffect(() => { useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchFriends(); fetchFriends();
}, [fetchFriends]); }, [fetchFriends]);
+199
View File
@@ -0,0 +1,199 @@
import { useEffect, useState } from 'react';
import { useDatabase } from './useDatabase';
const DAY_MS = 24 * 60 * 60 * 1000;
const WEEK_MS = 7 * DAY_MS;
const MONTH_MS = 30 * DAY_MS;
export interface DailyCompletion {
label: string;
count: number;
}
export interface CategoryStat {
id: string;
name: string;
color: string;
count: number;
}
export interface PriorityStat {
priority: string;
label: string;
count: number;
}
export interface TaskStats {
loading: boolean;
totalCompleted: number;
completedLast7: number;
completedLast30: number;
activeCount: number;
overdueCount: number;
completionRate: number;
daily: DailyCompletion[];
byCategory: CategoryStat[];
byPriority: PriorityStat[];
currentStreak: number;
}
export const PRIORITY_LABELS: Record<string, string> = {
high: 'High',
medium: 'Medium',
low: 'Low',
none: 'No priority',
};
function startOfDay(ts: number): number {
const d = new Date(ts);
d.setHours(0, 0, 0, 0);
return d.getTime();
}
function dayLabel(dayStart: number): string {
const d = new Date(dayStart);
const today = new Date();
today.setHours(0, 0, 0, 0);
const diff = Math.round((today.getTime() - d.getTime()) / DAY_MS);
if (diff === 0) return 'Today';
if (diff === 1) return 'Yest';
return d.toLocaleDateString(undefined, { weekday: 'short' });
}
const EMPTY_STATS: TaskStats = {
loading: true,
totalCompleted: 0,
completedLast7: 0,
completedLast30: 0,
activeCount: 0,
overdueCount: 0,
completionRate: 0,
daily: [],
byCategory: [],
byPriority: [],
currentStreak: 0,
};
export function useStats(): TaskStats {
const { collections, isReady } = useDatabase();
const [stats, setStats] = useState<TaskStats>(EMPTY_STATS);
useEffect(() => {
if (!isReady) return;
let mounted = true;
const compute = () => {
Promise.all([
collections.tasks.query().fetch(),
collections.categories.query().fetch(),
])
.then(([allTasks, categories]) => {
const now = Date.now();
const todayStart = startOfDay(now);
const weekStart = todayStart - WEEK_MS;
const monthStart = todayStart - MONTH_MS;
const categoryCounts = new Map<string, number>();
const priorityCounts = new Map<string, number>();
const dayBucket = new Map<number, number>();
let totalCompleted = 0;
let completedLast7 = 0;
let completedLast30 = 0;
let activeCount = 0;
let overdueCount = 0;
for (const t of allTasks) {
if (t.completed) {
if (t.completedAt == null) continue;
const ts = t.completedAt as number;
totalCompleted++;
categoryCounts.set(t.categoryId, (categoryCounts.get(t.categoryId) ?? 0) + 1);
priorityCounts.set(t.priority, (priorityCounts.get(t.priority) ?? 0) + 1);
if (ts >= weekStart) completedLast7++;
if (ts >= monthStart) completedLast30++;
const day = startOfDay(ts);
dayBucket.set(day, (dayBucket.get(day) ?? 0) + 1);
} else {
activeCount++;
if (t.dueDate > 0 && t.dueDate < now) overdueCount++;
}
}
const daily: DailyCompletion[] = [];
for (let i = 6; i >= 0; i--) {
const dayStart = todayStart - i * DAY_MS;
daily.push({
label: dayLabel(dayStart),
count: dayBucket.get(dayStart) ?? 0,
});
}
const categoryMap = new Map(categories.map((c) => [c.id, c]));
const byCategory: CategoryStat[] = Array.from(categoryMap.values())
.map((c) => ({
id: c.id,
name: c.name,
color: c.color,
count: categoryCounts.get(c.id) ?? 0,
}))
.filter((c) => c.count > 0)
.sort((a, b) => b.count - a.count);
const priorityOrder = ['high', 'medium', 'low', 'none'];
const byPriority: PriorityStat[] = priorityOrder
.map((p) => ({
priority: p,
label: PRIORITY_LABELS[p],
count: priorityCounts.get(p) ?? 0,
}))
.filter((p) => p.count > 0);
let streak = 0;
const todayCount = dayBucket.get(todayStart) ?? 0;
let cursor = todayCount > 0 ? todayStart : todayStart - DAY_MS;
while ((dayBucket.get(cursor) ?? 0) > 0 && streak < 365) {
streak++;
cursor -= DAY_MS;
}
if (mounted) {
setStats({
loading: false,
totalCompleted,
completedLast7,
completedLast30,
activeCount,
overdueCount,
completionRate: totalCompleted > 0
? Math.round((totalCompleted / (totalCompleted + activeCount)) * 100)
: 0,
daily,
byCategory,
byPriority,
currentStreak: streak,
});
}
})
.catch(() => {
if (mounted) setStats((s) => ({ ...s, loading: false }));
});
};
compute();
const subscription = collections.tasks.query().observe().subscribe({
next: () => {
compute();
},
error: () => {},
});
return () => {
mounted = false;
subscription.unsubscribe();
};
}, [collections, isReady]);
return stats;
}
+344
View File
@@ -0,0 +1,344 @@
import React, { useCallback, useRef, useState } from 'react';
import { View, Text, StyleSheet, Modal, TextInput, TouchableOpacity } from 'react-native';
import { useRouter } from 'expo-router';
import { TaskData, SubtaskData } from '@/types';
import { PRIORITY_LABELS } from '@/constants';
import { useCategories } from '@/hooks/useDatabase';
import { useSettings } from '@/theme';
import { TaskOverflowMenu } from '@/components/TaskOverflowMenu';
import { TaskDeleteModal } from '@/components/TaskDeleteModal';
import { OptionPickerModal } from '@/components/OptionPickerModal';
import {
toggleTaskComplete,
deleteTaskOccurrences,
duplicateTask,
setTaskCategory,
setTaskPriority,
toggleSubtaskComplete,
deleteSubtask,
duplicateSubtask,
setSubtaskPriority,
createSubtask,
TaskDeleteScope,
} from '@/utils/taskActions';
const PRIORITY_OPTIONS = Object.entries(PRIORITY_LABELS).map(([value, label]) => ({ value, label }));
interface AddSubtaskModalProps {
visible: boolean;
parentSubtask: SubtaskData | null;
onClose: () => void;
onAdd: (title: string) => void;
}
function AddSubtaskModal({ visible, parentSubtask, onClose, onAdd }: AddSubtaskModalProps) {
const { theme } = useSettings();
const [title, setTitle] = React.useState('');
React.useEffect(() => {
if (visible) {
setTitle('');
}
}, [visible]);
const handleSubmit = () => {
if (title.trim()) {
onAdd(title.trim());
}
};
if (!visible || !parentSubtask) return null;
const styles = StyleSheet.create({
overlay: {
flex: 1,
justifyContent: 'flex-end',
},
sheet: {
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
paddingTop: 8,
paddingHorizontal: 16,
paddingBottom: 32,
},
handle: {
width: 40,
height: 4,
borderRadius: 2,
alignSelf: 'center',
marginBottom: 12,
},
title: {
fontSize: 16,
fontWeight: '600',
textAlign: 'center',
marginBottom: 16,
color: theme.text,
},
inputContainer: {
marginBottom: 16,
},
input: {
fontSize: 16,
paddingHorizontal: 16,
paddingVertical: 12,
borderRadius: 12,
borderWidth: 1,
color: theme.text,
backgroundColor: theme.card,
borderColor: theme.border,
},
buttonRow: {
flexDirection: 'row',
gap: 8,
},
button: {
flex: 1,
paddingVertical: 14,
borderRadius: 12,
alignItems: 'center',
},
buttonText: {
fontSize: 16,
fontWeight: '600',
color: '#FFFFFF',
},
buttonCancel: {
backgroundColor: theme.card,
borderWidth: 1,
borderColor: theme.border,
},
buttonAdd: {
backgroundColor: theme.accent,
},
});
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<TouchableOpacity
style={[styles.overlay, { backgroundColor: theme.overlay }]}
activeOpacity={1}
onPress={onClose}
>
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
<View style={[styles.handle, { backgroundColor: theme.borderStrong }]} />
<Text style={styles.title}>
Add Subtask to &ldquo;{parentSubtask.title}&rdquo;
</Text>
<View style={styles.inputContainer}>
<TextInput
style={styles.input}
placeholder="Subtask name"
value={title}
onChangeText={setTitle}
autoCapitalize="sentences"
maxLength={100}
autoFocus
onSubmitEditing={handleSubmit}
/>
</View>
<View style={styles.buttonRow}>
<TouchableOpacity style={styles.buttonCancel} onPress={onClose} activeOpacity={0.7}>
<Text style={styles.buttonText}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.buttonAdd} onPress={handleSubmit} activeOpacity={0.7}>
<Text style={styles.buttonText}>Add</Text>
</TouchableOpacity>
</View>
</View>
</TouchableOpacity>
</Modal>
);
}
export function useTaskModals() {
const router = useRouter();
const categories = useCategories();
const [menuTask, setMenuTask] = useState<TaskData | null>(null);
const [menuSubtask, setMenuSubtask] = useState<SubtaskData | null>(null);
const [deleteTarget, setDeleteTarget] = useState<TaskData | null>(null);
const [deleteSubtaskTarget, setDeleteSubtaskTarget] = useState<SubtaskData | null>(null);
const [picker, setPicker] = useState<null | { type: 'category' | 'priority'; taskId?: string }>(null);
const [subtaskPicker, setSubtaskPicker] = useState<null | { type: 'priority'; subtaskId?: string }>(null);
const [addSubtaskTarget, setAddSubtaskTarget] = useState<SubtaskData | null>(null);
const refreshRef = useRef<() => void>(() => {});
const openTaskMenu = useCallback((task: TaskData) => setMenuTask(task), []);
const openSubtaskMenu = useCallback((subtask: SubtaskData) => setMenuSubtask(subtask), []);
const openTaskDelete = useCallback((task: TaskData) => setDeleteTarget(task), []);
const openSubtaskDelete = useCallback((subtask: SubtaskData) => setDeleteSubtaskTarget(subtask), []);
const openTaskEdit = useCallback((taskId: string) => {
router.push({ pathname: '/task-detail', params: { id: taskId } });
}, [router]);
const openSubtaskEdit = useCallback((subtaskId: string) => {
router.push({ pathname: '/subtask-detail', params: { id: subtaskId } });
}, [router]);
const openSubtaskAdd = useCallback((subtask: SubtaskData) => {
setAddSubtaskTarget(subtask);
}, []);
const handleToggleComplete = useCallback(async (taskId: string) => {
await toggleTaskComplete(taskId);
refreshRef.current();
}, []);
const handleSubtaskToggle = useCallback(async (subtask: SubtaskData) => {
await toggleSubtaskComplete(subtask.id);
refreshRef.current();
}, []);
const handleDuplicate = useCallback(async (taskId: string) => {
await duplicateTask(taskId);
refreshRef.current();
}, []);
const handleSubtaskDuplicate = useCallback(async (subtaskId: string) => {
await duplicateSubtask(subtaskId);
refreshRef.current();
}, []);
const handleDeleteScope = useCallback(async (scope: TaskDeleteScope) => {
const target = deleteTarget;
if (!target) return;
setDeleteTarget(null);
await deleteTaskOccurrences(target.id, scope);
refreshRef.current();
}, [deleteTarget]);
const handleDeleteSubtask = useCallback(async () => {
const target = deleteSubtaskTarget;
if (!target) return;
setDeleteSubtaskTarget(null);
await deleteSubtask(target.id);
refreshRef.current();
}, [deleteSubtaskTarget]);
const handleSingleCategory = useCallback(async (value: string | string[]) => {
if (picker?.taskId) {
await setTaskCategory(picker.taskId, Array.isArray(value) ? value[0] : value);
refreshRef.current();
}
}, [picker]);
const handleSinglePriority = useCallback(async (value: string | string[]) => {
if (picker?.taskId) {
await setTaskPriority(picker.taskId, (Array.isArray(value) ? value[0] : value) as TaskData['priority']);
refreshRef.current();
}
}, [picker]);
const handleSubtaskPriority = useCallback(async (value: string | string[]) => {
if (subtaskPicker?.subtaskId) {
await setSubtaskPriority(subtaskPicker.subtaskId, (Array.isArray(value) ? value[0] : value) as TaskData['priority']);
refreshRef.current();
}
}, [subtaskPicker]);
const handleAddSubtask = useCallback(async (title: string) => {
const target = addSubtaskTarget;
if (!target || !title.trim()) return;
await createSubtask({
taskId: target.taskId,
parentSubtaskId: target.id,
title: title.trim(),
});
setAddSubtaskTarget(null);
refreshRef.current();
}, [addSubtaskTarget]);
const modals = (refresh: () => void) => {
refreshRef.current = refresh;
return (
<>
<TaskDeleteModal
visible={deleteTarget !== null}
taskId={deleteTarget?.id ?? null}
taskTitle={deleteTarget?.title}
isRepeating={deleteTarget ? deleteTarget.repeat !== 'none' : false}
onClose={() => setDeleteTarget(null)}
onDelete={handleDeleteScope}
/>
<TaskDeleteModal
visible={deleteSubtaskTarget !== null}
taskId={deleteSubtaskTarget?.id ?? null}
taskTitle={deleteSubtaskTarget?.title}
subtask
onClose={() => setDeleteSubtaskTarget(null)}
onDelete={handleDeleteSubtask}
/>
<TaskOverflowMenu
visible={menuTask !== null}
task={menuTask}
onClose={() => setMenuTask(null)}
onEdit={() => menuTask && openTaskEdit(menuTask.id)}
onDelete={() => menuTask && openTaskDelete(menuTask)}
onDuplicate={() => menuTask && handleDuplicate(menuTask.id)}
onToggleComplete={() => menuTask && handleToggleComplete(menuTask.id)}
onChangeCategory={() => menuTask && setPicker({ type: 'category', taskId: menuTask.id })}
onChangePriority={() => menuTask && setPicker({ type: 'priority', taskId: menuTask.id })}
/>
<TaskOverflowMenu
visible={menuSubtask !== null}
task={menuSubtask}
subtask
onClose={() => setMenuSubtask(null)}
onEdit={() => menuSubtask && openSubtaskEdit(menuSubtask.id)}
onDelete={() => menuSubtask && openSubtaskDelete(menuSubtask)}
onDuplicate={() => menuSubtask && handleSubtaskDuplicate(menuSubtask.id)}
onToggleComplete={() => menuSubtask && handleSubtaskToggle(menuSubtask)}
onChangePriority={() => menuSubtask && setSubtaskPicker({ type: 'priority', subtaskId: menuSubtask.id })}
onAddSubtask={() => menuSubtask && openSubtaskAdd(menuSubtask)}
/>
<AddSubtaskModal
visible={addSubtaskTarget !== null}
parentSubtask={addSubtaskTarget}
onClose={() => setAddSubtaskTarget(null)}
onAdd={handleAddSubtask}
/>
<OptionPickerModal
visible={picker?.type === 'category'}
title="Change Category"
options={categories.map((c) => ({ value: c.id, label: c.name, color: c.color }))}
selectedValue={menuTask?.categoryId}
onSelect={handleSingleCategory}
onClose={() => setPicker(null)}
/>
<OptionPickerModal
visible={picker?.type === 'priority'}
title="Change Priority"
options={PRIORITY_OPTIONS}
selectedValue={menuTask?.priority}
onSelect={handleSinglePriority}
onClose={() => setPicker(null)}
/>
<OptionPickerModal
visible={subtaskPicker?.type === 'priority'}
title="Change Priority"
options={PRIORITY_OPTIONS}
selectedValue={menuSubtask?.priority}
onSelect={handleSubtaskPriority}
onClose={() => setSubtaskPicker(null)}
/>
</>
);
};
return {
modals,
openTaskMenu,
openSubtaskMenu,
openTaskDelete,
openSubtaskDelete,
openTaskEdit,
openSubtaskEdit,
openSubtaskAdd,
};
}
+15 -89
View File
@@ -1,6 +1,6 @@
import { useDatabase } from './useDatabase'; import { useDatabase } from './useDatabase';
import { Q } from '@nozbe/watermelondb'; import { Q } from '@nozbe/watermelondb';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useState, useMemo } from 'react';
import Task from '../models/Task'; import Task from '../models/Task';
export function useTasks(categoryId?: string, showCompleted = false) { export function useTasks(categoryId?: string, showCompleted = false) {
@@ -16,7 +16,9 @@ export function useTasks(categoryId?: string, showCompleted = false) {
conditions.push(Q.where('category_id', categoryId)); conditions.push(Q.where('category_id', categoryId));
} }
if (!showCompleted) { if (showCompleted) {
conditions.push(Q.where('completed', true));
} else {
conditions.push(Q.where('completed', false)); conditions.push(Q.where('completed', false));
} }
@@ -47,21 +49,21 @@ export function useTasks(categoryId?: string, showCompleted = false) {
return { tasks, loading }; return { tasks, loading };
} }
export function useTask(taskId: string) {
const { collections } = useDatabase();
return collections.tasks.find(taskId);
}
export function useTasksByDate(date: Date) { export function useTasksByDate(date: Date) {
const { collections } = useDatabase(); const { collections } = useDatabase();
const [tasks, setTasks] = useState<Task[]>([]); const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const startOfDay = new Date(date); const startOfDay = useMemo(() => {
startOfDay.setHours(0, 0, 0, 0); const d = new Date(date);
d.setHours(0, 0, 0, 0);
const endOfDay = new Date(date); return d;
endOfDay.setHours(23, 59, 59, 999); }, [date]);
const endOfDay = useMemo(() => {
const d = new Date(date);
d.setHours(23, 59, 59, 999);
return d;
}, [date]);
useEffect(() => { useEffect(() => {
let mounted = true; let mounted = true;
@@ -87,83 +89,7 @@ export function useTasksByDate(date: Date) {
mounted = false; mounted = false;
subscription.unsubscribe(); subscription.unsubscribe();
}; };
}, [collections, startOfDay.getTime(), endOfDay.getTime()]); }, [collections, startOfDay, endOfDay]);
return { tasks, loading };
}
export function useOverdueTasks() {
const { collections } = useDatabase();
const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true);
const now = useMemo(() => Date.now(), []);
useEffect(() => {
let mounted = true;
const subscription = collections.tasks
.query(
Q.where('completed', false),
Q.where('due_date', Q.lt(now)),
Q.sortBy('due_date', 'asc')
)
.observe()
.subscribe({
next: (result) => {
if (mounted) {
setTasks(result);
setLoading(false);
}
},
error: () => {
if (mounted) setLoading(false);
},
});
return () => {
mounted = false;
subscription.unsubscribe();
};
}, [collections, now]);
return { tasks, loading };
}
export function useTasksDueToday() {
const { collections } = useDatabase();
const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const today = new Date();
today.setHours(0, 0, 0, 0);
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
let mounted = true;
const subscription = collections.tasks
.query(
Q.where('completed', false),
Q.where('due_date', Q.between(today.getTime(), tomorrow.getTime() - 1)),
Q.sortBy('due_date', 'asc')
)
.observe()
.subscribe({
next: (result) => {
if (mounted) {
setTasks(result);
setLoading(false);
}
},
error: () => {
if (mounted) setLoading(false);
},
});
return () => {
mounted = false;
subscription.unsubscribe();
};
}, [collections]);
return { tasks, loading }; return { tasks, loading };
} }
+1 -1
View File
@@ -1,5 +1,5 @@
import { Model } from '@nozbe/watermelondb'; import { Model } from '@nozbe/watermelondb';
import { field, date, readonly } from '@nozbe/watermelondb/decorators'; import { field, date } from '@nozbe/watermelondb/decorators';
export default class Category extends Model { export default class Category extends Model {
static table = 'categories'; static table = 'categories';
+1 -1
View File
@@ -1,5 +1,5 @@
import { Model } from '@nozbe/watermelondb'; import { Model } from '@nozbe/watermelondb';
import { field, date, readonly } from '@nozbe/watermelondb/decorators'; import { field, date } from '@nozbe/watermelondb/decorators';
export default class Friendship extends Model { export default class Friendship extends Model {
static table = 'friendships'; static table = 'friendships';
+9 -1
View File
@@ -1,11 +1,17 @@
import { Model } from '@nozbe/watermelondb'; import { Model } from '@nozbe/watermelondb';
import { field, date, readonly } from '@nozbe/watermelondb/decorators'; import { field, date, children } from '@nozbe/watermelondb/decorators';
import { Associations } from '@nozbe/watermelondb/Model';
import { Priority, Repeat, Reminder } from '@/types'; import { Priority, Repeat, Reminder } from '@/types';
export default class Subtask extends Model { export default class Subtask extends Model {
static table = 'subtasks'; static table = 'subtasks';
static associations: Associations = {
subtasks: { type: 'has_many', foreignKey: 'parent_subtask_id' },
};
@field('task_id') taskId!: string; @field('task_id') taskId!: string;
@field('parent_subtask_id') parentSubtaskId!: string | null;
@field('title') title!: string; @field('title') title!: string;
@field('description') description!: string; @field('description') description!: string;
@field('priority') priority!: Priority; @field('priority') priority!: Priority;
@@ -24,4 +30,6 @@ export default class Subtask extends Model {
@field('order') order!: number; @field('order') order!: number;
@date('created_at') createdAt!: Date; @date('created_at') createdAt!: Date;
@date('updated_at') updatedAt!: Date; @date('updated_at') updatedAt!: Date;
@children('subtasks') subtasks!: any;
} }
+3 -1
View File
@@ -1,5 +1,5 @@
import { Model } from '@nozbe/watermelondb'; import { Model } from '@nozbe/watermelondb';
import { field, date, readonly, children } from '@nozbe/watermelondb/decorators'; import { field, date, children } from '@nozbe/watermelondb/decorators';
import { Associations } from '@nozbe/watermelondb/Model'; import { Associations } from '@nozbe/watermelondb/Model';
import { Priority, Repeat, Reminder } from '@/types'; import { Priority, Repeat, Reminder } from '@/types';
@@ -15,6 +15,7 @@ export default class Task extends Model {
@field('category_id') categoryId!: string; @field('category_id') categoryId!: string;
@field('priority') priority!: Priority; @field('priority') priority!: Priority;
@field('completed') completed!: boolean; @field('completed') completed!: boolean;
@field('completed_at') completedAt!: number | null;
@field('due_date') dueDate!: number; @field('due_date') dueDate!: number;
@field('due_time') dueTime!: string; @field('due_time') dueTime!: string;
@field('end_time') endTime!: string; @field('end_time') endTime!: string;
@@ -27,6 +28,7 @@ export default class Task extends Model {
@field('reminder') reminder!: Reminder; @field('reminder') reminder!: Reminder;
@field('reminders') reminders!: string; @field('reminders') reminders!: string;
@field('assignee_id') assigneeId!: string | null; @field('assignee_id') assigneeId!: string | null;
@field('order') order!: number;
@date('created_at') createdAt!: Date; @date('created_at') createdAt!: Date;
@date('updated_at') updatedAt!: Date; @date('updated_at') updatedAt!: Date;
@@ -24,15 +24,14 @@ async function writeIdMap(map: Record<string, string[]>): Promise<void> {
function getNotificationsModule(): any { function getNotificationsModule(): any {
if (Platform.OS === 'web') return null; if (Platform.OS === 'web') return null;
try { try {
// eslint-disable-next-line @typescript-eslint/no-var-requires return import('expo-notifications');
return require('expo-notifications');
} catch { } catch {
return null; return null;
} }
} }
export async function scheduleTaskReminder(task: any): Promise<void> { export async function scheduleTaskReminder(task: any): Promise<void> {
const notifications = getNotificationsModule(); const notifications = await getNotificationsModule();
const dueDate = task.dueDate ? new Date(task.dueDate) : null; const dueDate = task.dueDate ? new Date(task.dueDate) : null;
if (!notifications) return; if (!notifications) return;
@@ -99,7 +98,7 @@ export async function scheduleTaskReminder(task: any): Promise<void> {
} }
export async function cancelTaskReminder(taskId: string): Promise<void> { export async function cancelTaskReminder(taskId: string): Promise<void> {
const notifications = getNotificationsModule(); const notifications = await getNotificationsModule();
if (!notifications) return; if (!notifications) return;
const idMap = await readIdMap(); const idMap = await readIdMap();
@@ -118,7 +117,7 @@ export async function cancelTaskReminder(taskId: string): Promise<void> {
} }
export async function requestNotificationPermission(): Promise<boolean> { export async function requestNotificationPermission(): Promise<boolean> {
const notifications = getNotificationsModule(); const notifications = await getNotificationsModule();
if (!notifications) return false; if (!notifications) return false;
try { try {
const settings = await notifications.getPermissionsAsync(); const settings = await notifications.getPermissionsAsync();
@@ -134,7 +133,7 @@ export async function requestNotificationPermission(): Promise<boolean> {
} }
export async function rescheduleAllReminders(tasks: any[]): Promise<void> { export async function rescheduleAllReminders(tasks: any[]): Promise<void> {
const notifications = getNotificationsModule(); const notifications = await getNotificationsModule();
if (!notifications) return; if (!notifications) return;
try { try {
await notifications.cancelAllScheduledNotificationsAsync(); await notifications.cancelAllScheduledNotificationsAsync();
+94
View File
@@ -0,0 +1,94 @@
import Constants from 'expo-constants';
import AsyncStorage from '@react-native-async-storage/async-storage';
const GITEA_BASE_URL = 'https://gitea.techmos.org';
const GITEA_OWNER = 'tech08mag';
const GITEA_REPO = 'carry-your-live';
const LAST_PROMPTED_KEY = 'updates:lastPromptedRelease';
export interface AppUpdate {
version: string;
tagName: string;
notes: string;
apkUrl: string | null;
releaseUrl: string;
}
export function getCurrentAppVersion(): string {
return Constants.expoConfig?.version ?? '1.0.0';
}
async function fetchWithTimeout(path: string, timeoutMs: number): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(`${GITEA_BASE_URL}${path}`, { signal: controller.signal });
} finally {
clearTimeout(timer);
}
}
export async function fetchLatestRelease(): Promise<AppUpdate | null> {
try {
const response = await fetchWithTimeout(
`/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/releases/latest`,
8000,
);
if (!response.ok) return null;
const release = await response.json();
const apkAsset = (release.assets ?? []).find(
(asset: { name?: string }) => asset.name && /\.apk$/i.test(asset.name),
);
const tagName: string = release.tag_name ?? release.name ?? '';
return {
version: tagName.replace(/^v/i, ''),
tagName,
notes: release.body ?? '',
apkUrl: apkAsset?.browser_download_url ?? null,
releaseUrl:
release.html_url ??
`${GITEA_BASE_URL}/${GITEA_OWNER}/${GITEA_REPO}/releases/tag/${tagName}`,
};
} catch {
return null;
}
}
export function compareVersions(a: string, b: string): number {
const parse = (value: string) =>
value.split('.').map((part) => parseInt(part.replace(/\D/g, ''), 10) || 0);
const left = parse(a);
const right = parse(b);
const length = Math.max(left.length, right.length);
for (let i = 0; i < length; i++) {
const x = left[i] ?? 0;
const y = right[i] ?? 0;
if (x > y) return 1;
if (x < y) return -1;
}
return 0;
}
export async function checkForUpdates(): Promise<AppUpdate | null> {
const release = await fetchLatestRelease();
if (!release) return null;
if (compareVersions(release.version, getCurrentAppVersion()) <= 0) return null;
return release;
}
export async function wasPromptedFor(tagName: string): Promise<boolean> {
try {
const stored = await AsyncStorage.getItem(LAST_PROMPTED_KEY);
return stored === tagName;
} catch {
return false;
}
}
export async function markPrompted(tagName: string): Promise<void> {
try {
await AsyncStorage.setItem(LAST_PROMPTED_KEY, tagName);
} catch {
// ignore
}
}
+38 -20
View File
@@ -1,4 +1,4 @@
import React, { createContext, useContext, useEffect, useState, ReactNode } from 'react'; import React, { createContext, useCallback, useContext, useEffect, useMemo, useState, ReactNode } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import { DEFAULT_API_BASE_URL, API_URL_KEY } from '@/services/auth'; import { DEFAULT_API_BASE_URL, API_URL_KEY } from '@/services/auth';
@@ -99,10 +99,13 @@ function useStoredSetting<T>(key: string, initialValue: T): [T, (value: T) => vo
.finally(() => setLoaded(true)); .finally(() => setLoaded(true));
}, [key]); }, [key]);
const update = (next: T) => { const update = useCallback(
setValue(next); (next: T) => {
AsyncStorage.setItem(key, JSON.stringify(next)).catch(() => {}); setValue(next);
}; AsyncStorage.setItem(key, JSON.stringify(next)).catch(() => {});
},
[key]
);
return [value, update, loaded]; return [value, update, loaded];
} }
@@ -119,22 +122,37 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
const theme = colors; const theme = colors;
const value = useMemo<SettingsContextType>(
() => ({
notifications,
setNotifications,
defaultCategoryId,
setDefaultCategoryId,
sortBy,
setSortBy,
reminderPreference,
setReminderPreference,
apiUrl,
setApiUrl,
theme,
}),
[
notifications,
setNotifications,
defaultCategoryId,
setDefaultCategoryId,
sortBy,
setSortBy,
reminderPreference,
setReminderPreference,
apiUrl,
setApiUrl,
theme,
]
);
return ( return (
<SettingsContext.Provider <SettingsContext.Provider value={value}>
value={{
notifications,
setNotifications,
defaultCategoryId,
setDefaultCategoryId,
sortBy,
setSortBy,
reminderPreference,
setReminderPreference,
apiUrl,
setApiUrl,
theme,
}}
>
{children} {children}
</SettingsContext.Provider> </SettingsContext.Provider>
); );
+21 -24
View File
@@ -86,8 +86,6 @@ export function toRemindersString(reminders: Reminder[]): string {
return reminders.filter((r) => r !== 'none').join(','); return reminders.filter((r) => r !== 'none').join(',');
} }
export type TaskStatus = 'pending' | 'completed' | 'overdue' | 'due_today';
export interface CategoryData { export interface CategoryData {
id: string; id: string;
name: string; name: string;
@@ -120,6 +118,7 @@ export interface TaskData {
export interface SubtaskData { export interface SubtaskData {
id: string; id: string;
taskId: string; taskId: string;
parentSubtaskId: string | null;
title: string; title: string;
description: string; description: string;
priority: Priority; priority: Priority;
@@ -135,33 +134,14 @@ export interface SubtaskData {
reminder: Reminder; reminder: Reminder;
assigneeId: string | null; assigneeId: string | null;
order: number; order: number;
} subtasks: SubtaskData[];
export interface TaskWithCategory extends TaskData {
category: CategoryData;
}
export interface CreateTaskInput {
title: string;
description: string;
categoryId: string;
priority: Priority;
dueDate: Date | null;
dueTime: string;
endTime: string;
allDay: boolean;
repeat: Repeat;
repeatInterval: number;
repeatDays: string;
reminder: Reminder;
reminders: string;
assigneeId: string | null;
subtasks: { title: string }[];
} }
export interface SubtaskFormValue { export interface SubtaskFormValue {
title: string; title: string;
_key?: string; _key?: string;
parentSubtaskId?: string | null;
subtasks?: SubtaskFormValue[];
} }
export interface TaskFormData { export interface TaskFormData {
@@ -180,4 +160,21 @@ export interface TaskFormData {
reminders: string; reminders: string;
assigneeId: string | null; assigneeId: string | null;
subtasks?: SubtaskFormValue[]; subtasks?: SubtaskFormValue[];
}
export interface SubtaskFormData {
title: string;
description?: string;
priority: Priority;
dueDate: Date | null;
dueTime?: string;
endTime?: string;
allDay?: boolean;
repeat: Repeat;
repeatInterval?: number;
repeatDays?: number[];
reminder: Reminder;
reminders: string;
assigneeId: string | null;
parentSubtaskId?: string | null;
} }
@@ -0,0 +1,20 @@
type QuickAddListener = () => void;
const listeners = new Set<QuickAddListener>();
let pending = false;
export function requestQuickAddFocus(): void {
pending = true;
listeners.forEach((listener) => listener());
}
export function subscribeToQuickAdd(listener: QuickAddListener): () => void {
if (pending) {
listener();
pending = false;
}
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
+257 -4
View File
@@ -1,6 +1,6 @@
import { database, collections } from '@/database'; import { database, collections } from '@/database';
import { Q } from '@nozbe/watermelondb'; import { Q } from '@nozbe/watermelondb';
import { Priority, Repeat } from '@/types'; import { Priority, Repeat, Reminder } from '@/types';
import { scheduleTaskReminder, cancelTaskReminder } from '@/services/notifications'; import { scheduleTaskReminder, cancelTaskReminder } from '@/services/notifications';
function addDays(date: Date, days: number): Date { function addDays(date: Date, days: number): Date {
@@ -155,6 +155,7 @@ export async function toggleTaskComplete(taskId: string): Promise<string | null>
const completing = !task.completed; const completing = !task.completed;
await task.update((t) => { await task.update((t) => {
t.completed = completing; t.completed = completing;
t.completedAt = completing ? Date.now() : null;
t.updatedAt = new Date(); t.updatedAt = new Date();
}); });
if (completing && task.repeat !== 'none' && task.dueDate) { if (completing && task.repeat !== 'none' && task.dueDate) {
@@ -169,11 +170,128 @@ export async function toggleTaskComplete(taskId: string): Promise<string | null>
} }
export async function toggleSubtaskComplete(subtaskId: string): Promise<void> { export async function toggleSubtaskComplete(subtaskId: string): Promise<void> {
const subtask = await collections.subtasks.find(subtaskId);
const completing = !subtask.completed;
await database.write(async () => { await database.write(async () => {
const subtask = await collections.subtasks.find(subtaskId);
await subtask.update((s) => { await subtask.update((s) => {
s.completed = completing; s.completed = !s.completed;
s.updatedAt = new Date();
});
const task = await collections.tasks.find(subtask.taskId);
await task.update((t) => {
t.updatedAt = new Date();
});
});
const subtask = await collections.subtasks.find(subtaskId);
if (subtask.completed) await cancelTaskReminder(subtaskId);
else await scheduleTaskReminder(subtask);
}
export interface SubtaskUpdateData {
title: string;
description?: string;
priority: Priority;
dueDate: number;
dueTime?: string;
endTime?: string;
allDay?: boolean;
repeat: Repeat;
repeatInterval?: number;
repeatDays?: string;
reminder?: Reminder;
assigneeId?: string | null;
}
export async function updateSubtask(subtaskId: string, data: SubtaskUpdateData): Promise<void> {
await database.write(async () => {
const subtask = await collections.subtasks.find(subtaskId);
await subtask.update((s) => {
s.title = data.title.trim();
s.description = data.description || '';
s.priority = data.priority;
s.dueDate = data.dueDate;
s.dueTime = data.dueTime || '';
s.endTime = data.endTime || '';
s.allDay = data.allDay ?? false;
s.repeat = data.repeat;
s.repeatInterval = data.repeatInterval || 1;
s.repeatDays = data.repeatDays || '';
s.reminder = data.reminder || 'none';
s.assigneeId = data.assigneeId ?? null;
s.updatedAt = new Date();
});
const task = await collections.tasks.find(subtask.taskId);
await task.update((t) => {
t.updatedAt = new Date();
});
});
const subtask = await collections.subtasks.find(subtaskId);
if (subtask.completed) await cancelTaskReminder(subtaskId);
else await scheduleTaskReminder(subtask);
}
export async function deleteSubtask(subtaskId: string): Promise<void> {
let parentTaskId: string | null = null;
await database.write(async () => {
const subtask = await collections.subtasks.find(subtaskId);
parentTaskId = subtask.taskId;
await subtask.destroyPermanently();
});
await cancelTaskReminder(subtaskId);
if (parentTaskId) {
try {
await database.write(async () => {
const task = await collections.tasks.find(parentTaskId!);
await task.update((t) => {
t.updatedAt = new Date();
});
});
} catch {
// parent already gone
}
}
}
export async function duplicateSubtask(subtaskId: string): Promise<void> {
let clone: any = null;
await database.write(async () => {
const subtask = await collections.subtasks.find(subtaskId);
const siblings = await collections.subtasks.query(Q.where('task_id', subtask.taskId)).fetch();
const now = new Date();
clone = await collections.subtasks.create((s) => {
s.taskId = subtask.taskId;
s.title = subtask.title;
s.description = subtask.description;
s.priority = subtask.priority;
s.completed = false;
s.dueDate = subtask.dueDate;
s.dueTime = subtask.dueTime;
s.endTime = subtask.endTime || '';
s.allDay = subtask.allDay ?? false;
s.repeat = subtask.repeat || 'none';
s.repeatInterval = subtask.repeatInterval || 1;
s.repeatDays = subtask.repeatDays || '';
s.seriesId = '';
s.reminder = subtask.reminder || 'none';
s.assigneeId = subtask.assigneeId ?? null;
s.order = siblings.length;
s.createdAt = now;
s.updatedAt = now;
});
const task = await collections.tasks.find(subtask.taskId);
await task.update((t) => {
t.updatedAt = new Date();
});
});
await scheduleTaskReminder(clone);
}
export async function setSubtaskPriority(subtaskId: string, priority: Priority): Promise<void> {
await database.write(async () => {
const subtask = await collections.subtasks.find(subtaskId);
await subtask.update((s) => {
s.priority = priority;
s.updatedAt = new Date(); s.updatedAt = new Date();
}); });
const task = await collections.tasks.find(subtask.taskId); const task = await collections.tasks.find(subtask.taskId);
@@ -277,12 +395,82 @@ export async function convertTaskToSubtask(taskId: string, parentTaskId: string)
}); });
} }
export async function convertSubtaskToTask(subtaskId: string): Promise<void> {
let newTaskId: string | null = null;
await database.write(async () => {
const subtask = await collections.subtasks.find(subtaskId);
const parent = await collections.tasks.find(subtask.taskId);
const now = new Date();
const task = await collections.tasks.create((t) => {
t.title = subtask.title;
t.description = subtask.description || '';
t.categoryId = parent.categoryId;
t.priority = subtask.priority;
t.completed = subtask.completed;
t.dueDate = subtask.dueDate;
t.dueTime = subtask.dueTime || '';
t.endTime = subtask.endTime || '';
t.allDay = subtask.allDay ?? false;
t.repeat = subtask.repeat || 'none';
t.repeatInterval = subtask.repeatInterval || 1;
t.repeatDays = subtask.repeatDays || '';
t.seriesId = subtask.seriesId || '';
t.reminder = subtask.reminder || 'none';
t.assigneeId = subtask.assigneeId ?? null;
t.createdAt = now;
t.updatedAt = now;
});
newTaskId = task.id;
await subtask.destroyPermanently();
await parent.update((p) => {
p.updatedAt = now;
});
});
await cancelTaskReminder(subtaskId);
if (newTaskId) {
const task = await collections.tasks.find(newTaskId);
await scheduleTaskReminder(task);
}
}
export async function moveSubtaskToTask(subtaskId: string, toTaskId: string): Promise<void> {
if (!toTaskId) return;
await database.write(async () => {
const subtask = await collections.subtasks.find(subtaskId);
if (subtask.taskId === toTaskId) return;
const oldParentId = subtask.taskId;
const siblings = await collections.subtasks.query(Q.where('task_id', toTaskId)).fetch();
const now = new Date();
await subtask.update((s) => {
s.taskId = toTaskId;
s.order = siblings.length;
s.updatedAt = now;
});
try {
const oldParent = await collections.tasks.find(oldParentId);
await oldParent.update((p) => {
p.updatedAt = now;
});
} catch {
// parent already gone
}
const newParent = await collections.tasks.find(toTaskId);
await newParent.update((p) => {
p.updatedAt = now;
});
});
}
export async function setTaskCompleted(taskId: string, completed: boolean): Promise<void> { export async function setTaskCompleted(taskId: string, completed: boolean): Promise<void> {
let nextOccurrence: any = null; let nextOccurrence: any = null;
await database.write(async () => { await database.write(async () => {
const task = await collections.tasks.find(taskId); const task = await collections.tasks.find(taskId);
await task.update((t) => { await task.update((t) => {
t.completed = completed; t.completed = completed;
t.completedAt = completed ? Date.now() : null;
t.updatedAt = new Date(); t.updatedAt = new Date();
}); });
if (completed && task.repeat !== 'none' && task.dueDate) { if (completed && task.repeat !== 'none' && task.dueDate) {
@@ -410,3 +598,68 @@ export async function duplicateTask(taskId: string): Promise<void> {
}); });
await scheduleTaskReminder(clone); await scheduleTaskReminder(clone);
} }
export async function reorderTasks(taskIds: string[]): Promise<void> {
await database.write(async () => {
const now = new Date();
for (let i = 0; i < taskIds.length; i++) {
const task = await collections.tasks.find(taskIds[i]);
await task.update((t) => {
t.order = i;
t.updatedAt = now;
});
}
});
}
export interface CreateSubtaskData {
taskId: string;
parentSubtaskId?: string | null;
title: string;
description?: string;
priority?: Priority;
dueDate?: number;
dueTime?: string;
endTime?: string;
allDay?: boolean;
repeat?: Repeat;
repeatInterval?: number;
repeatDays?: string;
reminder?: Reminder;
assigneeId?: string | null;
}
export async function createSubtask(data: CreateSubtaskData): Promise<string> {
let newSubtaskId: string | null = null;
await database.write(async () => {
const now = new Date();
const siblings = await collections.subtasks.query(
Q.where('task_id', data.taskId),
data.parentSubtaskId ? Q.where('parent_subtask_id', data.parentSubtaskId) : Q.where('parent_subtask_id', null)
).fetch();
const subtask = await collections.subtasks.create((s) => {
s.taskId = data.taskId;
s.parentSubtaskId = data.parentSubtaskId || null;
s.title = data.title.trim();
s.description = data.description || '';
s.priority = data.priority || 'none';
s.completed = false;
s.dueDate = data.dueDate || 0;
s.dueTime = data.dueTime || '';
s.endTime = data.endTime || '';
s.allDay = data.allDay ?? false;
s.repeat = data.repeat || 'none';
s.repeatInterval = data.repeatInterval || 1;
s.repeatDays = data.repeatDays || '';
s.seriesId = '';
s.reminder = data.reminder || 'none';
s.assigneeId = data.assigneeId ?? null;
s.order = siblings.length;
s.createdAt = now;
s.updatedAt = now;
});
newSubtaskId = subtask.id;
});
return newSubtaskId!;
}
-1
View File
@@ -1 +0,0 @@
opencode -s ses_031130d6fffeMxWjOd3Pv18pcB
+10 -1
View File
@@ -22,4 +22,13 @@ implemennt that just task for today and the past can be checked ✓
implement stats implement stats
implemnt an android widget for quickadd tasks implemnt an android widget for quickadd tasks
fix spacing for todo cards more space for todos
fix edit todo form layout so it fitts nicely on phone screen
fix buggy scroll for time selection