working push notifiations
This commit is contained in:
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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();
|
||||
}, [])
|
||||
);
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user