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: {