diff --git a/README.md b/README.md index 2aa4340..7b2203f 100644 --- a/README.md +++ b/README.md @@ -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 | | Animations | React Native Reanimated | | 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 @@ -210,6 +211,8 @@ npx expo start --web ``` ### Building + +#### EAS Build (cloud) ```bash # Install EAS CLI npm install -g eas-cli @@ -222,6 +225,51 @@ eas build --platform ios eas build --platform android 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 diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 6713427..3318804 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -75,6 +75,7 @@ export const subtasks = pgTable('subtasks', { id: text('id').primaryKey(), userId: text('user_id').notNull().references(() => users.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(), description: text('description').notNull().default(''), priority: text('priority', { enum: ['none', 'low', 'medium', 'high', 'critical'] }).notNull().default('none'), diff --git a/backend/src/routes/subtasks.ts b/backend/src/routes/subtasks.ts index 0528f7e..f60205d 100644 --- a/backend/src/routes/subtasks.ts +++ b/backend/src/routes/subtasks.ts @@ -11,6 +11,17 @@ const router = Router(); 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) => { 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))) .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) => { @@ -49,10 +62,16 @@ router.post('/task/:taskId', asyncHandler(async (req: Request, res: Response) => } const now = Date.now(); + const parentSubtaskId = data.parentSubtaskId || null; + const maxOrder = await db .select({ order: subtasks.order }) .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)) .limit(1); @@ -62,6 +81,7 @@ router.post('/task/:taskId', asyncHandler(async (req: Request, res: Response) => id: subtaskId, userId, taskId: req.params.taskId, + parentSubtaskId, title: data.title, description: data.description ?? '', priority: data.priority ?? 'none', diff --git a/backend/src/utils/validation.ts b/backend/src/utils/validation.ts index 1eb1337..77c89d1 100644 --- a/backend/src/utils/validation.ts +++ b/backend/src/utils/validation.ts @@ -69,6 +69,7 @@ export const subtaskCreateSchema = z.object({ reminders: z.string().max(100).optional(), assigneeId: z.string().nullable().optional(), order: z.number().int().min(0).optional(), + parentSubtaskId: z.string().nullable().optional(), }); export const subtaskUpdateSchema = z.object({ @@ -88,6 +89,7 @@ export const subtaskUpdateSchema = z.object({ reminders: z.string().max(100).optional(), assigneeId: z.string().nullable().optional(), order: z.number().int().min(0).optional(), + parentSubtaskId: z.string().nullable().optional(), }); export const userSettingsSchema = z.object({ diff --git a/building b/building new file mode 100644 index 0000000..2baebf0 --- /dev/null +++ b/building @@ -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 \ No newline at end of file diff --git a/carry-your-live/app.json b/carry-your-live/app.json index efc663f..14b0e30 100644 --- a/carry-your-live/app.json +++ b/carry-your-live/app.json @@ -31,12 +31,19 @@ "icon": "./assets/android-icon-foreground.png", "color": "#1E88E5" } - ] + ], + "./plugins/withQuickAddWidget" ], "extra": { "eas": { "projectId": "93a56631-01e5-45ab-9de1-7e6fb863c0a9" } + }, + "runtimeVersion": { + "policy": "appVersion" + }, + "updates": { + "url": "https://u.expo.dev/93a56631-01e5-45ab-9de1-7e6fb863c0a9" } } } diff --git a/carry-your-live/app/(tabs)/_layout.tsx b/carry-your-live/app/(tabs)/_layout.tsx index 8706552..3c77013 100644 --- a/carry-your-live/app/(tabs)/_layout.tsx +++ b/carry-your-live/app/(tabs)/_layout.tsx @@ -45,6 +45,15 @@ export default function TabLayout() { ), }} /> + ( + + ), + }} + /> new Date()); const [selectedDate, setSelectedDate] = useState(() => new Date()); const stripRef = useRef(null); @@ -28,46 +31,57 @@ export default function CalendarScreen() { const { tasks, loading } = useTasksByDate(selectedDate); - const handleDayPress = (day: Date) => { + const handleDayPress = useCallback((day: Date) => { setSelectedDate(day); if (!isSameMonth(day, visibleMonth)) { setVisibleMonth(day); } - }; + }, [visibleMonth]); - const handlePrevMonth = () => { + const handlePrevMonth = useCallback(() => { const prev = addMonths(visibleMonth, -1); setVisibleMonth(prev); if (!isSameMonth(selectedDate, prev)) { setSelectedDate(startOfMonth(prev)); } - }; + }, [visibleMonth, selectedDate]); - const handleNextMonth = () => { + const handleNextMonth = useCallback(() => { const next = addMonths(visibleMonth, 1); setVisibleMonth(next); if (!isSameMonth(selectedDate, next)) { setSelectedDate(startOfMonth(next)); } - }; + }, [visibleMonth, selectedDate]); - const handleToggleComplete = async (taskId: string) => { + const handleToggleComplete = useCallback(async (taskId: string) => { await toggleTaskComplete(taskId); - }; + }, []); - const today = new Date(); - - const scrollToDay = (day: Date) => { + const scrollToDay = useCallback((day: Date) => { const index = days.findIndex((d) => isSameDay(d, day)); if (index >= 0) { stripRef.current?.scrollTo({ x: Math.max(0, index * (DAY_WIDTH + DAY_GAP) - 24), animated: true }); } - }; + }, [days]); React.useEffect(() => { - scrollToDay(isSameMonth(selectedDate, visibleMonth) ? selectedDate : today); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [visibleMonth]); + const target = isSameMonth(selectedDate, visibleMonth) ? selectedDate : startOfDay(new Date()); + scrollToDay(target); + }, [visibleMonth, scrollToDay, selectedDate]); + + const renderTask = useCallback( + ({ item }: { item: TaskData }) => ( + handleToggleComplete(item.id)} + onDelete={() => openTaskDelete(item)} + onPress={() => router.push({ pathname: '/task-detail', params: { id: item.id } })} + onMenuOpen={() => openTaskMenu(item)} + /> + ), + [handleToggleComplete, openTaskDelete, openTaskMenu, router] + ); return ( @@ -79,30 +93,16 @@ export default function CalendarScreen() { showsHorizontalScrollIndicator={false} contentContainerStyle={styles.dateStrip} > - {days.map((day) => { - const isSelected = isSameDay(day, selectedDate); - const isCurrent = isToday(day); - return ( - handleDayPress(day)} - activeOpacity={0.7} - > - - {format(day, 'EEE').charAt(0)} - - - {format(day, 'd')} - - - ); - })} + {days.map((day) => ( + + ))} @@ -122,14 +122,8 @@ export default function CalendarScreen() { item.id} - renderItem={({ item }) => ( - handleToggleComplete(item.id)} - onPress={() => router.push({ pathname: '/task-detail', params: { id: item.id } })} - /> - )} - ItemSeparatorComponent={() => } + renderItem={renderTask} + ItemSeparatorComponent={MemoSeparator} ListEmptyComponent={ loading ? ( @@ -149,10 +143,46 @@ export default function CalendarScreen() { dueDate={selectedDate.getTime()} placeholder={`Add task for ${format(selectedDate, 'MMM d')}`} /> + + {modals(() => {})} ); } +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 ( + onPress(day)} + activeOpacity={0.7} + > + + {format(day, 'EEE').charAt(0)} + + + {format(day, 'd')} + + + ); +}); + +const MemoSeparator = React.memo(function Separator() { + return ; +}); + const styles = StyleSheet.create({ container: { flex: 1, diff --git a/carry-your-live/app/(tabs)/index.tsx b/carry-your-live/app/(tabs)/index.tsx index 7cd9abd..bcf7377 100644 --- a/carry-your-live/app/(tabs)/index.tsx +++ b/carry-your-live/app/(tabs)/index.tsx @@ -11,7 +11,6 @@ export default function TasksScreen() { const { isReady } = useDatabase(); const { theme } = useSettings(); const [selectedCategory, setSelectedCategory] = React.useState('all'); - const [selectionActive, setSelectionActive] = React.useState(false); if (!isReady) { return ( @@ -24,8 +23,10 @@ export default function TasksScreen() { return (
- - + + + + ); @@ -35,6 +36,9 @@ const styles = StyleSheet.create({ container: { flex: 1, }, + categoryFilterWrapper: { + height: 36, + }, loadingContainer: { flex: 1, justifyContent: 'center', diff --git a/carry-your-live/app/(tabs)/settings.tsx b/carry-your-live/app/(tabs)/settings.tsx index 10a3811..7a2cd15 100644 --- a/carry-your-live/app/(tabs)/settings.tsx +++ b/carry-your-live/app/(tabs)/settings.tsx @@ -1,5 +1,5 @@ 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 { ListItem } from '@/components/ListItem'; import { OptionPickerModal } from '@/components/OptionPickerModal'; @@ -12,6 +12,7 @@ import SyncStatus from '@/components/SyncStatus'; import { useSettings, SORT_OPTIONS, REMINDER_OPTIONS } from '@/theme'; import { useCategories } from '@/hooks/useDatabase'; import { getAuthUser, getAuthToken } from '@/services/auth'; +import { checkForUpdates, getCurrentAppVersion } from '@/services/updates'; import { getLastSyncTime } from '@/database/sync'; import Category from '@/models/Category'; import Svg, { Path } from 'react-native-svg'; @@ -26,6 +27,28 @@ export default function SettingsScreen() { const [legalVisible, setLegalVisible] = useState(null); const [serverUrlVisible, setServerUrlVisible] = useState(false); 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 token = await getAuthToken(); @@ -45,6 +68,7 @@ export default function SettingsScreen() { }; React.useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect refreshSyncStatus(); }, []); @@ -52,12 +76,12 @@ export default function SettingsScreen() { const defaultCategoryLabel = defaultCategory?.name ?? (categories.length > 0 ? categories[0].name : 'None'); const reminderLabel = REMINDER_OPTIONS.find((o) => o.value === reminderPreference)?.label ?? 'No reminder'; - const handleDefaultCategory = (value: string) => { - setDefaultCategoryId(value); + const handleDefaultCategory = (value: string | string[]) => { + setDefaultCategoryId(Array.isArray(value) ? value[0] : value); }; - const handleReminderPreference = (value: string) => { - setReminderPreference(value as typeof reminderPreference); + const handleReminderPreference = (value: string | string[]) => { + setReminderPreference((Array.isArray(value) ? value[0] : value) as typeof reminderPreference); }; const editorVisible = editingCategory !== null; @@ -146,9 +170,15 @@ export default function SettingsScreen() { showChevron /> About + d.count)); + + return ( + +
+ + + Overview + + + {stats.totalCompleted} + Completed + + + {stats.completedLast7} + Last 7 days + + + {stats.completedLast30} + Last 30 days + + + {stats.completionRate}% + Success rate + + + {stats.currentStreak} + Day streak + + + 0 ? theme.accent : theme.text }]}> + {stats.overdueCount} + + Overdue + + + + + + Last 7 days + + {stats.daily.map((d, i) => { + const height = (d.count / maxDaily) * BASE_HEIGHT; + return ( + + + 0 ? theme.accent : theme.cardAlt }, + ]} + /> + + {d.label} + + ); + })} + + + + + By category + {stats.byCategory.length === 0 ? ( + No completed tasks yet + ) : ( + stats.byCategory.map((c) => ( + + + {c.name} + {c.count} + + )) + )} + + + + By priority + {stats.byPriority.length === 0 ? ( + No completed tasks yet + ) : ( + stats.byPriority.map((p) => ( + + + {p.label} + {p.count} + + )) + )} + + + + ); +} + +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, + }, +}); \ No newline at end of file diff --git a/carry-your-live/app/_layout.tsx b/carry-your-live/app/_layout.tsx index f77d8d0..40e5fd4 100644 --- a/carry-your-live/app/_layout.tsx +++ b/carry-your-live/app/_layout.tsx @@ -1,11 +1,31 @@ -import { Stack } from 'expo-router'; +import { Stack, useRouter } from 'expo-router'; import { DatabaseProvider } from '@/hooks/useDatabase'; import { SettingsProvider } from '@/theme'; import { FriendsProvider } from '@/hooks/useFriends'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; 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() { + 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 ( @@ -23,6 +43,7 @@ export default function RootLayout() { + diff --git a/carry-your-live/app/add-task.tsx b/carry-your-live/app/add-task.tsx index f7ea53d..8df1e7b 100644 --- a/carry-your-live/app/add-task.tsx +++ b/carry-your-live/app/add-task.tsx @@ -35,6 +35,7 @@ const taskSchema = z.object({ 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(), subtasks: z.array(z.object({ title: z.string(), _key: z.string().optional() })).optional(), }); @@ -69,6 +70,7 @@ export default function AddTaskScreen() { repeatInterval: 1, repeatDays: [], reminder: 'none', + reminders: '', assigneeId: null, subtasks: [], }, @@ -88,6 +90,7 @@ export default function AddTaskScreen() { 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'); @@ -124,6 +127,7 @@ export default function AddTaskScreen() { t.repeatDays = (data.repeatDays || []).join(','); t.seriesId = seriesId; t.reminder = data.reminder || 'none'; + t.reminders = data.reminders || ''; t.assigneeId = data.assigneeId ?? null; t.createdAt = now; t.updatedAt = now; @@ -213,9 +217,9 @@ export default function AddTaskScreen() { }} /> setValue('reminder', value)} + onChange={(value) => setValue('reminders', value)} /> (); + 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({ + 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 ( + + {notFound ? 'Subtask not found' : 'Loading...'} + + ); + } + + return ( + +
+ + + + + } + /> + + + + ( + + )} + /> + setValue('priority', value)} + /> + + { + setValue('repeat', nextRepeat); + setValue('repeatInterval', nextInterval); + setValue('repeatDays', nextDays); + }} + /> + setValue('reminders', value)} + /> + setValue('assigneeId', value)} + friends={friends.map((f) => f.username)} + /> + ( + + )} + /> + + + + + + ); +} + +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', + }, +}); diff --git a/carry-your-live/app/task-detail.tsx b/carry-your-live/app/task-detail.tsx index 770f767..6219fc6 100644 --- a/carry-your-live/app/task-detail.tsx +++ b/carry-your-live/app/task-detail.tsx @@ -22,9 +22,9 @@ import { Q } from '@nozbe/watermelondb'; import { TaskFormData } from '@/types'; import { useSettings } from '@/theme'; import { deleteTaskOccurrences } from '@/utils/taskActions'; -import { scheduleTaskReminder, cancelTaskReminder } from '@/services/notifications'; +import { scheduleTaskReminder } from '@/services/notifications'; import { useFriends } from '@/hooks/useFriends'; -import Svg, { Path } from 'react-native-svg'; +import Svg, { Path, Circle } from 'react-native-svg'; const taskSchema = z.object({ 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(), 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(), 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 ( + + setExpanded(!expanded)} activeOpacity={0.8}> + + {icon} + {title} + + + + + + {expanded && {children}} + + ); +} + export default function TaskDetailScreen() { const { id } = useLocalSearchParams<{ id: string }>(); const { isReady } = useDatabase(); @@ -80,6 +108,7 @@ export default function TaskDetailScreen() { 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'); const [deleteModalVisible, setDeleteModalVisible] = React.useState(false); @@ -253,38 +282,89 @@ export default function TaskDetailScreen() { value={priority} onChange={(value) => setValue('priority', value)} /> - - { - setValue('repeat', nextRepeat); - setValue('repeatInterval', nextInterval); - setValue('repeatDays', nextDays); - }} - /> - setValue('reminder', value)} - /> - setValue('assigneeId', value)} - friends={friends.map((f) => f.username)} - /> - ( - + + + + } defaultExpanded={!!dueDate}> + + + + + - )} - /> + + } defaultExpanded={repeat !== 'none'}> + { + setValue('repeat', nextRepeat); + setValue('repeatInterval', nextInterval); + setValue('repeatDays', nextDays); + }} + /> + + + + + + } defaultExpanded={!!reminders && reminders !== ''}> + setValue('reminders', value)} + /> + + + + + + + } defaultExpanded={!!assigneeId}> + setValue('assigneeId', value)} + friends={friends.map((f) => f.username)} + /> + + + + + + } defaultExpanded={!!methods.getValues('description')}> + ( + + )} + /> + @@ -315,15 +395,40 @@ const styles = StyleSheet.create({ flex: 1, }, scrollContent: { - paddingHorizontal: 16, + paddingHorizontal: 12, paddingTop: 8, - paddingBottom: 100, - gap: 24, + paddingBottom: 80, + 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: { - width: 36, - height: 36, - borderRadius: 12, + width: 32, + height: 32, + borderRadius: 10, borderWidth: 1, alignItems: 'center', justifyContent: 'center', diff --git a/carry-your-live/eslint.config.js b/carry-your-live/eslint.config.js index ba708ed..a73cb38 100644 --- a/carry-your-live/eslint.config.js +++ b/carry-your-live/eslint.config.js @@ -2,9 +2,25 @@ const { defineConfig } = require('eslint/config'); 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([ expoConfig, { ignores: ["dist/*"], + }, + { + settings: { + 'import/resolver': { + typescript: { + extensions: platformExtensions, + }, + }, + }, } ]); diff --git a/carry-your-live/package-lock.json b/carry-your-live/package-lock.json index 4a6dd8d..e57b372 100644 --- a/carry-your-live/package-lock.json +++ b/carry-your-live/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "carry-your-live", "version": "1.0.0", + "hasInstallScript": true, "dependencies": { "@hookform/resolvers": "^3.3.4", "@nozbe/watermelondb": "^0.28.1-0", @@ -21,6 +22,7 @@ "expo-router": "~57.0.10", "expo-sqlite": "~57.0.1", "expo-status-bar": "~57.0.1", + "expo-updates": "~57.0.12", "react": "19.2.3", "react-dom": "19.2.3", "react-hook-form": "^7.51.5", @@ -40,6 +42,7 @@ "@types/react": "~19.2.2", "eslint": "^9.0.0", "eslint-config-expo": "~57.0.1", + "patch-package": "^8.0.1", "prettier": "^3.9.6", "typescript": "~6.0.3" } @@ -3594,6 +3597,13 @@ "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": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -5965,6 +5975,12 @@ "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": { "version": "57.0.1", "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-57.0.1.tgz", @@ -6297,6 +6313,12 @@ "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": { "version": "57.0.1", "resolved": "https://registry.npmjs.org/expo-symbols/-/expo-symbols-57.0.1.tgz", @@ -6313,6 +6335,43 @@ "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": { "version": "57.0.1", "resolved": "https://registry.npmjs.org/expo-updates-interface/-/expo-updates-interface-57.0.1.tgz", @@ -6322,6 +6381,12 @@ "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": { "version": "57.0.12", "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-57.0.12.tgz", @@ -6861,6 +6926,16 @@ "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": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", @@ -6919,6 +6994,21 @@ "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": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -8112,6 +8202,26 @@ "dev": true, "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": { "version": "1.0.1", "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_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": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -8157,6 +8290,16 @@ "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": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -9628,6 +9771,52 @@ "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": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -10965,6 +11154,16 @@ "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", "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": { "version": "1.6.9", "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.9.tgz", @@ -11398,6 +11597,16 @@ "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": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -11687,6 +11896,16 @@ "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": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", diff --git a/carry-your-live/package.json b/carry-your-live/package.json index a406696..3b4942b 100644 --- a/carry-your-live/package.json +++ b/carry-your-live/package.json @@ -16,18 +16,16 @@ "expo-router": "~57.0.10", "expo-sqlite": "~57.0.1", "expo-status-bar": "~57.0.1", + "expo-updates": "~57.0.12", "react": "19.2.3", "react-dom": "19.2.3", "react-hook-form": "^7.51.5", "react-native": "0.86.2", "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-screens": "4.26.0", "react-native-svg": "^15.15.4", "react-native-web": "^0.21.2", - "react-native-worklets": "0.10.1", "zod": "^3.23.8" }, "devDependencies": { @@ -35,6 +33,7 @@ "@types/react": "~19.2.2", "eslint": "^9.0.0", "eslint-config-expo": "~57.0.1", + "patch-package": "^8.0.1", "prettier": "^3.9.6", "typescript": "~6.0.3" }, @@ -50,7 +49,8 @@ "build:android": "eas build --platform android", "build:web": "eas build --platform web", "submit:ios": "eas submit --platform ios", - "submit:android": "eas submit --platform android" + "submit:android": "eas submit --platform android", + "postinstall": "patch-package" }, "private": true } diff --git a/carry-your-live/patches/react-native+0.86.2.patch b/carry-your-live/patches/react-native+0.86.2.patch new file mode 100644 index 0000000..c95c8d8 --- /dev/null +++ b/carry-your-live/patches/react-native+0.86.2.patch @@ -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, + }); diff --git a/carry-your-live/plugins/withQuickAddWidget.js b/carry-your-live/plugins/withQuickAddWidget.js new file mode 100644 index 0000000..99d5bb7 --- /dev/null +++ b/carry-your-live/plugins/withQuickAddWidget.js @@ -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 = ` + +`; + +const WIDGET_LAYOUT_XML = ` + + + + + + +`; + +const WIDGET_BG_XML = ` + + + + + +`; + +const WIDGET_BUTTON_BG_XML = ` + + + + +`; + +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; diff --git a/carry-your-live/src/components/AssigneeSelector.tsx b/carry-your-live/src/components/AssigneeSelector.tsx index 1891a36..6f6bff5 100644 --- a/carry-your-live/src/components/AssigneeSelector.tsx +++ b/carry-your-live/src/components/AssigneeSelector.tsx @@ -1,5 +1,5 @@ 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 { useFriends } from '@/hooks/useFriends'; @@ -15,11 +15,19 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee const { friends, searchUsers, loading: friendsLoading } = useFriends(); const [showModal, setShowModal] = React.useState(false); const [searchQuery, setSearchQuery] = React.useState(''); - const [searchResults, setSearchResults] = React.useState>([]); + const [searchResults, setSearchResults] = React.useState<{ id: string; username: string }[]>([]); const [searching, setSearching] = React.useState(false); 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(() => { if (showModal && searchQuery.length >= 2) { const timeout = setTimeout(async () => { @@ -34,8 +42,6 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee } }, 300); return () => clearTimeout(timeout); - } else { - setSearchResults([]); } }, [searchQuery, showModal, friends, searchUsers]); @@ -97,7 +103,10 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee setShowModal(false)}> - + Assign Task @@ -197,7 +206,7 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee ) : null} - + ); @@ -210,48 +219,48 @@ const styles = StyleSheet.create({ selectorButton: { flexDirection: 'row', alignItems: 'center', - gap: 10, - paddingVertical: 14, - paddingHorizontal: 12, - borderRadius: 12, + gap: 8, + paddingVertical: 12, + paddingHorizontal: 10, + borderRadius: 10, borderWidth: 1, - minHeight: 52, + minHeight: 48, }, selectorIcon: { - fontSize: 20, + fontSize: 18, }, selectorContent: { flex: 1, justifyContent: 'center', }, selectorLabel: { - fontSize: 11, + fontSize: 10, fontWeight: '600', textTransform: 'uppercase', letterSpacing: 0.5, - marginBottom: 2, + marginBottom: 1, }, selectorValue: { - fontSize: 15, + fontSize: 14, fontWeight: '500', }, clearButton: { - padding: 4, + padding: 3, }, clearText: { - fontSize: 18, + fontSize: 16, fontWeight: '300', }, modalOverlay: { flex: 1, alignItems: 'center', justifyContent: 'center', - padding: 24, + padding: 20, }, modalSheet: { width: '100%', - maxWidth: 400, - borderRadius: 20, + maxWidth: 360, + borderRadius: 16, overflow: 'hidden', maxHeight: '85%', }, @@ -259,81 +268,81 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', - padding: 20, + padding: 16, borderBottomWidth: 1, }, modalTitle: { - fontSize: 18, + fontSize: 16, fontWeight: '700', }, closeText: { - fontSize: 22, + fontSize: 20, fontWeight: '300', }, modalSection: { - padding: 12, - paddingBottom: 20, + padding: 10, + paddingBottom: 16, borderBottomWidth: 1, }, sectionTitle: { - fontSize: 12, + fontSize: 11, fontWeight: '600', textTransform: 'uppercase', letterSpacing: 0.5, - marginBottom: 8, - paddingHorizontal: 8, + marginBottom: 6, + paddingHorizontal: 6, }, optionRow: { flexDirection: 'row', alignItems: 'center', - gap: 12, - paddingVertical: 12, - paddingHorizontal: 16, - borderRadius: 10, + gap: 10, + paddingVertical: 10, + paddingHorizontal: 14, + borderRadius: 9, borderWidth: 1, }, optionIcon: { - width: 36, - height: 36, - borderRadius: 18, + width: 32, + height: 32, + borderRadius: 16, alignItems: 'center', justifyContent: 'center', backgroundColor: 'rgba(0,0,0,0.05)', }, optionIconText: { - fontSize: 16, + fontSize: 15, }, optionText: { - fontSize: 16, + fontSize: 15, fontWeight: '500', flex: 1, }, optionSubtext: { - fontSize: 12, - marginTop: 2, + fontSize: 11, + marginTop: 1, }, checkmark: { - fontSize: 18, + fontSize: 16, fontWeight: '700', }, loading: { - padding: 20, + padding: 16, alignItems: 'center', }, emptyText: { - fontSize: 14, + fontSize: 13, textAlign: 'center', - paddingHorizontal: 20, + paddingHorizontal: 16, }, searchContainer: { - padding: 12, - paddingBottom: 8, + padding: 10, + paddingBottom: 6, }, searchInput: { - fontSize: 16, - paddingVertical: 12, - paddingHorizontal: 16, - borderRadius: 12, + fontSize: 15, + paddingVertical: 10, + paddingHorizontal: 14, + borderRadius: 10, borderWidth: 1, }, }); \ No newline at end of file diff --git a/carry-your-live/src/components/CategoryEditorModal.tsx b/carry-your-live/src/components/CategoryEditorModal.tsx index 344406a..214b83c 100644 --- a/carry-your-live/src/components/CategoryEditorModal.tsx +++ b/carry-your-live/src/components/CategoryEditorModal.tsx @@ -1,5 +1,5 @@ -import React, { useEffect, useState } from 'react'; -import { Modal, View, Text, StyleSheet, TextInput, TouchableOpacity, ScrollView, KeyboardAvoidingView, Platform } from 'react-native'; +import React, { useState } from 'react'; +import { Modal, View, Text, StyleSheet, TextInput, TouchableOpacity, ScrollView, KeyboardAvoidingView } from 'react-native'; import Category from '@/models/Category'; import { useSettings } from '@/theme'; import { CATEGORY_COLORS } from '@/constants'; @@ -19,13 +19,15 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose const { theme } = useSettings(); const [name, setName] = useState(''); const [color, setColor] = useState(CATEGORY_COLORS[0]); + const [prevVisible, setPrevVisible] = useState(visible); - useEffect(() => { + if (prevVisible !== visible) { + setPrevVisible(visible); if (visible) { setName(category?.name ?? ''); setColor(category?.color ?? CATEGORY_COLORS[0]); } - }, [visible, category]); + } const canDelete = category !== null && categoryCount > 1; @@ -58,7 +60,7 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose @@ -133,6 +135,7 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose Save + diff --git a/carry-your-live/src/components/CategoryFilter.tsx b/carry-your-live/src/components/CategoryFilter.tsx index a72c79e..79a33a4 100644 --- a/carry-your-live/src/components/CategoryFilter.tsx +++ b/carry-your-live/src/components/CategoryFilter.tsx @@ -1,6 +1,6 @@ import React from 'react'; 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 Category from '@/models/Category'; @@ -10,7 +10,7 @@ interface CategoryFilterProps { } export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) { - const categories = useCategories(); + const categories = useUniqueCategories(); const { theme } = useSettings(); if (categories.length === 0) { @@ -91,9 +91,9 @@ interface AnimatedCategoryButtonProps { } function AnimatedCategoryButton({ category, selected, onPress, theme }: AnimatedCategoryButtonProps) { - const scaleAnim = React.useRef(new Animated.Value(selected ? 1.05 : 1)).current; - const borderWidthAnim = React.useRef(new Animated.Value(selected ? 2 : 1)).current; - const shadowOpacityAnim = React.useRef(new Animated.Value(selected ? 0.15 : 0)).current; + const [scaleAnim] = React.useState(() => new Animated.Value(selected ? 1.05 : 1)); + const [borderWidthAnim] = React.useState(() => new Animated.Value(selected ? 2 : 1)); + const [shadowOpacityAnim] = React.useState(() => new Animated.Value(selected ? 0.15 : 0)); React.useEffect(() => { Animated.timing(scaleAnim, { @@ -151,28 +151,30 @@ function AnimatedCategoryButton({ category, selected, onPress, theme }: Animated const styles = StyleSheet.create({ scrollView: { paddingVertical: 0, + marginBottom: 0, }, container: { - paddingHorizontal: 16, - paddingBottom: 4, - gap: 8, - alignItems: 'center', + paddingHorizontal: 12, + paddingTop: 0, + paddingBottom: 0, + gap: 6, + alignItems: 'flex-start', }, button: { flexDirection: 'row', alignItems: 'center', - paddingHorizontal: 14, - paddingVertical: 8, - borderRadius: 20, + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 16, borderWidth: 1, - minWidth: 72, + minWidth: 64, justifyContent: 'center', }, animatedButton: { shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowRadius: 8, - elevation: 3, + shadowOffset: { width: 0, height: 1 }, + shadowRadius: 4, + elevation: 2, }, buttonInner: { flexDirection: 'row', @@ -180,17 +182,17 @@ const styles = StyleSheet.create({ gap: 6, }, colorDot: { + width: 8, + height: 8, + borderRadius: 4, + }, + colorDotSelected: { width: 10, height: 10, borderRadius: 5, }, - colorDotSelected: { - width: 12, - height: 12, - borderRadius: 6, - }, buttonText: { - fontSize: 13, + fontSize: 12, fontWeight: '500', }, }); \ No newline at end of file diff --git a/carry-your-live/src/components/CategorySelector.tsx b/carry-your-live/src/components/CategorySelector.tsx index f780692..2b9ae74 100644 --- a/carry-your-live/src/components/CategorySelector.tsx +++ b/carry-your-live/src/components/CategorySelector.tsx @@ -1,5 +1,5 @@ -import React from 'react'; -import { View, Text, StyleSheet, TouchableOpacity, ScrollView } from 'react-native'; +import React, { useState } from 'react'; +import { View, Text, StyleSheet, TouchableOpacity, ScrollView, Modal, Pressable, KeyboardAvoidingView } from 'react-native'; import { useCategories } from '@/hooks/useDatabase'; import { useSettings } from '@/theme'; @@ -12,82 +12,109 @@ interface CategorySelectorProps { export function CategorySelector({ value, onChange, error }: CategorySelectorProps) { const categories = useCategories(); const { theme } = useSettings(); + const [showModal, setShowModal] = useState(false); + + const selectedCategory = categories.find(c => c.id === value); return ( Category - - setShowModal(true)} + activeOpacity={0.8} > - {categories.map((category) => ( - onChange(category.id)} - activeOpacity={0.8} - > - - - {category.name} - - - ))} - - {error && {error}} + + + + {selectedCategory?.name || 'Select category'} + + + + + + + {error && {error}} + + setShowModal(false)}> + + setShowModal(false)}> + + + Select Category + setShowModal(false)} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}> + + + + + {categories.map((category) => ( + { onChange(category.id); setShowModal(false); }} + activeOpacity={0.8} + > + + + {category.name} + + {value === category.id && ( + + + + )} + + ))} + + + + + ); } +import Svg, { Path } from 'react-native-svg'; + const styles = StyleSheet.create({ container: { - gap: 8, + gap: 6, }, label: { fontSize: 14, fontWeight: '600', }, - requiredIndicator: { - 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, + selectorButton: { flexDirection: 'row', alignItems: 'center', - gap: 8, - minWidth: 90, - justifyContent: 'center', + justifyContent: 'space-between', + paddingVertical: 14, + paddingHorizontal: 12, + borderRadius: 12, + borderWidth: 1, + minHeight: 52, + }, + selectorContent: { + flex: 1, + }, + selectorRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, }, colorCircle: { width: 12, @@ -99,13 +126,59 @@ const styles = StyleSheet.create({ height: 14, borderRadius: 7, }, - categoryName: { - fontSize: 13, + selectorValue: { + fontSize: 15, fontWeight: '500', }, errorText: { fontSize: 12, - color: '#E53935', - marginLeft: 16, + marginLeft: 4, + }, + 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, }, }); \ No newline at end of file diff --git a/carry-your-live/src/components/DateTimeInput.native.tsx b/carry-your-live/src/components/DateTimeInput.native.tsx index 1ed9b7c..87f1048 100644 --- a/carry-your-live/src/components/DateTimeInput.native.tsx +++ b/carry-your-live/src/components/DateTimeInput.native.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { View, StyleSheet } from 'react-native'; +import { StyleSheet } from 'react-native'; import DateTimePicker, { DateTimePickerEvent } from '@react-native-community/datetimepicker'; interface NativeDateTimeInputProps { diff --git a/carry-your-live/src/components/DateTimeInput.web.tsx b/carry-your-live/src/components/DateTimeInput.web.tsx index c819cd3..4760f29 100644 --- a/carry-your-live/src/components/DateTimeInput.web.tsx +++ b/carry-your-live/src/components/DateTimeInput.web.tsx @@ -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 { DateTimePickerEvent } from '@react-native-community/datetimepicker'; @@ -33,10 +33,12 @@ export default function WebDateTimeInput({ is24Hour, }: WebDateTimeInputProps) { const [inputValue, setInputValue] = useState(() => dateInputValue(value)); + const [prevValue, setPrevValue] = useState(value); - useEffect(() => { + if (value !== prevValue) { + setPrevValue(value); setInputValue(dateInputValue(value)); - }, [value]); + } const emit = (raw: string) => { if (!raw) return; diff --git a/carry-your-live/src/components/DateTimePicker.tsx b/carry-your-live/src/components/DateTimePicker.tsx index 214f15f..6d4fb58 100644 --- a/carry-your-live/src/components/DateTimePicker.tsx +++ b/carry-your-live/src/components/DateTimePicker.tsx @@ -15,14 +15,12 @@ interface DateTimePickerComponentProps { export function DateTimePickerComponent({ control }: DateTimePickerComponentProps) { const { theme } = useSettings(); const [picker, setPicker] = React.useState<'date' | 'time' | 'endTime' | null>(null); - const dateValueRef = useRef(null); - const timeValueRef = useRef(''); - const endTimeValueRef = useRef(''); const dateRef = useRef<((value: Date | null) => void) | null>(null); const timeRef = useRef<((value: string) => void) | null>(null); const endTimeRef = useRef<((value: string) => void) | null>(null); const allDay = useWatch({ control, name: 'allDay' }) ?? false; + const dueDate = useWatch({ control, name: 'dueDate' }) ?? null; const startTime = useWatch({ control, name: 'dueTime' }) ?? ''; const endTime = useWatch({ control, name: 'endTime' }) ?? ''; @@ -37,7 +35,6 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp const renderTimeButton = ( fieldName: 'dueTime' | 'endTime', ref: React.MutableRefObject<((value: string) => void) | null>, - valueRef: React.MutableRefObject, placeholder: string, onPress: () => void ) => ( @@ -46,7 +43,6 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp name={fieldName} render={({ field }) => { ref.current = field.onChange; - valueRef.current = field.value ?? ''; return ( { dateRef.current = field.onChange; - dateValueRef.current = field.value; return ( - {renderTimeButton('dueTime', timeRef, timeValueRef, 'Start Time', () => setPicker('time'))} - {renderTimeButton('endTime', endTimeRef, endTimeValueRef, 'End Time', () => setPicker('endTime'))} + {renderTimeButton('dueTime', timeRef, 'Start Time', () => setPicker('time'))} + {renderTimeButton('endTime', endTimeRef, 'End Time', () => setPicker('endTime'))} {endTimeInvalid && ( @@ -203,7 +198,7 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp { if (picker === 'endTime') { @@ -261,7 +256,7 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', - paddingVertical: 12, + paddingVertical: 14, }, allDayLabel: { fontSize: 15, @@ -293,7 +288,7 @@ const styles = StyleSheet.create({ fontWeight: '500', }, clearButton: { - padding: 2, + padding: 4, }, warningRow: { flexDirection: 'row', diff --git a/carry-your-live/src/components/DescriptionInput.tsx b/carry-your-live/src/components/DescriptionInput.tsx index aff19a6..57bd3b0 100644 --- a/carry-your-live/src/components/DescriptionInput.tsx +++ b/carry-your-live/src/components/DescriptionInput.tsx @@ -1,6 +1,5 @@ import React from 'react'; -import { View, Text, StyleSheet, TextInput } from 'react-native'; -import { TextInputProps } from 'react-native'; +import { View, Text, StyleSheet, TextInput , TextInputProps } from 'react-native'; import { useSettings } from '@/theme'; interface DescriptionInputProps extends TextInputProps { @@ -38,30 +37,30 @@ export function DescriptionInput({ error, ...props }: DescriptionInputProps) { const styles = StyleSheet.create({ container: { - gap: 6, + gap: 5, }, label: { - fontSize: 14, + fontSize: 13, fontWeight: '600', }, input: { - minHeight: 100, - padding: 16, - borderRadius: 12, + minHeight: 88, + padding: 14, + borderRadius: 10, borderWidth: 1, - fontSize: 15, + fontSize: 14, }, inputError: { borderColor: '#E53935', borderWidth: 1.5, }, charCount: { - fontSize: 11, + fontSize: 10, textAlign: 'right', - marginTop: -4, + marginTop: -3, }, errorText: { - fontSize: 12, + fontSize: 11, color: '#E53935', marginLeft: 4, }, diff --git a/carry-your-live/src/components/FloatingActionButton.tsx b/carry-your-live/src/components/FloatingActionButton.tsx index 394e4fb..18ab7bd 100644 --- a/carry-your-live/src/components/FloatingActionButton.tsx +++ b/carry-your-live/src/components/FloatingActionButton.tsx @@ -1,5 +1,5 @@ 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 Svg, { Path } from 'react-native-svg'; diff --git a/carry-your-live/src/components/FormButtons.tsx b/carry-your-live/src/components/FormButtons.tsx index 924c8b2..9ad688c 100644 --- a/carry-your-live/src/components/FormButtons.tsx +++ b/carry-your-live/src/components/FormButtons.tsx @@ -3,6 +3,7 @@ import { View, StyleSheet, TouchableOpacity, Text } from 'react-native'; import { useFormContext } from 'react-hook-form'; import { useRouter } from 'expo-router'; import { useSettings } from '@/theme'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; interface FormButtonsProps { onSubmit: (data: any) => void; @@ -13,6 +14,7 @@ export function FormButtons({ onSubmit, submitLabel = 'Submit' }: FormButtonsPro const router = useRouter(); const { theme } = useSettings(); const { handleSubmit, formState: { isSubmitting } } = useFormContext(); + const insets = useSafeAreaInsets(); const cancel = () => { if (router.canGoBack()) { @@ -23,7 +25,7 @@ export function FormButtons({ onSubmit, submitLabel = 'Submit' }: FormButtonsPro }; return ( - + >([]); + const [searchResults, setSearchResults] = useState<{ id: string; username: string }[]>([]); const [searching, setSearching] = useState(false); 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(() => { if (selectedTab === 'add' && searchQuery.length >= 2) { const timeout = setTimeout(async () => { @@ -42,23 +49,9 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) { } }, 300); return () => clearTimeout(timeout); - } else { - setSearchResults([]); } }, [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) => { try { await acceptRequest(requestId); @@ -94,7 +87,7 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) { @@ -142,7 +135,7 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) { ) : friends.length === 0 ? ( No friends yet - Tap "Add" to find friends by username + {'Tap "Add" to find friends by username'} ) : ( {item.username} { - try { - await sendRequest(item.username); + onPress={() => sendRequest(item.username) + .then(() => { setSearchQuery(''); setSearchResults([]); setSelectedTab('friends'); - } catch (err) { - Alert.alert('Error', err instanceof Error ? err.message : 'Failed to send request'); - } - }} + }) + .catch(err => Alert.alert('Error', err instanceof Error ? err.message : 'Failed to send request')) + } > Add @@ -287,7 +278,7 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) { )} - + ); } diff --git a/carry-your-live/src/components/Header.tsx b/carry-your-live/src/components/Header.tsx index 19cb8e7..403ad51 100644 --- a/carry-your-live/src/components/Header.tsx +++ b/carry-your-live/src/components/Header.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { View, Text, StyleSheet } from 'react-native'; +import { View, Text, StyleSheet, StatusBar, Platform } from 'react-native'; import { useSettings } from '@/theme'; interface HeaderProps { @@ -10,9 +10,10 @@ interface HeaderProps { export function Header({ title, showLogo, rightAction }: HeaderProps) { const { theme } = useSettings(); + const topInset = Platform.OS === 'android' ? (StatusBar.currentHeight ?? 24) : 0; return ( - + {showLogo && ( @@ -28,22 +29,20 @@ export function Header({ title, showLogo, rightAction }: HeaderProps) { const styles = StyleSheet.create({ header: { - borderBottomLeftRadius: 24, - borderBottomRightRadius: 24, + borderBottomLeftRadius: 16, + borderBottomRightRadius: 16, shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.05, - shadowRadius: 8, - elevation: 2, + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.04, + shadowRadius: 4, + elevation: 1, }, headerContent: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 20, - paddingTop: 2, - paddingBottom: 6, - height: 44, + height: 48, }, logoContainer: { width: 32, @@ -69,10 +68,4 @@ const styles = StyleSheet.create({ width: 32, alignItems: 'flex-end', }, - bottomRounded: { - height: 16, - borderBottomLeftRadius: 16, - borderBottomRightRadius: 16, - marginTop: -16, - }, }); \ No newline at end of file diff --git a/carry-your-live/src/components/LegalModal.tsx b/carry-your-live/src/components/LegalModal.tsx index cd837ec..8dd8780 100644 --- a/carry-your-live/src/components/LegalModal.tsx +++ b/carry-your-live/src/components/LegalModal.tsx @@ -123,12 +123,12 @@ For questions about these Terms, contact us through the app's feedback channel. `; export function LegalModal({ visible, type, onClose }: LegalModalProps) { - if (!visible || !type) return null; - const { theme } = useSettings(); const content = type === 'privacy' ? PRIVACY_POLICY : TERMS_OF_SERVICE; const title = type === 'privacy' ? 'Privacy Policy' : 'Terms of Service'; + if (!visible || !type) return null; + return ( @@ -192,6 +192,5 @@ const styles = StyleSheet.create({ body: { fontSize: 14, lineHeight: 22, - whiteSpace: 'pre-wrap' as const, }, }); \ No newline at end of file diff --git a/carry-your-live/src/components/OptionPickerModal.tsx b/carry-your-live/src/components/OptionPickerModal.tsx index 8fde62c..360a078 100644 --- a/carry-your-live/src/components/OptionPickerModal.tsx +++ b/carry-your-live/src/components/OptionPickerModal.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { View, Text, StyleSheet, Modal, TouchableOpacity, FlatList } from 'react-native'; import { useSettings } from '@/theme'; -import Svg, { Path } from 'react-native-svg'; +import Svg, { Path, Circle } from 'react-native-svg'; export interface PickerOption { value: string; @@ -13,14 +13,17 @@ interface OptionPickerModalProps { visible: boolean; title: string; options: PickerOption[]; - selectedValue?: string; - onSelect: (value: string) => void; + selectedValue?: string | string[]; + onSelect: (value: string | string[]) => 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 selectedValues = Array.isArray(selectedValue) ? selectedValue : selectedValue ? [selectedValue] : []; + return ( @@ -30,7 +33,7 @@ export function OptionPickerModal({ visible, title, options, selectedValue, onSe data={options} keyExtractor={(item) => item.value} renderItem={({ item }) => { - const selected = item.value === selectedValue; + const selected = selectedValues.includes(item.value); return ( { - onSelect(item.value); - onClose(); + if (multiSelect) { + const newValues = selected + ? selectedValues.filter((v) => v !== item.value) + : [...selectedValues, item.value]; + onSelect(newValues); + } else { + onSelect(item.value); + onClose(); + } }} activeOpacity={0.7} > @@ -50,7 +60,14 @@ export function OptionPickerModal({ visible, title, options, selectedValue, onSe {item.label} - {selected && ( + {multiSelect ? ( + + + {selected && ( + + )} + + ) : selected && ( diff --git a/carry-your-live/src/components/PrioritySelector.tsx b/carry-your-live/src/components/PrioritySelector.tsx index 3d348d4..4b0d417 100644 --- a/carry-your-live/src/components/PrioritySelector.tsx +++ b/carry-your-live/src/components/PrioritySelector.tsx @@ -3,14 +3,13 @@ import { View, Text, StyleSheet, TouchableOpacity } from 'react-native'; import { PRIORITY_COLORS } from '@/constants'; import { Priority } from '@/types'; import { useSettings } from '@/theme'; -import Svg, { Circle } from 'react-native-svg'; interface PrioritySelectorProps { value: Priority; onChange: (value: Priority) => void; } -const priorities: Array<{ value: Priority; label: string }> = [ +const priorities: { value: Priority; label: string }[] = [ { value: 'none', label: 'None' }, { value: 'low', label: 'Low' }, { value: 'medium', label: 'Medium' }, @@ -57,15 +56,15 @@ export function PrioritySelector({ value, onChange }: PrioritySelectorProps) { const styles = StyleSheet.create({ container: { - gap: 10, + gap: 6, }, label: { - fontSize: 14, + fontSize: 13, fontWeight: '600', }, options: { flexDirection: 'row', - gap: 8, + gap: 6, }, option: { flex: 1, @@ -73,16 +72,17 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center', gap: 6, - paddingVertical: 12, - paddingHorizontal: 16, - borderRadius: 12, + paddingVertical: 10, + paddingHorizontal: 8, + borderRadius: 10, borderWidth: 1, + minHeight: 40, }, colorIndicator: { width: 10, height: 10, borderRadius: 5, - opacity: 0.5, + opacity: 0.6, }, colorIndicatorSelected: { opacity: 1, diff --git a/carry-your-live/src/components/QuickAddBar.tsx b/carry-your-live/src/components/QuickAddBar.tsx index 1fe11b8..3f91d4d 100644 --- a/carry-your-live/src/components/QuickAddBar.tsx +++ b/carry-your-live/src/components/QuickAddBar.tsx @@ -1,11 +1,12 @@ -import React, { useEffect, useState } from 'react'; -import { View, Text, StyleSheet, TextInput, TouchableOpacity, KeyboardAvoidingView, Platform } from 'react-native'; +import React, { useState, useEffect, useRef } from 'react'; +import { View, StyleSheet, TextInput, TouchableOpacity, Platform, Keyboard, Animated, Easing } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { database, collections } from '@/database'; import { useCategories } from '@/hooks/useDatabase'; import { useSettings } from '@/theme'; import { OptionPickerModal } from '@/components/OptionPickerModal'; import Svg, { Path } from 'react-native-svg'; +import { subscribeToQuickAdd } from '@/utils/quickAddFocus'; interface QuickAddBarProps { dueDate?: number; @@ -17,17 +18,46 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) { const insets = useSafeAreaInsets(); const categories = useCategories(); 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 inputRef = useRef(null); + const keyboardHeight = useRef(new Animated.Value(0)).current; const categoryColor = categories.find((c) => c.id === categoryId)?.color ?? '#9E9E9E'; useEffect(() => { - if (!categoryId) { - setCategoryId(defaultCategoryId || categories[0]?.id || ''); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [categories, defaultCategoryId]); + return subscribeToQuickAdd(() => { + setTimeout(() => { + inputRef.current?.focus(); + }, 100); + }); + }, []); + + 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 trimmed = title.trim(); @@ -56,11 +86,14 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) { setTitle(''); }; + const animatedBottom = keyboardHeight.interpolate({ + inputRange: [0, 500], + outputRange: [insets.bottom + 0, insets.bottom + 0 + 500], + extrapolate: 'clamp', + }); + return ( - + ({ value: c.id, label: c.name, color: c.color }))} selectedValue={categoryId} - onSelect={(value) => setCategoryId(value)} + onSelect={(value) => setCategoryId(Array.isArray(value) ? value[0] : value)} onClose={() => setCategoryPickerVisible(false)} /> - + ); } const styles = StyleSheet.create({ wrapper: { position: 'absolute', - left: 16, - right: 16, - bottom: 24, + left: 8, + right: 8, + bottom: 12, }, bar: { flexDirection: 'row', alignItems: 'center', - gap: 10, - paddingHorizontal: 10, - paddingVertical: 10, - borderRadius: 16, + gap: 6, + paddingHorizontal: 8, + paddingVertical: 8, + borderRadius: 14, borderWidth: 1, shadowColor: '#000', - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.12, - shadowRadius: 12, - elevation: 8, + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.08, + shadowRadius: 6, + elevation: 4, }, categoryButton: { flexDirection: 'row', alignItems: 'center', - gap: 6, - paddingVertical: 10, - paddingHorizontal: 12, - borderRadius: 12, + gap: 3, + paddingVertical: 6, + paddingHorizontal: 6, + borderRadius: 8, borderWidth: 1, + height: 40, }, categoryButtonDot: { - width: 12, - height: 12, - borderRadius: 6, + width: 8, + height: 8, + borderRadius: 4, }, input: { flex: 1, fontSize: 15, paddingVertical: 10, + minHeight: 40, }, submit: { width: 40, height: 40, - borderRadius: 12, + borderRadius: 8, alignItems: 'center', justifyContent: 'center', }, diff --git a/carry-your-live/src/components/ReminderSelector.tsx b/carry-your-live/src/components/ReminderSelector.tsx index dbb74ee..491f19b 100644 --- a/carry-your-live/src/components/ReminderSelector.tsx +++ b/carry-your-live/src/components/ReminderSelector.tsx @@ -1,13 +1,13 @@ import React, { useState } from 'react'; 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 { OptionPickerModal } from '@/components/OptionPickerModal'; -import Svg, { Path, Circle } from 'react-native-svg'; +import Svg, { Path, Circle, Rect } from 'react-native-svg'; interface ReminderSelectorProps { - value: Reminder; - onChange: (value: Reminder) => void; + value: string; + onChange: (value: string) => void; hasDueDate: boolean; } @@ -15,17 +15,21 @@ export function ReminderSelector({ value, onChange, hasDueDate }: ReminderSelect const { theme } = useSettings(); 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 reminderLabels = selectedReminders.length > 0 + ? selectedReminders.map(r => REMINDER_OPTIONS.find(o => o.value === r)?.label).filter(Boolean).join(', ') + : 'No reminder'; + return ( - Reminder + Reminders 0 && { borderColor: theme.accent, backgroundColor: theme.accentSoft }, disabled && { opacity: 0.5 }, ]} onPress={() => setShowPicker(true)} @@ -35,22 +39,22 @@ export function ReminderSelector({ value, onChange, hasDueDate }: ReminderSelect 0 ? theme.accent : theme.textMuted} strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" fill="none" /> - + 0 ? theme.accent : 'transparent'} stroke={selectedReminders.length > 0 ? theme.accent : theme.textMuted} strokeWidth={1.5} /> 0 ? theme.text : theme.textMuted }, + selectedReminders.length > 0 && styles.valueTextFilled, ]} > - {value !== 'none' ? selected.label : 'No reminder'} + {reminderLabels} @@ -59,16 +63,17 @@ export function ReminderSelector({ value, onChange, hasDueDate }: ReminderSelect {!hasDueDate && ( - Set a due date to add a reminder. + Set a due date to add reminders. )} ({ value: o.value, label: o.label }))} - selectedValue={value} - onSelect={(v) => onChange(v as Reminder)} + title="Reminders" + options={REMINDER_OPTIONS.filter((o) => o.value !== 'none').map((o) => ({ value: o.value, label: o.label }))} + selectedValue={selectedReminders} + onSelect={(v) => onChange(toRemindersString(v as Reminder[]))} onClose={() => setShowPicker(false)} + multiSelect /> ); @@ -76,10 +81,10 @@ export function ReminderSelector({ value, onChange, hasDueDate }: ReminderSelect const styles = StyleSheet.create({ container: { - gap: 8, + gap: 6, }, label: { - fontSize: 14, + fontSize: 13, fontWeight: '600', }, row: { @@ -93,7 +98,7 @@ const styles = StyleSheet.create({ }, valueText: { flex: 1, - fontSize: 15, + fontSize: 14, }, valueTextFilled: { fontWeight: '500', @@ -102,7 +107,7 @@ const styles = StyleSheet.create({ transform: [{ rotate: '-90deg' }], }, hint: { - fontSize: 12, + fontSize: 11, marginLeft: 4, }, }); diff --git a/carry-your-live/src/components/RepeatSelector.tsx b/carry-your-live/src/components/RepeatSelector.tsx index 381a32e..1018cc5 100644 --- a/carry-your-live/src/components/RepeatSelector.tsx +++ b/carry-your-live/src/components/RepeatSelector.tsx @@ -1,5 +1,5 @@ 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 { useSettings } from '@/theme'; import { useRepeatProfiles } from '@/hooks/useDatabase'; @@ -246,43 +246,48 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect animationType="fade" onRequestClose={() => setSaveModalVisible(false)} > - setSaveModalVisible(false)}> - - Save repeat profile - - {`${REPEAT_OPTIONS.find((o) => o.value === value)?.label.replace('No Repeat', 'None')}, every ${interval} ${unitLabel(value)}${interval > 1 ? 's' : ''}`} - {isDaysBased(value) && days.length > 0 ? ` · ${days.map((d) => WEEKDAY_LABELS[d]).join(' ')}` : ''} - - - - setSaveModalVisible(false)} - activeOpacity={0.8} - > - Cancel - - - Save - - + + setSaveModalVisible(false)}> + + Save repeat profile + + {`${REPEAT_OPTIONS.find((o) => o.value === value)?.label.replace('No Repeat', 'None')}, every ${interval} ${unitLabel(value)}${interval > 1 ? 's' : ''}`} + {isDaysBased(value) && days.length > 0 ? ` · ${days.map((d) => WEEKDAY_LABELS[d]).join(' ')}` : ''} + + + + setSaveModalVisible(false)} + activeOpacity={0.8} + > + Cancel + + + Save + + + - + ); @@ -290,115 +295,115 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect const styles = StyleSheet.create({ container: { - gap: 10, + gap: 8, }, label: { - fontSize: 14, + fontSize: 13, fontWeight: '600', }, chipRow: { flexDirection: 'row', flexWrap: 'wrap', - gap: 8, + gap: 6, }, chip: { flexDirection: 'row', alignItems: 'center', - gap: 6, - paddingVertical: 10, - paddingHorizontal: 12, - borderRadius: 20, + gap: 5, + paddingVertical: 8, + paddingHorizontal: 10, + borderRadius: 18, borderWidth: 1, }, chipText: { - fontSize: 13, + fontSize: 12, fontWeight: '500', }, settings: { - gap: 10, + gap: 8, }, intervalRow: { flexDirection: 'row', alignItems: 'center', - gap: 10, - paddingHorizontal: 14, - paddingVertical: 10, - borderRadius: 12, + gap: 8, + paddingHorizontal: 12, + paddingVertical: 8, + borderRadius: 10, borderWidth: 1, }, intervalLabel: { - fontSize: 14, + fontSize: 13, fontWeight: '500', }, intervalValue: { - fontSize: 16, + fontSize: 15, fontWeight: '700', - minWidth: 24, + minWidth: 22, textAlign: 'center', }, stepButton: { - width: 32, - height: 32, - borderRadius: 8, + width: 28, + height: 28, + borderRadius: 7, borderWidth: 1, alignItems: 'center', justifyContent: 'center', }, stepButtonText: { - fontSize: 18, + fontSize: 16, fontWeight: '600', - lineHeight: 20, + lineHeight: 18, }, dayRow: { flexDirection: 'row', justifyContent: 'space-between', - gap: 6, + gap: 5, }, dayChip: { flex: 1, - height: 40, - borderRadius: 10, + height: 36, + borderRadius: 9, borderWidth: 1, alignItems: 'center', justifyContent: 'center', }, dayChipLast: {}, dayChipText: { - fontSize: 13, + fontSize: 12, fontWeight: '600', }, profileRow: { flexDirection: 'row', alignItems: 'flex-start', - gap: 8, + gap: 6, }, profileLabel: { - fontSize: 12, + fontSize: 11, fontWeight: '600', - paddingTop: 8, + paddingTop: 6, }, profileChips: { flex: 1, flexDirection: 'row', flexWrap: 'wrap', - gap: 8, + gap: 6, }, profileChip: { flexDirection: 'row', alignItems: 'center', - gap: 6, - paddingVertical: 6, - paddingHorizontal: 10, - borderRadius: 12, + gap: 5, + paddingVertical: 5, + paddingHorizontal: 8, + borderRadius: 10, borderWidth: 1, }, profileChipText: { - fontSize: 13, + fontSize: 12, fontWeight: '500', }, profileChipX: { - fontSize: 14, - lineHeight: 16, + fontSize: 13, + lineHeight: 15, fontWeight: '700', paddingHorizontal: 2, }, @@ -406,14 +411,14 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', justifyContent: 'center', - gap: 6, - paddingVertical: 10, - borderRadius: 12, + gap: 5, + paddingVertical: 8, + borderRadius: 10, borderWidth: 1, borderStyle: 'dashed', }, saveProfileText: { - fontSize: 13, + fontSize: 12, fontWeight: '600', }, modalBackdrop: { @@ -421,45 +426,45 @@ const styles = StyleSheet.create({ backgroundColor: 'rgba(0,0,0,0.5)', alignItems: 'center', justifyContent: 'center', - padding: 24, + padding: 20, }, modalCard: { width: '100%', - maxWidth: 400, - borderRadius: 16, + maxWidth: 360, + borderRadius: 14, borderWidth: 1, - padding: 20, - gap: 12, + padding: 16, + gap: 10, }, modalTitle: { - fontSize: 16, + fontSize: 15, fontWeight: '700', }, modalHint: { - fontSize: 13, + fontSize: 12, }, modalInput: { - borderRadius: 10, + borderRadius: 9, borderWidth: 1, - paddingHorizontal: 12, - paddingVertical: 10, - fontSize: 14, + paddingHorizontal: 10, + paddingVertical: 8, + fontSize: 13, }, modalButtons: { flexDirection: 'row', - gap: 10, + gap: 8, }, modalButton: { flex: 1, alignItems: 'center', - paddingVertical: 11, - borderRadius: 10, + paddingVertical: 10, + borderRadius: 9, borderWidth: 1, }, modalButtonPrimary: { borderWidth: 0, }, modalButtonText: { - fontSize: 14, + fontSize: 13, }, }); diff --git a/carry-your-live/src/components/ServerUrlModal.tsx b/carry-your-live/src/components/ServerUrlModal.tsx index cd02ef8..143650b 100644 --- a/carry-your-live/src/components/ServerUrlModal.tsx +++ b/carry-your-live/src/components/ServerUrlModal.tsx @@ -1,5 +1,5 @@ -import React, { useEffect, useState } from 'react'; -import { Modal, View, Text, StyleSheet, TextInput, TouchableOpacity, KeyboardAvoidingView, Platform } from 'react-native'; +import React, { useState } from 'react'; +import { Modal, View, Text, StyleSheet, TextInput, TouchableOpacity, KeyboardAvoidingView } from 'react-native'; import { useSettings } from '@/theme'; import { DEFAULT_API_BASE_URL } from '@/services/auth'; import Svg, { Path } from 'react-native-svg'; @@ -12,12 +12,14 @@ interface ServerUrlModalProps { export function ServerUrlModal({ visible, onClose }: ServerUrlModalProps) { const { theme, apiUrl, setApiUrl } = useSettings(); const [value, setValue] = useState(apiUrl); + const [prevVisible, setPrevVisible] = useState(visible); - useEffect(() => { + if (prevVisible !== visible) { + setPrevVisible(visible); if (visible) { setValue(apiUrl); } - }, [visible, apiUrl]); + } const handleSave = () => { const trimmed = value.trim().replace(/\/+$/, ''); @@ -33,7 +35,7 @@ export function ServerUrlModal({ visible, onClose }: ServerUrlModalProps) { diff --git a/carry-your-live/src/components/SubtaskItem.tsx b/carry-your-live/src/components/SubtaskItem.tsx index fc8e61e..dc0bcac 100644 --- a/carry-your-live/src/components/SubtaskItem.tsx +++ b/carry-your-live/src/components/SubtaskItem.tsx @@ -1,145 +1,96 @@ -import React from 'react'; -import { View, Text, StyleSheet, TouchableOpacity } from 'react-native'; +import React, { useState } from 'react'; +import { View, StyleSheet } from 'react-native'; import { SubtaskData } from '@/types'; -import { PRIORITY_COLORS } from '@/constants'; +import { TaskItem } from './TaskItem'; import { useSettings } from '@/theme'; -import Svg, { Path } from 'react-native-svg'; interface SubtaskItemProps { subtask: SubtaskData; - onToggle: () => void; + onToggle: () => void | Promise; + 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 [expanded, setExpanded] = useState(true); + const hasChildren = subtask.subtasks && subtask.subtasks.length > 0; + + const handleExpand = () => setExpanded(!expanded); return ( - - - - {subtask.completed ? ( - <> - + {})} + onLongPress={onLongPress} + onMenuOpen={onMenuOpen} + selected={selected} + selectionMode={selectionMode} + completedSection={subtask.completed} + draggable={draggable} + onDragStart={onDragStart} + onDragUpdate={onDragUpdate} + onDragEnd={onDragEnd} + /> + {hasChildren && expanded && ( + + {subtask.subtasks + .slice() + .sort((a, b) => a.order - b.order) + .map((child) => ( + {}} + onDelete={onDelete} + onPress={onPress} + onLongPress={onLongPress} + onMenuOpen={onMenuOpen} + selected={selected} + selectionMode={selectionMode} + draggable={draggable} + onDragStart={onDragStart} + onDragUpdate={onDragUpdate} + onDragEnd={onDragEnd} + depth={depth + 1} /> - - ) : ( - - )} - - - - - {subtask.title} - - - {subtask.priority && subtask.priority !== 'none' && ( - - {subtask.priority.charAt(0).toUpperCase()} + ))} )} - - {subtask.dueDate && subtask.dueDate > 0 && ( - - {formatDueDate(subtask.dueDate, subtask.dueTime, subtask.endTime || '')} - - )} ); -} - -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({ - container: { - flexDirection: 'row', - 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%', + nestedSubtasks: { + marginTop: 4, }, }); diff --git a/carry-your-live/src/components/SubtasksSection.tsx b/carry-your-live/src/components/SubtasksSection.tsx index 6b40a02..b3db8bf 100644 --- a/carry-your-live/src/components/SubtasksSection.tsx +++ b/carry-your-live/src/components/SubtasksSection.tsx @@ -159,35 +159,35 @@ function SubtaskItem({ index, value, onChange, onRemove }: SubtaskItemProps) { const styles = StyleSheet.create({ container: { - gap: 8, + gap: 6, }, header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', - paddingVertical: 4, + paddingVertical: 2, }, headerLeft: { flexDirection: 'row', alignItems: 'center', - gap: 8, + gap: 6, }, label: { - fontSize: 14, + fontSize: 13, fontWeight: '600', }, badge: { - paddingHorizontal: 8, - paddingVertical: 2, - borderRadius: 10, + paddingHorizontal: 6, + paddingVertical: 1, + borderRadius: 8, }, badgeText: { - fontSize: 12, + fontSize: 11, fontWeight: '700', }, chevron: { - width: 16, - height: 16, + width: 14, + height: 14, alignItems: 'center', justifyContent: 'center', }, @@ -195,43 +195,43 @@ const styles = StyleSheet.create({ overflow: 'hidden', }, list: { - gap: 8, - paddingBottom: 8, + gap: 6, + paddingBottom: 6, }, item: { flexDirection: 'row', alignItems: 'center', - gap: 10, - paddingHorizontal: 12, - paddingVertical: 10, - borderRadius: 12, + gap: 8, + paddingHorizontal: 10, + paddingVertical: 8, + borderRadius: 10, borderWidth: 1, }, checkbox: { - width: 22, - height: 22, + width: 20, + height: 20, alignItems: 'center', justifyContent: 'center', }, input: { flex: 1, - fontSize: 15, - paddingVertical: 4, + fontSize: 14, + paddingVertical: 2, }, removeButton: { - padding: 4, + padding: 3, }, addButton: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', - gap: 6, - paddingVertical: 10, - borderRadius: 12, + gap: 5, + paddingVertical: 8, + borderRadius: 10, borderWidth: 1, }, addButtonText: { - fontSize: 13, + fontSize: 12, fontWeight: '500', }, }); \ No newline at end of file diff --git a/carry-your-live/src/components/SyncModal.tsx b/carry-your-live/src/components/SyncModal.tsx index 7c3749d..f650538 100644 --- a/carry-your-live/src/components/SyncModal.tsx +++ b/carry-your-live/src/components/SyncModal.tsx @@ -7,13 +7,11 @@ import { TextInput, TouchableOpacity, KeyboardAvoidingView, - Platform, ActivityIndicator, } from 'react-native'; import { useSettings } from '@/theme'; import { AuthUser, getAuthToken, getAuthUser, login, register, signOutAuth } from '@/services/auth'; -import { runSync, getLastSyncTime, SyncResult } from '@/database/sync'; -import Svg, { Path } from 'react-native-svg'; +import { runSyncGuarded, startAutoSync, stopAutoSync, getLastSyncTime, SyncResult } from '@/database/sync'; interface SyncModalProps { visible: boolean; @@ -35,14 +33,21 @@ export function SyncModal({ visible, onClose }: SyncModalProps) { const [status, setStatus] = useState('idle'); const [lastSync, setLastSync] = useState(null); const [result, setResult] = useState(null); + const [prevVisible, setPrevVisible] = useState(visible); + + if (prevVisible !== visible) { + setPrevVisible(visible); + if (visible) { + setChecking(true); + setError(null); + setStatus('idle'); + setResult(null); + } + } useEffect(() => { if (!visible) return; let mounted = true; - setChecking(true); - setError(null); - setStatus('idle'); - setResult(null); (async () => { const token = await getAuthToken(); @@ -77,6 +82,7 @@ export function SyncModal({ visible, onClose }: SyncModalProps) { ? await register(username.trim(), password) : await login(username.trim(), password); setUser(authedUser); + startAutoSync(); } catch (err: any) { setError(err?.message ?? 'Sign in failed'); } finally { @@ -89,13 +95,15 @@ export function SyncModal({ visible, onClose }: SyncModalProps) { setError(null); setStatus('syncing'); try { - const syncResult = await runSync(); + const syncResult = await runSyncGuarded(); setResult(syncResult); setStatus('success'); setLastSync(Date.now()); } catch (err: any) { - setStatus('error'); - if (err?.message === 'NOT_SIGNED_IN') { + setStatus('idle'); + if (err?.message === 'SYNC_IN_FLIGHT') { + setError('Sync already in progress'); + } else if (err?.message === 'NOT_SIGNED_IN') { setUser(null); setError('Not signed in. Sign in to sync.'); } else { @@ -106,6 +114,7 @@ export function SyncModal({ visible, onClose }: SyncModalProps) { const handleSignOut = async () => { await signOutAuth(); + stopAutoSync(); setUser(null); setError(null); setStatus('idle'); @@ -116,7 +125,7 @@ export function SyncModal({ visible, onClose }: SyncModalProps) { diff --git a/carry-your-live/src/components/SyncStatus.tsx b/carry-your-live/src/components/SyncStatus.tsx index 0d895b6..0033418 100644 --- a/carry-your-live/src/components/SyncStatus.tsx +++ b/carry-your-live/src/components/SyncStatus.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react'; import { View, Text, StyleSheet, TouchableOpacity } from 'react-native'; import { useSettings } from '@/theme'; import Svg, { Path, Circle } from 'react-native-svg'; -import { runSync, getLastSyncTime } from '@/database/sync'; +import { runSyncGuarded, getLastSyncTime } from '@/database/sync'; interface SyncStatusProps { compact?: boolean; @@ -13,21 +13,23 @@ export default function SyncStatus({ compact = false }: SyncStatusProps) { const [status, setStatus] = useState<'idle' | 'syncing' | 'success' | 'error'>('idle'); const [lastSync, setLastSync] = useState(null); const [error, setError] = useState(null); - - useEffect(() => { - loadLastSync(); - }, []); + const [now] = useState(() => Date.now()); const loadLastSync = async () => { const time = await getLastSyncTime(); setLastSync(time); }; + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + loadLastSync(); + }, []); + const handleSync = async () => { setStatus('syncing'); setError(null); try { - await runSync(); + await runSyncGuarded(); setStatus('success'); await loadLastSync(); setTimeout(() => setStatus('idle'), 3000); @@ -40,7 +42,7 @@ export default function SyncStatus({ compact = false }: SyncStatusProps) { const formatTime = (timestamp: number | null): string => { if (!timestamp) return 'Never'; - const diff = Date.now() - timestamp; + const diff = now - timestamp; if (diff < 60000) return 'Just now'; if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`; if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`; diff --git a/carry-your-live/src/components/TabBarIcon.tsx b/carry-your-live/src/components/TabBarIcon.tsx index 7f23a76..5fb2b27 100644 --- a/carry-your-live/src/components/TabBarIcon.tsx +++ b/carry-your-live/src/components/TabBarIcon.tsx @@ -3,7 +3,7 @@ import { ColorValue } from 'react-native'; import Svg, { Path, Circle } from 'react-native-svg'; interface TabBarIconProps { - name: 'checklist' | 'calendar' | 'gear'; + name: 'checklist' | 'calendar' | 'gear' | 'stats'; focused: boolean; color: ColorValue; size?: number; @@ -51,6 +51,20 @@ export function TabBarIcon({ name, focused, color, size = 24 }: TabBarIconProps) fill="none" /> )} + {name === 'stats' && ( + <> + + {focused && ( + + )} + + )} ); } \ No newline at end of file diff --git a/carry-your-live/src/components/TaskDeleteModal.tsx b/carry-your-live/src/components/TaskDeleteModal.tsx index 053f232..954ef1e 100644 --- a/carry-your-live/src/components/TaskDeleteModal.tsx +++ b/carry-your-live/src/components/TaskDeleteModal.tsx @@ -9,16 +9,17 @@ interface TaskDeleteModalProps { taskId: string | null; taskTitle?: string; isRepeating?: boolean; + subtask?: boolean; onClose: () => 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 [counts, setCounts] = useState({ future: 1, all: 1 }); useEffect(() => { - if (!visible || !taskId) return; + if (!visible || !taskId || subtask) return; let mounted = true; getSeriesOccurrenceCounts(taskId) .then((c) => { @@ -26,11 +27,11 @@ export function TaskDeleteModal({ visible, taskId, taskTitle, isRepeating, onClo }) .catch(() => {}); return () => { mounted = false; }; - }, [visible, taskId]); + }, [visible, taskId, subtask]); - const options: Array<{ scope: TaskDeleteScope; label: string; hint?: string }> = [ - { scope: 'this', label: 'This task only' }, - ]; + const options: { scope: TaskDeleteScope; label: string; hint?: string }[] = subtask + ? [{ scope: 'this', label: 'This subtask only' }] + : [{ scope: 'this', label: 'This task only' }]; if (isRepeating) { 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 - Delete Task + {subtask ? 'Delete Subtask' : 'Delete Task'} diff --git a/carry-your-live/src/components/TaskItem.tsx b/carry-your-live/src/components/TaskItem.tsx index 8e9c053..0e19391 100644 --- a/carry-your-live/src/components/TaskItem.tsx +++ b/carry-your-live/src/components/TaskItem.tsx @@ -1,13 +1,27 @@ import React from 'react'; -import { View, Text, StyleSheet, TouchableOpacity, Animated } from 'react-native'; -import { Swipeable, Gesture, GestureDetector } from 'react-native-gesture-handler'; -import { TaskData } from '@/types'; +import { View, Text, StyleSheet, TouchableOpacity, Animated, Platform, Dimensions } from 'react-native'; +import { Swipeable, Gesture, GestureDetector, PanGestureHandler } from 'react-native-gesture-handler'; +import { TaskData, SubtaskData, Priority, Repeat, Reminder } from '@/types'; import { PRIORITY_COLORS } from '@/constants'; import { useSettings } from '@/theme'; 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 { - task: TaskData; + task: TaskData | SubtaskData | TaskLike; onToggle: () => void | Promise; onDelete?: () => void; onPress: () => void; @@ -20,17 +34,22 @@ interface TaskItemProps { hovered?: boolean; onDragStart?: () => void; onDragUpdate?: (absoluteY: number) => void; - onDragEnd?: (absoluteY: number) => void; - assigneeUsername?: string; - expanded?: boolean; + onDragEnd?: (absoluteY: number) => void; + onReorderStart?: () => void; + 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 [opacityAnim] = React.useState(new Animated.Value(task.completed ? 0.5 : 1)); const [dragTranslateX] = 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(null); 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 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( () => Gesture.Pan() .activateAfterLongPress(400) .minDistance(2) .runOnJS(true) - . onStart(() => { + .onStart(() => { setDragging(true); onDragStart?.(); }) @@ -78,12 +124,24 @@ export function TaskItem({ task, onToggle, onDelete, onPress, onLongPress, onMen }).start(); }, [task.completed, opacityAnim]); - const startOfToday = new Date(); - startOfToday.setHours(0, 0, 0, 0); - 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 < new Date().setHours(23, 59, 59, 999); - const canComplete = !hasDueDate || isOverdue || isDueToday; + const dueInfo = React.useMemo(() => { + const startOfToday = new Date(); + startOfToday.setHours(0, 0, 0, 0); + const endOfToday = new Date(); + endOfToday.setHours(23, 59, 59, 999); + 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) => { const translateX = progress.interpolate({ inputRange: [0, 1], outputRange: [80, 0] }); @@ -138,19 +196,20 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation) => overshootRight={false} overshootLeft={false} > - + 0) && { marginLeft: depth > 0 ? depth * 12 : 32, marginBottom: 4 }, task.completed && styles.taskCompleted, isOverdue && styles.taskOverdue, isDueToday && styles.taskDueToday, selectionMode && styles.taskSelected, selected && { borderColor: theme.accent, borderWidth: 2 }, - hovered && { borderColor: theme.accent, borderWidth: 2, backgroundColor: theme.accentSoft }, completedSection && { backgroundColor: theme.cardAlt }, dragging && styles.dragLifted, + hovered && styles.taskHovered, { transform: [{ translateX: dragTranslateX }, { translateY: dragTranslateY }] }, ]} pointerEvents={dragging ? 'none' : 'auto'} @@ -164,6 +223,24 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation) => > + {}} + onPressOut={() => {}} + > + + + + + + + + + + + + ) => - {task.dueDate && ( + {hasDueDate && ( @@ -266,7 +343,7 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation) => isDueToday && styles.dueTextDueToday, ]} > - {formatDueDate(task.dueDate, task.dueTime, task.endTime || '')} + {formattedDueDate} {task.reminder && task.reminder !== 'none' && ( @@ -302,7 +379,7 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation) => ); -} +}); function formatDueDate(dueDate: number, dueTime: string, endTime?: string): string { const date = new Date(dueDate); @@ -338,19 +415,24 @@ const styles = StyleSheet.create({ container: { flexDirection: 'row', alignItems: 'center', - paddingHorizontal: 16, + paddingHorizontal: 12, paddingVertical: 14, borderRadius: 16, borderWidth: 1, + marginVertical: 4, shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, - shadowOpacity: 0.04, - shadowRadius: 4, + shadowOpacity: 0.05, + shadowRadius: 6, elevation: 1, }, taskCompleted: { opacity: 0.5, }, + indented: { + marginLeft: 32, + marginBottom: 4, + }, taskOverdue: { borderColor: '#4A2B2B', }, @@ -363,6 +445,10 @@ const styles = StyleSheet.create({ shadowRadius: 6, elevation: 2, }, + taskHovered: { + borderColor: '#1E88E5', + backgroundColor: 'rgba(30, 136, 229, 0.05)', + }, dragLifted: { zIndex: 100, elevation: 12, @@ -375,18 +461,26 @@ const styles = StyleSheet.create({ flex: 1, }, content: { - gap: 4, + gap: 12, }, titleRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', }, + dragHandle: { + width: 28, + height: 28, + borderRadius: 14, + alignItems: 'center', + justifyContent: 'center', + marginRight: 8, + }, title: { - fontSize: 16, + fontSize: 17, fontWeight: '500', flex: 1, - marginRight: 8, + marginRight: 16, }, titleCompleted: { textDecorationLine: 'line-through', @@ -444,7 +538,7 @@ const styles = StyleSheet.create({ dueRow: { flexDirection: 'row', alignItems: 'center', - gap: 4, + gap: 8, }, dueText: { fontSize: 13, diff --git a/carry-your-live/src/components/TaskList.tsx b/carry-your-live/src/components/TaskList.tsx index 3e1617e..01032aa 100644 --- a/carry-your-live/src/components/TaskList.tsx +++ b/carry-your-live/src/components/TaskList.tsx @@ -1,30 +1,25 @@ -import React, { useState, useCallback, useMemo, useRef, useEffect } from 'react'; -import { View, Text, StyleSheet, FlatList, TouchableOpacity, Animated, RefreshControl, Alert } from 'react-native'; -import { useRouter } from 'expo-router'; +import React, { useState, useCallback, useMemo, useRef } from 'react'; +import { View, Text, StyleSheet, Animated, RefreshControl, Alert, TouchableOpacity } from 'react-native'; import { useTasks } from '@/hooks/useTasks'; +import { useTaskModals } from '@/hooks/useTaskModals'; import { TaskItem } from './TaskItem'; import { SubtaskItem } from './SubtaskItem'; 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 { OptionPickerModal } from './OptionPickerModal'; -import { TaskOverflowMenu } from './TaskOverflowMenu'; -import { TaskDeleteModal } from './TaskDeleteModal'; import { toggleTaskComplete, deleteTask, - duplicateTask, - setTaskCategory, - setTaskPriority, setTaskCompleted, - deleteTaskOccurrences, convertTaskToSubtask, + convertSubtaskToTask, + moveSubtaskToTask, toggleSubtaskComplete, - TaskDeleteScope, + reorderTasks, } from '@/utils/taskActions'; -import { PRIORITY_LABELS } from '@/constants'; import { Q } from '@nozbe/watermelondb'; -import Svg, { Path } from 'react-native-svg'; +import Svg, { Path, Rect } from 'react-native-svg'; interface TaskListProps { categoryId?: string; @@ -33,11 +28,20 @@ interface TaskListProps { const PRIORITY_RANK: Record = { none: 0, low: 1, medium: 2, high: 3, critical: 4 }; +const SEPARATOR = () => ; +const MemoSeparator = React.memo(SEPARATOR); + +const DropIndicator = ({ theme }: { theme: any }) => ( + + + + + +); + export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProps) { - const router = useRouter(); const { theme, sortBy } = useSettings(); const { collections } = useDatabase(); - const categories = useCategories(); const { tasks, loading } = useTasks(categoryId, false); const { tasks: completedTasks } = useTasks(categoryId, true); @@ -45,16 +49,30 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp const [refreshing, setRefreshing] = useState(false); const [selectionMode, setSelectionMode] = useState(false); const [selectedIds, setSelectedIds] = useState>(new Set()); - const [menuTaskId, setMenuTaskId] = useState(null); - const [deleteTaskId, setDeleteTaskId] = useState(null); - const [picker, setPicker] = useState(null); const [hoverTaskId, setHoverTaskId] = useState(null); const [expandedTasks, setExpandedTasks] = useState>(new Set()); const [subtasksMap, setSubtasksMap] = useState>(new Map()); + const [reorderState, setReorderState] = useState<{ draggedId: string; draggedIndex: number; targetIndex: number | null; positions: Record } | null>(null); + const [dropIndicator, setDropIndicator] = useState<{ targetId: string | null; position: 'above' | 'below' } | null>(null); const itemRefs = useRef>(new Map()); const dragStateRef = useRef<{ taskId: string; positions: Record } | null>(null); + const subtaskDragRef = useRef<{ subtaskId: string; parentTaskId: string; positions: Record } | 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 sorted = [...tasks]; @@ -75,16 +93,6 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp return sorted; }, [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(() => { setRefreshing(true); setTimeout(() => setRefreshing(false), 600); @@ -118,22 +126,12 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp }); }, [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 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) => ({ id: s.id, taskId: s.taskId, + parentSubtaskId: s.parentSubtaskId || null, title: s.title, description: s.description || '', 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'], assigneeId: s.assigneeId ?? null, 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 => { + 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 => { + 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]); + 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) => { setExpandedTasks((prev) => { const next = new Set(prev); @@ -182,23 +231,6 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp await fetchSubtasks(taskId); }, [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(() => { Alert.alert(`Delete ${selectedIds.size} task${selectedIds.size > 1 ? 's' : ''}?`, 'This cannot be undone.', [ { text: 'Cancel', style: 'cancel' }, @@ -206,9 +238,7 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp text: 'Delete', style: 'destructive', onPress: async () => { - for (const taskId of selectedIds) { - await deleteTask(taskId); - } + await Promise.all(Array.from(selectedIds).map(taskId => deleteTask(taskId))); exitSelection(); refreshAll(); }, @@ -217,27 +247,11 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp }, [selectedIds, exitSelection, refreshAll]); const handleBulkComplete = useCallback(async () => { - for (const taskId of selectedIds) { - await setTaskCompleted(taskId, true); - } + await Promise.all(Array.from(selectedIds).map(taskId => setTaskCompleted(taskId, true))); 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 positions: Record = {}; const entries = Array.from(itemRefs.current.entries()); @@ -262,6 +276,13 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp return null; }, []); + const calculateDropPosition = useCallback((absoluteY: number, targetId: string, positions: Record) => { + 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) => { dragStateRef.current = { taskId, positions: await measureItems() }; }, [measureItems]); @@ -271,12 +292,26 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp if (!state) return; const target = findHoverTarget(absoluteY, state.taskId, state.positions); 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 state = dragStateRef.current; dragStateRef.current = null; setHoverTaskId(null); + setDropIndicator(null); if (!state) return; const target = findHoverTarget(absoluteY, state.taskId, state.positions); @@ -288,6 +323,222 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp } }, [findHoverTarget, refreshAll]); + const measureReorderItems = useCallback(async () => { + const positions: Record = {}; + const entries = Array.from(itemRefs.current.entries()); + await Promise.all(entries.map(([id, ref]) => { + return new Promise((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) => { + 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) => { + 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 ( + + {showDropAbove && } + + {showDropBelow && } + + ); + }, + [ + 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 ( + + No tasks yet + Tap + to add your first task + + ); + }, [sortedTasks.length, completedTasks.length, theme.textSecondary, theme.textMuted]); + + const listFooter = useMemo(() => { + const footerContent = completedTasks.length === 0 ? null : ( + 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 ( + + {footerContent} + {showDropAtEnd && } + + ); + }, [ + completedTasks, + handleToggle, + openTaskDelete, + openTaskMenu, + enterSelection, + selectionMode, + selectedIds, + toggleSelect, + dropIndicator, + theme, + ]); + if (loading && !refreshing) { return ( @@ -296,87 +547,16 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp ); } - const priorityOptions = Object.entries(PRIORITY_LABELS).map(([value, label]) => ({ value, label })); - return ( item.id} - renderItem={({ item }) => { - const isExpanded = expandedTasks.has(item.id); - const itemSubtasks = subtasksMap.get(item.id) ?? []; - return ( - { - if (ref) { - itemRefs.current.set(item.id, ref); - } else { - itemRefs.current.delete(item.id); - } - }} - > - 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 && ( - - {itemSubtasks - .slice() - .sort((a, b) => a.order - b.order) - .map((sub) => ( - handleSubtaskToggle(sub.id, item.id)} - /> - ))} - - )} - - ); - }} - ItemSeparatorComponent={() => } - ListHeaderComponent={ - sortedTasks.length === 0 && completedTasks.length === 0 ? ( - - No tasks yet - Tap + to add your first task - - ) : sortedTasks.length === 0 ? ( - - All caught up! - No pending tasks - - ) : null - } - ListFooterComponent={ - completedTasks.length > 0 ? ( - 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 - } + renderItem={renderItem} + extraData={{ expandedTasks, subtasksMap, selectedIds, selectionMode, hoverTaskId, dropIndicator }} + ItemSeparatorComponent={MemoSeparator} + ListHeaderComponent={listHeader} + ListFooterComponent={listFooter} refreshControl={ - setDeleteTaskId(null)} - onDelete={handleDeleteScope} - /> - - 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 })} - /> - - ({ value: c.id, label: c.name, color: c.color }))} - selectedValue={menuTask?.categoryId} - onSelect={handleSingleCategory} - onClose={() => setPicker(null)} - /> - - setPicker(null)} - /> + {modals(refreshAll)} {selectionMode && ( @@ -447,8 +590,7 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp ); } -function SelectionButton({ label, color, onPress }: { label: string; color: string; onPress: () => void }) { - const { theme } = useSettings(); +const SelectionButton = React.memo(function SelectionButton({ label, color, onPress }: { label: string; color: string; onPress: () => void }) { return ( {label} ); +}); + +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; + onDelete: (task: Task) => void; + onExpand: (taskId: string) => void | Promise; + 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; } +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 ( + registerRef(task.id, ref)} + style={styles.dragContainer} + > + 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 && ( + + {sortedSubtasks.map((sub) => ( + 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} + /> + ))} + + )} + + ); +}); + interface CompletedSectionProps { tasks: TaskData[]; onToggle: (task: TaskData) => void; @@ -471,7 +722,7 @@ interface CompletedSectionProps { 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 [expanded, setExpanded] = useState(false); @@ -508,7 +759,7 @@ function CompletedSection({ tasks, onToggle, onDelete, onMenuOpen, onLongPress, )} ); -} +}); const styles = StyleSheet.create({ container: { @@ -516,8 +767,8 @@ const styles = StyleSheet.create({ }, listContent: { paddingHorizontal: 16, - paddingTop: 2, - paddingBottom: 100, + paddingTop: 8, + paddingBottom: 120, }, loadingContainer: { flex: 1, @@ -533,6 +784,25 @@ const styles = StyleSheet.create({ subtaskList: { paddingLeft: 8, 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: { alignItems: 'center', diff --git a/carry-your-live/src/components/TaskNameInput.tsx b/carry-your-live/src/components/TaskNameInput.tsx index fb95b08..d9010db 100644 --- a/carry-your-live/src/components/TaskNameInput.tsx +++ b/carry-your-live/src/components/TaskNameInput.tsx @@ -1,6 +1,5 @@ import React from 'react'; -import { View, Text, StyleSheet, TextInput } from 'react-native'; -import { TextInputProps } from 'react-native'; +import { View, Text, StyleSheet, TextInput , TextInputProps } from 'react-native'; import { useSettings } from '@/theme'; interface TaskNameInputProps extends TextInputProps { @@ -35,34 +34,34 @@ export function TaskNameInput({ error, ...props }: TaskNameInputProps) { const styles = StyleSheet.create({ container: { - gap: 6, + gap: 5, }, labelRow: { flexDirection: 'row', alignItems: 'center', }, label: { - fontSize: 14, + fontSize: 13, fontWeight: '600', }, required: { color: '#E53935', - fontSize: 14, + fontSize: 13, fontWeight: '600', }, input: { - height: 52, - paddingHorizontal: 16, - borderRadius: 12, + height: 48, + paddingHorizontal: 14, + borderRadius: 10, borderWidth: 1, - fontSize: 16, + fontSize: 15, }, inputError: { borderColor: '#E53935', borderWidth: 1.5, }, errorText: { - fontSize: 12, + fontSize: 11, color: '#E53935', marginLeft: 4, }, diff --git a/carry-your-live/src/components/TaskOverflowMenu.tsx b/carry-your-live/src/components/TaskOverflowMenu.tsx index 7b09a5e..fc14deb 100644 --- a/carry-your-live/src/components/TaskOverflowMenu.tsx +++ b/carry-your-live/src/components/TaskOverflowMenu.tsx @@ -1,19 +1,21 @@ import React from 'react'; import { View, Text, StyleSheet, Modal, TouchableOpacity } from 'react-native'; import { useSettings } from '@/theme'; -import { TaskData } from '@/types'; +import { TaskData, SubtaskData } from '@/types'; import Svg, { Path } from 'react-native-svg'; interface TaskOverflowMenuProps { visible: boolean; - task: TaskData | null; + task: TaskData | SubtaskData | null; + subtask?: boolean; onClose: () => void; onEdit: () => void; onDelete: () => void; onDuplicate: () => void; onToggleComplete: () => void; - onChangeCategory: () => void; + onChangeCategory?: () => void; onChangePriority: () => void; + onAddSubtask?: () => void; } interface MenuAction { @@ -27,6 +29,7 @@ interface MenuAction { export function TaskOverflowMenu({ visible, task, + subtask, onClose, onEdit, onDelete, @@ -34,6 +37,7 @@ export function TaskOverflowMenu({ onToggleComplete, onChangeCategory, onChangePriority, + onAddSubtask, }: TaskOverflowMenuProps) { const { theme } = useSettings(); @@ -58,12 +62,18 @@ export function TaskOverflowMenu({ icon: , onPress: onDuplicate, }, - { + ]; + + if (!subtask) { + actions.push({ key: 'category', label: 'Change Category', icon: , - onPress: onChangeCategory, - }, + onPress: onChangeCategory ?? (() => {}), + }); + } + + actions.push( { key: 'priority', label: 'Change Priority', @@ -77,7 +87,16 @@ export function TaskOverflowMenu({ icon: , onPress: onDelete, }, - ]; + ); + + if (subtask && onAddSubtask) { + actions.push({ + key: 'addSubtask', + label: 'Add Subtask', + icon: , + onPress: onAddSubtask, + }); + } return ( diff --git a/carry-your-live/src/components/UpdateNotifier.tsx b/carry-your-live/src/components/UpdateNotifier.tsx new file mode 100644 index 0000000..e70d3d5 --- /dev/null +++ b/carry-your-live/src/components/UpdateNotifier.tsx @@ -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; +} \ No newline at end of file diff --git a/carry-your-live/src/components/WheelTimePicker.tsx b/carry-your-live/src/components/WheelTimePicker.tsx index b8bd846..75214b4 100644 --- a/carry-your-live/src/components/WheelTimePicker.tsx +++ b/carry-your-live/src/components/WheelTimePicker.tsx @@ -110,12 +110,6 @@ function WheelColumn({ [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( ({ item, index }: ListRenderItemInfo) => ( { - // Rest the wheel exactly on the snapped row so the selection band - // and the highlighted text always line up (FlatList doesn't snap on web). - snapToNearest(); - }, [snapToNearest]); + // Let FlatList's native snap handle the alignment + // Just update the indexRef from the scroll position + const current = listRef.current; + if (!current) return; + // The native snap will handle positioning, we just sync the index + }, []); return ( i * ITEM_HEIGHT)} - decelerationRate="fast" + decelerationRate="normal" showsVerticalScrollIndicator={false} onScroll={handleScroll} onScrollEndDrag={handleScrollEnd} @@ -169,6 +165,8 @@ function WheelColumn({ scrollEventThrottle={16} style={[styles.column, { width }]} contentContainerStyle={{ paddingBottom: (VISIBLE_ITEMS - 1) * ITEM_HEIGHT, alignItems: 'stretch' }} + snapToAlignment="center" + snapToInterval={ITEM_HEIGHT} /> ); } diff --git a/carry-your-live/src/database/migrations.ts b/carry-your-live/src/database/migrations.ts index d3c43b6..6e2ecbb 100644 --- a/carry-your-live/src/database/migrations.ts +++ b/carry-your-live/src/database/migrations.ts @@ -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({ 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 }], + }), + ], + }, ], }); diff --git a/carry-your-live/src/database/schema.ts b/carry-your-live/src/database/schema.ts index a8c8078..002c245 100644 --- a/carry-your-live/src/database/schema.ts +++ b/carry-your-live/src/database/schema.ts @@ -1,7 +1,7 @@ import { appSchema, tableSchema } from '@nozbe/watermelondb'; export const schema = appSchema({ - version: 12, + version: 15, tables: [ tableSchema({ name: 'categories', @@ -10,7 +10,7 @@ export const schema = appSchema({ { name: 'color', type: 'string' }, { name: 'order', type: 'number' }, { name: 'created_at', type: 'number' }, - { name: 'updated_at', type: 'number' }, + { name: 'updated_at', type: 'number', isIndexed: true }, ], }), tableSchema({ @@ -20,7 +20,8 @@ export const schema = appSchema({ { name: 'description', type: 'string' }, { name: 'category_id', type: 'string', isIndexed: true }, { 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_time', type: 'string' }, { name: 'end_time', type: 'string' }, @@ -33,14 +34,15 @@ export const schema = appSchema({ { name: 'reminder', type: 'string' }, { name: 'reminders', type: 'string' }, { name: 'assignee_id', type: 'string', isOptional: true }, - { name: 'created_at', type: 'number' }, - { name: 'updated_at', type: 'number' }, + { name: 'created_at', type: 'number', isIndexed: true }, + { name: 'updated_at', type: 'number', isIndexed: true }, ], }), tableSchema({ name: 'subtasks', columns: [ { name: 'task_id', type: 'string', isIndexed: true }, + { name: 'parent_subtask_id', type: 'string', isIndexed: true, isOptional: true }, { name: 'title', type: 'string' }, { name: 'description', 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: 'assignee_id', type: 'string', isOptional: true }, { name: 'order', type: 'number' }, - { name: 'created_at', type: 'number' }, - { name: 'updated_at', type: 'number' }, + { name: 'created_at', type: 'number', isIndexed: true }, + { name: 'updated_at', type: 'number', isIndexed: true }, ], }), tableSchema({ @@ -69,7 +71,7 @@ export const schema = appSchema({ { name: 'repeat_interval', type: 'number' }, { name: 'repeat_days', type: 'string' }, { name: 'created_at', type: 'number' }, - { name: 'updated_at', type: 'number' }, + { name: 'updated_at', type: 'number', isIndexed: true }, ], }), tableSchema({ @@ -79,7 +81,7 @@ export const schema = appSchema({ { name: 'friend_id', type: 'string', isIndexed: true }, { name: 'status', type: 'string' }, { name: 'created_at', type: 'number' }, - { name: 'updated_at', type: 'number' }, + { name: 'updated_at', type: 'number', isIndexed: true }, ], }), ], diff --git a/carry-your-live/src/database/sync.ts b/carry-your-live/src/database/sync.ts index d4fa0c0..f0080f4 100644 --- a/carry-your-live/src/database/sync.ts +++ b/carry-your-live/src/database/sync.ts @@ -1,6 +1,7 @@ import { AppState, AppStateStatus } from 'react-native'; import { database, collections } from './index'; import AsyncStorage from '@react-native-async-storage/async-storage'; +import { Q } from '@nozbe/watermelondb'; import { apiFetch, getAuthToken } from '@/services/auth'; import Category from '@/models/Category'; import Task from '@/models/Task'; @@ -8,15 +9,6 @@ import Subtask from '@/models/Subtask'; import RepeatProfile from '@/models/RepeatProfile'; 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_RUN_AT_KEY = 'sync:lastRunAt'; @@ -108,17 +100,13 @@ export async function runSync(): Promise { async function pushChanges(): Promise { const lastPulledAt = await getLastPulledAt(); - const [categories, tasks, subtasks, repeatProfiles, friendships] = await Promise.all([ - collections.categories.query().fetch(), - collections.tasks.query().fetch(), - collections.subtasks.query().fetch(), - collections.repeatProfiles.query().fetch(), - collections.friendships.query().fetch(), + const [tasks, subtasks, repeatProfiles, friendships] = await Promise.all([ + collections.tasks.query(Q.where('updated_at', Q.gt(lastPulledAt))).fetch(), + collections.subtasks.query(Q.where('updated_at', Q.gt(lastPulledAt))).fetch(), + collections.repeatProfiles.query(Q.where('updated_at', Q.gt(lastPulledAt))).fetch(), + collections.friendships.query(Q.where('updated_at', Q.gt(lastPulledAt))).fetch(), ]); - const localTaskIds = new Set(tasks.map((t) => t.id)); - const taskById = new Map(tasks.map((t) => [t.id, t])); - const taskPayload = (t: Task) => ({ id: t.id, title: t.title, @@ -141,81 +129,97 @@ async function pushChanges(): Promise { updatedAt: t.updatedAt.getTime(), }); - const changedTasks = tasks - .filter((t) => t.updatedAt.getTime() > lastPulledAt) - .map(taskPayload); + const changedTasks: ReturnType[] = []; + const includedTaskIds = new Set(); + const missingTaskIds = new Set(); + const referencedCategoryIds = new Set(); - const changedSubtasks = subtasks - .filter((s) => localTaskIds.has(s.taskId) && s.updatedAt.getTime() > lastPulledAt) - .map((s) => ({ - id: s.id, - 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); + for (const t of tasks) { + changedTasks.push(taskPayload(t)); + includedTaskIds.add(t.id); + if (t.categoryId) referencedCategoryIds.add(t.categoryId); } - const changedCategories = categories - .filter((c) => c.updatedAt.getTime() > lastPulledAt || referencedCategoryIds.has(c.id)) - .map((c) => ({ - id: c.id, - name: c.name, - color: c.color, - order: c.order, - createdAt: c.createdAt.getTime(), - updatedAt: c.updatedAt.getTime(), - })); + const changedSubtasks = subtasks.map((s) => ({ + id: s.id, + 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 changedRepeatProfiles = repeatProfiles - .filter((p) => p.updatedAt.getTime() > lastPulledAt) - .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(), - })); + for (const sub of changedSubtasks) { + if (!includedTaskIds.has(sub.taskId)) { + missingTaskIds.add(sub.taskId); + } + } - const changedFriendships = friendships - .filter((f) => f.updatedAt.getTime() > lastPulledAt) - .map((f) => ({ - id: f.id, - userId: f.userId, - friendId: f.friendId, - status: f.status, - createdAt: f.createdAt.getTime(), - updatedAt: f.updatedAt.getTime(), - })); + if (missingTaskIds.size > 0) { + const parentTasks = await collections.tasks + .query(Q.where('id', Q.oneOf(Array.from(missingTaskIds)))) + .fetch(); + for (const t of parentTasks) { + includedTaskIds.add(t.id); + changedTasks.push(taskPayload(t)); + if (t.categoryId) referencedCategoryIds.add(t.categoryId); + } + } + + 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 ( changedCategories.length === 0 && @@ -542,6 +546,30 @@ export function stopWatchingForUpdates(): void { watcherStop?.(); } +export function startAutoSync(intervalMs: number = DEFAULT_WATCH_INTERVAL_MS): UpdateWatcher { + return watchForUpdates(intervalMs); +} + +export function stopAutoSync(): void { + stopWatchingForUpdates(); +} + +export async function runSyncGuarded(): Promise { + 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 { return watcherActive; } diff --git a/carry-your-live/src/hooks/useDatabase.tsx b/carry-your-live/src/hooks/useDatabase.tsx index 0d4bc8a..138da63 100644 --- a/carry-your-live/src/hooks/useDatabase.tsx +++ b/carry-your-live/src/hooks/useDatabase.tsx @@ -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 { database, collections } from '../database'; -import { runSync, watchForUpdates, stopWatchingForUpdates } from '../database/sync'; +import { runSyncGuarded, startAutoSync, stopAutoSync } from '../database/sync'; import { getAuthToken } from '../services/auth'; import { DEFAULT_CATEGORIES } from '../constants'; import Category from '../models/Category'; import RepeatProfile from '../models/RepeatProfile'; +import { Q } from '@nozbe/watermelondb'; interface DatabaseContextType { database: typeof database; @@ -23,7 +24,43 @@ export function DatabaseProvider({ children }: { children: ReactNode }) { try { const existingCategories = await collections.categories.query().fetch(); - if (existingCategories.length === 0) { + // Merge categories with the same spelling (case-insensitive) + const categoriesByName = new Map(); + 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 () => { for (const cat of DEFAULT_CATEGORIES) { await collections.categories.create((c) => { @@ -41,13 +78,10 @@ export function DatabaseProvider({ children }: { children: ReactNode }) { const token = await getAuthToken(); if (token) { - runSync() - .catch(() => {}) - .finally(() => { - watchForUpdates(); - }); + runSyncGuarded().catch(() => {}); + startAutoSync(); } else { - stopWatchingForUpdates(); + stopAutoSync(); } } catch (error) { console.error('Failed to initialize database:', error); @@ -56,9 +90,10 @@ export function DatabaseProvider({ children }: { children: ReactNode }) { }; useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect initializeDatabase(); return () => { - stopWatchingForUpdates(); + stopAutoSync(); }; }, []); @@ -103,9 +138,17 @@ export function useCategories(): Category[] { return categories; } -export function useCategory(categoryId: string) { - const { collections } = useDatabase(); - return collections.categories.find(categoryId); +export function useUniqueCategories(): Category[] { + const categories = useCategories(); + return useMemo(() => { + const seen = new Map(); + 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[] { diff --git a/carry-your-live/src/hooks/useFriends.tsx b/carry-your-live/src/hooks/useFriends.tsx index 94f97c9..aa37c1c 100644 --- a/carry-your-live/src/hooks/useFriends.tsx +++ b/carry-your-live/src/hooks/useFriends.tsx @@ -40,6 +40,7 @@ export function FriendsProvider({ children }: { children: React.ReactNode }) { }, []); useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect fetchFriends(); }, [fetchFriends]); diff --git a/carry-your-live/src/hooks/useStats.tsx b/carry-your-live/src/hooks/useStats.tsx new file mode 100644 index 0000000..07c8035 --- /dev/null +++ b/carry-your-live/src/hooks/useStats.tsx @@ -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 = { + 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(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(); + const priorityCounts = new Map(); + const dayBucket = new Map(); + + 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; +} \ No newline at end of file diff --git a/carry-your-live/src/hooks/useTaskModals.tsx b/carry-your-live/src/hooks/useTaskModals.tsx new file mode 100644 index 0000000..59a5570 --- /dev/null +++ b/carry-your-live/src/hooks/useTaskModals.tsx @@ -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 ( + + + + + + Add Subtask to “{parentSubtask.title}” + + + + + + + Cancel + + + Add + + + + + + ); +} + +export function useTaskModals() { + const router = useRouter(); + const categories = useCategories(); + + const [menuTask, setMenuTask] = useState(null); + const [menuSubtask, setMenuSubtask] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleteSubtaskTarget, setDeleteSubtaskTarget] = useState(null); + const [picker, setPicker] = useState(null); + const [subtaskPicker, setSubtaskPicker] = useState(null); + const [addSubtaskTarget, setAddSubtaskTarget] = useState(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 ( + <> + setDeleteTarget(null)} + onDelete={handleDeleteScope} + /> + + setDeleteSubtaskTarget(null)} + onDelete={handleDeleteSubtask} + /> + + 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 })} + /> + + 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)} + /> + + setAddSubtaskTarget(null)} + onAdd={handleAddSubtask} + /> + + ({ value: c.id, label: c.name, color: c.color }))} + selectedValue={menuTask?.categoryId} + onSelect={handleSingleCategory} + onClose={() => setPicker(null)} + /> + + setPicker(null)} + /> + + setSubtaskPicker(null)} + /> + + ); + }; + + return { + modals, + openTaskMenu, + openSubtaskMenu, + openTaskDelete, + openSubtaskDelete, + openTaskEdit, + openSubtaskEdit, + openSubtaskAdd, + }; +} diff --git a/carry-your-live/src/hooks/useTasks.tsx b/carry-your-live/src/hooks/useTasks.tsx index 0f8e18a..d5a241f 100644 --- a/carry-your-live/src/hooks/useTasks.tsx +++ b/carry-your-live/src/hooks/useTasks.tsx @@ -1,6 +1,6 @@ import { useDatabase } from './useDatabase'; import { Q } from '@nozbe/watermelondb'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useState, useMemo } from 'react'; import Task from '../models/Task'; 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)); } - if (!showCompleted) { + if (showCompleted) { + conditions.push(Q.where('completed', true)); + } else { conditions.push(Q.where('completed', false)); } @@ -47,21 +49,21 @@ export function useTasks(categoryId?: string, showCompleted = false) { return { tasks, loading }; } -export function useTask(taskId: string) { - const { collections } = useDatabase(); - return collections.tasks.find(taskId); -} - export function useTasksByDate(date: Date) { const { collections } = useDatabase(); const [tasks, setTasks] = useState([]); const [loading, setLoading] = useState(true); - const startOfDay = new Date(date); - startOfDay.setHours(0, 0, 0, 0); - - const endOfDay = new Date(date); - endOfDay.setHours(23, 59, 59, 999); + const startOfDay = useMemo(() => { + const d = new Date(date); + d.setHours(0, 0, 0, 0); + return d; + }, [date]); + const endOfDay = useMemo(() => { + const d = new Date(date); + d.setHours(23, 59, 59, 999); + return d; + }, [date]); useEffect(() => { let mounted = true; @@ -87,83 +89,7 @@ export function useTasksByDate(date: Date) { mounted = false; subscription.unsubscribe(); }; - }, [collections, startOfDay.getTime(), endOfDay.getTime()]); - - return { tasks, loading }; -} - -export function useOverdueTasks() { - const { collections } = useDatabase(); - const [tasks, setTasks] = useState([]); - 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([]); - 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]); + }, [collections, startOfDay, endOfDay]); return { tasks, loading }; } diff --git a/carry-your-live/src/models/Category.ts b/carry-your-live/src/models/Category.ts index 1d89600..a4165d5 100644 --- a/carry-your-live/src/models/Category.ts +++ b/carry-your-live/src/models/Category.ts @@ -1,5 +1,5 @@ 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 { static table = 'categories'; diff --git a/carry-your-live/src/models/Friendship.ts b/carry-your-live/src/models/Friendship.ts index 7886475..eb2e118 100644 --- a/carry-your-live/src/models/Friendship.ts +++ b/carry-your-live/src/models/Friendship.ts @@ -1,5 +1,5 @@ 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 { static table = 'friendships'; diff --git a/carry-your-live/src/models/Subtask.ts b/carry-your-live/src/models/Subtask.ts index 85fa610..c0d32a9 100644 --- a/carry-your-live/src/models/Subtask.ts +++ b/carry-your-live/src/models/Subtask.ts @@ -1,11 +1,17 @@ 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'; export default class Subtask extends Model { static table = 'subtasks'; + static associations: Associations = { + subtasks: { type: 'has_many', foreignKey: 'parent_subtask_id' }, + }; + @field('task_id') taskId!: string; + @field('parent_subtask_id') parentSubtaskId!: string | null; @field('title') title!: string; @field('description') description!: string; @field('priority') priority!: Priority; @@ -24,4 +30,6 @@ export default class Subtask extends Model { @field('order') order!: number; @date('created_at') createdAt!: Date; @date('updated_at') updatedAt!: Date; + + @children('subtasks') subtasks!: any; } \ No newline at end of file diff --git a/carry-your-live/src/models/Task.ts b/carry-your-live/src/models/Task.ts index aecb8cd..309cee4 100644 --- a/carry-your-live/src/models/Task.ts +++ b/carry-your-live/src/models/Task.ts @@ -1,5 +1,5 @@ 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 { Priority, Repeat, Reminder } from '@/types'; @@ -15,6 +15,7 @@ export default class Task extends Model { @field('category_id') categoryId!: string; @field('priority') priority!: Priority; @field('completed') completed!: boolean; + @field('completed_at') completedAt!: number | null; @field('due_date') dueDate!: number; @field('due_time') dueTime!: string; @field('end_time') endTime!: string; @@ -27,6 +28,7 @@ export default class Task extends Model { @field('reminder') reminder!: Reminder; @field('reminders') reminders!: string; @field('assignee_id') assigneeId!: string | null; + @field('order') order!: number; @date('created_at') createdAt!: Date; @date('updated_at') updatedAt!: Date; diff --git a/carry-your-live/src/services/notifications.ts b/carry-your-live/src/services/notifications.ts index c76d63c..50fff8c 100644 --- a/carry-your-live/src/services/notifications.ts +++ b/carry-your-live/src/services/notifications.ts @@ -24,15 +24,14 @@ async function writeIdMap(map: Record): Promise { function getNotificationsModule(): any { if (Platform.OS === 'web') return null; try { - // eslint-disable-next-line @typescript-eslint/no-var-requires - return require('expo-notifications'); + return import('expo-notifications'); } catch { return null; } } export async function scheduleTaskReminder(task: any): Promise { - const notifications = getNotificationsModule(); + const notifications = await getNotificationsModule(); const dueDate = task.dueDate ? new Date(task.dueDate) : null; if (!notifications) return; @@ -99,7 +98,7 @@ export async function scheduleTaskReminder(task: any): Promise { } export async function cancelTaskReminder(taskId: string): Promise { - const notifications = getNotificationsModule(); + const notifications = await getNotificationsModule(); if (!notifications) return; const idMap = await readIdMap(); @@ -118,7 +117,7 @@ export async function cancelTaskReminder(taskId: string): Promise { } export async function requestNotificationPermission(): Promise { - const notifications = getNotificationsModule(); + const notifications = await getNotificationsModule(); if (!notifications) return false; try { const settings = await notifications.getPermissionsAsync(); @@ -134,7 +133,7 @@ export async function requestNotificationPermission(): Promise { } export async function rescheduleAllReminders(tasks: any[]): Promise { - const notifications = getNotificationsModule(); + const notifications = await getNotificationsModule(); if (!notifications) return; try { await notifications.cancelAllScheduledNotificationsAsync(); diff --git a/carry-your-live/src/services/updates.ts b/carry-your-live/src/services/updates.ts new file mode 100644 index 0000000..15ba33d --- /dev/null +++ b/carry-your-live/src/services/updates.ts @@ -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 { + 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 { + 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 { + 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 { + try { + const stored = await AsyncStorage.getItem(LAST_PROMPTED_KEY); + return stored === tagName; + } catch { + return false; + } +} + +export async function markPrompted(tagName: string): Promise { + try { + await AsyncStorage.setItem(LAST_PROMPTED_KEY, tagName); + } catch { + // ignore + } +} \ No newline at end of file diff --git a/carry-your-live/src/theme.tsx b/carry-your-live/src/theme.tsx index 5cdb338..e67430a 100644 --- a/carry-your-live/src/theme.tsx +++ b/carry-your-live/src/theme.tsx @@ -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 { DEFAULT_API_BASE_URL, API_URL_KEY } from '@/services/auth'; @@ -99,10 +99,13 @@ function useStoredSetting(key: string, initialValue: T): [T, (value: T) => vo .finally(() => setLoaded(true)); }, [key]); - const update = (next: T) => { - setValue(next); - AsyncStorage.setItem(key, JSON.stringify(next)).catch(() => {}); - }; + const update = useCallback( + (next: T) => { + setValue(next); + AsyncStorage.setItem(key, JSON.stringify(next)).catch(() => {}); + }, + [key] + ); return [value, update, loaded]; } @@ -119,22 +122,37 @@ export function SettingsProvider({ children }: { children: ReactNode }) { const theme = colors; +const value = useMemo( + () => ({ + notifications, + setNotifications, + defaultCategoryId, + setDefaultCategoryId, + sortBy, + setSortBy, + reminderPreference, + setReminderPreference, +apiUrl, + setApiUrl, + theme, + }), + [ + notifications, + setNotifications, + defaultCategoryId, + setDefaultCategoryId, + sortBy, + setSortBy, + reminderPreference, + setReminderPreference, + apiUrl, + setApiUrl, + theme, + ] + ); + return ( - + {children} ); diff --git a/carry-your-live/src/types/index.ts b/carry-your-live/src/types/index.ts index 234eb92..0df524f 100644 --- a/carry-your-live/src/types/index.ts +++ b/carry-your-live/src/types/index.ts @@ -86,8 +86,6 @@ export function toRemindersString(reminders: Reminder[]): string { return reminders.filter((r) => r !== 'none').join(','); } -export type TaskStatus = 'pending' | 'completed' | 'overdue' | 'due_today'; - export interface CategoryData { id: string; name: string; @@ -120,6 +118,7 @@ export interface TaskData { export interface SubtaskData { id: string; taskId: string; + parentSubtaskId: string | null; title: string; description: string; priority: Priority; @@ -135,33 +134,14 @@ export interface SubtaskData { reminder: Reminder; assigneeId: string | null; order: number; -} - -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 }[]; + subtasks: SubtaskData[]; } export interface SubtaskFormValue { title: string; _key?: string; + parentSubtaskId?: string | null; + subtasks?: SubtaskFormValue[]; } export interface TaskFormData { @@ -180,4 +160,21 @@ export interface TaskFormData { reminders: string; assigneeId: string | null; 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; } \ No newline at end of file diff --git a/carry-your-live/src/utils/quickAddFocus.ts b/carry-your-live/src/utils/quickAddFocus.ts new file mode 100644 index 0000000..88d21ed --- /dev/null +++ b/carry-your-live/src/utils/quickAddFocus.ts @@ -0,0 +1,20 @@ +type QuickAddListener = () => void; + +const listeners = new Set(); +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); + }; +} diff --git a/carry-your-live/src/utils/taskActions.ts b/carry-your-live/src/utils/taskActions.ts index b5e5496..8b7ca23 100644 --- a/carry-your-live/src/utils/taskActions.ts +++ b/carry-your-live/src/utils/taskActions.ts @@ -1,6 +1,6 @@ import { database, collections } from '@/database'; import { Q } from '@nozbe/watermelondb'; -import { Priority, Repeat } from '@/types'; +import { Priority, Repeat, Reminder } from '@/types'; import { scheduleTaskReminder, cancelTaskReminder } from '@/services/notifications'; function addDays(date: Date, days: number): Date { @@ -155,6 +155,7 @@ export async function toggleTaskComplete(taskId: string): Promise const completing = !task.completed; await task.update((t) => { t.completed = completing; + t.completedAt = completing ? Date.now() : null; t.updatedAt = new Date(); }); if (completing && task.repeat !== 'none' && task.dueDate) { @@ -169,11 +170,128 @@ export async function toggleTaskComplete(taskId: string): Promise } export async function toggleSubtaskComplete(subtaskId: string): Promise { - const subtask = await collections.subtasks.find(subtaskId); - const completing = !subtask.completed; await database.write(async () => { + const subtask = await collections.subtasks.find(subtaskId); 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 { + 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 { + 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 { + 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 { + await database.write(async () => { + const subtask = await collections.subtasks.find(subtaskId); + await subtask.update((s) => { + s.priority = priority; s.updatedAt = new Date(); }); 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 { + 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 { + 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 { let nextOccurrence: any = null; await database.write(async () => { const task = await collections.tasks.find(taskId); await task.update((t) => { t.completed = completed; + t.completedAt = completed ? Date.now() : null; t.updatedAt = new Date(); }); if (completed && task.repeat !== 'none' && task.dueDate) { @@ -410,3 +598,68 @@ export async function duplicateTask(taskId: string): Promise { }); await scheduleTaskReminder(clone); } + +export async function reorderTasks(taskIds: string[]): Promise { + 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 { + 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!; +} diff --git a/opencode.txt b/opencode.txt deleted file mode 100644 index a8efb08..0000000 --- a/opencode.txt +++ /dev/null @@ -1 +0,0 @@ -opencode -s ses_031130d6fffeMxWjOd3Pv18pcB \ No newline at end of file diff --git a/todo.md b/todo.md index c3bce4c..e0984d3 100644 --- a/todo.md +++ b/todo.md @@ -22,4 +22,13 @@ implemennt that just task for today and the past can be checked ✓ implement stats -implemnt an android widget for quickadd tasks \ No newline at end of file +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 +