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: {
|
||||
|
||||
Generated
+51
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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 }]}>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user