working push notifiations

This commit is contained in:
2026-08-23 11:36:17 +02:00
parent 9312783793
commit bdc6562116
49 changed files with 1858 additions and 419 deletions
+84 -46
View File
@@ -1,6 +1,7 @@
import { Tabs } from 'expo-router';
import React from 'react';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { View, StyleSheet, TouchableOpacity } from 'react-native';
import { TabBarIcon } from '@/components/TabBarIcon';
import { useSettings } from '@/theme';
import { setLastVisitedTab } from '@/utils/tabHistory';
@@ -13,19 +14,19 @@ export default function TabLayout() {
<Tabs
screenOptions={{
tabBarActiveTintColor: theme.accent,
tabBarInactiveTintColor: theme.text,
tabBarInactiveTintColor: theme.textSecondary,
tabBarStyle: {
backgroundColor: theme.tabBarBg,
borderTopWidth: 1,
borderTopColor: theme.border,
height: 68 + insets.bottom,
borderTopWidth: 0,
height: 84 + insets.bottom,
paddingBottom: insets.bottom,
paddingTop: 6,
elevation: 8,
paddingTop: 12,
paddingHorizontal: 16,
elevation: 0,
shadowColor: '#000',
shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.08,
shadowRadius: 8,
shadowOffset: { width: 0, height: -4 },
shadowOpacity: 0.12,
shadowRadius: 20,
},
tabBarItemStyle: {
paddingVertical: 0,
@@ -33,10 +34,54 @@ export default function TabLayout() {
tabBarLabelStyle: {
fontSize: 11,
fontWeight: '700',
marginTop: 4,
},
headerShown: false,
sceneStyle: { backgroundColor: theme.background },
}}
tabBar={(props) => {
return (
<View
style={[
styles.tabBarContainer,
{
backgroundColor: theme.tabBarBg,
paddingBottom: Math.max(props.insets.bottom, 8),
shadowColor: '#000',
shadowOffset: { width: 0, height: -4 },
shadowOpacity: 0.12,
shadowRadius: 20,
elevation: 8,
},
]}
>
{props.navigation.getState().routes.map((route) => {
const focused = props.navigation.getState().index === props.navigation.getState().routes.indexOf(route);
return (
<TouchableOpacity
key={route.name}
style={[
styles.tabItem,
focused && styles.tabItemActive,
{ backgroundColor: focused ? theme.accentSoft : 'transparent' },
]}
onPress={() => props.navigation.navigate(route.name as never)}
accessibilityRole="button"
accessibilityLabel={route.name === 'index' ? 'ToDo' : route.name.charAt(0).toUpperCase() + route.name.slice(1)}
accessibilityState={{ selected: focused }}
>
<TabBarIcon
name={route.name === 'index' ? 'checklist' : route.name === 'calendar' ? 'calendar' : route.name === 'stats' ? 'stats' : 'gear'}
focused={focused}
color={focused ? theme.accent : theme.textSecondary}
size={24}
/>
</TouchableOpacity>
);
})}
</View>
);
}}
screenListeners={({ route }) => ({
focus: () => {
if (route.name !== 'settings') {
@@ -45,42 +90,35 @@ export default function TabLayout() {
},
})}
>
<Tabs.Screen
name="index"
options={{
title: 'ToDo',
tabBarIcon: ({ color, focused }) => (
<TabBarIcon name="checklist" focused={focused} color={color} size={26} />
),
}}
/>
<Tabs.Screen
name="calendar"
options={{
title: 'Calendar',
tabBarIcon: ({ color, focused }) => (
<TabBarIcon name="calendar" focused={focused} color={color} size={26} />
),
}}
/>
<Tabs.Screen
name="stats"
options={{
title: 'Stats',
tabBarIcon: ({ color, focused }) => (
<TabBarIcon name="stats" focused={focused} color={color} size={26} />
),
}}
/>
<Tabs.Screen
name="settings"
options={{
title: 'Settings',
tabBarIcon: ({ color, focused }) => (
<TabBarIcon name="gear" focused={focused} color={color} size={26} />
),
}}
/>
<Tabs.Screen name="index" />
<Tabs.Screen name="calendar" />
<Tabs.Screen name="stats" />
<Tabs.Screen name="settings" />
</Tabs>
);
}
}
const styles = StyleSheet.create({
tabBarContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-around',
paddingTop: 10,
paddingHorizontal: 16,
},
tabItem: {
flex: 1,
alignItems: 'center',
paddingVertical: 8,
paddingHorizontal: 8,
borderRadius: 16,
minWidth: 70,
},
tabItemActive: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 4,
},
});
+41 -32
View File
@@ -1,5 +1,5 @@
import React, { useMemo, useRef, useState, useCallback } from 'react';
import { View, Text, StyleSheet, SafeAreaView, ScrollView, TouchableOpacity, Animated, Dimensions, BackHandler, KeyboardAvoidingView } from 'react-native';
import React, { useMemo, useRef, useState, useCallback, useEffect } from 'react';
import { View, Text, StyleSheet, SafeAreaView, ScrollView, TouchableOpacity, Animated, Dimensions, KeyboardAvoidingView } from 'react-native';
import { useRouter, useFocusEffect } from 'expo-router';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import { Header } from '@/components/Header';
@@ -8,7 +8,7 @@ import { useSubtasks } from '@/hooks/useSubtasks';
import { useTaskModals } from '@/hooks/useTaskModals';
import { useSettings } from '@/theme';
import { useCategories } from '@/hooks/useDatabase';
import { toggleTaskComplete } from '@/utils/taskActions';
import { confirmAndToggleTaskComplete } from '@/utils/taskActions';
import { QuickAddBar } from '@/components/QuickAddBar';
import { OptionPickerModal } from '@/components/OptionPickerModal';
import Task from '@/models/Task';
@@ -27,6 +27,7 @@ const MONTH_NAMES = [
export default function CalendarScreen() {
const router = useRouter();
const { theme } = useSettings();
const styles = React.useMemo(() => createStyles(theme), [theme]);
const categories = useCategories();
const { modals, openTaskEdit } = useTaskModals();
@@ -37,9 +38,18 @@ export default function CalendarScreen() {
const { map: subtasksByTask, refresh: refreshSubtasks } = useSubtasks();
const visibleMonthRef = useRef(visibleMonth);
visibleMonthRef.current = visibleMonth;
const selectedDateRef = useRef(selectedDate);
selectedDateRef.current = selectedDate;
const translateX = useRef<Animated.Value>(new Animated.Value(0));
const animatingRef = useRef(false);
const gridWidthRef = useRef<number>(WIDTH);
useEffect(() => {
visibleMonthRef.current = visibleMonth;
}, [visibleMonth]);
useEffect(() => {
selectedDateRef.current = selectedDate;
}, [selectedDate]);
const { tasks: selectedDayTasks, refresh: refreshDayTasks } = useTasksByDate(selectedDate);
const monthTasks = useTasksInMonth(visibleMonth);
@@ -47,18 +57,12 @@ export default function CalendarScreen() {
useFocusEffect(
useCallback(() => {
const sub = BackHandler.addEventListener('hardwareBackPress', () => true);
refreshDayTasks();
refreshMonthTasks();
refreshSubtasks();
return () => sub.remove();
}, [refreshDayTasks, refreshMonthTasks, refreshSubtasks])
);
const translateX = useRef(new Animated.Value(0)).current;
const animatingRef = useRef(false);
const gridWidthRef = useRef<number>(WIDTH);
const weeks = useMemo(() => {
const first = startOfMonth(visibleMonth);
const offset = (first.getDay() + 6) % 7; // week starts Monday
@@ -78,16 +82,16 @@ export default function CalendarScreen() {
}
if (!animate) {
setVisibleMonth(next);
translateX.setValue(0);
translateX.current.setValue(0);
return;
}
animatingRef.current = true;
const w = gridWidthRef.current || WIDTH;
const target = dir === 1 ? -w : w;
Animated.timing(translateX, { toValue: target, duration: 220, useNativeDriver: false }).start(() => {
Animated.timing(translateX.current, { toValue: target, duration: 220, useNativeDriver: false }).start(() => {
setVisibleMonth(next);
translateX.setValue(-target);
Animated.timing(translateX, { toValue: 0, duration: 180, useNativeDriver: false }).start(() => {
translateX.current.setValue(-target);
Animated.timing(translateX.current, { toValue: 0, duration: 180, useNativeDriver: false }).start(() => {
animatingRef.current = false;
});
});
@@ -113,21 +117,23 @@ export default function CalendarScreen() {
.activeOffsetX([-16, 16])
.minDistance(6)
.runOnJS(true)
// eslint-disable-next-line react-hooks/refs
.onUpdate((e) => {
if (!animatingRef.current) translateX.setValue(e.translationX);
if (!animatingRef.current) translateX.current.setValue(e.translationX);
})
// eslint-disable-next-line react-hooks/refs
.onEnd((e) => {
if (animatingRef.current) return;
const w = gridWidthRef.current || WIDTH;
const dx = e.translationX;
if (dx <= -w / 4) {
translateX.stopAnimation();
translateX.current.stopAnimation();
transitionTo(1, false);
} else if (dx >= w / 4) {
translateX.stopAnimation();
translateX.current.stopAnimation();
transitionTo(-1, false);
} else {
Animated.spring(translateX, { toValue: 0, useNativeDriver: false }).start();
Animated.spring(translateX.current, { toValue: 0, useNativeDriver: false }).start();
}
}),
[translateX, transitionTo]
@@ -152,10 +158,11 @@ export default function CalendarScreen() {
}, [currentYear]);
const handleToggleComplete = useCallback(async (taskId: string) => {
await toggleTaskComplete(taskId);
refreshDayTasks();
refreshMonthTasks();
refreshSubtasks();
await confirmAndToggleTaskComplete(taskId, () => {
refreshDayTasks();
refreshMonthTasks();
refreshSubtasks();
});
}, [refreshDayTasks, refreshMonthTasks, refreshSubtasks]);
return (
@@ -169,7 +176,8 @@ export default function CalendarScreen() {
onLayout={(e) => {
gridWidthRef.current = e.nativeEvent.layout.width;
}}
style={[styles.calendarArea, { transform: [{ translateX }] }]}
// eslint-disable-next-line react-hooks/refs
style={[styles.calendarArea, { transform: [{ translateX: translateX.current }] }]}
>
<View style={styles.monthRow}>
<TouchableOpacity
@@ -221,7 +229,7 @@ export default function CalendarScreen() {
key={day.toISOString()}
day={day}
label={cellTitle(byDayOf(monthTasks.byDay, day))}
dotColor={cellColor(byDayOf(monthTasks.byDay, day), categories)}
dotColor={cellColor(byDayOf(monthTasks.byDay, day), categories, theme)}
selected={isSameDay(day, selectedDate)}
today={isToday(day)}
inMonth={isSameMonth(day, visibleMonth)}
@@ -349,10 +357,10 @@ function cellTitle(tasks: Task[]): string | null {
return `${tasks[0].title} +${tasks.length - 1}`;
}
function cellColor(tasks: Task[], categories: { id: string; color: string }[]): string {
if (tasks.length === 0) return '#8E8E8E';
function cellColor(tasks: Task[], categories: { id: string; color: string }[], theme: ThemeColors): string {
if (tasks.length === 0) return theme.textMuted;
const cat = categories.find((c) => c.id === tasks[0].categoryId);
return cat ? desaturate(cat.color, 0.3) : '#8E8E8E';
return cat ? desaturate(cat.color, 0.3) : theme.textMuted;
}
interface DayCellProps {
@@ -367,6 +375,7 @@ interface DayCellProps {
}
const DayCell = React.memo(function DayCell({ day, label, dotColor, selected, today, inMonth, theme, onPress }: DayCellProps) {
const styles = React.useMemo(() => createStyles(theme), [theme]);
return (
<TouchableOpacity
style={[styles.dayCell, selected && { backgroundColor: theme.accentSoft, borderColor: theme.accentBorder }]}
@@ -397,7 +406,7 @@ const DayCell = React.memo(function DayCell({ day, label, dotColor, selected, to
);
});
const styles = StyleSheet.create({
const createStyles = (theme: ThemeColors) => StyleSheet.create({
container: {
flex: 1,
},
@@ -485,7 +494,7 @@ const styles = StyleSheet.create({
fontWeight: '600',
},
dayNumberSelected: {
color: '#FFFFFF',
color: theme.text,
},
chip: {
marginTop: 3,
@@ -558,7 +567,7 @@ const styles = StyleSheet.create({
},
eventCompleted: {
textDecorationLine: 'line-through',
color: '#9E9E9E',
color: theme.textMuted,
},
menuButton: {
padding: 4,
@@ -585,6 +594,6 @@ const styles = StyleSheet.create({
},
bulletCompleted: {
textDecorationLine: 'line-through',
color: '#6E6E6E',
color: theme.textMuted,
},
});
+2 -2
View File
@@ -1,5 +1,5 @@
import React, { useCallback, useMemo, useState } from 'react';
import { View, Text, StyleSheet, SafeAreaView, BackHandler, KeyboardAvoidingView, TouchableOpacity } from 'react-native';
import { View, Text, StyleSheet, SafeAreaView, KeyboardAvoidingView, TouchableOpacity, BackHandler } from 'react-native';
import { useFocusEffect } from 'expo-router';
import { Header } from '@/components/Header';
import { CategoryFilter } from '@/components/CategoryFilter';
@@ -18,7 +18,7 @@ export default function TasksScreen() {
useFocusEffect(
useCallback(() => {
const sub = BackHandler.addEventListener('hardwareBackPress', () => true);
const sub = BackHandler.addEventListener('hardwareBackPress', () => false);
return () => sub.remove();
}, [])
);
+6 -6
View File
@@ -119,12 +119,12 @@ export default function SettingsScreen() {
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
<Header title="Settings" showLogo={false} />
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>Categories</Text>
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>Tags</Text>
{categories.map((category) => (
<ListItem
key={category.id}
title={category.name}
subtitle={category.id === defaultCategoryId ? 'Default category' : undefined}
subtitle={category.id === defaultCategoryId ? 'Default tag' : undefined}
leftElement={
<View style={[styles.categoryDot, { backgroundColor: category.color }]} />
}
@@ -142,7 +142,7 @@ export default function SettingsScreen() {
<Path d="M12 5v14M5 12h14" stroke={theme.accentText} strokeWidth={2} strokeLinecap="round" />
</Svg>
</View>
<Text style={[styles.addCategoryText, { color: theme.accent }]}>Add Category</Text>
<Text style={[styles.addCategoryText, { color: theme.accent }]}>Add Tag</Text>
</TouchableOpacity>
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>Notifications</Text>
<ListItem
@@ -165,7 +165,7 @@ export default function SettingsScreen() {
/>
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>Tasks</Text>
<ListItem
title="Default Category"
title="Default Tag"
subtitle={defaultCategoryLabel}
onPress={() => setPicker('category')}
showChevron
@@ -269,8 +269,8 @@ export default function SettingsScreen() {
<OptionPickerModal
visible={picker === 'category'}
title="Default Category"
options={[{ value: '', label: 'None', color: '#9E9E9E' }, ...categories.map((c) => ({ value: c.id, label: c.name, color: c.color }))]}
title="Default Tag"
options={[{ value: '', label: 'None', color: theme.textMuted }, ...categories.map((c) => ({ value: c.id, label: c.name, color: c.color }))]}
selectedValue={defaultCategoryId}
onSelect={handleDefaultCategory}
onClose={() => setPicker(null)}
+12 -12
View File
@@ -1,9 +1,9 @@
import React, { useCallback } from 'react';
import { View, Text, StyleSheet, SafeAreaView, ScrollView, BackHandler } from 'react-native';
import { View, Text, StyleSheet, SafeAreaView, ScrollView } from 'react-native';
import { useFocusEffect } from 'expo-router';
import { Header } from '@/components/Header';
import { useStats } from '@/hooks/useStats';
import { useSettings } from '@/theme';
import { useSettings, type ThemeColors } from '@/theme';
export default function StatsScreen() {
const { theme } = useSettings();
@@ -11,8 +11,7 @@ export default function StatsScreen() {
useFocusEffect(
useCallback(() => {
const sub = BackHandler.addEventListener('hardwareBackPress', () => true);
return () => sub.remove();
return () => {};
}, [])
);
@@ -77,7 +76,7 @@ export default function StatsScreen() {
</View>
<View style={[styles.card, { backgroundColor: theme.card }]}>
<Text style={[styles.cardTitle, { color: theme.textMuted }]}>By category</Text>
<Text style={[styles.cardTitle, { color: theme.textMuted }]}>By tags</Text>
{stats.byCategory.length === 0 ? (
<Text style={[styles.emptyText, { color: theme.textFaint }]}>No completed tasks yet</Text>
) : (
@@ -98,7 +97,7 @@ export default function StatsScreen() {
) : (
stats.byPriority.map((p) => (
<View key={p.priority} style={styles.row}>
<View style={[styles.dot, { backgroundColor: priorityColor(p.priority) }]} />
<View style={[styles.dot, { backgroundColor: priorityColor(theme, p.priority) }]} />
<Text style={[styles.rowLabel, { color: theme.text }]}>{p.label}</Text>
<Text style={[styles.rowValue, { color: theme.textMuted }]}>{p.count}</Text>
</View>
@@ -112,16 +111,17 @@ export default function StatsScreen() {
const BASE_HEIGHT = 90;
function priorityColor(priority: string): string {
function priorityColor(theme: ThemeColors, priority: string): string {
switch (priority) {
case 'critical':
return theme.dangerStrong;
case 'high':
return '#EF5350';
case 'medium':
return '#FFA726';
return theme.warning;
case 'low':
return '#66BB6A';
case 'medium':
return theme.success;
default:
return '#9E9E9E';
return theme.textMuted;
}
}
+37
View File
@@ -8,11 +8,46 @@ import { UpdateNotifier } from '@/components/UpdateNotifier';
import { Linking } from 'react-native';
import { useEffect } from 'react';
import { requestQuickAddFocus } from '@/utils/quickAddFocus';
import { registerForPushNotificationsAsync, setupNotificationChannels, addNotificationResponseReceivedListener, removeNotificationSubscription } from '@/services/pushNotifications';
import * as Notifications from 'expo-notifications';
import { Platform } from 'react-native';
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: true,
shouldShowBanner: true,
shouldShowList: true,
}),
});
function RootNavigator() {
const router = useRouter();
useEffect(() => {
setupNotificationChannels();
registerForPushNotificationsAsync().catch(() => {});
const responseListener = addNotificationResponseReceivedListener((response) => {
const data = response.notification.request.content.data as Record<string, any> | undefined;
if (!data) return;
switch (data.type) {
case 'task_reminder':
case 'task_assignment':
if (data.taskId) {
router.push({ pathname: '/task-detail', params: { id: data.taskId } });
}
break;
case 'friend_request':
if (data.friendshipId) {
router.push({ pathname: '/(tabs)/settings' });
}
break;
}
});
const handleUrl = (url: string | null) => {
if (!url || !url.includes('://quick-add')) return;
router.replace('/(tabs)');
@@ -21,8 +56,10 @@ function RootNavigator() {
Linking.getInitialURL().then(handleUrl).catch(() => {});
const subscription = Linking.addEventListener('url', ({ url }) => handleUrl(url));
return () => {
subscription.remove();
removeNotificationSubscription(responseListener);
};
}, [router]);
+14 -5
View File
@@ -1,5 +1,5 @@
import React, { useMemo } from 'react';
import { View, Text, StyleSheet, SafeAreaView, ScrollView, KeyboardAvoidingView, Platform } from 'react-native';
import React, { useMemo, useRef } from 'react';
import { View, Text, StyleSheet, SafeAreaView, ScrollView, KeyboardAvoidingView, Platform, Keyboard } from 'react-native';
import { Header } from '@/components/Header';
import { CategorySelector } from '@/components/CategorySelector';
import { TaskNameInput } from '@/components/TaskNameInput';
@@ -20,6 +20,7 @@ import { database, collections } from '@/database';
import { TaskFormData, tagsToString } from '@/types';
import { useSettings } from '@/theme';
import { scheduleTaskReminder } from '@/services/notifications';
import { ReminderPreference } from '@/types';
import { useFriends } from '@/hooks/useFriends';
const taskSchema = z.object({
@@ -48,12 +49,14 @@ export default function AddTaskScreen() {
const { theme } = useSettings();
const { date: dateParam } = useLocalSearchParams<{ date?: string }>();
const { friends } = useFriends();
const { notifications: notificationsEnabled, reminderPreference } = useSettings();
const initialCategory = defaultCategoryId || '';
const initialDate = useMemo(() => {
if (!dateParam) return null;
const parsed = new Date(Array.isArray(dateParam) ? dateParam[0] : dateParam);
return Number.isNaN(parsed.getTime()) ? null : parsed;
}, [dateParam]);
const scrollViewRef = useRef<ScrollView>(null);
const methods = useForm<TaskFormData>({
resolver: zodResolver(taskSchema),
@@ -104,6 +107,9 @@ export default function AddTaskScreen() {
const onSubmit = async (data: TaskFormData) => {
if (!isReady) return;
Keyboard.dismiss();
scrollViewRef.current?.scrollTo({ y: 0, animated: true });
const now = new Date();
const dueDateTimestamp = data.dueDate ? data.dueDate.getTime() : 0;
const seriesId = data.repeat !== 'none'
@@ -160,7 +166,7 @@ export default function AddTaskScreen() {
}
});
await scheduleTaskReminder(createdTask);
await scheduleTaskReminder(createdTask, notificationsEnabled, reminderPreference);
router.back();
};
@@ -179,12 +185,15 @@ export default function AddTaskScreen() {
<FormProvider {...methods}>
<KeyboardAvoidingView
style={styles.keyboardAvoiding}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 0}
>
<ScrollView
ref={scrollViewRef}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="interactive"
>
<CategorySelector
value={tags}
@@ -263,7 +272,7 @@ const styles = StyleSheet.create({
scrollContent: {
paddingHorizontal: 16,
paddingTop: 12,
paddingBottom: 120,
paddingBottom: 100,
gap: 20,
},
});
+12 -8
View File
@@ -1,18 +1,19 @@
import React, { useCallback, useMemo } from 'react';
import { View, Text, StyleSheet, SafeAreaView, ScrollView, TouchableOpacity, KeyboardAvoidingView, BackHandler } from 'react-native';
import { useRouter, useLocalSearchParams, useFocusEffect } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Header } from '@/components/Header';
import { useTasksByDate } from '@/hooks/useTasks';
import { useSubtasks } from '@/hooks/useSubtasks';
import { useTaskModals } from '@/hooks/useTaskModals';
import { useSettings } from '@/theme';
import { useCategories } from '@/hooks/useDatabase';
import { toggleTaskComplete, toggleSubtaskComplete } from '@/utils/taskActions';
import { confirmAndToggleTaskComplete, toggleSubtaskComplete } from '@/utils/taskActions';
import { QuickAddBar } from '@/components/QuickAddBar';
import { SubtaskData } from '@/types';
import { format, startOfDay } from 'date-fns';
import Svg, { Path, Circle } from 'react-native-svg';
import { desaturate } from '@/theme';
import { desaturate, type ThemeColors } from '@/theme';
function isDayMatch(timestamp: number, day: Date): boolean {
const d = new Date(timestamp);
@@ -22,6 +23,8 @@ function isDayMatch(timestamp: number, day: Date): boolean {
export default function DayViewScreen() {
const router = useRouter();
const { theme } = useSettings();
const insets = useSafeAreaInsets();
const styles = useMemo(() => createStyles(theme), [theme]);
const categories = useCategories();
const { modals, openTaskEdit } = useTaskModals();
const { date: dateParam } = useLocalSearchParams<{ date?: string }>();
@@ -54,9 +57,10 @@ export default function DayViewScreen() {
}, [subtasksByTask, day]);
const handleToggleComplete = useCallback(async (taskId: string) => {
await toggleTaskComplete(taskId);
refresh();
refreshSubtasks();
await confirmAndToggleTaskComplete(taskId, () => {
refresh();
refreshSubtasks();
});
}, [refresh, refreshSubtasks]);
const handleToggleSubtask = useCallback(async (subtaskId: string) => {
@@ -65,7 +69,7 @@ export default function DayViewScreen() {
}, [refreshSubtasks]);
return (
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
<SafeAreaView style={[styles.container, { backgroundColor: theme.background, paddingBottom: insets.bottom }]}>
<Header title={format(day, 'EEEE, MMM d')} showLogo={false} />
<KeyboardAvoidingView style={styles.kbAvoid} behavior="padding">
<ScrollView style={styles.scrollBody} contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
@@ -205,7 +209,7 @@ export default function DayViewScreen() {
);
}
const styles = StyleSheet.create({
const createStyles = (theme: ThemeColors) => StyleSheet.create({
container: {
flex: 1,
},
@@ -252,7 +256,7 @@ const styles = StyleSheet.create({
},
eventCompleted: {
textDecorationLine: 'line-through',
color: '#9E9E9E',
color: theme.textMuted,
},
eventSub: {
fontSize: 13,
+9 -8
View File
@@ -15,7 +15,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useDatabase } from '@/hooks/useDatabase';
import { collections } from '@/database';
import { SubtaskFormData, Reminder, parseReminders, toRemindersString } from '@/types';
import { SubtaskFormData, Reminder, parseReminders, toRemindersString, parseTaskTags, tagsToString } from '@/types';
import { useSettings } from '@/theme';
import { updateSubtask, deleteSubtask } from '@/utils/taskActions';
import { CategorySelector } from '@/components/CategorySelector';
@@ -25,7 +25,7 @@ import Svg, { Path } from 'react-native-svg';
const subtaskSchema = z.object({
title: z.string().trim().min(1, 'Subtask name is required').max(100),
description: z.string().max(1000).optional(),
categoryId: z.string().optional(),
tags: z.array(z.string()).optional(),
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']),
dueDate: z.date().nullable().optional(),
dueTime: z.string().optional(),
@@ -53,7 +53,7 @@ export default function SubtaskDetailScreen() {
defaultValues: {
title: '',
description: '',
categoryId: '',
tags: [],
priority: 'none',
dueDate: null,
dueTime: '',
@@ -71,7 +71,7 @@ export default function SubtaskDetailScreen() {
const { handleSubmit, watch, setValue, control, reset, formState: { errors } } = methods;
const priority = watch('priority');
const categoryId = watch('categoryId');
const tags = watch('tags') ?? [];
const repeat = watch('repeat');
const repeatInterval = watch('repeatInterval') ?? 1;
const repeatDays = watch('repeatDays') ?? [];
@@ -90,7 +90,7 @@ export default function SubtaskDetailScreen() {
reset({
title: subtask.title,
description: subtask.description,
categoryId: subtask.categoryId || '',
tags: parseTaskTags(subtask.tags, subtask.categoryId),
priority: subtask.priority,
dueDate: subtask.dueDate ? new Date(subtask.dueDate) : null,
dueTime: subtask.dueTime,
@@ -125,7 +125,8 @@ export default function SubtaskDetailScreen() {
await updateSubtask(id, {
title: data.title,
description: data.description || '',
categoryId: data.categoryId || '',
categoryId: (data.tags && data.tags[0]) || '',
tags: tagsToString(data.tags || []),
priority: data.priority,
dueDate: data.dueDate ? data.dueDate.getTime() : 0,
dueTime: data.dueTime || '',
@@ -200,8 +201,8 @@ export default function SubtaskDetailScreen() {
)}
/>
<CategorySelector
value={categoryId ? [categoryId] : []}
onChange={(value) => setValue('categoryId', value[0] ?? '')}
value={tags}
onChange={(value) => setValue('tags', value)}
/>
<PrioritySelector
value={priority}
+19 -5
View File
@@ -1,5 +1,5 @@
import React from 'react';
import { View, Text, StyleSheet, SafeAreaView, ScrollView, KeyboardAvoidingView, Platform, TouchableOpacity } from 'react-native';
import React, { useRef } from 'react';
import { View, Text, StyleSheet, SafeAreaView, ScrollView, KeyboardAvoidingView, Platform, TouchableOpacity, Keyboard } from 'react-native';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { Header } from '@/components/Header';
import { CategorySelector } from '@/components/CategorySelector';
@@ -23,6 +23,7 @@ import { TaskFormData, parseTaskTags, tagsToString } from '@/types';
import { useSettings } from '@/theme';
import { deleteTaskOccurrences } from '@/utils/taskActions';
import { scheduleTaskReminder } from '@/services/notifications';
import { ReminderPreference } from '@/types';
import { recordTombstonesInBatch } from '@/database/tombstones';
import { useFriends } from '@/hooks/useFriends';
import Svg, { Path, Circle } from 'react-native-svg';
@@ -79,8 +80,10 @@ export default function TaskDetailScreen() {
const router = useRouter();
const { theme } = useSettings();
const { friends } = useFriends();
const { notifications: notificationsEnabled, reminderPreference } = useSettings();
const [loaded, setLoaded] = React.useState(false);
const [notFound, setNotFound] = React.useState(false);
const scrollViewRef = useRef<ScrollView>(null);
const methods = useForm<TaskFormData>({
resolver: zodResolver(taskSchema),
@@ -126,6 +129,8 @@ export default function TaskDetailScreen() {
const subtasks = await collections.subtasks.query(Q.where('task_id', id)).fetch();
if (!mounted) return;
const remindersValue = task.reminders || (task.reminder && task.reminder !== 'none' ? task.reminder : '');
reset({
title: task.title,
description: task.description,
@@ -140,6 +145,7 @@ export default function TaskDetailScreen() {
repeatInterval: task.repeatInterval || 1,
repeatDays: (task.repeatDays || '').split(',').map(Number).filter((d) => !Number.isNaN(d)),
reminder: (task.reminder || 'none') as TaskFormData['reminder'],
reminders: remindersValue,
assigneeId: task.assigneeId ?? null,
subtasks: subtasks.map((s) => ({ title: s.title, _key: s.id })),
});
@@ -155,6 +161,9 @@ export default function TaskDetailScreen() {
const onSubmit = async (data: TaskFormData) => {
if (!id || !isReady) return;
Keyboard.dismiss();
scrollViewRef.current?.scrollTo({ y: 0, animated: true });
const now = new Date();
const dueDateTimestamp = data.dueDate ? data.dueDate.getTime() : 0;
@@ -179,6 +188,7 @@ export default function TaskDetailScreen() {
t.repeatInterval = data.repeatInterval || 1;
t.repeatDays = (data.repeatDays || []).join(',');
t.reminder = data.reminder || 'none';
t.reminders = data.reminders || '';
t.assigneeId = data.assigneeId ?? null;
if (data.repeat !== 'none' && !t.seriesId) {
t.seriesId = `series_${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}`;
@@ -207,6 +217,7 @@ export default function TaskDetailScreen() {
await collections.subtasks.create((s) => {
s.taskId = task.id;
s.categoryId = task.categoryId || '';
s.tags = task.tags || '';
s.title = trimmed;
s.description = '';
s.priority = 'none';
@@ -247,7 +258,7 @@ export default function TaskDetailScreen() {
}
});
await scheduleTaskReminder(savedTask);
await scheduleTaskReminder(savedTask, notificationsEnabled, reminderPreference);
router.back();
};
@@ -287,12 +298,15 @@ export default function TaskDetailScreen() {
<FormProvider {...methods}>
<KeyboardAvoidingView
style={styles.keyboardAvoiding}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 0}
>
<ScrollView
ref={scrollViewRef}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="interactive"
>
<CategorySelector
value={tags}
@@ -431,7 +445,7 @@ const styles = StyleSheet.create({
scrollContent: {
paddingHorizontal: 12,
paddingTop: 8,
paddingBottom: 80,
paddingBottom: 60,
gap: 12,
},
section: {
+51
View File
@@ -15,8 +15,10 @@
"@react-native-community/datetimepicker": "9.1.0",
"date-fns": "^3.6.0",
"expo": "~57.0.10",
"expo-blur": "~57.0.2",
"expo-constants": "~57.0.9",
"expo-dev-client": "~57.0.1",
"expo-device": "^57.0.1",
"expo-linking": "~57.0.5",
"expo-notifications": "~57.0.8",
"expo-router": "~57.0.10",
@@ -5889,6 +5891,17 @@
"expo": "*"
}
},
"node_modules/expo-blur": {
"version": "57.0.2",
"resolved": "https://registry.npmjs.org/expo-blur/-/expo-blur-57.0.2.tgz",
"integrity": "sha512-Aoud8H8lmlNkbRufyvRLefmGFELdBf1n5Te/Xm+Zx8ORINH+aXL+gKb5mbftFSha860+I7pMArz77TBYz8HDVg==",
"license": "MIT",
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*"
}
},
"node_modules/expo-constants": {
"version": "57.0.9",
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.9.tgz",
@@ -5955,6 +5968,44 @@
"expo": "*"
}
},
"node_modules/expo-device": {
"version": "57.0.1",
"resolved": "https://registry.npmjs.org/expo-device/-/expo-device-57.0.1.tgz",
"integrity": "sha512-jyEMDUticH+dhcL3GHa2aiifOvGXJsmb3oVT2R2q4i8bN7Bddy61+NkpMmuS2VAZrvoLQwf0TJJ/1vi1ukvutA==",
"license": "MIT",
"dependencies": {
"ua-parser-js": "^0.7.33"
},
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-device/node_modules/ua-parser-js": {
"version": "0.7.41",
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.41.tgz",
"integrity": "sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/ua-parser-js"
},
{
"type": "paypal",
"url": "https://paypal.me/faisalman"
},
{
"type": "github",
"url": "https://github.com/sponsors/faisalman"
}
],
"license": "MIT",
"bin": {
"ua-parser-js": "script/cli.js"
},
"engines": {
"node": "*"
}
},
"node_modules/expo-eas-client": {
"version": "57.0.1",
"resolved": "https://registry.npmjs.org/expo-eas-client/-/expo-eas-client-57.0.1.tgz",
+2
View File
@@ -9,8 +9,10 @@
"@react-native-community/datetimepicker": "9.1.0",
"date-fns": "^3.6.0",
"expo": "~57.0.10",
"expo-blur": "~57.0.2",
"expo-constants": "~57.0.9",
"expo-dev-client": "~57.0.1",
"expo-device": "^57.0.1",
"expo-linking": "~57.0.5",
"expo-notifications": "~57.0.8",
"expo-router": "~57.0.10",
@@ -1,12 +1,12 @@
import React, { useState } from 'react';
import { Modal, View, Text, StyleSheet, TextInput, TouchableOpacity, ScrollView, KeyboardAvoidingView, Platform, Pressable } from 'react-native';
import React, { useState, useEffect } from 'react';
import { Modal, View, Text, StyleSheet, TextInput, TouchableOpacity, ScrollView, KeyboardAvoidingView, Platform, Pressable, useWindowDimensions } from 'react-native';
import Category from '@/models/Category';
import { useSettings } from '@/theme';
import { CATEGORY_COLORS } from '@/constants';
import { createCategory, updateCategory, deleteCategory } from '@/utils/categoryActions';
import { confirmAction } from '@/utils/confirm';
import ColorPickerInput from '@/components/ColorPickerInput';
import Svg, { Path } from 'react-native-svg';
import Svg, { Path, Circle } from 'react-native-svg';
interface CategoryEditorModalProps {
visible: boolean;
@@ -20,6 +20,7 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose
const [name, setName] = useState('');
const [color, setColor] = useState(CATEGORY_COLORS[0]);
const [prevVisible, setPrevVisible] = useState(visible);
const { height: screenHeight } = useWindowDimensions();
if (prevVisible !== visible) {
setPrevVisible(visible);
@@ -45,8 +46,8 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose
const handleDelete = () => {
if (!category || !canDelete) return;
confirmAction({
title: 'Delete Category',
message: `Tasks in "${category.name}" will be moved to another category.`,
title: 'Delete Tag',
message: `Tasks in "${category.name}" will be moved to another tag.`,
confirmLabel: 'Delete',
destructive: true,
onConfirm: async () => {
@@ -60,7 +61,8 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<KeyboardAvoidingView
style={styles.fill}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={0}
>
<Pressable
style={styles.overlay}
@@ -74,14 +76,16 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose
<ScrollView
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
contentContainerStyle={styles.scrollContent}
>
<View style={styles.header}>
<Text style={[styles.title, { color: theme.text }]}>
{category ? 'Edit Category' : 'New Category'}
{category ? 'Edit Tag' : 'New Tag'}
</Text>
<TouchableOpacity onPress={onClose} style={styles.closeButton} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} accessibilityRole="button" accessibilityLabel="Close category editor">
<Svg width={18} height={18} viewBox="0 0 24 24">
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textSecondary} strokeWidth={2.2} strokeLinecap="round" />
<TouchableOpacity onPress={onClose} style={styles.closeButton} hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }} accessibilityRole="button" accessibilityLabel="Close tag editor">
<Svg width={22} height={22} viewBox="0 0 24 24">
<Circle cx={12} cy={12} r={10} fill={theme.cardAlt} />
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" />
</Svg>
</TouchableOpacity>
</View>
@@ -92,7 +96,7 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose
styles.input,
{ backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text },
]}
placeholder="Category name"
placeholder="Tag name"
placeholderTextColor={theme.textMuted}
value={name}
onChangeText={setName}
@@ -171,10 +175,18 @@ const styles = StyleSheet.create({
sheet: {
width: '100%',
maxWidth: 380,
maxHeight: '100%',
maxHeight: '90%',
borderRadius: 16,
padding: 20,
gap: 8,
shadowColor: '#000',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.25,
shadowRadius: 16,
elevation: 12,
},
scrollContent: {
minHeight: 200,
},
header: {
flexDirection: 'row',
@@ -188,6 +200,8 @@ const styles = StyleSheet.create({
},
closeButton: {
padding: 4,
borderRadius: 12,
backgroundColor: 'transparent',
},
label: {
fontSize: 13,
@@ -1,5 +1,5 @@
import React from 'react';
import { View, Text, StyleSheet, ScrollView, TouchableOpacity } from 'react-native';
import React, { useState } from 'react';
import { Text, StyleSheet, ScrollView, TouchableOpacity, Animated } from 'react-native';
import { useRouter } from 'expo-router';
import { useUniqueCategories } from '@/hooks/useDatabase';
import { useSettings, ThemeColors } from '@/theme';
@@ -17,6 +17,7 @@ export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) {
const selectedSet = new Set(selected);
const isAnythingSelected = selected.length > 0;
const [scaleAnim] = useState(new Animated.Value(1));
const toggle = (id: string) => {
const next = new Set(selectedSet);
@@ -28,6 +29,15 @@ export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) {
onSelect(Array.from(next));
};
const animatePress = (id: string, isPressing: boolean) => {
Animated.spring(scaleAnim, {
toValue: isPressing ? 0.92 : 1,
friction: 8,
tension: 100,
useNativeDriver: true,
}).start();
};
return (
<ScrollView
horizontal
@@ -38,10 +48,14 @@ export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) {
<CategoryButton
id="all"
name="All"
color="#9E9E9E"
color={theme.textMuted}
selected={!isAnythingSelected}
onPress={() => onSelect([])}
onPressIn={() => animatePress('all', true)}
onPressOut={() => animatePress('all', false)}
theme={theme}
isAllCategory={true}
scaleAnim={scaleAnim}
/>
{categories.map((category) => (
<CategoryButton
@@ -51,7 +65,10 @@ export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) {
color={category.color}
selected={selectedSet.has(category.id)}
onPress={() => toggle(category.id)}
onPressIn={() => animatePress(category.id, true)}
onPressOut={() => animatePress(category.id, false)}
theme={theme}
scaleAnim={scaleAnim}
/>
))}
<TouchableOpacity
@@ -59,7 +76,7 @@ export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) {
onPress={() => router.push('/settings')}
activeOpacity={0.7}
accessibilityRole="button"
accessibilityLabel="Manage categories"
accessibilityLabel="Manage tags"
hitSlop={{ top: 8, bottom: 8, left: 4, right: 4 }}
>
<Svg width={14} height={14} viewBox="0 0 24 24">
@@ -80,34 +97,43 @@ interface CategoryButtonProps {
color: string;
selected: boolean;
onPress: () => void;
onPressIn: () => void;
onPressOut: () => void;
theme: ThemeColors;
isAllCategory?: boolean;
scaleAnim: Animated.Value;
}
function CategoryButton({ name, color, selected, onPress, theme }: CategoryButtonProps) {
function CategoryButton({ color, selected, onPress, onPressIn, onPressOut, theme, isAllCategory, scaleAnim }: CategoryButtonProps) {
const animatedStyle = {
transform: [{ scale: scaleAnim }],
shadowColor: selected ? theme.accent : '#000',
shadowOffset: { width: 0, height: selected ? 4 : 2 },
shadowOpacity: selected ? 0.25 : 0.1,
shadowRadius: selected ? 8 : 4,
elevation: selected ? 6 : 2,
};
return (
<TouchableOpacity
style={[
styles.button,
{ backgroundColor: theme.card, borderColor: theme.borderStrong },
selected && { borderColor: theme.accent, backgroundColor: theme.accentSoft },
{ backgroundColor: color },
selected && styles.buttonSelected,
animatedStyle,
]}
onPress={onPress}
activeOpacity={0.8}
hitSlop={{ top: 6, bottom: 6, left: 4, right: 4 }}
onPressIn={onPressIn}
onPressOut={onPressOut}
activeOpacity={1}
hitSlop={{ top: 10, bottom: 10, left: 8, right: 8 }}
>
<View
style={[
styles.colorDot,
{ backgroundColor: color },
]}
/>
<Text style={[
styles.buttonText,
{ color: theme.textSecondary },
selected && { color: theme.text, fontWeight: '600' },
]}>
{name}
</Text>
{isAllCategory ? (
<Svg width={20} height={20} viewBox="0 0 24 24">
<Circle cx={12} cy={12} r={9} stroke="#FFFFFF" strokeWidth={2.5} fill="none" opacity={0.9} />
<Path d="M6 12l4 4 8-8" stroke="#FFFFFF" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" opacity={0.9} />
</Svg>
) : null}
</TouchableOpacity>
);
}
@@ -122,24 +148,27 @@ const styles = StyleSheet.create({
alignItems: 'center',
},
button: {
flexDirection: 'row',
width: 44,
height: 44,
borderRadius: 22,
borderWidth: 2,
borderColor: 'transparent',
alignItems: 'center',
paddingHorizontal: 12,
paddingVertical: 10,
borderRadius: 18,
borderWidth: 1.5,
minHeight: 38,
justifyContent: 'center',
gap: 8,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 2,
},
colorDot: {
width: 8,
height: 8,
borderRadius: 4,
},
buttonText: {
fontSize: 12,
fontWeight: '500',
buttonSelected: {
borderColor: '#FFFFFF',
borderWidth: 3,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.25,
shadowRadius: 8,
elevation: 6,
},
manageButton: {
alignSelf: 'center',
@@ -68,7 +68,7 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
<Path d="M9 18l6-6-6-6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
</Svg>
</TouchableOpacity>
{error && <Text style={[styles.errorText, { color: '#E53935' }]}>{error}</Text>}
{error && <Text style={[styles.errorText, { color: theme.dangerStrong }]}>{error}</Text>}
<Modal visible={showModal} transparent animationType="fade" onRequestClose={() => setShowModal(false)}>
<KeyboardAvoidingView
@@ -49,7 +49,7 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
styles.pickerButton,
{ backgroundColor: theme.inputBg, borderColor: theme.borderStrong },
field.value && { borderColor: theme.accent, backgroundColor: theme.accentSoft },
fieldName === 'endTime' && endTimeInvalid && { borderColor: '#E53935', backgroundColor: theme.accentSoft },
fieldName === 'endTime' && endTimeInvalid && { borderColor: theme.dangerStrong, backgroundColor: theme.accentSoft },
]}
onPress={onPress}
activeOpacity={0.8}
@@ -150,6 +150,28 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
);
}}
/>
<TouchableOpacity
style={[styles.calendarButton, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong }]}
onPress={() => setPicker('date')}
activeOpacity={0.8}
accessibilityLabel="Open calendar"
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
>
<Svg width={22} height={22} viewBox="0 0 24 24">
<Path
d="M8 2v4M16 2v4M3 10h18M3 18h18"
stroke={theme.textSecondary}
strokeWidth={1.8}
strokeLinecap="round"
/>
<Path
d="M10 2H14a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2H10a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2z"
stroke={theme.textSecondary}
strokeWidth={1.8}
fill="none"
/>
</Svg>
</TouchableOpacity>
</View>
<Controller
control={control}
@@ -181,12 +203,12 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
</View>
{endTimeInvalid && (
<View style={[styles.warningRow, { backgroundColor: theme.accentSoft, borderColor: '#E53935' }]}>
<View style={[styles.warningRow, { backgroundColor: theme.accentSoft, borderColor: theme.dangerStrong }]}>
<Svg width={14} height={14} viewBox="0 0 24 24">
<Circle cx={12} cy={12} r={10} stroke="#EF5350" strokeWidth={1.8} fill="none" />
<Path d="M12 8v5M12 16.5v.5" stroke="#E53935" strokeWidth={1.8} strokeLinecap="round" />
<Circle cx={12} cy={12} r={10} stroke={theme.dangerStrong} strokeWidth={1.8} fill="none" />
<Path d="M12 8v5M12 16.5v.5" stroke={theme.dangerStrong} strokeWidth={1.8} strokeLinecap="round" />
</Svg>
<Text style={[styles.warningText, { color: '#E53935' }]}>
<Text style={[styles.warningText, { color: theme.dangerStrong }]}>
{endTime === startTime
? 'The end time is the same as the start time.'
: 'The end time is before the start time.'}
@@ -299,6 +321,14 @@ const styles = StyleSheet.create({
borderRadius: 10,
borderWidth: 1,
},
calendarButton: {
width: 52,
height: 52,
borderRadius: 12,
borderWidth: 1,
alignItems: 'center',
justifyContent: 'center',
},
warningText: {
fontSize: 13,
fontWeight: '500',
@@ -1,6 +1,6 @@
import React from 'react';
import React, { useMemo } from 'react';
import { View, Text, StyleSheet, TextInput , TextInputProps } from 'react-native';
import { useSettings } from '@/theme';
import { useSettings, type ThemeColors } from '@/theme';
interface DescriptionInputProps extends TextInputProps {
error?: string;
@@ -8,6 +8,7 @@ interface DescriptionInputProps extends TextInputProps {
export function DescriptionInput({ error, ...props }: DescriptionInputProps) {
const { theme } = useSettings();
const styles = useMemo(() => createStyles(theme), [theme]);
return (
<View style={styles.container}>
@@ -35,7 +36,7 @@ export function DescriptionInput({ error, ...props }: DescriptionInputProps) {
);
}
const styles = StyleSheet.create({
const createStyles = (theme: ThemeColors) => StyleSheet.create({
container: {
gap: 5,
},
@@ -51,7 +52,7 @@ const styles = StyleSheet.create({
fontSize: 14,
},
inputError: {
borderColor: '#E53935',
borderColor: theme.dangerStrong,
borderWidth: 1.5,
},
charCount: {
@@ -61,7 +62,7 @@ const styles = StyleSheet.create({
},
errorText: {
fontSize: 11,
color: '#E53935',
color: theme.dangerStrong,
marginLeft: 4,
},
});
@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { StyleSheet, TouchableOpacity, Animated, Easing } from 'react-native';
import { StyleSheet, TouchableOpacity, Animated, Easing, View, Text } from 'react-native';
import { useRouter } from 'expo-router';
import Svg, { Path } from 'react-native-svg';
import { useSettings } from '@/theme';
@@ -70,7 +70,7 @@ export function FloatingActionButton() {
>
<Svg width={24} height={24} viewBox="0 0 24 24">
<Path
d="M12 5v14M5 12h14"
d="M12 19V5M12 5l-5 5M12 5l5 5"
stroke={theme.accentText}
strokeWidth={2.5}
strokeLinecap="round"
@@ -79,6 +79,9 @@ export function FloatingActionButton() {
</Svg>
</Animated.View>
</TouchableOpacity>
<View style={styles.labelContainer}>
<Text style={[styles.label, { color: theme.accentText }]}>Add a Task</Text>
</View>
</Animated.View>
);
}
@@ -92,6 +95,7 @@ const styles = StyleSheet.create({
shadowOpacity: 0.3,
shadowRadius: 12,
elevation: 6,
alignItems: 'flex-end',
},
button: {
width: 56,
@@ -100,4 +104,16 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center',
},
labelContainer: {
marginTop: 8,
paddingHorizontal: 12,
paddingVertical: 4,
borderRadius: 12,
backgroundColor: 'rgba(0,0,0,0.6)',
},
label: {
fontSize: 12,
fontWeight: '600',
textAlign: 'center',
},
});
@@ -0,0 +1,279 @@
import React, { useState } from 'react';
import {
Modal,
View,
Text,
StyleSheet,
TextInput,
TouchableOpacity,
KeyboardAvoidingView,
ActivityIndicator,
Alert,
} from 'react-native';
import { useSettings } from '@/theme';
import { forgotPassword, resetPassword } from '@/services/auth';
interface ForgotPasswordModalProps {
visible: boolean;
onClose: () => void;
}
type ForgotPasswordStep = 'enterUsername' | 'enterResetCode';
export function ForgotPasswordModal({ visible, onClose }: ForgotPasswordModalProps) {
const { theme } = useSettings();
const [step, setStep] = useState<ForgotPasswordStep>('enterUsername');
const [username, setUsername] = useState('');
const [resetToken, setResetToken] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
if (!visible) return null;
const handleRequestReset = async () => {
if (busy) return;
if (!username.trim()) {
setError('Username is required');
return;
}
setBusy(true);
setError(null);
try {
const result = await forgotPassword(username.trim());
if (result.success) {
if (result.resetToken) {
Alert.alert('Reset Token (Dev Mode)', `Your reset token is: ${result.resetToken}\n\nEnter this token on the next screen.`, [
{ text: 'OK', onPress: () => setStep('enterResetCode') },
]);
} else {
Alert.alert('Reset Email Sent', 'If the account exists, a password reset link has been sent.', [
{ text: 'OK', onPress: () => setStep('enterResetCode') },
]);
}
} else {
setError('Failed to request password reset');
}
} catch (err: any) {
setError(err?.message ?? 'Failed to request password reset');
} finally {
setBusy(false);
}
};
const handleResetPassword = async () => {
if (busy) return;
if (!resetToken.trim()) {
setError('Reset token is required');
return;
}
if (newPassword.length < 8) {
setError('Password must be at least 8 characters');
return;
}
if (newPassword !== confirmPassword) {
setError('Passwords do not match');
return;
}
setBusy(true);
setError(null);
try {
const result = await resetPassword(resetToken.trim(), newPassword);
if (result.success) {
Alert.alert('Password Reset', 'Your password has been reset successfully.', [
{ text: 'OK', onPress: onClose },
]);
} else {
setError('Failed to reset password');
}
} catch (err: any) {
setError(err?.message ?? 'Failed to reset password. Token may be invalid or expired.');
} finally {
setBusy(false);
}
};
const handleBack = () => {
if (step === 'enterResetCode') {
setStep('enterUsername');
setResetToken('');
setNewPassword('');
setConfirmPassword('');
setError(null);
} else {
onClose();
}
};
const renderEnterUsername = () => (
<View style={styles.body}>
<Text style={[styles.subtitle, { color: theme.textSecondary }]}>
Enter your username to receive a password reset link.
</Text>
<TextInput
style={[styles.input, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text }]}
placeholder="Username"
placeholderTextColor={theme.textMuted}
value={username}
onChangeText={setUsername}
autoCapitalize="none"
autoCorrect={false}
/>
{error && <Text style={[styles.errorText, { color: theme.dangerStrong }]}>{error}</Text>}
<TouchableOpacity
style={[styles.primaryButton, { backgroundColor: theme.accent }]}
onPress={handleRequestReset}
disabled={busy}
activeOpacity={0.8}
>
{busy ? (
<ActivityIndicator color={theme.accentText} />
) : (
<Text style={[styles.primaryButtonText, { color: theme.accentText }]}>Send Reset Link</Text>
)}
</TouchableOpacity>
</View>
);
const renderEnterResetCode = () => (
<View style={styles.body}>
<Text style={[styles.subtitle, { color: theme.textSecondary }]}>
Enter the reset token and your new password.
</Text>
<TextInput
style={[styles.input, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text }]}
placeholder="Reset Token"
placeholderTextColor={theme.textMuted}
value={resetToken}
onChangeText={setResetToken}
autoCapitalize="none"
autoCorrect={false}
/>
<TextInput
style={[styles.input, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text }]}
placeholder="New Password (min 8 chars)"
placeholderTextColor={theme.textMuted}
value={newPassword}
onChangeText={setNewPassword}
secureTextEntry
/>
<TextInput
style={[styles.input, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text }]}
placeholder="Confirm New Password"
placeholderTextColor={theme.textMuted}
value={confirmPassword}
onChangeText={setConfirmPassword}
secureTextEntry
/>
{error && <Text style={[styles.errorText, { color: theme.dangerStrong }]}>{error}</Text>}
<TouchableOpacity
style={[styles.primaryButton, { backgroundColor: theme.accent }]}
onPress={handleResetPassword}
disabled={busy}
activeOpacity={0.8}
>
{busy ? (
<ActivityIndicator color={theme.accentText} />
) : (
<Text style={[styles.primaryButtonText, { color: theme.accentText }]}>Reset Password</Text>
)}
</TouchableOpacity>
</View>
);
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={handleBack}>
<KeyboardAvoidingView
style={styles.overlay}
behavior="padding"
>
<TouchableOpacity style={styles.backdrop} activeOpacity={1} onPress={handleBack} />
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
<View style={[styles.handle, { backgroundColor: theme.borderStrong }]} />
<Text style={[styles.title, { color: theme.text }]}>Reset Password</Text>
{step === 'enterUsername' ? renderEnterUsername() : renderEnterResetCode()}
<TouchableOpacity
style={[styles.secondaryButton, { borderColor: theme.borderStrong }]}
onPress={handleBack}
activeOpacity={0.7}
>
<Text style={[styles.secondaryButtonText, { color: theme.textSecondary }]}>Cancel</Text>
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
justifyContent: 'flex-end',
},
backdrop: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.4)',
},
sheet: {
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
paddingTop: 8,
paddingHorizontal: 20,
paddingBottom: 32,
},
handle: {
width: 40,
height: 4,
borderRadius: 2,
alignSelf: 'center',
marginBottom: 12,
},
title: {
fontSize: 18,
fontWeight: '700',
textAlign: 'center',
marginBottom: 16,
},
body: {
gap: 12,
marginBottom: 16,
},
subtitle: {
fontSize: 14,
},
input: {
borderRadius: 12,
borderWidth: 1,
paddingHorizontal: 14,
paddingVertical: 12,
fontSize: 15,
},
primaryButton: {
paddingVertical: 14,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
minHeight: 50,
},
primaryButtonText: {
fontSize: 15,
fontWeight: '700',
},
secondaryButton: {
paddingVertical: 12,
borderRadius: 12,
borderWidth: 1,
alignItems: 'center',
},
secondaryButtonText: {
fontSize: 15,
fontWeight: '600',
},
errorText: {
fontSize: 13,
fontWeight: '500',
},
});
+65 -44
View File
@@ -1,5 +1,6 @@
import React, { useState, useEffect, useMemo, useRef } from 'react';
import { View, StyleSheet, TextInput, TouchableOpacity, Keyboard, Text } from 'react-native';
import { View, StyleSheet, TextInput, TouchableOpacity, Keyboard, Text, Platform } from 'react-native';
import { BlurView } from 'expo-blur';
import { database, collections } from '@/database';
import { useCategories } from '@/hooks/useDatabase';
import { useSettings } from '@/theme';
@@ -13,6 +14,21 @@ interface QuickAddBarProps {
placeholder?: string;
}
const BlurContainer = ({ children, style, theme }: { children: React.ReactNode; style: any; theme: any }) => {
if (Platform.OS === 'web') {
return (
<View style={[style, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }]}>
{children}
</View>
);
}
return (
<BlurView style={style} intensity={80} tint="dark">
{children}
</BlurView>
);
};
export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
const { theme, defaultCategoryId } = useSettings();
const categories = useCategories();
@@ -25,7 +41,7 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
() => categories.filter((c) => c.name.toLowerCase() !== 'calendar'),
[categories]
);
const categoryColor = visibleCategories.find((c) => c.id === categoryId)?.color ?? '#9E9E9E';
const categoryColor = visibleCategories.find((c) => c.id === categoryId)?.color ?? theme.textMuted;
useEffect(() => {
return subscribeToQuickAdd(() => {
@@ -66,58 +82,57 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
return (
<View style={styles.wrapper}>
<View style={[styles.bar, { backgroundColor: theme.card, borderColor: theme.border }]}>
<TouchableOpacity
style={[styles.categoryButton, { borderColor: theme.borderStrong, backgroundColor: theme.cardAlt }]}
onPress={() => setCategoryPickerVisible(true)}
activeOpacity={0.7}
accessibilityLabel="Select category"
>
<View style={[styles.categoryButtonDot, { backgroundColor: categoryColor }]} />
<Svg width={12} height={12} viewBox="0 0 24 24">
<Path d="M6 9l6 6 6-6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
</Svg>
</TouchableOpacity>
<TextInput
ref={inputRef}
style={[styles.input, { color: theme.text }]}
placeholder={placeholder ?? 'Add a task'}
placeholderTextColor={theme.textMuted}
value={title}
onChangeText={setTitle}
onSubmitEditing={handleAdd}
returnKeyType="done"
accessibilityLabel="Quick add task"
accessibilityHint="Enter a task name and press the add button"
/>
<TouchableOpacity
style={[styles.submit, { backgroundColor: theme.accent }, title.trim() ? {} : styles.submitDisabled]}
onPress={title.trim() ? handleAdd : undefined}
disabled={!title.trim()}
activeOpacity={0.8}
accessibilityLabel="Add task"
accessibilityHint="Adds the entered task to the list"
accessibilityState={{ disabled: !title.trim() }}
>
<View style={styles.submitLabelRow}>
<Text style={[styles.submitText, { color: theme.accentText }]}>Add a Task</Text>
<Svg width={18} height={18} viewBox="0 0 24 24">
<BlurContainer theme={theme} style={styles.blurContainer}>
<View style={[styles.bar, { backgroundColor: 'rgba(255,255,255,0.05)', borderColor: 'rgba(255,255,255,0.1)' }]}>
<TouchableOpacity
style={[styles.categoryButton, { borderColor: theme.borderStrong, backgroundColor: theme.cardAlt }]}
onPress={() => setCategoryPickerVisible(true)}
activeOpacity={0.7}
accessibilityLabel="Select tags"
>
<View style={[styles.categoryButtonDot, { backgroundColor: categoryColor }]} />
<Svg width={12} height={12} viewBox="0 0 24 24">
<Path d="M6 9l6 6 6-6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
</Svg>
</TouchableOpacity>
<TextInput
ref={inputRef}
style={[styles.input, { color: theme.text }]}
placeholder={placeholder ?? 'Add a task'}
placeholderTextColor={theme.textMuted}
value={title}
onChangeText={setTitle}
onSubmitEditing={handleAdd}
returnKeyType="done"
accessibilityLabel="Quick add task"
accessibilityHint="Enter a task name and press the add button"
/>
<TouchableOpacity
style={[styles.submit, { backgroundColor: theme.accent }, title.trim() ? {} : styles.submitDisabled]}
onPress={title.trim() ? handleAdd : undefined}
disabled={!title.trim()}
activeOpacity={0.8}
accessibilityLabel="Add task"
accessibilityHint="Adds the entered task to the list"
accessibilityState={{ disabled: !title.trim() }}
>
<Svg width={20} height={20} viewBox="0 0 24 24">
<Path
d="M12 5v14M12 5l-5 5M12 5l5 5"
d="M12 19V5M12 5l-5 5M12 5l5 5"
stroke={theme.accentText}
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
</View>
</TouchableOpacity>
</View>
</TouchableOpacity>
</View>
</BlurContainer>
<OptionPickerModal
visible={categoryPickerVisible}
title="Select Category"
options={[{ value: '', label: 'None', color: '#9E9E9E' }, ...visibleCategories.map((c) => ({ value: c.id, label: c.name, color: c.color }))]}
title="Select Tags"
options={[{ value: '', label: 'None', color: theme.textMuted }, ...visibleCategories.map((c) => ({ value: c.id, label: c.name, color: c.color }))]}
selectedValue={categoryId}
onSelect={(value) => setCategoryId(Array.isArray(value) ? value[0] : value)}
onClose={() => setCategoryPickerVisible(false)}
@@ -130,6 +145,11 @@ const styles = StyleSheet.create({
wrapper: {
paddingHorizontal: 8,
paddingTop: 4,
paddingBottom: 8,
},
blurContainer: {
borderRadius: 14,
overflow: 'hidden',
},
bar: {
flexDirection: 'row',
@@ -145,6 +165,7 @@ const styles = StyleSheet.create({
shadowOpacity: 0.10,
shadowRadius: 6,
elevation: 6,
minHeight: 56,
},
categoryButton: {
flexDirection: 'row',
+30 -4
View File
@@ -12,6 +12,8 @@ import {
import { useSettings } from '@/theme';
import { AuthUser, getAuthToken, getAuthUser, login, register, signOutAuth } from '@/services/auth';
import { runSyncGuarded, startAutoSync, stopAutoSync, getLastSyncTime, SyncResult } from '@/database/sync';
import { ForgotPasswordModal } from './ForgotPasswordModal';
import { registerForPushNotificationsAsync, unregisterForPushNotificationsAsync } from '@/services/pushNotifications';
interface SyncModalProps {
visible: boolean;
@@ -34,6 +36,7 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
const [lastSync, setLastSync] = useState<number | null>(null);
const [result, setResult] = useState<SyncResult | null>(null);
const [prevVisible, setPrevVisible] = useState(visible);
const [showForgotPassword, setShowForgotPassword] = useState(false);
if (prevVisible !== visible) {
setPrevVisible(visible);
@@ -83,6 +86,7 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
: await login(username.trim(), password);
setUser(authedUser);
startAutoSync();
registerForPushNotificationsAsync().catch(() => {});
} catch (err: any) {
setError(err?.message ?? 'Sign in failed');
} finally {
@@ -115,6 +119,7 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
const handleSignOut = async () => {
await signOutAuth();
stopAutoSync();
unregisterForPushNotificationsAsync().catch(() => {});
setUser(null);
setError(null);
setStatus('idle');
@@ -159,7 +164,7 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
</TouchableOpacity>
{status === 'success' && result && (
<Text style={[styles.successText, { color: '#43A047' }]}>
<Text style={[styles.successText, { color: theme.successStrong }]}>
Synced! {result.pushed.tasks + result.pulled.tasks} tasks,{' '}
{result.pushed.categories + result.pulled.categories} categories,{' '}
{result.pushed.subtasks + result.pulled.subtasks} subtasks,{' '}
@@ -168,7 +173,7 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
</Text>
)}
{status === 'error' && error && (
<Text style={[styles.errorText, { color: '#E53935' }]}>{error}</Text>
<Text style={[styles.errorText, { color: theme.dangerStrong }]}>{error}</Text>
)}
<TouchableOpacity
@@ -176,7 +181,7 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
onPress={handleSignOut}
activeOpacity={0.7}
>
<Text style={[styles.secondaryButtonText, { color: '#E53935' }]}>Sign Out</Text>
<Text style={[styles.secondaryButtonText, { color: theme.dangerStrong }]}>Sign Out</Text>
</TouchableOpacity>
</View>
) : (
@@ -234,7 +239,17 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
secureTextEntry
/>
{error && <Text style={[styles.errorText, { color: '#E53935' }]}>{error}</Text>}
{mode === 'login' && (
<TouchableOpacity
style={styles.forgotPasswordLink}
onPress={() => setShowForgotPassword(true)}
activeOpacity={0.7}
>
<Text style={[styles.forgotPasswordText, { color: theme.accent }]}>Forgot Password?</Text>
</TouchableOpacity>
)}
{error && <Text style={[styles.errorText, { color: theme.dangerStrong }]}>{error}</Text>}
<TouchableOpacity
style={[styles.primaryButton, { backgroundColor: theme.accent }]}
@@ -253,6 +268,8 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
</View>
)}
<ForgotPasswordModal visible={showForgotPassword} onClose={() => setShowForgotPassword(false)} />
<TouchableOpacity
style={[styles.secondaryButton, { borderColor: theme.borderStrong }]}
onPress={onClose}
@@ -374,4 +391,13 @@ const styles = StyleSheet.create({
fontSize: 13,
fontWeight: '500',
},
forgotPasswordLink: {
alignSelf: 'flex-end',
marginTop: -4,
marginBottom: 8,
},
forgotPasswordText: {
fontSize: 13,
fontWeight: '500',
},
});
@@ -66,14 +66,14 @@ export default function SyncStatus({ compact = false }: SyncStatusProps) {
case 'success':
return (
<Svg width={16} height={16} viewBox="0 0 24 24">
<Path d="M5 12l5 5 9-10" stroke="#66BB6A" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
<Path d="M5 12l5 5 9-10" stroke={theme.success} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
</Svg>
);
case 'error':
return (
<Svg width={16} height={16} viewBox="0 0 24 24">
<Circle cx={12} cy={12} r={10} stroke="#EF5350" strokeWidth={2} fill="none" />
<Path d="M12 8v4M12 16h.01" stroke="#EF5350" strokeWidth={2} strokeLinecap="round" />
<Circle cx={12} cy={12} r={10} stroke={theme.danger} strokeWidth={2} fill="none" />
<Path d="M12 8v4M12 16h.01" stroke={theme.danger} strokeWidth={2} strokeLinecap="round" />
</Svg>
);
default:
@@ -128,7 +128,7 @@ export default function SyncStatus({ compact = false }: SyncStatusProps) {
</View>
{error && (
<View style={styles.error}>
<Text style={[styles.errorText, { color: '#E53935' }]}>{error}</Text>
<Text style={[styles.errorText, { color: theme.dangerStrong }]}>{error}</Text>
</View>
)}
<Text style={[styles.lastSync, { color: theme.textFaint }]}>
+25 -30
View File
@@ -3,7 +3,8 @@ import { View, Text, StyleSheet, TouchableOpacity, Animated, Platform, Dimension
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 { useSettings, mixHex } from '@/theme';
import type { ThemeColors } from '@/theme';
import Svg, { Path, Circle } from 'react-native-svg';
export type TaskLike = {
@@ -47,6 +48,7 @@ interface TaskItemProps {
export const TaskItem = React.memo(function TaskItem({ task, onToggle, onDelete, onPress, onLongPress, onMenuOpen, selected, selectionMode, completedSection, draggable, hovered, onDragStart, onDragUpdate, onDragEnd, onReorderStart, onReorderUpdate, onReorderEnd, expanded, indented, depth = 0, categoryColor, categoryColors }: TaskItemProps) {
const { theme } = useSettings();
const styles = React.useMemo(() => createStyles(theme), [theme]);
const [opacityAnim] = React.useState(new Animated.Value(task.completed ? 0.5 : 1));
const [dragTranslateX] = React.useState(new Animated.Value(0));
const [dragTranslateY] = React.useState(new Animated.Value(0));
@@ -216,16 +218,9 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
<View style={styles.content}>
<View style={styles.titleRow}>
<View style={styles.categoryDotSlot}>
{(categoryColors ?? (categoryColor ? [categoryColor] : [])).slice(0, 3).map((color, i) => (
<View
key={`${color}-${i}`}
style={[
styles.categoryDot,
{ backgroundColor: color },
i > 0 && { marginLeft: -6 },
]}
/>
))}
{categoryColor && (
<View style={[styles.categoryDot, { backgroundColor: categoryColor }]} />
)}
</View>
<TouchableOpacity
style={styles.checkCircle}
@@ -242,7 +237,7 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
<Path d="M7 12l3 3 7-7" stroke="#FFFFFF" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
</>
) : (
<Circle cx={12} cy={12} r={10} stroke={isOverdue ? '#FF8A80' : theme.textSecondary} strokeWidth={2.5} fill="none" />
<Circle cx={12} cy={12} r={10} stroke={isOverdue ? theme.danger : theme.textSecondary} strokeWidth={2.5} fill="none" />
)}
</Svg>
</TouchableOpacity>
@@ -403,7 +398,7 @@ function formatTime12(time: string): string {
return `${h}:${String(minutes).padStart(2, '0')} ${period}`;
}
const styles = StyleSheet.create({
const createStyles = (theme: ThemeColors) => StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
@@ -426,10 +421,10 @@ const styles = StyleSheet.create({
marginBottom: 4,
},
taskOverdue: {
borderColor: '#573431',
borderColor: theme.dangerBorder,
},
taskDueToday: {
borderColor: '#2C4766',
borderColor: mixHex(theme.info, theme.background, 0.55),
},
taskSelected: {
shadowColor: '#000',
@@ -438,8 +433,8 @@ const styles = StyleSheet.create({
elevation: 2,
},
taskHovered: {
borderColor: '#2C4766',
backgroundColor: 'rgba(44, 71, 102, 0.15)',
borderColor: mixHex(theme.info, theme.background, 0.55),
backgroundColor: mixHex(theme.info, theme.background, 0.85),
},
dragLifted: {
zIndex: 100,
@@ -476,9 +471,9 @@ const styles = StyleSheet.create({
marginRight: 8,
},
categoryDot: {
width: 10,
height: 10,
borderRadius: 5,
width: 12,
height: 12,
borderRadius: 6,
},
title: {
fontSize: 17,
@@ -488,7 +483,7 @@ const styles = StyleSheet.create({
},
titleCompleted: {
textDecorationLine: 'line-through',
color: '#9E9E9E',
color: theme.textMuted,
},
repeatIcon: {
marginLeft: 8,
@@ -512,7 +507,7 @@ const styles = StyleSheet.create({
justifyContent: 'center',
},
titleOverdue: {
color: '#E57373',
color: theme.danger,
},
priorityBadge: {
paddingHorizontal: 6,
@@ -530,14 +525,14 @@ const styles = StyleSheet.create({
width: 20,
height: 20,
borderRadius: 10,
backgroundColor: '#43302E',
backgroundColor: theme.dangerSoft,
alignItems: 'center',
justifyContent: 'center',
},
assigneeText: {
fontSize: 9,
fontWeight: '700',
color: '#FF9C8F',
color: theme.danger,
},
dueRow: {
flexDirection: 'row',
@@ -548,11 +543,11 @@ const styles = StyleSheet.create({
fontSize: 13,
},
dueTextOverdue: {
color: '#E57373',
color: theme.danger,
fontWeight: '600',
},
dueTextDueToday: {
color: '#64B5F6',
color: theme.info,
fontWeight: '600',
},
menuButton: {
@@ -567,12 +562,12 @@ const styles = StyleSheet.create({
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 8,
backgroundColor: 'rgba(100, 181, 246, 0.15)',
backgroundColor: mixHex(theme.info, theme.background, 0.85),
},
allDayText: {
fontSize: 11,
fontWeight: '600',
color: '#64B5F6',
color: theme.info,
},
checkCircle: {
width: 30,
@@ -589,12 +584,12 @@ const styles = StyleSheet.create({
gap: 4,
},
swipeDelete: {
backgroundColor: '#B8504A',
backgroundColor: theme.dangerStrong,
borderTopRightRadius: 16,
borderBottomRightRadius: 16,
},
swipeComplete: {
backgroundColor: '#3D7A4A',
backgroundColor: theme.successStrong,
borderTopLeftRadius: 16,
borderBottomLeftRadius: 16,
},
+11 -6
View File
@@ -10,8 +10,9 @@ import { SubtaskItem } from './SubtaskItem';
import { TaskData, SubtaskData, parseTaskTags } from '@/types';
import Task from '@/models/Task';
import { useSettings } from '@/theme';
import { runSyncGuarded } from '@/database/sync';
import {
toggleTaskComplete,
confirmAndToggleTaskComplete,
deleteTask,
setTaskCompleted,
convertTaskToSubtask,
@@ -126,8 +127,13 @@ export function TaskList({ categoryIds = [], showCompleted = false, onSelectionC
}, [refreshTasks, refreshSubtasks])
);
const onRefresh = useCallback(() => {
const onRefresh = useCallback(async () => {
setRefreshing(true);
try {
await runSyncGuarded();
} catch (error) {
if (__DEV__) console.warn('Sync failed:', error);
}
refreshTasks();
refreshSubtasks();
setTimeout(() => setRefreshing(false), 600);
@@ -162,8 +168,7 @@ export function TaskList({ categoryIds = [], showCompleted = false, onSelectionC
}, [onSelectionChange]);
const handleToggle = useCallback(async (taskId: string) => {
await toggleTaskComplete(taskId);
refreshTasks();
await confirmAndToggleTaskComplete(taskId, () => refreshTasks());
}, [refreshTasks]);
const toggleExpand = useCallback(async (taskId: string) => {
@@ -511,8 +516,8 @@ export function TaskList({ categoryIds = [], showCompleted = false, onSelectionC
</TouchableOpacity>
<Text style={[styles.selectionCount, { color: theme.text }]}>{selectedIds.size} selected</Text>
<View style={styles.selectionActions}>
<SelectionButton label="Complete" color="#43A047" onPress={handleBulkComplete} />
<SelectionButton label="Delete" color="#E53935" onPress={handleBulkDelete} />
<SelectionButton label="Complete" color={theme.successStrong} onPress={handleBulkComplete} />
<SelectionButton label="Delete" color={theme.dangerStrong} onPress={handleBulkDelete} />
</View>
</View>
)}
@@ -1,6 +1,6 @@
import React from 'react';
import { View, Text, StyleSheet, TextInput , TextInputProps } from 'react-native';
import { useSettings } from '@/theme';
import { useSettings, type ThemeColors } from '@/theme';
interface TaskNameInputProps extends TextInputProps {
error?: string;
@@ -8,6 +8,7 @@ interface TaskNameInputProps extends TextInputProps {
export function TaskNameInput({ error, ...props }: TaskNameInputProps) {
const { theme } = useSettings();
const styles = React.useMemo(() => createStyles(theme), [theme]);
return (
<View style={styles.container}>
@@ -34,7 +35,7 @@ export function TaskNameInput({ error, ...props }: TaskNameInputProps) {
);
}
const styles = StyleSheet.create({
const createStyles = (theme: ThemeColors) => StyleSheet.create({
container: {
gap: 5,
},
@@ -47,7 +48,7 @@ const styles = StyleSheet.create({
fontWeight: '600',
},
required: {
color: '#E53935',
color: theme.dangerStrong,
fontSize: 13,
fontWeight: '600',
},
@@ -59,12 +60,12 @@ const styles = StyleSheet.create({
fontSize: 15,
},
inputError: {
borderColor: '#E53935',
borderColor: theme.dangerStrong,
borderWidth: 1.5,
},
errorText: {
fontSize: 11,
color: '#E53935',
color: theme.dangerStrong,
marginLeft: 4,
},
});
@@ -1,6 +1,7 @@
import React from 'react';
import { View, Text, StyleSheet, Modal, TouchableOpacity } from 'react-native';
import { useSettings } from '@/theme';
import type { ThemeColors } from '@/theme';
import { TaskData, SubtaskData } from '@/types';
import Svg, { Path } from 'react-native-svg';
@@ -40,6 +41,7 @@ export function TaskOverflowMenu({
onAddSubtask,
}: TaskOverflowMenuProps) {
const { theme } = useSettings();
const styles = React.useMemo(() => createStyles(theme), [theme]);
if (!task) return null;
@@ -67,7 +69,7 @@ export function TaskOverflowMenu({
if (!subtask) {
actions.push({
key: 'category',
label: 'Change Category',
label: 'Change Tags',
icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Circle2 /></Svg>,
onPress: onChangeCategory ?? (() => {}),
});
@@ -84,7 +86,7 @@ export function TaskOverflowMenu({
key: 'delete',
label: 'Delete',
destructive: true,
icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13" stroke="#E57373" strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" fill="none" /></Svg>,
icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13" stroke={theme.danger} strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" fill="none" /></Svg>,
onPress: onDelete,
},
);
@@ -144,7 +146,7 @@ function Circle2() {
);
}
const styles = StyleSheet.create({
const createStyles = (theme: ThemeColors) => StyleSheet.create({
overlay: {
flex: 1,
justifyContent: 'flex-end',
@@ -185,7 +187,7 @@ const styles = StyleSheet.create({
fontWeight: '500',
},
destructiveText: {
color: '#E53935',
color: theme.dangerStrong,
fontWeight: '600',
},
});
+2 -3
View File
@@ -1,7 +1,7 @@
import { appSchema, tableSchema } from '@nozbe/watermelondb';
export const schema = appSchema({
version: 20,
version: 21,
tables: [
tableSchema({
name: 'categories',
@@ -45,9 +45,8 @@ export const schema = appSchema({
columns: [
{ name: 'task_id', type: 'string', isIndexed: true },
{ name: 'parent_subtask_id', type: 'string', isIndexed: true, isOptional: true },
{ name: 'category_id', type: 'string', isOptional: true },
{ name: 'tags', type: 'string', isOptional: true },
{ name: 'category_id', type: 'string', isIndexed: true, isOptional: true },
{ name: 'tags', type: 'string', isOptional: true },
{ name: 'title', type: 'string' },
{ name: 'description', type: 'string', isOptional: true },
{ name: 'priority', type: 'string', isOptional: true },
+35 -11
View File
@@ -13,6 +13,7 @@ import { TombstoneEntity } from '@/models/Tombstone';
import { fetchPendingTombstones, removeTombstonesInBatch } from './tombstones';
const LAST_PULLED_AT_KEY = 'sync:lastPulledAt';
const LAST_PUSHED_AT_KEY = 'sync:lastPushedAt';
const LAST_RUN_AT_KEY = 'sync:lastRunAt';
export interface SyncResult {
@@ -55,6 +56,24 @@ async function setLastPulledAt(timestamp: number): Promise<void> {
}
}
async function getLastPushedAt(): Promise<number> {
try {
const raw = await AsyncStorage.getItem(LAST_PUSHED_AT_KEY);
const value = raw ? Number(raw) : 0;
return Number.isFinite(value) ? value : 0;
} catch {
return 0;
}
}
async function setLastPushedAt(timestamp: number): Promise<void> {
try {
await AsyncStorage.setItem(LAST_PUSHED_AT_KEY, String(timestamp));
} catch {
// ignore
}
}
export async function getLastSyncTime(): Promise<number | null> {
try {
const raw = await AsyncStorage.getItem(LAST_RUN_AT_KEY);
@@ -89,8 +108,12 @@ export async function runSync(): Promise<SyncResult> {
await pruneSyncedTombstones(conflicts, pushedDeletions);
const lastPulledAt = await getLastPulledAt();
const response = await apiFetch(`/sync?since=${lastPulledAt}`);
// Update last pushed timestamp to avoid re-pushing same changes
await setLastPushedAt(Date.now());
// Use current time as pull cutoff to avoid re-pulling what we just pushed
const pullSince = Date.now();
const response = await apiFetch(`/sync?since=${pullSince}`);
const data = await response.json();
const pulledCounts = await applyServerChanges(data);
@@ -103,13 +126,13 @@ export async function runSync(): Promise<SyncResult> {
}
async function pushChanges(): Promise<{ conflicts: PushConflict[]; pushedDeletions: { entity: TombstoneEntity; id: string }[] }> {
const lastPulledAt = await getLastPulledAt();
const lastPushedAt = await getLastPushedAt();
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(),
collections.tasks.query(Q.where('updated_at', Q.gt(lastPushedAt))).fetch(),
collections.subtasks.query(Q.where('updated_at', Q.gt(lastPushedAt))).fetch(),
collections.repeatProfiles.query(Q.where('updated_at', Q.gt(lastPushedAt))).fetch(),
collections.friendships.query(Q.where('updated_at', Q.gt(lastPushedAt))).fetch(),
]);
const taskPayload = (t: Task) => ({
@@ -194,12 +217,12 @@ async function pushChanges(): Promise<{ conflicts: PushConflict[]; pushedDeletio
if (referencedCategoryIds.size > 0) {
categoryQuery = collections.categories.query(
Q.or(
Q.where('updated_at', Q.gt(lastPulledAt)),
Q.where('updated_at', Q.gt(lastPushedAt)),
Q.where('id', Q.oneOf(Array.from(referencedCategoryIds)))
)
);
} else {
categoryQuery = collections.categories.query(Q.where('updated_at', Q.gt(lastPulledAt)));
categoryQuery = collections.categories.query(Q.where('updated_at', Q.gt(lastPushedAt)));
}
const categories = await categoryQuery.fetch();
@@ -231,7 +254,7 @@ async function pushChanges(): Promise<{ conflicts: PushConflict[]; pushedDeletio
updatedAt: f.updatedAt.getTime(),
}));
const pendingTombstones = await fetchPendingTombstones(lastPulledAt);
const pendingTombstones = await fetchPendingTombstones(lastPushedAt);
const changedDeletions = pendingTombstones.map((t) => ({
entity: t.entity,
id: t.entityId,
@@ -260,7 +283,7 @@ async function pushChanges(): Promise<{ conflicts: PushConflict[]; pushedDeletio
friendships: changedFriendships,
},
deleted: changedDeletions,
lastPulledAt,
lastPulledAt: await getLastPulledAt(),
}),
});
@@ -563,6 +586,7 @@ async function upsertSubtask(row: ServerRow, force: boolean): Promise<boolean> {
s.taskId = String(row.taskId ?? s.taskId);
s.parentSubtaskId = row.parentSubtaskId == null ? null : String(row.parentSubtaskId);
s.categoryId = row.categoryId ? String(row.categoryId) : s.categoryId;
s.tags = row.tags != null ? String(row.tags) : s.tags;
s.title = String(row.title ?? s.title);
s.description = String(row.description ?? s.description);
s.priority = row.priority ?? s.priority;
@@ -46,12 +46,6 @@ export function DatabaseProvider({ children }: { children: ReactNode }) {
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();
}
+3 -4
View File
@@ -9,7 +9,6 @@ import { TaskOverflowMenu } from '@/components/TaskOverflowMenu';
import { TaskDeleteModal } from '@/components/TaskDeleteModal';
import { OptionPickerModal } from '@/components/OptionPickerModal';
import {
toggleTaskComplete,
deleteTaskOccurrences,
duplicateTask,
setTaskCategory,
@@ -20,6 +19,7 @@ import {
setSubtaskPriority,
createSubtask,
TaskDeleteScope,
confirmAndToggleTaskComplete,
} from '@/utils/taskActions';
const PRIORITY_OPTIONS = Object.entries(PRIORITY_LABELS).map(([value, label]) => ({ value, label }));
@@ -179,8 +179,7 @@ export function useTaskModals() {
}, []);
const handleToggleComplete = useCallback(async (taskId: string) => {
await toggleTaskComplete(taskId);
refreshRef.current();
await confirmAndToggleTaskComplete(taskId, () => refreshRef.current());
}, []);
const handleSubtaskToggle = useCallback(async (subtask: SubtaskData) => {
@@ -303,7 +302,7 @@ export function useTaskModals() {
<OptionPickerModal
visible={picker?.type === 'category'}
title="Change Category"
title="Change Tags"
options={[{ value: '', label: 'None', color: '#9E9E9E' }, ...categories.map((c) => ({ value: c.id, label: c.name, color: c.color }))]}
selectedValue={menuTask?.categoryId}
onSelect={handleSingleCategory}
+35
View File
@@ -136,3 +136,38 @@ export async function register(username: string, password: string): Promise<Auth
await setAuth(data.token, data.user);
return data.user as AuthUser;
}
export async function forgotPassword(username: string): Promise<{ success: boolean; resetToken?: string }> {
const response = await apiFetch('/auth/forgot-password', {
method: 'POST',
body: JSON.stringify({ username }),
});
const data = await response.json();
return data;
}
export async function resetPassword(token: string, password: string): Promise<{ success: boolean }> {
const response = await apiFetch('/auth/reset-password', {
method: 'POST',
body: JSON.stringify({ token, password }),
});
const data = await response.json();
return data;
}
export async function registerPushToken(pushToken: string): Promise<{ success: boolean }> {
const response = await apiFetch('/push/push-token', {
method: 'POST',
body: JSON.stringify({ pushToken }),
});
const data = await response.json();
return data;
}
export async function unregisterPushToken(): Promise<{ success: boolean }> {
const response = await apiFetch('/push/push-token', {
method: 'DELETE',
});
const data = await response.json();
return data;
}
+63 -13
View File
@@ -1,6 +1,6 @@
import { Platform } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Reminder, reminderToMinutes, parseReminders } from '@/types';
import { Reminder, reminderToMinutes, parseReminders, ReminderPreference } from '@/types';
const ID_MAP_KEY = 'notifications:taskIdToNotificationIds';
@@ -30,11 +30,52 @@ function getNotificationsModule(): any {
}
}
export async function scheduleTaskReminder(task: any): Promise<void> {
function reminderPreferenceToReminder(pref: ReminderPreference): Reminder {
switch (pref) {
case 'none':
return 'none';
case 'at_time':
return 'at_time';
case '15':
return '15';
case '30':
return '30';
case '60':
return '60';
case '120':
return '120';
case '1440':
return '1440';
default:
return 'none';
}
}
function getRemindersForTask(task: any, globalPreference: ReminderPreference): Reminder[] {
const taskReminders: Reminder[] = task.reminders
? parseReminders(task.reminders)
: task.reminder && task.reminder !== 'none'
? [task.reminder as Reminder]
: [];
if (taskReminders.length > 0) {
return taskReminders;
}
const globalReminder = reminderPreferenceToReminder(globalPreference);
if (globalReminder !== 'none') {
return [globalReminder];
}
return [];
}
export async function scheduleTaskReminder(task: any, notificationsEnabled: boolean = true, globalReminderPreference: ReminderPreference = '15'): Promise<void> {
const notifications = await getNotificationsModule();
const dueDate = task.dueDate ? new Date(task.dueDate) : null;
if (!notifications) return;
if (!notificationsEnabled) return;
const idMap = await readIdMap();
@@ -51,11 +92,7 @@ export async function scheduleTaskReminder(task: any): Promise<void> {
await writeIdMap(idMap);
// Determine the set of reminders to schedule
const reminders: Reminder[] = task.reminders
? parseReminders(task.reminders)
: task.reminder && task.reminder !== 'none'
? [task.reminder as Reminder]
: [];
const reminders = getRemindersForTask(task, globalReminderPreference);
if (reminders.length === 0 || !dueDate || !task.dueTime) return;
@@ -132,7 +169,24 @@ export async function requestNotificationPermission(): Promise<boolean> {
}
}
export async function rescheduleAllReminders(tasks: any[]): Promise<void> {
export async function cancelAllReminders(): Promise<void> {
const notifications = await getNotificationsModule();
if (!notifications) return;
try {
await notifications.cancelAllScheduledNotificationsAsync();
} catch {
// ignore
}
const idMap: Record<string, string[]> = {};
await writeIdMap(idMap);
}
export async function rescheduleAllReminders(tasks: any[], notificationsEnabled: boolean = true, globalReminderPreference: ReminderPreference = '15'): Promise<void> {
if (!notificationsEnabled) {
await cancelAllReminders();
return;
}
const notifications = await getNotificationsModule();
if (!notifications) return;
try {
@@ -145,11 +199,7 @@ export async function rescheduleAllReminders(tasks: any[]): Promise<void> {
for (const task of tasks) {
const dueDate = task.dueDate ? new Date(task.dueDate) : null;
const reminders: Reminder[] = task.reminders
? parseReminders(task.reminders)
: task.reminder && task.reminder !== 'none'
? [task.reminder as Reminder]
: [];
const reminders = getRemindersForTask(task, globalReminderPreference);
if (reminders.length === 0 || !dueDate || !task.dueTime) continue;
@@ -0,0 +1,139 @@
import { Platform } from 'react-native';
import * as Device from 'expo-device';
import * as Notifications from 'expo-notifications';
import Constants from 'expo-constants';
import { registerPushToken, unregisterPushToken } from './auth';
export async function registerForPushNotificationsAsync(): Promise<string | null> {
if (!Device.isDevice) {
if (__DEV__) console.warn('Push notifications require a physical device');
return null;
}
const { status: existingStatus } = await Notifications.getPermissionsAsync();
let finalStatus = existingStatus;
if (existingStatus !== 'granted') {
const { status } = await Notifications.requestPermissionsAsync({
ios: {
allowAlert: true,
allowBadge: true,
allowSound: true,
allowCriticalAlerts: true,
},
});
finalStatus = status;
}
if (finalStatus !== 'granted') {
if (__DEV__) console.warn('Push notification permission not granted');
return null;
}
try {
const projectId = (Constants.expoConfig as any)?.extra?.eas?.projectId ?? (Constants.expoConfig as any)?.projectId;
const tokenData = await Notifications.getExpoPushTokenAsync({
projectId,
});
const pushToken = tokenData.data;
await registerPushToken(pushToken);
if (__DEV__) console.log('Push token registered:', pushToken);
return pushToken;
} catch (error) {
if (__DEV__) console.error('Failed to get push token:', error);
return null;
}
}
export async function unregisterForPushNotificationsAsync(): Promise<void> {
await unregisterPushToken();
}
export function setupNotificationChannels(): void {
if (Platform.OS === 'android') {
Notifications.setNotificationChannelAsync('task-reminders', {
name: 'Task Reminders',
importance: Notifications.AndroidImportance.HIGH,
vibrationPattern: [0, 250, 250, 250],
lightColor: '#EF5350',
sound: 'default',
enableVibrate: true,
enableLights: true,
});
Notifications.setNotificationChannelAsync('friend-requests', {
name: 'Friend Requests',
importance: Notifications.AndroidImportance.HIGH,
vibrationPattern: [0, 250, 250, 250],
lightColor: '#29B6F6',
sound: 'default',
enableVibrate: true,
});
Notifications.setNotificationChannelAsync('task-assignments', {
name: 'Task Assignments',
importance: Notifications.AndroidImportance.HIGH,
vibrationPattern: [0, 250, 250, 250],
lightColor: '#66BB6A',
sound: 'default',
enableVibrate: true,
});
Notifications.setNotificationChannelAsync('default', {
name: 'Default',
importance: Notifications.AndroidImportance.DEFAULT,
sound: 'default',
});
}
}
export function addNotificationReceivedListener(
listener: (notification: Notifications.Notification) => void
): Notifications.EventSubscription {
return Notifications.addNotificationReceivedListener(listener);
}
export function addNotificationResponseReceivedListener(
listener: (response: Notifications.NotificationResponse) => void
): Notifications.EventSubscription {
return Notifications.addNotificationResponseReceivedListener(listener);
}
export function removeNotificationSubscription(subscription: Notifications.EventSubscription): void {
subscription.remove();
}
export async function getBadgeCount(): Promise<number> {
try {
return await Notifications.getBadgeCountAsync();
} catch {
return 0;
}
}
export async function setBadgeCount(count: number): Promise<void> {
try {
await Notifications.setBadgeCountAsync(count);
} catch {
// ignore
}
}
export function handleNotificationResponse(response: Notifications.NotificationResponse): void {
const data = response.notification.request.content.data as Record<string, any> | undefined;
if (!data) return;
switch (data.type) {
case 'task_reminder':
case 'task_assignment':
if (data.taskId) {
// Navigation will be handled by the app router
console.log('Navigate to task:', data.taskId);
}
break;
case 'friend_request':
if (data.friendshipId) {
console.log('Navigate to friend request:', data.friendshipId);
}
break;
}
}
+24 -8
View File
@@ -11,16 +11,16 @@ export const SORT_OPTIONS: { value: SortBy; label: string }[] = [
{ value: 'created', label: 'Creation Date' },
];
export type ReminderPreference = 'none' | 'atTime' | '5m' | '15m' | '30m' | '1h' | '1d';
export type ReminderPreference = 'none' | 'at_time' | '15' | '30' | '60' | '120' | '1440';
export const REMINDER_OPTIONS: { value: ReminderPreference; label: string }[] = [
{ value: 'none', label: 'No reminder' },
{ value: 'atTime', label: 'At due time' },
{ value: '5m', label: '5 minutes before' },
{ value: '15m', label: '15 minutes before' },
{ value: '30m', label: '30 minutes before' },
{ value: '1h', label: '1 hour before' },
{ value: '1d', label: '1 day before' },
{ value: 'at_time', label: 'At due time' },
{ value: '15', label: '15 minutes before' },
{ value: '30', label: '30 minutes before' },
{ value: '60', label: '1 hour before' },
{ value: '120', label: '2 hours before' },
{ value: '1440', label: '1 day before' },
];
export interface ThemeColors {
@@ -42,6 +42,14 @@ export interface ThemeColors {
overlay: string;
sheetBg: string;
tabBarBg: string;
danger: string;
dangerStrong: string;
dangerSoft: string;
dangerBorder: string;
success: string;
successStrong: string;
warning: string;
info: string;
}
export const DEFAULT_ACCENT = '#EF5350';
@@ -110,6 +118,14 @@ export function colors(accent: string = DEFAULT_ACCENT): ThemeColors {
overlay: 'rgba(0,0,0,0.6)',
sheetBg: '#26282D',
tabBarBg: '#16171A',
danger: '#E57373',
dangerStrong: '#E53935',
dangerSoft: '#43302E',
dangerBorder: '#573431',
success: '#66BB6A',
successStrong: '#43A047',
warning: '#FFA726',
info: '#64B5F6',
};
}
@@ -179,7 +195,7 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
const [sortBy, setSortBy] = useStoredSetting<SortBy>(STORAGE_KEYS.sortBy, 'date');
const [reminderPreference, setReminderPreference] = useStoredSetting<ReminderPreference>(
STORAGE_KEYS.reminderPreference,
'15m',
'15',
);
const [apiUrl, setApiUrl] = useStoredSetting<string>(API_URL_KEY, DEFAULT_API_BASE_URL);
const [accentColor, setAccentColor] = useStoredSetting<string>(STORAGE_KEYS.accentColor, DEFAULT_ACCENT);
+3
View File
@@ -1,5 +1,7 @@
export type Priority = 'none' | 'low' | 'medium' | 'high' | 'critical';
export type { ReminderPreference } from '../theme';
export type Repeat = 'none' | 'daily' | 'weekly' | 'monthly' | 'custom';
export const REPEAT_OPTIONS: { value: Repeat; label: string }[] = [
@@ -193,6 +195,7 @@ export interface TaskFormData {
export interface SubtaskFormData {
title: string;
description?: string;
tags?: string[];
categoryId?: string;
priority: Priority;
dueDate: Date | null;
+58 -3
View File
@@ -1,6 +1,7 @@
import { database, collections } from '@/database';
import { Q } from '@nozbe/watermelondb';
import { Priority, Repeat, Reminder, SubtaskData, tagsToString } from '@/types';
import { Alert } from 'react-native';
import { Priority, Repeat, Reminder, SubtaskData, tagsToString, parseTaskTags } from '@/types';
import { scheduleTaskReminder, cancelTaskReminder } from '@/services/notifications';
import { recordTombstonesInBatch } from '@/database/tombstones';
@@ -10,6 +11,7 @@ function mapSubtaskRow(s: any, taskId?: string): SubtaskData {
taskId: s.taskId ?? taskId ?? '',
parentSubtaskId: s.parentSubtaskId || null,
categoryId: s.categoryId || '',
tags: s.tags || '',
title: s.title,
description: s.description || '',
priority: (s.priority || 'none') as Priority,
@@ -216,6 +218,51 @@ export async function toggleTaskComplete(taskId: string): Promise<string | null>
return nextOccurrence ? nextOccurrence.id : null;
}
export async function countOpenSubtasks(taskId: string): Promise<number> {
const open = await collections.subtasks.query(
Q.where('task_id', taskId),
Q.where('completed', false)
).fetch();
return open.length;
}
export async function confirmAndToggleTaskComplete(
taskId: string,
onCompleted?: () => void
): Promise<void> {
let task: any;
try {
task = await collections.tasks.find(taskId);
} catch {
return;
}
const finish = async () => {
await toggleTaskComplete(taskId);
onCompleted?.();
};
if (task.completed) {
await finish();
return;
}
const openCount = await countOpenSubtasks(taskId);
if (openCount === 0) {
await finish();
return;
}
Alert.alert(
'Unfinished subtasks',
`"${task.title}" still has ${openCount} open subtask${openCount === 1 ? '' : 's'}. Mark it as done anyway?`,
[
{ text: 'Cancel', style: 'cancel' },
{ text: 'Mark done anyway', style: 'destructive', onPress: finish },
]
);
}
export async function toggleSubtaskComplete(subtaskId: string): Promise<void> {
await database.write(async () => {
const subtask = await collections.subtasks.find(subtaskId);
@@ -237,6 +284,7 @@ export interface SubtaskUpdateData {
title: string;
description?: string;
categoryId?: string;
tags?: string;
priority: Priority;
dueDate: number;
dueTime?: string;
@@ -256,6 +304,7 @@ export async function updateSubtask(subtaskId: string, data: SubtaskUpdateData):
s.title = data.title.trim();
s.description = data.description || '';
if (data.categoryId !== undefined) s.categoryId = data.categoryId;
if (data.tags !== undefined) s.tags = data.tags;
s.priority = data.priority;
s.dueDate = data.dueDate;
s.dueTime = data.dueTime || '';
@@ -318,6 +367,7 @@ export async function duplicateSubtask(subtaskId: string): Promise<void> {
clone = await collections.subtasks.create((s) => {
s.taskId = subtask.taskId;
s.categoryId = subtask.categoryId || '';
s.tags = subtask.tags || '';
s.title = subtask.title;
s.description = subtask.description;
s.priority = subtask.priority;
@@ -472,12 +522,13 @@ export async function convertSubtaskToTask(subtaskId: string): Promise<void> {
const subtask = await collections.subtasks.find(subtaskId);
const parent = await collections.tasks.find(subtask.taskId);
const now = new Date();
const subTags = parseTaskTags(subtask.tags, subtask.categoryId);
const task = await collections.tasks.create((t) => {
t.title = subtask.title;
t.description = subtask.description || '';
t.categoryId = subtask.categoryId || parent.categoryId || '';
t.tags = tagsToString([subtask.categoryId || parent.categoryId].filter(Boolean));
t.categoryId = subTags[0] || parent.categoryId || '';
t.tags = tagsToString(subTags.length > 0 ? subTags : (parent.categoryId ? [parent.categoryId] : []));
t.priority = subtask.priority;
t.completed = subtask.completed;
t.dueDate = subtask.dueDate;
@@ -650,6 +701,8 @@ async function createNextOccurrence(task: any, seriesId: string): Promise<any> {
s.taskId = next.id;
s.title = subtask.title;
s.description = subtask.description;
s.categoryId = subtask.categoryId || '';
s.tags = subtask.tags || '';
s.priority = subtask.priority;
s.completed = false;
s.dueDate = subtask.dueDate;
@@ -755,6 +808,7 @@ export interface CreateSubtaskData {
taskId: string;
parentSubtaskId?: string | null;
categoryId?: string;
tags?: string;
title: string;
description?: string;
priority?: Priority;
@@ -782,6 +836,7 @@ export async function createSubtask(data: CreateSubtaskData): Promise<string> {
s.taskId = data.taskId;
s.parentSubtaskId = data.parentSubtaskId || null;
s.categoryId = data.categoryId || '';
s.tags = data.tags || '';
s.title = data.title.trim();
s.description = data.description || '';
s.priority = data.priority || 'none';