fixxed color selector in the category settings and fixxed drag and drop
Build APK / build (push) Canceled after 2m0s
Build APK / build (push) Canceled after 2m0s
subtask
This commit is contained in:
@@ -6,11 +6,13 @@
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "dark",
|
||||
"backgroundColor": "#121212",
|
||||
"ios": {
|
||||
"supportsTablet": true
|
||||
},
|
||||
"android": {
|
||||
"softwareKeyboardLayoutMode": "resize",
|
||||
"backgroundColor": "#121212",
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#E6F4FE",
|
||||
"foregroundImage": "./assets/android-icon-foreground.png",
|
||||
|
||||
@@ -3,6 +3,7 @@ import React from 'react';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { TabBarIcon } from '@/components/TabBarIcon';
|
||||
import { useSettings } from '@/theme';
|
||||
import { setLastVisitedTab } from '@/utils/tabHistory';
|
||||
|
||||
export default function TabLayout() {
|
||||
const { theme } = useSettings();
|
||||
@@ -12,27 +13,44 @@ export default function TabLayout() {
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: theme.accent,
|
||||
tabBarInactiveTintColor: '#8E8E8E',
|
||||
tabBarInactiveTintColor: theme.text,
|
||||
tabBarStyle: {
|
||||
backgroundColor: theme.tabBarBg,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: theme.border,
|
||||
height: 64 + insets.bottom,
|
||||
height: 68 + insets.bottom,
|
||||
paddingBottom: insets.bottom,
|
||||
paddingTop: 6,
|
||||
elevation: 8,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: -2 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 8,
|
||||
},
|
||||
tabBarItemStyle: {
|
||||
paddingVertical: 0,
|
||||
},
|
||||
tabBarLabelStyle: {
|
||||
fontSize: 11,
|
||||
fontWeight: '500',
|
||||
fontWeight: '700',
|
||||
},
|
||||
headerShown: false,
|
||||
sceneStyle: { backgroundColor: theme.background },
|
||||
}}
|
||||
screenListeners={({ route }) => ({
|
||||
focus: () => {
|
||||
if (route.name !== 'settings') {
|
||||
setLastVisitedTab(route.name);
|
||||
}
|
||||
},
|
||||
})}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: 'Tasks',
|
||||
tabBarIcon: ({ focused, color }) => (
|
||||
<TabBarIcon name="checklist" focused={focused} color={color} />
|
||||
title: 'ToDo',
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<TabBarIcon name="checklist" focused={focused} color={color} size={26} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
@@ -40,8 +58,8 @@ export default function TabLayout() {
|
||||
name="calendar"
|
||||
options={{
|
||||
title: 'Calendar',
|
||||
tabBarIcon: ({ focused, color }) => (
|
||||
<TabBarIcon name="calendar" focused={focused} color={color} />
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<TabBarIcon name="calendar" focused={focused} color={color} size={26} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
@@ -49,8 +67,8 @@ export default function TabLayout() {
|
||||
name="stats"
|
||||
options={{
|
||||
title: 'Stats',
|
||||
tabBarIcon: ({ focused, color }) => (
|
||||
<TabBarIcon name="stats" focused={focused} color={color} />
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<TabBarIcon name="stats" focused={focused} color={color} size={26} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
@@ -58,11 +76,11 @@ export default function TabLayout() {
|
||||
name="settings"
|
||||
options={{
|
||||
title: 'Settings',
|
||||
tabBarIcon: ({ focused, color }) => (
|
||||
<TabBarIcon name="gear" focused={focused} color={color} />
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<TabBarIcon name="gear" focused={focused} color={color} size={26} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useMemo, useRef, useState, useCallback, useEffect } from 'react';
|
||||
import { View, Text, StyleSheet, SafeAreaView, ScrollView, TouchableOpacity, Animated, Dimensions } from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { View, Text, StyleSheet, SafeAreaView, ScrollView, TouchableOpacity, Animated, Dimensions, BackHandler, KeyboardAvoidingView } from 'react-native';
|
||||
import { useRouter, useFocusEffect } from 'expo-router';
|
||||
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
||||
import { Header } from '@/components/Header';
|
||||
import { useTasksByDate, useTasksInMonth } from '@/hooks/useTasks';
|
||||
@@ -15,10 +15,10 @@ import Task from '@/models/Task';
|
||||
import { format, addMonths, addDays, startOfMonth, isSameDay, isSameMonth, isToday } from 'date-fns';
|
||||
import { Q } from '@nozbe/watermelondb';
|
||||
import Svg, { Path, Circle } from 'react-native-svg';
|
||||
import { desaturate } from '@/theme';
|
||||
import type { ThemeColors } from '@/theme';
|
||||
|
||||
const WIDTH = Dimensions.get('window').width;
|
||||
const ORANGE = '#FF7043';
|
||||
const GERMAN_WEEKDAYS = ['mo', 'di', 'mi', 'do', 'fr', 'sa', 'so'];
|
||||
const MONTH_NAMES = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
@@ -43,6 +43,13 @@ export default function CalendarScreen() {
|
||||
const selectedDateRef = useRef(selectedDate);
|
||||
selectedDateRef.current = selectedDate;
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
const sub = BackHandler.addEventListener('hardwareBackPress', () => true);
|
||||
return () => sub.remove();
|
||||
}, [])
|
||||
);
|
||||
|
||||
const translateX = useRef(new Animated.Value(0)).current;
|
||||
const animatingRef = useRef(false);
|
||||
const gridWidthRef = useRef<number>(WIDTH);
|
||||
@@ -181,17 +188,17 @@ export default function CalendarScreen() {
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
|
||||
<GestureDetector gesture={pan}>
|
||||
<View style={styles.flex}>
|
||||
<Header title="Calendar" showLogo={false} />
|
||||
<Header title="Calendar" showLogo={false} />
|
||||
|
||||
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.scrollContent}>
|
||||
<Animated.View
|
||||
onLayout={(e) => {
|
||||
gridWidthRef.current = e.nativeEvent.layout.width;
|
||||
}}
|
||||
style={[styles.calendarArea, { transform: [{ translateX }] }]}
|
||||
>
|
||||
<KeyboardAvoidingView style={styles.kbAvoid} behavior="padding">
|
||||
<ScrollView style={styles.scrollBody} showsVerticalScrollIndicator={false} contentContainerStyle={styles.scrollContent}>
|
||||
<GestureDetector gesture={pan}>
|
||||
<Animated.View
|
||||
onLayout={(e) => {
|
||||
gridWidthRef.current = e.nativeEvent.layout.width;
|
||||
}}
|
||||
style={[styles.calendarArea, { transform: [{ translateX }] }]}
|
||||
>
|
||||
<View style={styles.monthRow}>
|
||||
<TouchableOpacity
|
||||
onPress={() => transitionTo(-1)}
|
||||
@@ -199,7 +206,7 @@ export default function CalendarScreen() {
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Path d="M15 18l-6-6 6-6" stroke={theme.textFaint} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
<Path d="M15 18l-6-6 6-6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -207,7 +214,7 @@ export default function CalendarScreen() {
|
||||
<TouchableOpacity onPress={() => setMonthPickerVisible(true)} activeOpacity={0.7} style={styles.monthButton}>
|
||||
<Text style={[styles.monthLabel, { color: theme.text }]}>{format(visibleMonth, 'MMMM')}</Text>
|
||||
<Svg width={14} height={14} viewBox="0 0 24 24">
|
||||
<Path d="M6 9l6 6 6-6" stroke={theme.textMuted} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
<Path d="M6 9l6 6 6-6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => setYearPickerVisible(true)} activeOpacity={0.7} style={styles.yearButton}>
|
||||
@@ -221,7 +228,7 @@ export default function CalendarScreen() {
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Path d="M9 6l6 6-6 6" stroke={theme.textFaint} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
<Path d="M9 6l6 6-6 6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
@@ -252,8 +259,9 @@ export default function CalendarScreen() {
|
||||
</View>
|
||||
))}
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
|
||||
<View style={styles.panelHeader}>
|
||||
<View style={styles.panelHeader}>
|
||||
<Text style={[styles.panelDate, { color: theme.text }]}>{format(selectedDate, 'EEEE, MMMM d')}</Text>
|
||||
{selectedDayTasks.length > 0 && (
|
||||
<Text style={[styles.panelCount, { color: theme.textFaint }]}>
|
||||
@@ -276,14 +284,14 @@ export default function CalendarScreen() {
|
||||
onPress={() => handleToggleComplete(task.id)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Svg width={22} height={22} viewBox="0 0 24 24">
|
||||
<Svg width={30} height={30} viewBox="0 0 24 24">
|
||||
{task.completed ? (
|
||||
<>
|
||||
<Circle cx={12} cy={12} r={10} fill={theme.accent} />
|
||||
<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={theme.textMuted} strokeWidth={2} fill="none" />
|
||||
<Circle cx={12} cy={12} r={10} stroke={theme.textSecondary} strokeWidth={2.5} fill="none" />
|
||||
)}
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
@@ -303,9 +311,7 @@ export default function CalendarScreen() {
|
||||
|
||||
<TouchableOpacity style={styles.menuButton} onPress={() => openTaskEdit(task.id)} activeOpacity={0.7} accessibilityRole="button" accessibilityLabel={`Edit ${task.title}`}>
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Circle cx={12} cy={5} r={1.5} fill={theme.textMuted} />
|
||||
<Circle cx={12} cy={12} r={1.5} fill={theme.textMuted} />
|
||||
<Circle cx={12} cy={19} r={1.5} fill={theme.textMuted} />
|
||||
<Path d="M9 6l6 6-6 6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
@@ -337,28 +343,27 @@ export default function CalendarScreen() {
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
<QuickAddBar dueDate={selectedDate.getTime()} placeholder={`Add event for ${format(selectedDate, 'MMM d')}`} />
|
||||
<QuickAddBar dueDate={selectedDate.getTime()} placeholder={`Add event for ${format(selectedDate, 'MMM d')}`} />
|
||||
</KeyboardAvoidingView>
|
||||
|
||||
{modals(() => {})}
|
||||
{modals(() => {})}
|
||||
|
||||
<OptionPickerModal
|
||||
visible={monthPickerVisible}
|
||||
title="Select Month"
|
||||
options={monthOptions}
|
||||
selectedValue={String(visibleMonth.getMonth())}
|
||||
onSelect={handleSelectMonth}
|
||||
onClose={() => setMonthPickerVisible(false)}
|
||||
/>
|
||||
<OptionPickerModal
|
||||
visible={yearPickerVisible}
|
||||
title="Select Year"
|
||||
options={yearOptions}
|
||||
selectedValue={String(visibleMonth.getFullYear())}
|
||||
onSelect={handleSelectYear}
|
||||
onClose={() => setYearPickerVisible(false)}
|
||||
/>
|
||||
</View>
|
||||
</GestureDetector>
|
||||
<OptionPickerModal
|
||||
visible={monthPickerVisible}
|
||||
title="Select Month"
|
||||
options={monthOptions}
|
||||
selectedValue={String(visibleMonth.getMonth())}
|
||||
onSelect={handleSelectMonth}
|
||||
onClose={() => setMonthPickerVisible(false)}
|
||||
/>
|
||||
<OptionPickerModal
|
||||
visible={yearPickerVisible}
|
||||
title="Select Year"
|
||||
options={yearOptions}
|
||||
selectedValue={String(visibleMonth.getFullYear())}
|
||||
onSelect={handleSelectYear}
|
||||
onClose={() => setYearPickerVisible(false)}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
@@ -376,7 +381,7 @@ function cellTitle(tasks: Task[]): string | null {
|
||||
function cellColor(tasks: Task[], categories: { id: string; color: string }[]): string {
|
||||
if (tasks.length === 0) return '#8E8E8E';
|
||||
const cat = categories.find((c) => c.id === tasks[0].categoryId);
|
||||
return cat?.color ?? '#8E8E8E';
|
||||
return cat ? desaturate(cat.color, 0.3) : '#8E8E8E';
|
||||
}
|
||||
|
||||
interface DayCellProps {
|
||||
@@ -393,7 +398,7 @@ interface DayCellProps {
|
||||
const DayCell = React.memo(function DayCell({ day, label, dotColor, selected, today, inMonth, theme, onPress }: DayCellProps) {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.dayCell, selected && { backgroundColor: ORANGE }]}
|
||||
style={[styles.dayCell, selected && { backgroundColor: theme.accentSoft, borderColor: theme.accentBorder }]}
|
||||
onPress={() => onPress(day)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
@@ -401,8 +406,8 @@ const DayCell = React.memo(function DayCell({ day, label, dotColor, selected, to
|
||||
style={[
|
||||
styles.dayNumber,
|
||||
{ color: theme.text },
|
||||
!inMonth && { color: theme.borderStrong },
|
||||
today && !selected && { color: ORANGE },
|
||||
!inMonth && { color: theme.textMuted },
|
||||
today && !selected && { color: theme.accent },
|
||||
selected && styles.dayNumberSelected,
|
||||
]}
|
||||
>
|
||||
@@ -425,12 +430,15 @@ const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
flex: {
|
||||
scrollContent: {
|
||||
paddingBottom: 16,
|
||||
flexGrow: 1,
|
||||
},
|
||||
scrollBody: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: 96,
|
||||
flexGrow: 1,
|
||||
kbAvoid: {
|
||||
flex: 1,
|
||||
},
|
||||
calendarArea: {
|
||||
paddingHorizontal: 12,
|
||||
@@ -565,8 +573,8 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
},
|
||||
checkCircle: {
|
||||
width: 24,
|
||||
marginRight: 10,
|
||||
width: 30,
|
||||
marginRight: 12,
|
||||
},
|
||||
eventTitleTouch: {
|
||||
flex: 1,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, SafeAreaView } from 'react-native';
|
||||
import React, { useCallback } from 'react';
|
||||
import { View, Text, StyleSheet, SafeAreaView, BackHandler, KeyboardAvoidingView } from 'react-native';
|
||||
import { useFocusEffect } from 'expo-router';
|
||||
import { Header } from '@/components/Header';
|
||||
import { CategoryFilter } from '@/components/CategoryFilter';
|
||||
import { TaskList } from '@/components/TaskList';
|
||||
@@ -12,6 +13,13 @@ export default function TasksScreen() {
|
||||
const { theme } = useSettings();
|
||||
const [selectedCategory, setSelectedCategory] = React.useState<string>('all');
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
const sub = BackHandler.addEventListener('hardwareBackPress', () => true);
|
||||
return () => sub.remove();
|
||||
}, [])
|
||||
);
|
||||
|
||||
if (!isReady) {
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: theme.background }]}>
|
||||
@@ -22,12 +30,17 @@ export default function TasksScreen() {
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
|
||||
<Header title="TODO" showLogo={false} />
|
||||
<Header title="ToDo" showLogo={false} />
|
||||
<View style={styles.categoryFilterWrapper}>
|
||||
<CategoryFilter selected={selectedCategory} onSelect={setSelectedCategory} />
|
||||
</View>
|
||||
<TaskList categoryId={selectedCategory} />
|
||||
<QuickAddBar />
|
||||
<KeyboardAvoidingView
|
||||
style={styles.kbAvoid}
|
||||
behavior="padding"
|
||||
>
|
||||
<TaskList categoryId={selectedCategory} />
|
||||
<QuickAddBar />
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
@@ -37,11 +50,14 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
},
|
||||
categoryFilterWrapper: {
|
||||
height: 36,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
kbAvoid: {
|
||||
flex: 1,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, StyleSheet, SafeAreaView, ScrollView, Switch, TouchableOpacity, Alert, Linking, Modal, Pressable } from 'react-native';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { View, Text, StyleSheet, SafeAreaView, ScrollView, Switch, TouchableOpacity, Alert, Linking, Modal, Pressable, BackHandler } from 'react-native';
|
||||
import { useFocusEffect, useRouter } from 'expo-router';
|
||||
import { Header } from '@/components/Header';
|
||||
import { ListItem } from '@/components/ListItem';
|
||||
import { OptionPickerModal } from '@/components/OptionPickerModal';
|
||||
@@ -17,8 +18,10 @@ import { getLastSyncTime } from '@/database/sync';
|
||||
import Category from '@/models/Category';
|
||||
import Svg, { Path } from 'react-native-svg';
|
||||
import ColorWheel from '@/components/ColorWheel';
|
||||
import { getLastVisitedTab, tabHref } from '@/utils/tabHistory';
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const router = useRouter();
|
||||
const { theme, notifications, setNotifications, defaultCategoryId, setDefaultCategoryId, sortBy, setSortBy, reminderPreference, setReminderPreference, apiUrl, accentColor, setAccentColor, todoAheadDays, setTodoAheadDays } = useSettings();
|
||||
const categories = useCategories();
|
||||
const [picker, setPicker] = useState<null | 'category' | 'sort' | 'reminder' | 'todoAhead'>(null);
|
||||
@@ -75,8 +78,21 @@ export default function SettingsScreen() {
|
||||
refreshSyncStatus();
|
||||
}, []);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
const sub = BackHandler.addEventListener('hardwareBackPress', () => {
|
||||
const target = getLastVisitedTab();
|
||||
if (target && target !== 'settings') {
|
||||
router.navigate(tabHref(target));
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return () => sub.remove();
|
||||
}, [router])
|
||||
);
|
||||
|
||||
const defaultCategory = categories.find((c) => c.id === defaultCategoryId);
|
||||
const defaultCategoryLabel = defaultCategory?.name ?? (categories.length > 0 ? categories[0].name : 'None');
|
||||
const defaultCategoryLabel = defaultCategory?.name ?? 'None';
|
||||
const reminderLabel = REMINDER_OPTIONS.find((o) => o.value === reminderPreference)?.label ?? 'No reminder';
|
||||
|
||||
const openCustomAccent = () => {
|
||||
@@ -123,7 +139,7 @@ export default function SettingsScreen() {
|
||||
>
|
||||
<View style={[styles.addIcon, { backgroundColor: theme.accentSoft }]}>
|
||||
<Svg width={16} height={16} viewBox="0 0 24 24">
|
||||
<Path d="M12 5v14M5 12h14" stroke={theme.accent} strokeWidth={2} strokeLinecap="round" />
|
||||
<Path d="M12 5v14M5 12h14" stroke={theme.accentText} strokeWidth={2} strokeLinecap="round" />
|
||||
</Svg>
|
||||
</View>
|
||||
<Text style={[styles.addCategoryText, { color: theme.accent }]}>Add Category</Text>
|
||||
@@ -242,8 +258,8 @@ export default function SettingsScreen() {
|
||||
<OptionPickerModal
|
||||
visible={picker === 'category'}
|
||||
title="Default Category"
|
||||
options={categories.map((c) => ({ value: c.id, label: c.name, color: c.color }))}
|
||||
selectedValue={defaultCategoryId || categories[0]?.id}
|
||||
options={[{ value: '', label: 'None', color: '#9E9E9E' }, ...categories.map((c) => ({ value: c.id, label: c.name, color: c.color }))]}
|
||||
selectedValue={defaultCategoryId}
|
||||
onSelect={handleDefaultCategory}
|
||||
onClose={() => setPicker(null)}
|
||||
/>
|
||||
@@ -298,7 +314,7 @@ export default function SettingsScreen() {
|
||||
<Text style={[styles.accentModalTitle, { color: theme.text }]}>Custom Accent Color</Text>
|
||||
<TouchableOpacity onPress={() => setCustomAccentVisible(false)} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
|
||||
<Svg width={18} height={18} viewBox="0 0 24 24">
|
||||
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textMuted} strokeWidth={2} strokeLinecap="round" />
|
||||
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textSecondary} strokeWidth={2.2} strokeLinecap="round" />
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, SafeAreaView, ScrollView } from 'react-native';
|
||||
import React, { useCallback } from 'react';
|
||||
import { View, Text, StyleSheet, SafeAreaView, ScrollView, BackHandler } from 'react-native';
|
||||
import { useFocusEffect } from 'expo-router';
|
||||
import { Header } from '@/components/Header';
|
||||
import { useStats } from '@/hooks/useStats';
|
||||
import { useSettings } from '@/theme';
|
||||
@@ -8,6 +9,13 @@ export default function StatsScreen() {
|
||||
const { theme } = useSettings();
|
||||
const stats = useStats();
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
const sub = BackHandler.addEventListener('hardwareBackPress', () => true);
|
||||
return () => sub.remove();
|
||||
}, [])
|
||||
);
|
||||
|
||||
const maxDaily = Math.max(1, ...stats.daily.map((d) => d.count));
|
||||
|
||||
return (
|
||||
|
||||
@@ -160,7 +160,7 @@ export default function SubtaskDetailScreen() {
|
||||
rightAction={
|
||||
<TouchableOpacity onPress={confirmDelete} activeOpacity={0.7} style={[styles.deleteButton, { borderColor: theme.accentBorder, backgroundColor: theme.accentSoft }]}>
|
||||
<Svg width={22} height={22} viewBox="0 0 24 24">
|
||||
<Path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13" stroke={theme.accent} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
<Path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13" stroke={theme.accent} strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
}
|
||||
|
||||
@@ -58,8 +58,8 @@ function CollapsibleSection({ title, children, defaultExpanded = false, icon }:
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Path
|
||||
d="M6 9l6 6 6-6"
|
||||
stroke={theme.textMuted}
|
||||
strokeWidth={2}
|
||||
stroke={theme.textSecondary}
|
||||
strokeWidth={2.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
@@ -245,7 +245,7 @@ export default function TaskDetailScreen() {
|
||||
rightAction={
|
||||
<TouchableOpacity onPress={handleDelete} activeOpacity={0.7} style={[styles.deleteButton, { borderColor: theme.accentBorder, backgroundColor: theme.accentSoft }]}>
|
||||
<Svg width={22} height={22} viewBox="0 0 24 24">
|
||||
<Path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13" stroke={theme.accent} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
<Path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13" stroke={theme.accent} strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
}
|
||||
@@ -285,8 +285,8 @@ export default function TaskDetailScreen() {
|
||||
|
||||
<CollapsibleSection title="Date & Time" icon={
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Circle cx={12} cy={12} r={10} stroke={theme.accent} strokeWidth={1.5} fill="none" />
|
||||
<Path d="M12 6v6l4 2" stroke={theme.accent} strokeWidth={1.5} strokeLinecap="round" />
|
||||
<Circle cx={12} cy={12} r={10} stroke={theme.accent} strokeWidth={1.8} fill="none" />
|
||||
<Path d="M12 6v6l4 2" stroke={theme.accent} strokeWidth={1.8} strokeLinecap="round" />
|
||||
</Svg>
|
||||
} defaultExpanded={!!dueDate}>
|
||||
<DateTimePickerComponent control={control} />
|
||||
@@ -297,7 +297,7 @@ export default function TaskDetailScreen() {
|
||||
<Path
|
||||
d="M17 2l4 4-4 4M3 11v-1a4 4 0 0 1 4-4h14M7 22l-4-4 4-4M21 13v1a4 4 0 0 1-4 4H3"
|
||||
stroke={theme.accent}
|
||||
strokeWidth={1.5}
|
||||
strokeWidth={1.8}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
@@ -321,7 +321,7 @@ export default function TaskDetailScreen() {
|
||||
<Path
|
||||
d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9M10.3 21a1.94 1.94 0 0 0 3.4 0"
|
||||
stroke={theme.accent}
|
||||
strokeWidth={1.5}
|
||||
strokeWidth={1.8}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
@@ -337,8 +337,8 @@ export default function TaskDetailScreen() {
|
||||
|
||||
<CollapsibleSection title="Assignee" icon={
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Circle cx={12} cy={12} r={10} stroke={theme.accent} strokeWidth={1.5} fill="none" />
|
||||
<Path d="M12 10v6M12 19v1" stroke={theme.accent} strokeWidth={1.5} strokeLinecap="round" />
|
||||
<Circle cx={12} cy={12} r={10} stroke={theme.accent} strokeWidth={1.8} fill="none" />
|
||||
<Path d="M12 10v6M12 19v1" stroke={theme.accent} strokeWidth={1.8} strokeLinecap="round" />
|
||||
</Svg>
|
||||
} defaultExpanded={!!assigneeId}>
|
||||
<AssigneeSelector
|
||||
@@ -350,7 +350,7 @@ export default function TaskDetailScreen() {
|
||||
|
||||
<CollapsibleSection title="Description" icon={
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Path d="M4 6h16M4 12h16M4 18h10" stroke={theme.accent} strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
<Path d="M4 6h16M4 12h16M4 18h10" stroke={theme.accent} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</Svg>
|
||||
} defaultExpanded={!!methods.getValues('description')}>
|
||||
<Controller
|
||||
|
||||
Generated
+36
-1
@@ -22,12 +22,14 @@
|
||||
"expo-router": "~57.0.10",
|
||||
"expo-sqlite": "~57.0.1",
|
||||
"expo-status-bar": "~57.0.1",
|
||||
"expo-system-ui": "~57.0.2",
|
||||
"expo-updates": "~57.0.12",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-hook-form": "^7.51.5",
|
||||
"react-native": "0.86.2",
|
||||
"react-native-gesture-handler": "2.32.0",
|
||||
"react-native-keyboard-controller": "1.21.9",
|
||||
"react-native-safe-area-context": "5.7.0",
|
||||
"react-native-screens": "4.26.0",
|
||||
"react-native-svg": "^15.15.4",
|
||||
@@ -6313,6 +6315,26 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-system-ui": {
|
||||
"version": "57.0.2",
|
||||
"resolved": "https://registry.npmjs.org/expo-system-ui/-/expo-system-ui-57.0.2.tgz",
|
||||
"integrity": "sha512-zABCRqFSioDBAo/RtmS0dQiGgDtDPZUVk01Y3Ti4ducobNM4HTM2sNAtq+YPpEUhaVW3hccPVsEUH4LK/ADVhA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@react-native/normalize-colors": "0.86.2",
|
||||
"debug": "^4.3.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
"react-native": "*",
|
||||
"react-native-web": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-native-web": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/expo-updates": {
|
||||
"version": "57.0.12",
|
||||
"resolved": "https://registry.npmjs.org/expo-updates/-/expo-updates-57.0.12.tgz",
|
||||
@@ -10263,12 +10285,25 @@
|
||||
"resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.3.1.tgz",
|
||||
"integrity": "sha512-NIXU/iT5+ORyCc7p0z2nnlkouYKX425vuU1OEm6bMMtWWR9yvb+Xg5AZmImTKoF9abxCPqrKC3rOZsKzUYgYZA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-keyboard-controller": {
|
||||
"version": "1.21.9",
|
||||
"resolved": "https://registry.npmjs.org/react-native-keyboard-controller/-/react-native-keyboard-controller-1.21.9.tgz",
|
||||
"integrity": "sha512-+TkkFldht4+AXBQeDy1hLE7iqiW8/NkY/ekhcFsKIiRdI9qC5JDzx0TfAg1iYZB2IeOXppmURIy2jFCUjOcV1w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-native-is-edge-to-edge": "^1.2.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-native": "*",
|
||||
"react-native-reanimated": ">=3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-reanimated": {
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.5.1.tgz",
|
||||
|
||||
@@ -16,12 +16,14 @@
|
||||
"expo-router": "~57.0.10",
|
||||
"expo-sqlite": "~57.0.1",
|
||||
"expo-status-bar": "~57.0.1",
|
||||
"expo-system-ui": "~57.0.2",
|
||||
"expo-updates": "~57.0.12",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-hook-form": "^7.51.5",
|
||||
"react-native": "0.86.2",
|
||||
"react-native-gesture-handler": "2.32.0",
|
||||
"react-native-keyboard-controller": "1.21.9",
|
||||
"react-native-safe-area-context": "5.7.0",
|
||||
"react-native-screens": "4.26.0",
|
||||
"react-native-svg": "^15.15.4",
|
||||
|
||||
@@ -60,8 +60,11 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
|
||||
await sendFriendRequest(username);
|
||||
onChange(friendId);
|
||||
setShowModal(false);
|
||||
} catch (err) {
|
||||
console.error('Failed to add friend:', err);
|
||||
} catch (err: any) {
|
||||
console.error('Failed to add friend:', err.message, err.details || '');
|
||||
if (err.details) {
|
||||
console.error('Validation details:', JSON.stringify(err.details, null, 2));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -76,7 +79,7 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
|
||||
onPress={() => !disabled && setShowModal(true)}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={[styles.selectorIcon, { color: theme.accent }]}>👤</Text>
|
||||
<Text style={[styles.selectorIcon, { color: theme.accentStrong }]}>👤</Text>
|
||||
<View style={styles.selectorContent}>
|
||||
<Text style={[styles.selectorLabel, { color: theme.textMuted }]}>Assignee</Text>
|
||||
<Text
|
||||
@@ -128,7 +131,7 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
|
||||
</View>
|
||||
<Text style={[styles.optionText, { color: theme.text }]}>Unassigned</Text>
|
||||
{selectedFriend && (
|
||||
<Text style={[styles.checkmark, { color: theme.accent }]}>✓</Text>
|
||||
<Text style={[styles.checkmark, { color: theme.accentStrong }]}>✓</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
@@ -158,7 +161,7 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
|
||||
</View>
|
||||
<Text style={[styles.optionText, { color: theme.text }]}>{item.username}</Text>
|
||||
{value === item.id && (
|
||||
<Text style={[styles.checkmark, { color: theme.accent }]}>✓</Text>
|
||||
<Text style={[styles.checkmark, { color: theme.accentStrong }]}>✓</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Modal, View, Text, StyleSheet, TextInput, TouchableOpacity, ScrollView, KeyboardAvoidingView } from 'react-native';
|
||||
import { Modal, View, Text, StyleSheet, TextInput, TouchableOpacity, ScrollView, KeyboardAvoidingView, Platform, Pressable } from 'react-native';
|
||||
import Category from '@/models/Category';
|
||||
import { useSettings } from '@/theme';
|
||||
import { CATEGORY_COLORS } from '@/constants';
|
||||
@@ -59,96 +59,111 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||||
<KeyboardAvoidingView
|
||||
style={styles.overlay}
|
||||
behavior="padding"
|
||||
style={styles.fill}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
>
|
||||
<ScrollView contentContainerStyle={styles.overlay} keyboardShouldPersistTaps="handled">
|
||||
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.title, { color: theme.text }]}>
|
||||
{category ? 'Edit Category' : 'New Category'}
|
||||
</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.textMuted} strokeWidth={2} strokeLinecap="round" />
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<Text style={[styles.label, { color: theme.textSecondary }]}>Name</Text>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
{ backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text },
|
||||
]}
|
||||
placeholder="Category name"
|
||||
placeholderTextColor={theme.textMuted}
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
maxLength={30}
|
||||
autoFocus
|
||||
autoCapitalize="sentences"
|
||||
/>
|
||||
|
||||
<Text style={[styles.label, { color: theme.textSecondary }]}>Color</Text>
|
||||
<View style={styles.colorGrid}>
|
||||
{CATEGORY_COLORS.map((c) => (
|
||||
<TouchableOpacity
|
||||
key={c}
|
||||
style={[
|
||||
styles.colorOption,
|
||||
{ backgroundColor: c },
|
||||
color === c && styles.colorOptionSelected,
|
||||
]}
|
||||
onPress={() => setColor(c)}
|
||||
activeOpacity={0.8}
|
||||
accessibilityRole="radio"
|
||||
accessibilityLabel={`Color ${c}`}
|
||||
accessibilityState={{ selected: color === c }}
|
||||
>
|
||||
{color === c && (
|
||||
<Svg width={16} height={16} viewBox="0 0 24 24">
|
||||
<Path d="M5 12l5 5 9-10" stroke="#FFFFFF" strokeWidth={3} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</Svg>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
<ColorPickerInput value={color} onChange={setColor} />
|
||||
|
||||
<View style={styles.actions}>
|
||||
{category && (
|
||||
<TouchableOpacity
|
||||
style={[styles.deleteButton, { borderColor: theme.accentBorder }]}
|
||||
onPress={handleDelete}
|
||||
disabled={!canDelete}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[styles.deleteButtonText, { color: canDelete ? theme.accent : theme.textMuted }]}>
|
||||
Delete
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<TouchableOpacity
|
||||
style={[styles.saveButton, { backgroundColor: theme.accent }, !name.trim() && styles.saveButtonDisabled]}
|
||||
onPress={handleSave}
|
||||
disabled={!name.trim()}
|
||||
activeOpacity={0.8}
|
||||
<Pressable
|
||||
style={styles.overlay}
|
||||
onPress={onClose}
|
||||
accessibilityRole="none"
|
||||
>
|
||||
<Pressable
|
||||
style={[styles.sheet, { backgroundColor: theme.sheetBg }]}
|
||||
onPress={() => {}}
|
||||
>
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<Text style={[styles.saveButtonText, { color: theme.accentText }]}>Save</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.title, { color: theme.text }]}>
|
||||
{category ? 'Edit Category' : 'New Category'}
|
||||
</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" />
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<Text style={[styles.label, { color: theme.textSecondary }]}>Name</Text>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
{ backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text },
|
||||
]}
|
||||
placeholder="Category name"
|
||||
placeholderTextColor={theme.textMuted}
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
maxLength={30}
|
||||
autoFocus
|
||||
autoCapitalize="sentences"
|
||||
/>
|
||||
|
||||
<Text style={[styles.label, { color: theme.textSecondary }]}>Color</Text>
|
||||
<View style={styles.colorGrid}>
|
||||
{CATEGORY_COLORS.map((c) => (
|
||||
<TouchableOpacity
|
||||
key={c}
|
||||
style={[
|
||||
styles.colorOption,
|
||||
{ backgroundColor: c },
|
||||
color === c && styles.colorOptionSelected,
|
||||
]}
|
||||
onPress={() => setColor(c)}
|
||||
activeOpacity={0.8}
|
||||
accessibilityRole="radio"
|
||||
accessibilityLabel={`Color ${c}`}
|
||||
accessibilityState={{ selected: color === c }}
|
||||
>
|
||||
{color === c && (
|
||||
<Svg width={16} height={16} viewBox="0 0 24 24">
|
||||
<Path d="M5 12l5 5 9-10" stroke="#FFFFFF" strokeWidth={3} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</Svg>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
<ColorPickerInput value={color} onChange={setColor} />
|
||||
|
||||
<View style={styles.actions}>
|
||||
{category && (
|
||||
<TouchableOpacity
|
||||
style={[styles.deleteButton, { borderColor: theme.accentBorder }]}
|
||||
onPress={handleDelete}
|
||||
disabled={!canDelete}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[styles.deleteButtonText, { color: canDelete ? theme.accent : theme.textMuted }]}>
|
||||
Delete
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<TouchableOpacity
|
||||
style={[styles.saveButton, { backgroundColor: theme.accent }, !name.trim() && styles.saveButtonDisabled]}
|
||||
onPress={handleSave}
|
||||
disabled={!name.trim()}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={[styles.saveButtonText, { color: theme.accentText }]}>Save</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</KeyboardAvoidingView>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
fill: {
|
||||
flex: 1,
|
||||
},
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.4)',
|
||||
backgroundColor: 'rgba(0,0,0,0.55)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 24,
|
||||
@@ -156,6 +171,7 @@ const styles = StyleSheet.create({
|
||||
sheet: {
|
||||
width: '100%',
|
||||
maxWidth: 380,
|
||||
maxHeight: '100%',
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
gap: 8,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, ScrollView, TouchableOpacity } from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useUniqueCategories } from '@/hooks/useDatabase';
|
||||
import { useSettings, ThemeColors } from '@/theme';
|
||||
import Category from '@/models/Category';
|
||||
import Svg, { Path, Circle } from 'react-native-svg';
|
||||
|
||||
interface CategoryFilterProps {
|
||||
selected: string;
|
||||
@@ -12,10 +13,7 @@ interface CategoryFilterProps {
|
||||
export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) {
|
||||
const categories = useUniqueCategories();
|
||||
const { theme } = useSettings();
|
||||
|
||||
if (categories.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
@@ -43,6 +41,22 @@ export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) {
|
||||
theme={theme}
|
||||
/>
|
||||
))}
|
||||
<TouchableOpacity
|
||||
style={[styles.manageButton, { borderColor: theme.borderStrong, backgroundColor: theme.cardAlt }]}
|
||||
onPress={() => router.push('/settings')}
|
||||
activeOpacity={0.7}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Manage categories"
|
||||
hitSlop={{ top: 8, bottom: 8, left: 4, right: 4 }}
|
||||
>
|
||||
<Svg width={14} height={14} viewBox="0 0 24 24">
|
||||
<Circle cx={12} cy={12} r={9} stroke={theme.textSecondary} strokeWidth={2.2} fill="none" />
|
||||
<Circle cx={12} cy={8.5} r={1.2} fill={theme.textSecondary} />
|
||||
<Circle cx={12} cy={15.5} r={1.2} fill={theme.textSecondary} />
|
||||
<Path d="M7.8 6.2l1 1.7M16.2 16.1l1 1.7M7.8 17.8l1-1.7M16.2 7.9l1-1.7" stroke={theme.textSecondary} strokeWidth={1.8} strokeLinecap="round" />
|
||||
</Svg>
|
||||
<Text style={[styles.manageLabel, { color: theme.textSecondary }]}>Manage</Text>
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -66,18 +80,18 @@ function CategoryButton({ name, color, selected, onPress, theme }: CategoryButto
|
||||
]}
|
||||
onPress={onPress}
|
||||
activeOpacity={0.8}
|
||||
hitSlop={{ top: 6, bottom: 6, left: 4, right: 4 }}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.colorDot,
|
||||
{ backgroundColor: color },
|
||||
selected && styles.colorDotSelected,
|
||||
]}
|
||||
/>
|
||||
<Text style={[
|
||||
styles.buttonText,
|
||||
{ color: theme.textSecondary },
|
||||
selected && { color: theme.accent, fontWeight: '600' },
|
||||
selected && { color: theme.text, fontWeight: '600' },
|
||||
]}>
|
||||
{name}
|
||||
</Text>
|
||||
@@ -87,39 +101,48 @@ function CategoryButton({ name, color, selected, onPress, theme }: CategoryButto
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scrollView: {
|
||||
paddingVertical: 0,
|
||||
marginBottom: 0,
|
||||
paddingVertical: 6,
|
||||
},
|
||||
container: {
|
||||
paddingHorizontal: 12,
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
gap: 6,
|
||||
alignItems: 'flex-start',
|
||||
gap: 8,
|
||||
alignItems: 'center',
|
||||
},
|
||||
button: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
minWidth: 64,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 18,
|
||||
borderWidth: 1.5,
|
||||
minHeight: 38,
|
||||
justifyContent: 'center',
|
||||
gap: 6,
|
||||
gap: 8,
|
||||
},
|
||||
colorDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
},
|
||||
colorDotSelected: {
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 5,
|
||||
},
|
||||
buttonText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
manageButton: {
|
||||
alignSelf: 'center',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 18,
|
||||
borderWidth: 1,
|
||||
borderStyle: 'dashed',
|
||||
minHeight: 38,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
manageLabel: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import React, { useState } from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, ScrollView, Modal, Pressable, KeyboardAvoidingView } from 'react-native';
|
||||
import { useCategories } from '@/hooks/useDatabase';
|
||||
import { useSettings } from '@/theme';
|
||||
import Svg, { Path } from 'react-native-svg';
|
||||
|
||||
interface CategorySelectorProps {
|
||||
value: string;
|
||||
@@ -37,7 +38,7 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
|
||||
</View>
|
||||
</View>
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Path d="M9 18l6-6-6-6" stroke={theme.textMuted} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
<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>}
|
||||
@@ -72,7 +73,7 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
|
||||
<Text style={[
|
||||
styles.categoryName,
|
||||
{ color: theme.textSecondary },
|
||||
!value && { color: theme.accent, fontWeight: '600' },
|
||||
!value && { color: theme.accentStrong, fontWeight: '600' },
|
||||
]}>
|
||||
None
|
||||
</Text>
|
||||
@@ -100,7 +101,7 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
|
||||
<Text style={[
|
||||
styles.categoryName,
|
||||
{ color: theme.textSecondary },
|
||||
value === category.id && { color: theme.accent, fontWeight: '600' },
|
||||
value === category.id && { color: theme.accentStrong, fontWeight: '600' },
|
||||
]}>
|
||||
{category.name}
|
||||
</Text>
|
||||
@@ -120,8 +121,6 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
|
||||
);
|
||||
}
|
||||
|
||||
import Svg, { Path } from 'react-native-svg';
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
gap: 6,
|
||||
|
||||
@@ -69,31 +69,44 @@ export default function ColorWheel({ color, onChange }: ColorWheelProps) {
|
||||
onChange(newColor);
|
||||
}, [hue, saturation, value, onChange]);
|
||||
|
||||
const updateFromWheel = (locationX: number, locationY: number) => {
|
||||
const center = WHEEL_SIZE / 2;
|
||||
const dx = locationX - center;
|
||||
const dy = locationY - center;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
const radius = (WHEEL_SIZE - THUMB_SIZE) / 2;
|
||||
if (distance > radius) return;
|
||||
const angle = Math.atan2(dy, dx) * (180 / Math.PI);
|
||||
let h = angle + 180;
|
||||
if (h >= 360) h -= 360;
|
||||
setHue(h);
|
||||
setSaturation(distance / radius);
|
||||
setValue(1 - distance / radius);
|
||||
};
|
||||
|
||||
const wheelPanResponder = React.useMemo(() => PanResponder.create({
|
||||
onStartShouldSetPanResponder: () => true,
|
||||
onMoveShouldSetPanResponder: () => true,
|
||||
onPanResponderMove: (_, gesture) => {
|
||||
const center = WHEEL_SIZE / 2;
|
||||
const dx = gesture.moveX - center;
|
||||
const dy = gesture.moveY - center;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
const radius = (WHEEL_SIZE - THUMB_SIZE) / 2;
|
||||
if (distance > radius) return;
|
||||
const angle = Math.atan2(dy, dx) * (180 / Math.PI);
|
||||
let h = angle + 180;
|
||||
if (h >= 360) h -= 360;
|
||||
setHue(h);
|
||||
setSaturation(distance / radius);
|
||||
setValue(1 - distance / radius);
|
||||
onPanResponderGrant: (event) => {
|
||||
updateFromWheel(event.nativeEvent.locationX, event.nativeEvent.locationY);
|
||||
},
|
||||
onPanResponderMove: (event) => {
|
||||
updateFromWheel(event.nativeEvent.locationX, event.nativeEvent.locationY);
|
||||
},
|
||||
}), []);
|
||||
|
||||
const setHueFromLocation = (locationX: number) => {
|
||||
setHue(Math.min(360, Math.max(0, (locationX / WHEEL_SIZE) * 360)));
|
||||
};
|
||||
|
||||
const huePanResponder = React.useMemo(() => PanResponder.create({
|
||||
onStartShouldSetPanResponder: () => true,
|
||||
onMoveShouldSetPanResponder: () => true,
|
||||
onPanResponderMove: (_, gesture) => {
|
||||
const h = (gesture.moveX / WHEEL_SIZE) * 360;
|
||||
setHue(Math.min(360, Math.max(0, h)));
|
||||
onPanResponderGrant: (event) => {
|
||||
setHueFromLocation(event.nativeEvent.locationX);
|
||||
},
|
||||
onPanResponderMove: (event) => {
|
||||
setHueFromLocation(event.nativeEvent.locationX);
|
||||
},
|
||||
}), []);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import { useSettings } from '@/theme';
|
||||
import Svg, { Rect, Circle, Defs, LinearGradient, Stop } from 'react-native-svg';
|
||||
@@ -55,8 +55,6 @@ export default function ColorWheel({ color, onChange }: ColorWheelProps) {
|
||||
const [saturation, setSaturation] = useState(initialHsv.s);
|
||||
const [value, setValue] = useState(initialHsv.v);
|
||||
const [prevColor, setPrevColor] = useState(color);
|
||||
const wheelRef = useRef<Svg>(null);
|
||||
const hueRef = useRef<Svg>(null);
|
||||
|
||||
if (prevColor !== color) {
|
||||
setPrevColor(color);
|
||||
@@ -71,14 +69,13 @@ export default function ColorWheel({ color, onChange }: ColorWheelProps) {
|
||||
}, [hue, saturation, value, onChange]);
|
||||
|
||||
const handleWheelMouseDown = (e: React.MouseEvent) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const center = WHEEL_SIZE / 2;
|
||||
const radius = (WHEEL_SIZE - THUMB_SIZE) / 2;
|
||||
const handleMove = (moveEvent: MouseEvent) => {
|
||||
if (!wheelRef.current) return;
|
||||
const rect = (wheelRef.current as unknown as HTMLElement).getBoundingClientRect();
|
||||
const center = WHEEL_SIZE / 2;
|
||||
const dx = moveEvent.clientX - rect.left - center;
|
||||
const dy = moveEvent.clientY - rect.top - center;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
const radius = (WHEEL_SIZE - THUMB_SIZE) / 2;
|
||||
if (distance > radius) return;
|
||||
const angle = Math.atan2(dy, dx) * (180 / Math.PI);
|
||||
let h = angle + 180;
|
||||
@@ -97,9 +94,8 @@ export default function ColorWheel({ color, onChange }: ColorWheelProps) {
|
||||
};
|
||||
|
||||
const handleHueMouseDown = (e: React.MouseEvent) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const handleMove = (moveEvent: MouseEvent) => {
|
||||
if (!hueRef.current) return;
|
||||
const rect = (hueRef.current as unknown as HTMLElement).getBoundingClientRect();
|
||||
const h = ((moveEvent.clientX - rect.left) / WHEEL_SIZE) * 360;
|
||||
setHue(Math.min(360, Math.max(0, h)));
|
||||
};
|
||||
@@ -120,7 +116,7 @@ export default function ColorWheel({ color, onChange }: ColorWheelProps) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.wheelContainer}>
|
||||
<Svg width={WHEEL_SIZE} height={WHEEL_SIZE} ref={wheelRef} {...({ onMouseDown: handleWheelMouseDown } as object)}>
|
||||
<Svg width={WHEEL_SIZE} height={WHEEL_SIZE} {...({ onMouseDown: handleWheelMouseDown } as object)}>
|
||||
<DefsElement>
|
||||
<LinearGradient id="satGradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<Stop offset="0%" stopColor={`hsl(${hue}, 100%, 50%)`} stopOpacity={1} />
|
||||
@@ -155,7 +151,7 @@ export default function ColorWheel({ color, onChange }: ColorWheelProps) {
|
||||
</View>
|
||||
|
||||
<View style={styles.hueContainer}>
|
||||
<Svg width={WHEEL_SIZE} height={36} ref={hueRef} {...({ onMouseDown: handleHueMouseDown } as object)}>
|
||||
<Svg width={WHEEL_SIZE} height={36} {...({ onMouseDown: handleHueMouseDown } as object)}>
|
||||
<DefsElement>
|
||||
<LinearGradient id="hueGradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<Stop offset="0%" stopColor="#FF0000" />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, TouchableOpacity, StyleSheet, Modal } from 'react-native';
|
||||
import { DateTimePickerEvent } from '@react-native-community/datetimepicker';
|
||||
import { useSettings } from '@/theme';
|
||||
|
||||
interface WebDateTimeInputProps {
|
||||
value: Date;
|
||||
@@ -32,6 +33,7 @@ export default function WebDateTimeInput({
|
||||
onDismiss,
|
||||
is24Hour,
|
||||
}: WebDateTimeInputProps) {
|
||||
const { theme } = useSettings();
|
||||
const [inputValue, setInputValue] = useState(() => dateInputValue(value));
|
||||
const [prevValue, setPrevValue] = useState(value);
|
||||
|
||||
@@ -57,11 +59,11 @@ export default function WebDateTimeInput({
|
||||
onChange(event, date);
|
||||
};
|
||||
|
||||
const bgColor = '#1E1E1E';
|
||||
const textColor = '#FFFFFF';
|
||||
const borderColor = '#333333';
|
||||
const overlayColor = 'rgba(0,0,0,0.6)';
|
||||
const mutedColor = '#9E9E9E';
|
||||
const bgColor = theme.sheetBg;
|
||||
const textColor = theme.text;
|
||||
const borderColor = theme.borderStrong;
|
||||
const overlayColor = theme.overlay;
|
||||
const mutedColor = theme.textFaint;
|
||||
|
||||
return (
|
||||
<Modal visible={isVisible} transparent animationType="fade" onRequestClose={onDismiss}>
|
||||
@@ -90,7 +92,7 @@ export default function WebDateTimeInput({
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${borderColor}`,
|
||||
outline: 'none',
|
||||
backgroundColor: '#2C2C2C',
|
||||
backgroundColor: theme.inputBg,
|
||||
color: textColor,
|
||||
fontFamily: 'inherit',
|
||||
marginVertical: 16,
|
||||
@@ -101,14 +103,14 @@ export default function WebDateTimeInput({
|
||||
</View>
|
||||
<View style={styles.actions}>
|
||||
<TouchableOpacity
|
||||
style={[styles.clearButton, { borderColor, backgroundColor: '#2C2C2C' }]}
|
||||
style={[styles.clearButton, { borderColor, backgroundColor: theme.inputBg }]}
|
||||
onPress={onDismiss}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[styles.clearButtonText, { color: mutedColor }]}>Clear</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.doneButton} onPress={onDismiss} activeOpacity={0.8}>
|
||||
<Text style={styles.doneButtonText}>Done</Text>
|
||||
<TouchableOpacity style={[styles.doneButton, { backgroundColor: theme.accent }]} onPress={onDismiss} activeOpacity={0.8}>
|
||||
<Text style={[styles.doneButtonText, { color: theme.accentText }]}>Done</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
@@ -159,11 +161,9 @@ const styles = StyleSheet.create({
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 10,
|
||||
backgroundColor: '#E53935',
|
||||
},
|
||||
doneButtonText: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
});
|
||||
@@ -55,8 +55,8 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Circle cx={12} cy={12} r={10} stroke={field.value ? theme.accent : theme.textMuted} strokeWidth={1.5} fill="none" />
|
||||
<Path d="M12 6v6l4 2" stroke={field.value ? theme.accent : theme.textMuted} strokeWidth={1.5} strokeLinecap="round" />
|
||||
<Circle cx={12} cy={12} r={10} stroke={field.value ? theme.accent : theme.textFaint} strokeWidth={1.8} fill="none" />
|
||||
<Path d="M12 6v6l4 2" stroke={field.value ? theme.accent : theme.textFaint} strokeWidth={1.8} strokeLinecap="round" />
|
||||
</Svg>
|
||||
<Text
|
||||
style={[
|
||||
@@ -68,7 +68,7 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
|
||||
>
|
||||
{field.value ? formatTime12(field.value) : placeholder}
|
||||
</Text>
|
||||
{field.value && (
|
||||
{field.value ? (
|
||||
<TouchableOpacity
|
||||
style={styles.clearButton}
|
||||
onPress={() => {
|
||||
@@ -82,10 +82,10 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
|
||||
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
||||
>
|
||||
<Svg width={14} height={14} viewBox="0 0 24 24">
|
||||
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textMuted} strokeWidth={2} strokeLinecap="round" />
|
||||
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textSecondary} strokeWidth={2.2} strokeLinecap="round" />
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
) : null}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}}
|
||||
@@ -114,14 +114,14 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Path
|
||||
d="M8 2v4M16 2v4M3 10h18M3 18h18"
|
||||
stroke={field.value ? theme.accent : theme.textMuted}
|
||||
strokeWidth={1.5}
|
||||
stroke={field.value ? theme.accent : theme.textFaint}
|
||||
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={field.value ? theme.accent : theme.textMuted}
|
||||
strokeWidth={1.5}
|
||||
stroke={field.value ? theme.accent : theme.textFaint}
|
||||
strokeWidth={1.8}
|
||||
fill="none"
|
||||
/>
|
||||
</Svg>
|
||||
@@ -135,17 +135,17 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
|
||||
>
|
||||
{field.value ? format(field.value, 'MMM d, yyyy') : 'Due Date'}
|
||||
</Text>
|
||||
{field.value && (
|
||||
{field.value ? (
|
||||
<TouchableOpacity
|
||||
style={styles.clearButton}
|
||||
onPress={() => setDateValue(null)}
|
||||
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
||||
>
|
||||
<Svg width={14} height={14} viewBox="0 0 24 24">
|
||||
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textMuted} strokeWidth={2} strokeLinecap="round" />
|
||||
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textSecondary} strokeWidth={2.2} strokeLinecap="round" />
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
) : null}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}}
|
||||
@@ -183,7 +183,7 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
|
||||
{endTimeInvalid && (
|
||||
<View style={[styles.warningRow, { backgroundColor: theme.accentSoft, borderColor: '#E53935' }]}>
|
||||
<Svg width={14} height={14} viewBox="0 0 24 24">
|
||||
<Circle cx={12} cy={12} r={10} stroke="#E53935" strokeWidth={1.5} fill="none" />
|
||||
<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" />
|
||||
</Svg>
|
||||
<Text style={[styles.warningText, { color: '#E53935' }]}>
|
||||
|
||||
@@ -2,9 +2,11 @@ import React, { useState } from 'react';
|
||||
import { StyleSheet, TouchableOpacity, Animated, Easing } from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import Svg, { Path } from 'react-native-svg';
|
||||
import { useSettings } from '@/theme';
|
||||
|
||||
export function FloatingActionButton() {
|
||||
const router = useRouter();
|
||||
const { theme } = useSettings();
|
||||
const [scaleAnim] = React.useState(new Animated.Value(1));
|
||||
const [rotateAnim] = React.useState(new Animated.Value(0));
|
||||
const [rotation, setRotation] = useState(0);
|
||||
@@ -52,7 +54,7 @@ export function FloatingActionButton() {
|
||||
]}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.button}
|
||||
style={[styles.button, { backgroundColor: theme.accent, shadowColor: theme.accent }]}
|
||||
onPress={handlePress}
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
@@ -69,7 +71,7 @@ export function FloatingActionButton() {
|
||||
<Svg width={24} height={24} viewBox="0 0 24 24">
|
||||
<Path
|
||||
d="M12 5v14M5 12h14"
|
||||
stroke="#FFFFFF"
|
||||
stroke={theme.accentText}
|
||||
strokeWidth={2.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
@@ -86,7 +88,6 @@ const styles = StyleSheet.create({
|
||||
position: 'absolute',
|
||||
bottom: 24,
|
||||
right: 24,
|
||||
shadowColor: '#E53935',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 12,
|
||||
@@ -96,7 +97,6 @@ const styles = StyleSheet.create({
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 16,
|
||||
backgroundColor: '#E53935',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
|
||||
@@ -38,7 +38,7 @@ export function FormButtons({ onSubmit, submitLabel = 'Submit' }: FormButtonsPro
|
||||
<Text style={[styles.cancelButtonText, { color: theme.textSecondary }]}>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.submitButton, isSubmitting && styles.buttonDisabled]}
|
||||
style={[styles.submitButton, { backgroundColor: theme.accent }, isSubmitting && styles.buttonDisabled]}
|
||||
onPress={handleSubmit(onSubmit)}
|
||||
disabled={isSubmitting}
|
||||
activeOpacity={0.8}
|
||||
@@ -46,7 +46,7 @@ export function FormButtons({ onSubmit, submitLabel = 'Submit' }: FormButtonsPro
|
||||
accessibilityLabel={isSubmitting ? 'Saving' : submitLabel}
|
||||
accessibilityState={{ disabled: isSubmitting, busy: isSubmitting }}
|
||||
>
|
||||
<Text style={styles.submitButtonText}>
|
||||
<Text style={[styles.submitButtonText, { color: theme.accentText }]}>
|
||||
{isSubmitting ? 'Saving...' : submitLabel}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
@@ -88,19 +88,12 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 10,
|
||||
backgroundColor: '#E53935',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
shadowColor: '#E53935',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 6,
|
||||
elevation: 3,
|
||||
},
|
||||
submitButtonText: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
buttonDisabled: {
|
||||
opacity: 0.6,
|
||||
|
||||
@@ -148,7 +148,7 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
|
||||
style={[styles.removeBtn, { backgroundColor: theme.accentSoft, borderColor: theme.accent }]}
|
||||
onPress={() => handleRemove(item.id)}
|
||||
>
|
||||
<Text style={[styles.removeBtnText, { color: theme.accent }]}>Remove</Text>
|
||||
<Text style={[styles.removeBtnText, { color: theme.accentStrong }]}>Remove</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -16,8 +16,8 @@ export function Header({ title, showLogo, rightAction }: HeaderProps) {
|
||||
<View style={[styles.header, { backgroundColor: theme.background, paddingTop: topInset }]}>
|
||||
<View style={styles.headerContent}>
|
||||
{showLogo && (
|
||||
<View style={styles.logoContainer}>
|
||||
<Text style={styles.logoText}>✓</Text>
|
||||
<View style={[styles.logoContainer, { backgroundColor: theme.accent }]}>
|
||||
<Text style={[styles.logoText, { color: theme.accentText }]}>✓</Text>
|
||||
</View>
|
||||
)}
|
||||
<Text style={[styles.title, { color: theme.text }]} accessibilityRole="header">{title}</Text>
|
||||
@@ -48,12 +48,10 @@ const styles = StyleSheet.create({
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 10,
|
||||
backgroundColor: '#E53935',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
logoText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
},
|
||||
|
||||
@@ -136,7 +136,7 @@ export function LegalModal({ visible, type, onClose }: LegalModalProps) {
|
||||
<Text style={[styles.title, { color: theme.text }]}>{title}</Text>
|
||||
<TouchableOpacity onPress={onClose} style={styles.closeButton} activeOpacity={0.7}>
|
||||
<Svg width={24} height={24} viewBox="0 0 24 24">
|
||||
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textMuted} strokeWidth={2} strokeLinecap="round" />
|
||||
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textSecondary} strokeWidth={2.2} strokeLinecap="round" />
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
@@ -42,8 +42,8 @@ export function ListItem({ title, subtitle, leftElement, rightElement, onPress,
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Path
|
||||
d="M9 18l6-6-6-6"
|
||||
stroke={theme.textMuted}
|
||||
strokeWidth={2}
|
||||
stroke={theme.textSecondary}
|
||||
strokeWidth={2.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
|
||||
@@ -22,7 +22,7 @@ interface OptionPickerModalProps {
|
||||
export function OptionPickerModal({ visible, title, options, selectedValue, onSelect, onClose, multiSelect = false }: OptionPickerModalProps) {
|
||||
const { theme } = useSettings();
|
||||
|
||||
const selectedValues = Array.isArray(selectedValue) ? selectedValue : selectedValue ? [selectedValue] : [];
|
||||
const selectedValues = Array.isArray(selectedValue) ? selectedValue : selectedValue == null ? [] : [selectedValue];
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||||
@@ -87,7 +87,7 @@ export function OptionPickerModal({ visible, title, options, selectedValue, onSe
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Cancel"
|
||||
>
|
||||
<Text style={[styles.cancelText, { color: theme.textFaint }]}>Cancel</Text>
|
||||
<Text style={[styles.cancelText, { color: theme.textSecondary }]}>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
}
|
||||
contentContainerStyle={styles.listContent}
|
||||
|
||||
@@ -46,7 +46,7 @@ export function PrioritySelector({ value, onChange }: PrioritySelectorProps) {
|
||||
<Text style={[
|
||||
styles.optionText,
|
||||
{ color: theme.textFaint },
|
||||
value === priority.value && { color: theme.accent, fontWeight: '600' },
|
||||
value === priority.value && { color: theme.accentStrong, fontWeight: '600' },
|
||||
]}>
|
||||
{priority.label}
|
||||
</Text>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { View, StyleSheet, TextInput, TouchableOpacity, Platform, Keyboard, Animated, Easing } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { View, StyleSheet, TextInput, TouchableOpacity, Keyboard, Text } from 'react-native';
|
||||
import { database, collections } from '@/database';
|
||||
import { useCategories } from '@/hooks/useDatabase';
|
||||
import { useSettings } from '@/theme';
|
||||
@@ -15,13 +14,11 @@ interface QuickAddBarProps {
|
||||
|
||||
export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
|
||||
const { theme, defaultCategoryId } = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
const categories = useCategories();
|
||||
const [title, setTitle] = useState('');
|
||||
const [categoryId, setCategoryId] = useState(() => defaultCategoryId || '');
|
||||
const [categoryPickerVisible, setCategoryPickerVisible] = useState(false);
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
const keyboardHeight = useRef(new Animated.Value(0)).current;
|
||||
|
||||
const categoryColor = categories.find((c) => c.id === categoryId)?.color ?? '#9E9E9E';
|
||||
|
||||
@@ -33,32 +30,6 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const showListener = Keyboard.addListener('keyboardDidShow', (e) => {
|
||||
const height = e.endCoordinates.height;
|
||||
Animated.timing(keyboardHeight, {
|
||||
toValue: height,
|
||||
duration: 250,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
useNativeDriver: false,
|
||||
}).start();
|
||||
});
|
||||
|
||||
const hideListener = Keyboard.addListener('keyboardDidHide', () => {
|
||||
Animated.timing(keyboardHeight, {
|
||||
toValue: 0,
|
||||
duration: 250,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
useNativeDriver: false,
|
||||
}).start();
|
||||
});
|
||||
|
||||
return () => {
|
||||
showListener.remove();
|
||||
hideListener.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleAdd = async () => {
|
||||
const trimmed = title.trim();
|
||||
if (!trimmed) return;
|
||||
@@ -84,16 +55,11 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
|
||||
});
|
||||
});
|
||||
setTitle('');
|
||||
Keyboard.dismiss();
|
||||
};
|
||||
|
||||
const animatedBottom = keyboardHeight.interpolate({
|
||||
inputRange: [0, 500],
|
||||
outputRange: [0, insets.bottom + 500],
|
||||
extrapolate: 'clamp',
|
||||
});
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.wrapper, { bottom: animatedBottom }]}>
|
||||
<View style={styles.wrapper}>
|
||||
<View style={[styles.bar, { backgroundColor: theme.card, borderColor: theme.border }]}>
|
||||
<TouchableOpacity
|
||||
style={[styles.categoryButton, { borderColor: theme.borderStrong, backgroundColor: theme.cardAlt }]}
|
||||
@@ -103,7 +69,7 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
|
||||
>
|
||||
<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.textFaint} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
<Path d="M6 9l6 6 6-6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
<TextInput
|
||||
@@ -127,15 +93,18 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
|
||||
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 5v14M5 12h14"
|
||||
stroke={theme.accentText}
|
||||
strokeWidth={2.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</Svg>
|
||||
<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">
|
||||
<Path
|
||||
d="M12 5v14M12 5l-5 5M12 5l5 5"
|
||||
stroke={theme.accentText}
|
||||
strokeWidth={2.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</Svg>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
@@ -147,16 +116,14 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
|
||||
onSelect={(value) => setCategoryId(Array.isArray(value) ? value[0] : value)}
|
||||
onClose={() => setCategoryPickerVisible(false)}
|
||||
/>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrapper: {
|
||||
position: 'absolute',
|
||||
left: 8,
|
||||
right: 8,
|
||||
bottom: 12,
|
||||
paddingHorizontal: 8,
|
||||
paddingTop: 4,
|
||||
},
|
||||
bar: {
|
||||
flexDirection: 'row',
|
||||
@@ -166,11 +133,12 @@ const styles = StyleSheet.create({
|
||||
paddingVertical: 8,
|
||||
borderRadius: 14,
|
||||
borderWidth: 1,
|
||||
borderBottomWidth: 1,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowOffset: { width: 0, height: -2 },
|
||||
shadowOpacity: 0.10,
|
||||
shadowRadius: 6,
|
||||
elevation: 4,
|
||||
elevation: 6,
|
||||
},
|
||||
categoryButton: {
|
||||
flexDirection: 'row',
|
||||
@@ -178,7 +146,7 @@ const styles = StyleSheet.create({
|
||||
gap: 3,
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 6,
|
||||
borderRadius: 8,
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
height: 40,
|
||||
},
|
||||
@@ -194,13 +162,23 @@ const styles = StyleSheet.create({
|
||||
minHeight: 40,
|
||||
},
|
||||
submit: {
|
||||
width: 40,
|
||||
minWidth: 88,
|
||||
height: 40,
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 10,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
submitLabelRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
},
|
||||
submitText: {
|
||||
fontSize: 13,
|
||||
fontWeight: '700',
|
||||
},
|
||||
submitDisabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -43,13 +43,13 @@ export function ReminderSelector({ value, onChange, hasDueDate }: ReminderSelect
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Path
|
||||
d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9M10.3 21a1.94 1.94 0 0 0 3.4 0"
|
||||
stroke={selectedReminders.length > 0 ? theme.accent : theme.textMuted}
|
||||
strokeWidth={1.5}
|
||||
stroke={selectedReminders.length > 0 ? theme.accentText : theme.textSecondary}
|
||||
strokeWidth={1.8}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<Circle cx={18.5} cy={5.5} r={3.5} fill={selectedReminders.length > 0 ? theme.accent : 'transparent'} stroke={selectedReminders.length > 0 ? theme.accent : theme.textMuted} strokeWidth={1.5} />
|
||||
<Circle cx={18.5} cy={5.5} r={3.5} fill={selectedReminders.length > 0 ? theme.accent : 'transparent'} stroke={selectedReminders.length > 0 ? theme.accentText : theme.textSecondary} strokeWidth={1.8} />
|
||||
</Svg>
|
||||
<Text
|
||||
style={[
|
||||
@@ -62,7 +62,7 @@ export function ReminderSelector({ value, onChange, hasDueDate }: ReminderSelect
|
||||
</Text>
|
||||
<View style={styles.chevron}>
|
||||
<Svg width={16} height={16} viewBox="0 0 24 24">
|
||||
<Path d="M9 18l6-6-6-6" stroke={theme.textMuted} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
<Path d="M9 18l6-6-6-6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</Svg>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, Modal, TextInput, Pressable, KeyboardAvoidingView } from 'react-native';
|
||||
import { Repeat, REPEAT_OPTIONS, WEEKDAY_LABELS, repeatDaysFromString } from '@/types';
|
||||
import { Repeat, REPEAT_OPTIONS, WEEKDAY_LABELS, WEEKDAY_ORDER, weekdaysInDisplayOrder, repeatDaysFromString } from '@/types';
|
||||
import { useSettings } from '@/theme';
|
||||
import { useRepeatProfiles } from '@/hooks/useDatabase';
|
||||
import { createRepeatProfile, deleteRepeatProfile } from '@/utils/repeatProfileActions';
|
||||
@@ -168,7 +168,7 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
|
||||
style={[
|
||||
styles.chipText,
|
||||
{ color: theme.textFaint },
|
||||
value === option.value && { color: theme.accent, fontWeight: '600' },
|
||||
value === option.value && { color: theme.accentStrong, fontWeight: '600' },
|
||||
]}
|
||||
>
|
||||
{option.label.replace('No Repeat', 'None')}
|
||||
@@ -212,9 +212,9 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
|
||||
|
||||
{isDaysBased(value) && (
|
||||
<View style={styles.dayRow}>
|
||||
{WEEKDAY_LABELS.map((label, day) => (
|
||||
{WEEKDAY_ORDER.map((day) => (
|
||||
<TouchableOpacity
|
||||
key={`${label}-${day}`}
|
||||
key={`${WEEKDAY_LABELS[day]}-${day}`}
|
||||
style={[
|
||||
styles.dayChip,
|
||||
{ backgroundColor: theme.card, borderColor: theme.borderStrong },
|
||||
@@ -234,7 +234,7 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
|
||||
days.includes(day) && { color: '#FFFFFF', fontWeight: '700' },
|
||||
]}
|
||||
>
|
||||
{label}
|
||||
{WEEKDAY_LABELS[day]}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
@@ -252,7 +252,7 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
|
||||
<Svg width={13} height={13} viewBox="0 0 24 24">
|
||||
<Path d="M12 5v14M5 12h14" stroke={theme.accent} strokeWidth={2.5} strokeLinecap="round" />
|
||||
</Svg>
|
||||
<Text style={[styles.saveProfileText, { color: theme.accent }]}>Save as profile</Text>
|
||||
<Text style={[styles.saveProfileText, { color: theme.accentStrong }]}>Save as profile</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
@@ -271,7 +271,7 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
|
||||
<Text style={[styles.modalTitle, { color: theme.text }]}>Save repeat profile</Text>
|
||||
<Text style={[styles.modalHint, { color: theme.textFaint }]}>
|
||||
{`${REPEAT_OPTIONS.find((o) => o.value === value)?.label.replace('No Repeat', 'None')}, every ${interval} ${unitLabel(value)}${interval > 1 ? 's' : ''}`}
|
||||
{isDaysBased(value) && days.length > 0 ? ` · ${days.map((d) => WEEKDAY_LABELS[d]).join(' ')}` : ''}
|
||||
{isDaysBased(value) && days.length > 0 ? ` · ${weekdaysInDisplayOrder(days).map((d) => WEEKDAY_LABELS[d]).join(' ')}` : ''}
|
||||
</Text>
|
||||
<TextInput
|
||||
style={[styles.modalInput, { backgroundColor: theme.cardAlt, borderColor: theme.borderStrong, color: theme.text }]}
|
||||
|
||||
@@ -42,7 +42,7 @@ export function ServerUrlModal({ visible, onClose }: ServerUrlModalProps) {
|
||||
<Text style={[styles.title, { color: theme.text }]}>Backend URL</Text>
|
||||
<TouchableOpacity onPress={onClose} style={styles.closeButton} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
|
||||
<Svg width={18} height={18} viewBox="0 0 24 24">
|
||||
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textMuted} strokeWidth={2} strokeLinecap="round" />
|
||||
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textSecondary} strokeWidth={2.2} strokeLinecap="round" />
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
import { SubtaskData } from '@/types';
|
||||
import { TaskItem } from './TaskItem';
|
||||
@@ -6,63 +6,78 @@ import { useSettings } from '@/theme';
|
||||
|
||||
interface SubtaskItemProps {
|
||||
subtask: SubtaskData;
|
||||
onToggle: () => void | Promise<void>;
|
||||
onDelete?: () => void;
|
||||
onPress?: () => void;
|
||||
onLongPress?: () => void;
|
||||
onMenuOpen?: () => void;
|
||||
onToggle?: (subtask: SubtaskData) => void | Promise<void>;
|
||||
onDelete?: (subtask: SubtaskData) => void;
|
||||
onPress?: (subtask: SubtaskData) => void;
|
||||
onLongPress?: (subtask: SubtaskData) => void;
|
||||
onMenuOpen?: (subtask: SubtaskData) => void;
|
||||
selected?: boolean;
|
||||
selectionMode?: boolean;
|
||||
draggable?: boolean;
|
||||
onDragStart?: () => void;
|
||||
onDragStart?: (subtask: SubtaskData) => void;
|
||||
onDragUpdate?: (absoluteY: number) => void;
|
||||
onDragEnd?: (absoluteY: number) => void;
|
||||
depth?: number;
|
||||
categoryColor?: string;
|
||||
registerRef?: (subtaskId: string, parentTaskId: string, ref: View | null) => void;
|
||||
hoveredId?: string | null;
|
||||
}
|
||||
|
||||
export const SubtaskItem = React.memo(function SubtaskItem({
|
||||
subtask,
|
||||
onToggle,
|
||||
onDelete,
|
||||
onPress,
|
||||
onLongPress,
|
||||
onMenuOpen,
|
||||
export const SubtaskItem = React.memo(function SubtaskItem({
|
||||
subtask,
|
||||
onToggle,
|
||||
onDelete,
|
||||
onPress,
|
||||
onLongPress,
|
||||
onMenuOpen,
|
||||
selected,
|
||||
selectionMode,
|
||||
draggable,
|
||||
onDragStart,
|
||||
onDragUpdate,
|
||||
onDragEnd,
|
||||
draggable,
|
||||
onDragStart,
|
||||
onDragUpdate,
|
||||
onDragEnd,
|
||||
depth = 1,
|
||||
categoryColor
|
||||
categoryColor,
|
||||
registerRef,
|
||||
hoveredId,
|
||||
}: SubtaskItemProps) {
|
||||
const { theme } = useSettings();
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const hasChildren = subtask.subtasks && subtask.subtasks.length > 0;
|
||||
const hovered = hoveredId === subtask.id;
|
||||
|
||||
const handleExpand = () => setExpanded(!expanded);
|
||||
|
||||
useEffect(() => {
|
||||
if (hovered && hasChildren && !expanded) {
|
||||
setExpanded(true);
|
||||
}
|
||||
}, [hovered, hasChildren, expanded]);
|
||||
|
||||
return (
|
||||
<View>
|
||||
<TaskItem
|
||||
task={subtask}
|
||||
indented
|
||||
depth={depth}
|
||||
onToggle={onToggle}
|
||||
onDelete={onDelete}
|
||||
onPress={onPress ?? (() => {})}
|
||||
onLongPress={onLongPress}
|
||||
onMenuOpen={onMenuOpen}
|
||||
selected={selected}
|
||||
selectionMode={selectionMode}
|
||||
completedSection={subtask.completed}
|
||||
draggable={draggable}
|
||||
onDragStart={onDragStart}
|
||||
onDragUpdate={onDragUpdate}
|
||||
onDragEnd={onDragEnd}
|
||||
categoryColor={categoryColor}
|
||||
/>
|
||||
<View ref={(ref) => registerRef?.(subtask.id, subtask.taskId, ref)}>
|
||||
<TaskItem
|
||||
task={subtask}
|
||||
indented
|
||||
depth={depth}
|
||||
expanded={hasChildren && expanded}
|
||||
onToggle={() => onToggle?.(subtask)}
|
||||
onDelete={() => onDelete?.(subtask)}
|
||||
onPress={onPress ? () => onPress(subtask) : handleExpand}
|
||||
onLongPress={() => onLongPress?.(subtask)}
|
||||
onMenuOpen={() => onMenuOpen?.(subtask)}
|
||||
selected={selected}
|
||||
selectionMode={selectionMode}
|
||||
completedSection={subtask.completed}
|
||||
draggable={draggable}
|
||||
hovered={hovered}
|
||||
onDragStart={() => onDragStart?.(subtask)}
|
||||
onDragUpdate={onDragUpdate}
|
||||
onDragEnd={onDragEnd}
|
||||
categoryColor={categoryColor}
|
||||
/>
|
||||
</View>
|
||||
{hasChildren && expanded && (
|
||||
<View style={[styles.nestedSubtasks, { marginLeft: depth * 12 }]}>
|
||||
{subtask.subtasks
|
||||
@@ -72,7 +87,7 @@ export const SubtaskItem = React.memo(function SubtaskItem({
|
||||
<SubtaskItem
|
||||
key={child.id}
|
||||
subtask={child}
|
||||
onToggle={() => {}}
|
||||
onToggle={onToggle}
|
||||
onDelete={onDelete}
|
||||
onPress={onPress}
|
||||
onLongPress={onLongPress}
|
||||
@@ -85,6 +100,8 @@ export const SubtaskItem = React.memo(function SubtaskItem({
|
||||
onDragEnd={onDragEnd}
|
||||
depth={depth + 1}
|
||||
categoryColor={categoryColor}
|
||||
registerRef={registerRef}
|
||||
hoveredId={hoveredId}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
@@ -97,4 +114,4 @@ const styles = StyleSheet.create({
|
||||
nestedSubtasks: {
|
||||
marginTop: 4,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -48,7 +48,7 @@ export function SubtasksSection({ control }: SubtasksSectionProps) {
|
||||
<Text style={[styles.label, { color: theme.text }]}>Subtasks</Text>
|
||||
{items.length > 0 && (
|
||||
<View style={[styles.badge, { backgroundColor: theme.accentSoft }]}>
|
||||
<Text style={[styles.badgeText, { color: theme.accent }]}>{items.length}</Text>
|
||||
<Text style={[styles.badgeText, { color: theme.accentStrong }]}>{items.length}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
@@ -67,8 +67,8 @@ export function SubtasksSection({ control }: SubtasksSectionProps) {
|
||||
<Svg width={16} height={16} viewBox="0 0 24 24">
|
||||
<Path
|
||||
d="M6 9l6 6 6-6"
|
||||
stroke={theme.textMuted}
|
||||
strokeWidth={2}
|
||||
stroke={theme.textSecondary}
|
||||
strokeWidth={2.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
@@ -105,7 +105,7 @@ export function SubtasksSection({ control }: SubtasksSectionProps) {
|
||||
<Svg width={16} height={16} viewBox="0 0 24 24">
|
||||
<Path d="M12 5v14M5 12h14" stroke={theme.accent} strokeWidth={2} strokeLinecap="round" />
|
||||
</Svg>
|
||||
<Text style={[styles.addButtonText, { color: theme.accent }]}>Add Subtask</Text>
|
||||
<Text style={[styles.addButtonText, { color: theme.accentStrong }]}>Add Subtask</Text>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
</View>
|
||||
@@ -131,8 +131,8 @@ function SubtaskItem({ index, value, onChange, onRemove }: SubtaskItemProps) {
|
||||
<Svg width={22} height={22} viewBox="0 0 24 24">
|
||||
<Path
|
||||
d="M4 12.5l5 5 10-10"
|
||||
stroke={theme.borderStrong}
|
||||
strokeWidth={2}
|
||||
stroke={theme.textFaint}
|
||||
strokeWidth={2.2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
@@ -150,7 +150,7 @@ function SubtaskItem({ index, value, onChange, onRemove }: SubtaskItemProps) {
|
||||
/>
|
||||
<TouchableOpacity style={styles.removeButton} onPress={onRemove} activeOpacity={0.7}>
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textMuted} strokeWidth={2} strokeLinecap="round" />
|
||||
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textSecondary} strokeWidth={2.2} strokeLinecap="round" />
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
@@ -66,21 +66,21 @@ 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="#43A047" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
<Path d="M5 12l5 5 9-10" stroke="#66BB6A" 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="#E53935" strokeWidth={2} fill="none" />
|
||||
<Path d="M12 8v4M12 16h.01" stroke="#E53935" strokeWidth={2} strokeLinecap="round" />
|
||||
<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" />
|
||||
</Svg>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Svg width={16} height={16} viewBox="0 0 24 24">
|
||||
<Path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" stroke={theme.textMuted} strokeWidth={1.5} fill="none" />
|
||||
<Path d="M12 22V12" stroke={theme.textMuted} strokeWidth={1.5} strokeLinecap="round" />
|
||||
<Path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" stroke={theme.textFaint} strokeWidth={1.8} fill="none" />
|
||||
<Path d="M12 22V12" stroke={theme.textFaint} strokeWidth={1.8} strokeLinecap="round" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { ColorValue } from 'react-native';
|
||||
import Svg, { Path, Circle } from 'react-native-svg';
|
||||
import Svg, { Path, Circle, Rect } from 'react-native-svg';
|
||||
|
||||
interface TabBarIconProps {
|
||||
name: 'checklist' | 'calendar' | 'gear' | 'stats';
|
||||
@@ -15,48 +15,64 @@ export function TabBarIcon({ name, focused, color, size = 24 }: TabBarIconProps)
|
||||
{name === 'checklist' && (
|
||||
<>
|
||||
<Path
|
||||
d="M3 5h18M3 12h18M3 19h18"
|
||||
d="M8.5 6h12.5M8.5 12h12.5M8.5 18h12.5"
|
||||
stroke={color}
|
||||
strokeWidth={focused ? 2.5 : 2}
|
||||
strokeWidth={focused ? 3.5 : 3}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{focused && (
|
||||
<Circle cx={6} cy={5} r={2} fill={color} />
|
||||
)}
|
||||
<Circle cx={4} cy={6} r={focused ? 1.75 : 1.5} fill={color} />
|
||||
<Circle cx={4} cy={12} r={focused ? 1.75 : 1.5} fill={color} />
|
||||
<Circle cx={4} cy={18} r={focused ? 1.75 : 1.5} fill={color} />
|
||||
</>
|
||||
)}
|
||||
{name === 'calendar' && (
|
||||
<>
|
||||
<Path
|
||||
d="M8 2v4M16 2v4M3 10h18M3 18h18"
|
||||
d="M8 2v4M16 2v4M3 10h18"
|
||||
stroke={color}
|
||||
strokeWidth={focused ? 2.5 : 2}
|
||||
strokeWidth={focused ? 3.5 : 3}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="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"
|
||||
<Rect
|
||||
x={3}
|
||||
y={4}
|
||||
width={18}
|
||||
height={18}
|
||||
rx={2}
|
||||
stroke={color}
|
||||
strokeWidth={focused ? 2.5 : 2}
|
||||
strokeWidth={focused ? 3.5 : 3}
|
||||
fill="none"
|
||||
/>
|
||||
{focused && (
|
||||
<>
|
||||
<Circle cx={12} cy={16} r={1.5} fill={color} />
|
||||
<Circle cx={7} cy={16} r={1.5} fill={color} />
|
||||
<Circle cx={17} cy={16} r={1.5} fill={color} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{name === 'gear' && (
|
||||
<Path
|
||||
d="M12 3.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1zM11.3 4.5a.7.7 0 0 1 1.4 0l.9.9a.5.5 0 0 1-.7.7l-.9-.9a.7.7 0 0 1 0-1.4zM20.5 12a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1zM17.3 11.3a.7.7 0 0 1 0 1.4l-.9.9a.5.5 0 0 1-.7-.7l.9-.9a.7.7 0 0 1 1.4 0zM12 20.5a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1zM11.3 19.6a.7.7 0 0 1 1.4 0l.9.9a.5.5 0 0 1-.7.7l-.9-.9a.7.7 0 0 1 0-1.4zM3.5 12a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1zM4.5 11.3a.7.7 0 0 1 1.4 0l.9.9a.5.5 0 0 1-.7.7l-.9-.9a.7.7 0 0 1 0-1.4zM12 7.5a4.5 4.5 0 1 0 0 9 4.5 4.5 0 0 0 0-9z"
|
||||
stroke={color}
|
||||
strokeWidth={focused ? 2.5 : 2}
|
||||
fill="none"
|
||||
/>
|
||||
<>
|
||||
<Circle cx={12} cy={12} r={3.5} stroke={color} strokeWidth={focused ? 3.5 : 3} fill="none" />
|
||||
<Path
|
||||
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"
|
||||
stroke={color}
|
||||
strokeWidth={focused ? 3 : 2.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{name === 'stats' && (
|
||||
<>
|
||||
<Path
|
||||
d="M4 20V10M10 20V4M16 20v-7M21 20H3"
|
||||
stroke={color}
|
||||
strokeWidth={focused ? 2.5 : 2}
|
||||
strokeWidth={focused ? 3.5 : 3}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
|
||||
@@ -46,7 +46,7 @@ export function TaskDeleteModal({ visible, taskId, taskTitle, isRepeating, subta
|
||||
<Text style={[styles.title, { color: theme.text }]}>{subtask ? 'Delete Subtask' : 'Delete Task'}</Text>
|
||||
<TouchableOpacity onPress={onClose} style={styles.closeButton} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
|
||||
<Svg width={18} height={18} viewBox="0 0 24 24">
|
||||
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textMuted} strokeWidth={2} strokeLinecap="round" />
|
||||
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textSecondary} strokeWidth={2.2} strokeLinecap="round" />
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
@@ -71,7 +71,7 @@ export function TaskDeleteModal({ visible, taskId, taskTitle, isRepeating, subta
|
||||
onPress={() => onDelete(option.scope)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[styles.actionLabel, { color: theme.accent }]}>{option.label}</Text>
|
||||
<Text style={[styles.actionLabel, { color: theme.accentStrong }]}>{option.label}</Text>
|
||||
{option.hint && <Text style={[styles.actionHint, { color: theme.textMuted }]}>{option.hint}</Text>}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
|
||||
@@ -133,11 +133,10 @@ export const TaskItem = React.memo(function TaskItem({ task, onToggle, onDelete,
|
||||
const hasDueDate = task.dueDate > 0;
|
||||
const isOverdue = hasDueDate && !task.completed && task.dueDate < startOfToday.getTime();
|
||||
const isDueToday = hasDueDate && !task.completed && task.dueDate >= startOfToday.getTime() && task.dueDate <= endOfToday.getTime();
|
||||
const canComplete = !hasDueDate || isOverdue || isDueToday;
|
||||
return { hasDueDate, isOverdue, isDueToday, canComplete };
|
||||
return { hasDueDate, isOverdue, isDueToday };
|
||||
}, [task.dueDate, task.completed]);
|
||||
|
||||
const { hasDueDate, isOverdue, isDueToday, canComplete } = dueInfo;
|
||||
const { hasDueDate, isOverdue, isDueToday } = dueInfo;
|
||||
|
||||
const formattedDueDate = React.useMemo(
|
||||
() => formatDueDate(task.dueDate, task.dueTime, task.endTime || ''),
|
||||
@@ -158,17 +157,6 @@ export const TaskItem = React.memo(function TaskItem({ task, onToggle, onDelete,
|
||||
|
||||
const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) => {
|
||||
const translateX = progress.interpolate({ inputRange: [0, 1], outputRange: [-80, 0] });
|
||||
if (!canComplete && !task.completed) {
|
||||
return (
|
||||
<Animated.View style={[styles.swipeAction, styles.swipeComplete, { transform: [{ translateX }] }]}>
|
||||
<Svg width={24} height={24} viewBox="0 0 24 24">
|
||||
<Circle cx={12} cy={12} r={10} stroke="#FFFFFF" strokeWidth={2} fill="none" />
|
||||
<Path d="M12 8v5M12 16.5v.5" stroke="#FFFFFF" strokeWidth={1.8} strokeLinecap="round" />
|
||||
</Svg>
|
||||
<Text style={styles.swipeActionText}>Not Due</Text>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Animated.View style={[styles.swipeAction, styles.swipeComplete, { transform: [{ translateX }] }]}>
|
||||
<Svg width={24} height={24} viewBox="0 0 24 24">
|
||||
@@ -189,9 +177,7 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
|
||||
swipeableRef.current?.close();
|
||||
}}
|
||||
onSwipeableLeftOpen={() => {
|
||||
if (canComplete || task.completed) {
|
||||
onToggle();
|
||||
}
|
||||
onToggle();
|
||||
swipeableRef.current?.close();
|
||||
}}
|
||||
overshootRight={false}
|
||||
@@ -234,21 +220,21 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
|
||||
) : null}
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={[styles.checkCircle, !canComplete && !task.completed && styles.checkCircleDisabled]}
|
||||
onPress={canComplete || task.completed ? onToggle : undefined}
|
||||
style={styles.checkCircle}
|
||||
onPress={onToggle}
|
||||
activeOpacity={0.7}
|
||||
accessibilityRole="checkbox"
|
||||
accessibilityLabel={task.completed ? 'Mark incomplete' : canComplete ? 'Mark complete' : 'Task not due yet'}
|
||||
accessibilityState={{ checked: task.completed, disabled: !canComplete && !task.completed }}
|
||||
accessibilityLabel={task.completed ? 'Mark incomplete' : 'Mark complete'}
|
||||
accessibilityState={{ checked: task.completed }}
|
||||
>
|
||||
<Svg width={24} height={24} viewBox="0 0 24 24">
|
||||
<Svg width={30} height={30} viewBox="0 0 24 24">
|
||||
{task.completed ? (
|
||||
<>
|
||||
<Circle cx={12} cy={12} r={10} fill={theme.accent} />
|
||||
<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={!canComplete ? theme.textFaint : isOverdue ? '#E53935' : theme.textMuted} strokeWidth={2} fill="none" />
|
||||
<Circle cx={12} cy={12} r={10} stroke={isOverdue ? '#FF8A80' : theme.textSecondary} strokeWidth={2.5} fill="none" />
|
||||
)}
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
@@ -286,8 +272,8 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
|
||||
<Svg width={14} height={14} viewBox="0 0 24 24">
|
||||
<Path
|
||||
d="M17 2l4 4-4 4M3 11v-1a4 4 0 0 1 4-4h14M7 22l-4-4 4-4M21 13v1a4 4 0 0 1-4 4H3"
|
||||
stroke={theme.textMuted}
|
||||
strokeWidth={2}
|
||||
stroke={theme.textSecondary}
|
||||
strokeWidth={2.2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
@@ -301,7 +287,7 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
|
||||
<Path
|
||||
d="M12 2v2M12 20v2M2 12h2M20 12h2M5.6 5.6l1.4 1.4M17 17l1.4 1.4M5.6 18.4l1.4-1.4M17 7l1.4-1.4"
|
||||
stroke={theme.accent}
|
||||
strokeWidth={1.5}
|
||||
strokeWidth={1.8}
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
/>
|
||||
@@ -314,8 +300,8 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
|
||||
<Svg width={16} height={16} viewBox="0 0 24 24">
|
||||
<Path
|
||||
d="M6 9l6 6 6-6"
|
||||
stroke={theme.textMuted}
|
||||
strokeWidth={2}
|
||||
stroke={theme.textSecondary}
|
||||
strokeWidth={2.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
@@ -326,8 +312,8 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
|
||||
{hasDueDate && (
|
||||
<View style={styles.dueRow}>
|
||||
<Svg width={14} height={14} viewBox="0 0 24 24">
|
||||
<Circle cx={12} cy={12} r={10} stroke={theme.textMuted} strokeWidth={1.5} fill="none" />
|
||||
<Path d="M12 6v6l4 2" stroke={theme.textMuted} strokeWidth={1.5} strokeLinecap="round" />
|
||||
<Circle cx={12} cy={12} r={10} stroke={theme.textSecondary} strokeWidth={2} fill="none" />
|
||||
<Path d="M12 6v6l4 2" stroke={theme.textSecondary} strokeWidth={2} strokeLinecap="round" />
|
||||
</Svg>
|
||||
<Animated.Text
|
||||
style={[
|
||||
@@ -344,8 +330,8 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
|
||||
<Svg width={13} height={13} viewBox="0 0 24 24">
|
||||
<Path
|
||||
d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9M10.3 21a1.94 1.94 0 0 0 3.4 0"
|
||||
stroke={theme.textFaint}
|
||||
strokeWidth={1.6}
|
||||
stroke={theme.textSecondary}
|
||||
strokeWidth={1.8}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
@@ -432,10 +418,10 @@ const styles = StyleSheet.create({
|
||||
marginBottom: 4,
|
||||
},
|
||||
taskOverdue: {
|
||||
borderColor: '#4A2B2B',
|
||||
borderColor: '#573431',
|
||||
},
|
||||
taskDueToday: {
|
||||
borderColor: '#1E88E5',
|
||||
borderColor: '#2C4766',
|
||||
},
|
||||
taskSelected: {
|
||||
shadowColor: '#000',
|
||||
@@ -444,8 +430,8 @@ const styles = StyleSheet.create({
|
||||
elevation: 2,
|
||||
},
|
||||
taskHovered: {
|
||||
borderColor: '#1E88E5',
|
||||
backgroundColor: 'rgba(30, 136, 229, 0.05)',
|
||||
borderColor: '#2C4766',
|
||||
backgroundColor: 'rgba(44, 71, 102, 0.15)',
|
||||
},
|
||||
dragLifted: {
|
||||
zIndex: 100,
|
||||
@@ -518,7 +504,7 @@ const styles = StyleSheet.create({
|
||||
justifyContent: 'center',
|
||||
},
|
||||
titleOverdue: {
|
||||
color: '#E53935',
|
||||
color: '#E57373',
|
||||
},
|
||||
priorityBadge: {
|
||||
paddingHorizontal: 6,
|
||||
@@ -536,14 +522,14 @@ const styles = StyleSheet.create({
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: 10,
|
||||
backgroundColor: '#4A2B2B',
|
||||
backgroundColor: '#43302E',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
assigneeText: {
|
||||
fontSize: 9,
|
||||
fontWeight: '700',
|
||||
color: '#EF5350',
|
||||
color: '#FF9C8F',
|
||||
},
|
||||
dueRow: {
|
||||
flexDirection: 'row',
|
||||
@@ -554,11 +540,11 @@ const styles = StyleSheet.create({
|
||||
fontSize: 13,
|
||||
},
|
||||
dueTextOverdue: {
|
||||
color: '#E53935',
|
||||
color: '#E57373',
|
||||
fontWeight: '600',
|
||||
},
|
||||
dueTextDueToday: {
|
||||
color: '#1E88E5',
|
||||
color: '#64B5F6',
|
||||
fontWeight: '600',
|
||||
},
|
||||
menuButton: {
|
||||
@@ -573,24 +559,21 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 8,
|
||||
backgroundColor: 'rgba(30, 136, 229, 0.15)',
|
||||
backgroundColor: 'rgba(100, 181, 246, 0.15)',
|
||||
},
|
||||
allDayText: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
color: '#1E88E5',
|
||||
color: '#64B5F6',
|
||||
},
|
||||
checkCircle: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 15,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: 12,
|
||||
},
|
||||
checkCircleDisabled: {
|
||||
opacity: 0.4,
|
||||
},
|
||||
swipeAction: {
|
||||
width: 80,
|
||||
alignItems: 'center',
|
||||
@@ -598,12 +581,12 @@ const styles = StyleSheet.create({
|
||||
gap: 4,
|
||||
},
|
||||
swipeDelete: {
|
||||
backgroundColor: '#E53935',
|
||||
backgroundColor: '#B8504A',
|
||||
borderTopRightRadius: 16,
|
||||
borderBottomRightRadius: 16,
|
||||
},
|
||||
swipeComplete: {
|
||||
backgroundColor: '#43A047',
|
||||
backgroundColor: '#3D7A4A',
|
||||
borderTopLeftRadius: 16,
|
||||
borderBottomLeftRadius: 16,
|
||||
},
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
convertTaskToSubtask,
|
||||
convertSubtaskToTask,
|
||||
moveSubtaskToTask,
|
||||
setSubtaskParent,
|
||||
toggleSubtaskComplete,
|
||||
fetchSubtaskTree,
|
||||
} from '@/utils/taskActions';
|
||||
@@ -60,8 +61,9 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
||||
const [subtasksMap, setSubtasksMap] = useState<Map<string, SubtaskData[]>>(new Map());
|
||||
const [dropIndicator, setDropIndicator] = useState<{ targetId: string | null; position: 'above' | 'below' } | null>(null);
|
||||
const itemRefs = useRef<Map<string, View>>(new Map());
|
||||
const dragStateRef = useRef<{ taskId: string; positions: Record<string, { top: number; bottom: number }> } | null>(null);
|
||||
const subtaskDragRef = useRef<{ subtaskId: string; parentTaskId: string; positions: Record<string, { top: number; bottom: number }> } | null>(null);
|
||||
const subtaskRefs = useRef<Map<string, { ref: View; parentTaskId: string }>>(new Map());
|
||||
const dragStateRef = useRef<{ taskId: string; positions: Record<string, { top: number; bottom: number }>; subPositions: Record<string, { top: number; bottom: number }> } | null>(null);
|
||||
const subtaskDragRef = useRef<{ subtaskId: string; parentTaskId: string; positions: Record<string, { top: number; bottom: number }>; subPositions: Record<string, { top: number; bottom: number }> } | null>(null);
|
||||
const {
|
||||
modals,
|
||||
openTaskDelete,
|
||||
@@ -78,6 +80,14 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
||||
}
|
||||
}, []);
|
||||
|
||||
const registerSubtaskRef = useCallback((subtaskId: string, parentTaskId: string, ref: View | null) => {
|
||||
if (ref) {
|
||||
subtaskRefs.current.set(subtaskId, { ref, parentTaskId });
|
||||
} else {
|
||||
subtaskRefs.current.delete(subtaskId);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const sortedTasks = useMemo(() => {
|
||||
const sorted = [...tasks];
|
||||
switch (sortBy) {
|
||||
@@ -210,7 +220,30 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
||||
return positions;
|
||||
}, []);
|
||||
|
||||
const findHoverTarget = useCallback((absoluteY: number, draggedId: string, positions: Record<string, { top: number; bottom: number }>) => {
|
||||
const measureAll = useCallback(async () => {
|
||||
const positions = await measureItems();
|
||||
const subPositions: Record<string, { top: number; bottom: number }> = {};
|
||||
const entries = Array.from(subtaskRefs.current.entries());
|
||||
await Promise.all(entries.map(([id, entry]) => {
|
||||
return new Promise<void>((resolve) => {
|
||||
entry.ref?.measureInWindow((_x, y, _w, h) => {
|
||||
subPositions[id] = { top: y, bottom: y + h };
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}));
|
||||
return { positions, subPositions };
|
||||
}, [measureItems]);
|
||||
|
||||
const findHoverTarget = useCallback((absoluteY: number, draggedId: string, positions: Record<string, { top: number; bottom: number }>, subPositions: Record<string, { top: number; bottom: number }>) => {
|
||||
const subEntries = Object.entries(subPositions);
|
||||
for (let i = subEntries.length - 1; i >= 0; i--) {
|
||||
const [id, p] = subEntries[i];
|
||||
if (id === draggedId) continue;
|
||||
if (absoluteY >= p.top && absoluteY <= p.bottom) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
for (const [id, p] of Object.entries(positions)) {
|
||||
if (id === draggedId) continue;
|
||||
if (absoluteY >= p.top && absoluteY <= p.bottom) {
|
||||
@@ -228,17 +261,43 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
||||
}, []);
|
||||
|
||||
const handleDragStart = useCallback(async (taskId: string) => {
|
||||
dragStateRef.current = { taskId, positions: await measureItems() };
|
||||
}, [measureItems]);
|
||||
dragStateRef.current = { taskId, ...(await measureAll()) };
|
||||
}, [measureAll]);
|
||||
|
||||
const refreshDragPositions = useCallback(() => {
|
||||
measureAll().then((m) => {
|
||||
if (subtaskDragRef.current) {
|
||||
subtaskDragRef.current = { ...subtaskDragRef.current, ...m };
|
||||
} else if (dragStateRef.current) {
|
||||
dragStateRef.current = { ...dragStateRef.current, ...m };
|
||||
}
|
||||
});
|
||||
}, [measureAll]);
|
||||
|
||||
const updateHoverTarget = useCallback((target: string | null) => {
|
||||
let changed = false;
|
||||
setHoverTaskId((prev) => {
|
||||
if (prev === target) return prev;
|
||||
changed = true;
|
||||
return target;
|
||||
});
|
||||
if (changed && target) {
|
||||
setTimeout(refreshDragPositions, 80);
|
||||
}
|
||||
}, [refreshDragPositions]);
|
||||
|
||||
const handleDragUpdate = useCallback((absoluteY: number) => {
|
||||
const state = dragStateRef.current;
|
||||
if (!state) return;
|
||||
const target = findHoverTarget(absoluteY, state.taskId, state.positions);
|
||||
setHoverTaskId((prev) => (prev === target ? prev : target));
|
||||
const target = findHoverTarget(absoluteY, state.taskId, state.positions, state.subPositions);
|
||||
updateHoverTarget(target);
|
||||
if (target) {
|
||||
const position = calculateDropPosition(absoluteY, target, state.positions);
|
||||
setDropIndicator({ targetId: target, position });
|
||||
if (state.subPositions[target]) {
|
||||
setDropIndicator(null);
|
||||
} else {
|
||||
const position = calculateDropPosition(absoluteY, target, state.positions);
|
||||
setDropIndicator({ targetId: target, position });
|
||||
}
|
||||
} else {
|
||||
// Check if below last item
|
||||
const positions = state.positions;
|
||||
@@ -249,7 +308,7 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
||||
setDropIndicator(null);
|
||||
}
|
||||
}
|
||||
}, [findHoverTarget, calculateDropPosition]);
|
||||
}, [findHoverTarget, calculateDropPosition, updateHoverTarget]);
|
||||
|
||||
const handleDragEnd = useCallback((absoluteY: number) => {
|
||||
const state = dragStateRef.current;
|
||||
@@ -258,25 +317,50 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
||||
setDropIndicator(null);
|
||||
if (!state) return;
|
||||
|
||||
const target = findHoverTarget(absoluteY, state.taskId, state.positions);
|
||||
const target = findHoverTarget(absoluteY, state.taskId, state.positions, state.subPositions);
|
||||
if (target) {
|
||||
(async () => {
|
||||
await convertTaskToSubtask(state.taskId, target);
|
||||
refreshAll();
|
||||
})();
|
||||
if (state.subPositions[target]) {
|
||||
const parentTaskId = subtaskRefs.current.get(target)?.parentTaskId ?? state.taskId;
|
||||
(async () => {
|
||||
await convertTaskToSubtask(state.taskId, parentTaskId, target);
|
||||
await fetchSubtasks(parentTaskId);
|
||||
refreshAll();
|
||||
})();
|
||||
} else {
|
||||
(async () => {
|
||||
await convertTaskToSubtask(state.taskId, target);
|
||||
refreshAll();
|
||||
})();
|
||||
}
|
||||
}
|
||||
}, [findHoverTarget, refreshAll]);
|
||||
}, [findHoverTarget, refreshAll, fetchSubtasks]);
|
||||
|
||||
const handleSubtaskDragStart = useCallback(async (subtaskId: string, parentTaskId: string) => {
|
||||
subtaskDragRef.current = { subtaskId, parentTaskId, positions: await measureItems() };
|
||||
}, [measureItems]);
|
||||
subtaskDragRef.current = { subtaskId, parentTaskId, ...(await measureAll()) };
|
||||
}, [measureAll]);
|
||||
|
||||
const handleSubtaskDragUpdate = useCallback((absoluteY: number) => {
|
||||
const state = subtaskDragRef.current;
|
||||
if (!state) return;
|
||||
const target = findHoverTarget(absoluteY, state.subtaskId, state.positions);
|
||||
setHoverTaskId((prev) => (prev === target ? prev : target));
|
||||
}, [findHoverTarget]);
|
||||
const target = findHoverTarget(absoluteY, state.subtaskId, state.positions, state.subPositions);
|
||||
updateHoverTarget(target);
|
||||
if (target) {
|
||||
if (state.subPositions[target]) {
|
||||
setDropIndicator(null);
|
||||
} else {
|
||||
const position = calculateDropPosition(absoluteY, target, state.positions);
|
||||
setDropIndicator({ targetId: target, position });
|
||||
}
|
||||
} else {
|
||||
const positions = state.positions;
|
||||
const lastItem = Object.values(positions).reduce((max, p) => p.bottom > max.bottom ? p : max, { bottom: 0 });
|
||||
if (absoluteY > lastItem.bottom) {
|
||||
setDropIndicator({ targetId: null, position: 'below' });
|
||||
} else {
|
||||
setDropIndicator(null);
|
||||
}
|
||||
}
|
||||
}, [findHoverTarget, calculateDropPosition, updateHoverTarget]);
|
||||
|
||||
const handleSubtaskDragEnd = useCallback(async (absoluteY: number) => {
|
||||
const state = subtaskDragRef.current;
|
||||
@@ -285,12 +369,16 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
||||
setDropIndicator(null);
|
||||
if (!state) return;
|
||||
|
||||
const target = findHoverTarget(absoluteY, state.subtaskId, state.positions);
|
||||
if (target === state.parentTaskId) return;
|
||||
const target = findHoverTarget(absoluteY, state.subtaskId, state.positions, state.subPositions);
|
||||
if (target === state.subtaskId) return;
|
||||
|
||||
if (target) {
|
||||
await moveSubtaskToTask(state.subtaskId, target);
|
||||
await fetchSubtasks(target);
|
||||
if (state.subPositions[target]) {
|
||||
await setSubtaskParent(state.subtaskId, target);
|
||||
} else if (target !== state.parentTaskId) {
|
||||
await moveSubtaskToTask(state.subtaskId, target);
|
||||
await fetchSubtasks(target);
|
||||
}
|
||||
} else {
|
||||
await convertSubtaskToTask(state.subtaskId);
|
||||
}
|
||||
@@ -315,6 +403,8 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
||||
selectionMode={selectionMode}
|
||||
hovered={hoverTaskId === item.id}
|
||||
registerRef={registerRef}
|
||||
registerSubtaskRef={registerSubtaskRef}
|
||||
hoverTaskId={hoverTaskId}
|
||||
onToggle={handleToggle}
|
||||
onDelete={openTaskDelete}
|
||||
onExpand={toggleExpand}
|
||||
@@ -323,7 +413,6 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
||||
onMenuOpen={(task) => openTaskEdit(task.id)}
|
||||
onSubtaskToggle={handleSubtaskToggle}
|
||||
onSubtaskDelete={openSubtaskDelete}
|
||||
onSubtaskEdit={openSubtaskEdit}
|
||||
onSubtaskMenuOpen={(subtask) => openSubtaskEdit(subtask.id)}
|
||||
onDragStart={handleDragStart}
|
||||
onDragUpdate={handleDragUpdate}
|
||||
@@ -420,7 +509,7 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
||||
<View style={[styles.selectionBar, { backgroundColor: theme.sheetBg, borderTopColor: theme.border }]}>
|
||||
<TouchableOpacity onPress={exitSelection} style={styles.selectionCancel} activeOpacity={0.7}>
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textFaint} strokeWidth={2} strokeLinecap="round" />
|
||||
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textSecondary} strokeWidth={2.2} strokeLinecap="round" />
|
||||
</Svg>
|
||||
<Text style={[styles.selectionCancelText, { color: theme.textFaint }]}>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
@@ -455,6 +544,8 @@ interface TaskRowProps {
|
||||
selectionMode: boolean;
|
||||
hovered: boolean;
|
||||
registerRef: (taskId: string, ref: View | null) => void;
|
||||
registerSubtaskRef: (subtaskId: string, parentTaskId: string, ref: View | null) => void;
|
||||
hoverTaskId: string | null;
|
||||
onToggle: (taskId: string) => void | Promise<void>;
|
||||
onDelete: (task: Task) => void;
|
||||
onExpand: (taskId: string) => void | Promise<void>;
|
||||
@@ -463,7 +554,6 @@ interface TaskRowProps {
|
||||
onMenuOpen: (task: Task) => void;
|
||||
onSubtaskToggle: (subtaskId: string, taskId: string) => void;
|
||||
onSubtaskDelete: (subtask: SubtaskData) => void;
|
||||
onSubtaskEdit: (subtaskId: string) => void;
|
||||
onSubtaskMenuOpen: (subtask: SubtaskData) => void;
|
||||
onDragStart: (taskId: string) => void;
|
||||
onDragUpdate: (absoluteY: number) => void;
|
||||
@@ -485,7 +575,9 @@ const TaskRow = React.memo(function TaskRow({
|
||||
selected,
|
||||
selectionMode,
|
||||
hovered,
|
||||
hoverTaskId,
|
||||
registerRef,
|
||||
registerSubtaskRef,
|
||||
onToggle,
|
||||
onDelete,
|
||||
onExpand,
|
||||
@@ -494,7 +586,6 @@ const TaskRow = React.memo(function TaskRow({
|
||||
onMenuOpen,
|
||||
onSubtaskToggle,
|
||||
onSubtaskDelete,
|
||||
onSubtaskEdit,
|
||||
onSubtaskMenuOpen,
|
||||
onDragStart,
|
||||
onDragUpdate,
|
||||
@@ -540,12 +631,13 @@ const TaskRow = React.memo(function TaskRow({
|
||||
{expanded && subtasks.length > 0 && (
|
||||
<View style={styles.subtaskList}>
|
||||
{sortedSubtasks.map((sub) => (
|
||||
<SubtaskItem
|
||||
key={sub.id}
|
||||
subtask={sub}
|
||||
onToggle={() => onSubtaskToggle(sub.id, task.id)}
|
||||
<SubtaskItem
|
||||
key={sub.id}
|
||||
subtask={sub}
|
||||
hoveredId={hoverTaskId}
|
||||
registerRef={registerSubtaskRef}
|
||||
onToggle={() => onSubtaskToggle(sub.id, task.id)}
|
||||
onDelete={() => onSubtaskDelete(sub)}
|
||||
onPress={() => onSubtaskEdit(sub.id)}
|
||||
onMenuOpen={() => onSubtaskMenuOpen(sub)}
|
||||
selected={selectedIds.has(sub.id)}
|
||||
selectionMode={selectionMode}
|
||||
@@ -569,7 +661,7 @@ const styles = StyleSheet.create({
|
||||
listContent: {
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 8,
|
||||
paddingBottom: 120,
|
||||
paddingBottom: 12,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
|
||||
@@ -47,19 +47,19 @@ export function TaskOverflowMenu({
|
||||
{
|
||||
key: 'edit',
|
||||
label: 'Edit',
|
||||
icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Path d="M4 20h4L19.5 8.5a2.1 2.1 0 0 0-3-3L5 17v3z" stroke={theme.textSecondary} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" fill="none" /></Svg>,
|
||||
icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Path d="M4 20h4L19.5 8.5a2.1 2.1 0 0 0-3-3L5 17v3z" stroke={theme.text} strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" fill="none" /></Svg>,
|
||||
onPress: onEdit,
|
||||
},
|
||||
{
|
||||
key: 'complete',
|
||||
label: task.completed ? 'Mark Incomplete' : 'Mark Complete',
|
||||
icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Path d="M5 12l5 5 9-10" stroke={theme.textSecondary} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" fill="none" /></Svg>,
|
||||
icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Path d="M5 12l5 5 9-10" stroke={theme.text} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" /></Svg>,
|
||||
onPress: onToggleComplete,
|
||||
},
|
||||
{
|
||||
key: 'duplicate',
|
||||
label: 'Duplicate',
|
||||
icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Path d="M8 8h12v12H8zM4 16V4h12" stroke={theme.textSecondary} strokeWidth={1.8} fill="none" /></Svg>,
|
||||
icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Path d="M8 8h12v12H8zM4 16V4h12" stroke={theme.text} strokeWidth={2.2} fill="none" /></Svg>,
|
||||
onPress: onDuplicate,
|
||||
},
|
||||
];
|
||||
@@ -77,14 +77,14 @@ export function TaskOverflowMenu({
|
||||
{
|
||||
key: 'priority',
|
||||
label: 'Change Priority',
|
||||
icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Path d="M5 3v18M5 5h14l-3 4 3 4H5" stroke={theme.textSecondary} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" fill="none" /></Svg>,
|
||||
icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Path d="M5 3v18M5 5h14l-3 4 3 4H5" stroke={theme.text} strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" fill="none" /></Svg>,
|
||||
onPress: onChangePriority,
|
||||
},
|
||||
{
|
||||
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="#E53935" strokeWidth={1.8} 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="#E57373" strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" fill="none" /></Svg>,
|
||||
onPress: onDelete,
|
||||
},
|
||||
);
|
||||
@@ -93,7 +93,7 @@ export function TaskOverflowMenu({
|
||||
actions.push({
|
||||
key: 'addSubtask',
|
||||
label: 'Add Subtask',
|
||||
icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Path d="M12 5v14M5 12h14" stroke={theme.textSecondary} strokeWidth={2} strokeLinecap="round" /></Svg>,
|
||||
icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Path d="M12 5v14M5 12h14" stroke={theme.text} strokeWidth={2.5} strokeLinecap="round" /></Svg>,
|
||||
onPress: onAddSubtask,
|
||||
});
|
||||
}
|
||||
@@ -138,8 +138,8 @@ function Circle2() {
|
||||
const { theme } = useSettings();
|
||||
return (
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Path d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20z" stroke={theme.textSecondary} strokeWidth={1.8} fill="none" />
|
||||
<Path d="M12 2a10 10 0 0 1 0 20z" fill={theme.textSecondary} opacity={0.3} />
|
||||
<Path d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20z" stroke={theme.text} strokeWidth={2.2} fill="none" />
|
||||
<Path d="M12 2a10 10 0 0 1 0 20z" fill={theme.text} opacity={0.35} />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -212,7 +212,7 @@ export function WheelTimePicker({
|
||||
</TouchableOpacity>
|
||||
<View style={styles.headerCenter}>
|
||||
<Text style={[styles.title, { color: theme.text }]}>{title}</Text>
|
||||
<Text style={[styles.preview, { color: theme.accent }]}>
|
||||
<Text style={[styles.preview, { color: theme.accentStrong }]}>
|
||||
{formatTime12(hourIndex, minuteIndex)}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -220,7 +220,7 @@ export function WheelTimePicker({
|
||||
onPress={() => onConfirm(`${String(hourIndex).padStart(2, '0')}:${String(minuteIndex).padStart(2, '0')}`)}
|
||||
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
||||
>
|
||||
<Text style={[styles.headerAction, styles.doneText, { color: theme.accent }]}>Done</Text>
|
||||
<Text style={[styles.headerAction, styles.doneText, { color: theme.accentStrong }]}>Done</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
@@ -238,7 +238,7 @@ export function WheelTimePicker({
|
||||
onIndexChange={setHourIndex}
|
||||
/>
|
||||
<View style={[styles.colonWrap, { width: 24, backgroundColor: theme.sheetBg }]}>
|
||||
<Text style={[styles.colon, { color: theme.accent }]}>:</Text>
|
||||
<Text style={[styles.colon, { color: theme.accentStrong }]}>:</Text>
|
||||
</View>
|
||||
<WheelColumn
|
||||
data={MINUTES}
|
||||
|
||||
@@ -5,10 +5,11 @@ import Task from '../models/Task';
|
||||
import Subtask from '../models/Subtask';
|
||||
import RepeatProfile from '../models/RepeatProfile';
|
||||
import Friendship from '../models/Friendship';
|
||||
import Tombstone from '../models/Tombstone';
|
||||
|
||||
export const database = new Database({
|
||||
adapter,
|
||||
modelClasses: [Category, Task, Subtask, RepeatProfile, Friendship],
|
||||
modelClasses: [Category, Task, Subtask, RepeatProfile, Friendship, Tombstone],
|
||||
});
|
||||
|
||||
export const collections = {
|
||||
@@ -17,4 +18,5 @@ export const collections = {
|
||||
subtasks: database.collections.get<Subtask>('subtasks'),
|
||||
repeatProfiles: database.collections.get<RepeatProfile>('repeat_profiles'),
|
||||
friendships: database.collections.get<Friendship>('friendships'),
|
||||
tombstones: database.collections.get<Tombstone>('tombstones'),
|
||||
};
|
||||
|
||||
@@ -180,5 +180,18 @@ export const migrations = schemaMigrations({
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
toVersion: 17,
|
||||
steps: [
|
||||
createTable({
|
||||
name: 'tombstones',
|
||||
columns: [
|
||||
{ name: 'entity', type: 'string' },
|
||||
{ name: 'entity_id', type: 'string', isIndexed: true },
|
||||
{ name: 'deleted_at', type: 'number', isIndexed: true },
|
||||
],
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { appSchema, tableSchema } from '@nozbe/watermelondb';
|
||||
|
||||
export const schema = appSchema({
|
||||
version: 16,
|
||||
version: 17,
|
||||
tables: [
|
||||
tableSchema({
|
||||
name: 'categories',
|
||||
@@ -85,5 +85,13 @@ export const schema = appSchema({
|
||||
{ name: 'updated_at', type: 'number', isIndexed: true },
|
||||
],
|
||||
}),
|
||||
tableSchema({
|
||||
name: 'tombstones',
|
||||
columns: [
|
||||
{ name: 'entity', type: 'string' },
|
||||
{ name: 'entity_id', type: 'string', isIndexed: true },
|
||||
{ name: 'deleted_at', type: 'number', isIndexed: true },
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -3,11 +3,14 @@ import { database, collections } from './index';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { Q } from '@nozbe/watermelondb';
|
||||
import { apiFetch, getAuthToken } from '@/services/auth';
|
||||
import { cancelTaskReminder } from '@/services/notifications';
|
||||
import Category from '@/models/Category';
|
||||
import Task from '@/models/Task';
|
||||
import Subtask from '@/models/Subtask';
|
||||
import RepeatProfile from '@/models/RepeatProfile';
|
||||
import Friendship from '@/models/Friendship';
|
||||
import { TombstoneEntity } from '@/models/Tombstone';
|
||||
import { fetchPendingTombstones, removeTombstonesInBatch } from './tombstones';
|
||||
|
||||
const LAST_PULLED_AT_KEY = 'sync:lastPulledAt';
|
||||
const LAST_RUN_AT_KEY = 'sync:lastRunAt';
|
||||
@@ -79,11 +82,13 @@ export async function runSync(): Promise<SyncResult> {
|
||||
timestamp: 0,
|
||||
};
|
||||
|
||||
const conflicts = await pushChanges();
|
||||
const { conflicts, pushedDeletions } = await pushChanges();
|
||||
result.conflicts = conflicts.length;
|
||||
|
||||
await applyConflicts(conflicts);
|
||||
|
||||
await pruneSyncedTombstones(conflicts, pushedDeletions);
|
||||
|
||||
const lastPulledAt = await getLastPulledAt();
|
||||
const response = await apiFetch(`/sync?since=${lastPulledAt}`);
|
||||
const data = await response.json();
|
||||
@@ -97,7 +102,7 @@ export async function runSync(): Promise<SyncResult> {
|
||||
return result;
|
||||
}
|
||||
|
||||
async function pushChanges(): Promise<PushConflict[]> {
|
||||
async function pushChanges(): Promise<{ conflicts: PushConflict[]; pushedDeletions: { entity: TombstoneEntity; id: string }[] }> {
|
||||
const lastPulledAt = await getLastPulledAt();
|
||||
|
||||
const [tasks, subtasks, repeatProfiles, friendships] = await Promise.all([
|
||||
@@ -125,6 +130,7 @@ async function pushChanges(): Promise<PushConflict[]> {
|
||||
reminder: t.reminder || 'none',
|
||||
reminders: t.reminders || '',
|
||||
assigneeId: t.assigneeId || null,
|
||||
completedAt: t.completedAt ?? null,
|
||||
createdAt: t.createdAt.getTime(),
|
||||
updatedAt: t.updatedAt.getTime(),
|
||||
});
|
||||
@@ -143,6 +149,7 @@ async function pushChanges(): Promise<PushConflict[]> {
|
||||
const changedSubtasks = subtasks.map((s) => ({
|
||||
id: s.id,
|
||||
taskId: s.taskId,
|
||||
parentSubtaskId: s.parentSubtaskId || null,
|
||||
title: s.title,
|
||||
description: s.description ?? '',
|
||||
priority: s.priority ?? 'none',
|
||||
@@ -221,14 +228,22 @@ async function pushChanges(): Promise<PushConflict[]> {
|
||||
updatedAt: f.updatedAt.getTime(),
|
||||
}));
|
||||
|
||||
const pendingTombstones = await fetchPendingTombstones(lastPulledAt);
|
||||
const changedDeletions = pendingTombstones.map((t) => ({
|
||||
entity: t.entity,
|
||||
id: t.entityId,
|
||||
updatedAt: t.deletedAt.getTime(),
|
||||
}));
|
||||
|
||||
if (
|
||||
changedCategories.length === 0 &&
|
||||
changedTasks.length === 0 &&
|
||||
changedSubtasks.length === 0 &&
|
||||
changedRepeatProfiles.length === 0 &&
|
||||
changedFriendships.length === 0
|
||||
changedFriendships.length === 0 &&
|
||||
changedDeletions.length === 0
|
||||
) {
|
||||
return [];
|
||||
return { conflicts: [], pushedDeletions: [] };
|
||||
}
|
||||
|
||||
const response = await apiFetch('/sync/push', {
|
||||
@@ -241,12 +256,40 @@ async function pushChanges(): Promise<PushConflict[]> {
|
||||
repeatProfiles: changedRepeatProfiles,
|
||||
friendships: changedFriendships,
|
||||
},
|
||||
deleted: changedDeletions,
|
||||
lastPulledAt,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
return (data.conflicts ?? []) as PushConflict[];
|
||||
return {
|
||||
conflicts: (data.conflicts ?? []) as PushConflict[],
|
||||
pushedDeletions: pendingTombstones.map((t) => ({ entity: t.entity, id: t.entityId })),
|
||||
};
|
||||
}
|
||||
|
||||
// Remove local tombstones that were just accepted by the server. Tombstones
|
||||
// whose deletion lost to a newer server row (conflict) are kept so the row
|
||||
// stays marked as deleted locally after the server version wins.
|
||||
async function pruneSyncedTombstones(
|
||||
conflicts: PushConflict[],
|
||||
pushedDeletions: { entity: TombstoneEntity; id: string }[]
|
||||
): Promise<void> {
|
||||
if (pushedDeletions.length === 0) return;
|
||||
const conflictKeys = new Set(conflicts.map((c) => `${c.entity}:${c.id}`));
|
||||
const accepted = pushedDeletions.filter((d) => !conflictKeys.has(`${d.entity}:${d.id}`));
|
||||
const grouped = new Map<TombstoneEntity, string[]>();
|
||||
for (const t of accepted) {
|
||||
const list = grouped.get(t.entity) ?? [];
|
||||
list.push(t.id);
|
||||
grouped.set(t.entity, list);
|
||||
}
|
||||
if (accepted.length === 0) return;
|
||||
await database.write(async () => {
|
||||
for (const [entity, ids] of grouped) {
|
||||
await removeTombstonesInBatch(entity, ids);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function applyConflicts(conflicts: PushConflict[]): Promise<void> {
|
||||
@@ -272,6 +315,9 @@ async function applyConflicts(conflicts: PushConflict[]): Promise<void> {
|
||||
await upsertFriendship(row, true);
|
||||
break;
|
||||
}
|
||||
// The server version of this row is newer - drop any pending local
|
||||
// tombstone so the resurrected row is kept and synced going forward.
|
||||
await removeTombstonesInBatch(conflict.entity, [conflict.id]);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -282,10 +328,16 @@ async function applyServerChanges(data: {
|
||||
subtasks: ServerRow[];
|
||||
repeatProfiles: ServerRow[];
|
||||
friendships: ServerRow[];
|
||||
deleted?: { entity: TombstoneEntity; id: string; updatedAt: number }[];
|
||||
}): Promise<SyncResult['pulled']> {
|
||||
const counts = { categories: 0, tasks: 0, subtasks: 0, repeatProfiles: 0, friendships: 0 };
|
||||
|
||||
await database.write(async () => {
|
||||
// Apply deletions first so rows that were removed server-side are gone
|
||||
// before any upsert re-imports their data.
|
||||
for (const tomb of data.deleted ?? []) {
|
||||
await applyRemoteTombstone(tomb);
|
||||
}
|
||||
for (const cat of data.categories) {
|
||||
if (await upsertCategory(cat, false)) counts.categories++;
|
||||
}
|
||||
@@ -306,6 +358,68 @@ async function applyServerChanges(data: {
|
||||
return counts;
|
||||
}
|
||||
|
||||
// Apply a server-side deletion to the local DB, cascading to every related
|
||||
// row (category -> tasks -> subtasks, task -> subtasks, subtask -> children).
|
||||
// LWW: if the local row is newer than the deletion, it survives.
|
||||
async function applyRemoteTombstone(tomb: { entity: TombstoneEntity; id: string; updatedAt: number }): Promise<void> {
|
||||
const { entity, id, updatedAt } = tomb;
|
||||
const row = await findOrNull<any>(collections[entity as keyof typeof collections] as any, id);
|
||||
if (row) {
|
||||
if (row.updatedAt.getTime() > updatedAt) {
|
||||
return; // local edit is newer than the remote deletion - keep it
|
||||
}
|
||||
await destroyLocalEntityCascade(entity, id);
|
||||
} else {
|
||||
// Row already gone locally - clear the tombstone if the server has it.
|
||||
await removeTombstonesInBatch(entity, [id]);
|
||||
}
|
||||
}
|
||||
|
||||
async function destroyLocalEntityCascade(entity: TombstoneEntity, id: string): Promise<void> {
|
||||
if (entity === 'tasks') {
|
||||
const subtasks = await collections.subtasks.query(Q.where('task_id', id)).fetch();
|
||||
for (const s of subtasks) {
|
||||
await destroyLocalEntityCascade('subtasks', s.id);
|
||||
}
|
||||
const task = await findOrNull<any>(collections.tasks, id).catch(() => null);
|
||||
if (task) {
|
||||
await task.destroyPermanently();
|
||||
await cancelTaskReminder(id).catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (entity === 'categories') {
|
||||
const tasks = await collections.tasks.query(Q.where('category_id', id)).fetch();
|
||||
for (const t of tasks) {
|
||||
await destroyLocalEntityCascade('tasks', t.id);
|
||||
}
|
||||
const cat = await findOrNull<any>(collections.categories, id).catch(() => null);
|
||||
if (cat) await cat.destroyPermanently();
|
||||
return;
|
||||
}
|
||||
if (entity === 'subtasks') {
|
||||
const children = await collections.subtasks.query(Q.where('parent_subtask_id', id)).fetch();
|
||||
for (const c of children) {
|
||||
await destroyLocalEntityCascade('subtasks', c.id);
|
||||
}
|
||||
const sub = await findOrNull<any>(collections.subtasks, id).catch(() => null);
|
||||
if (sub) {
|
||||
await sub.destroyPermanently();
|
||||
await cancelTaskReminder(id).catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (entity === 'repeatProfiles') {
|
||||
const p = await findOrNull<any>(collections.repeatProfiles, id).catch(() => null);
|
||||
if (p) await p.destroyPermanently();
|
||||
return;
|
||||
}
|
||||
if (entity === 'friendships') {
|
||||
const f = await findOrNull<any>(collections.friendships, id).catch(() => null);
|
||||
if (f) await f.destroyPermanently();
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertCategory(row: ServerRow, force: boolean): Promise<boolean> {
|
||||
const local = await findOrNull<Category>(collections.categories, row.id);
|
||||
if (!local) {
|
||||
@@ -347,7 +461,9 @@ async function upsertTask(row: ServerRow, force: boolean): Promise<boolean> {
|
||||
t.repeatDays = String(row.repeatDays ?? '');
|
||||
t.seriesId = String(row.seriesId ?? '');
|
||||
t.reminder = row.reminder ?? 'none';
|
||||
t.reminders = String(row.reminders ?? '');
|
||||
t.assigneeId = row.assigneeId ?? null;
|
||||
t.completedAt = row.completedAt == null ? null : Number(row.completedAt);
|
||||
t.createdAt = new Date(row.createdAt ?? Date.now());
|
||||
t.updatedAt = new Date(row.updatedAt ?? Date.now());
|
||||
});
|
||||
@@ -370,7 +486,9 @@ async function upsertTask(row: ServerRow, force: boolean): Promise<boolean> {
|
||||
t.repeatDays = String(row.repeatDays ?? t.repeatDays);
|
||||
t.seriesId = String(row.seriesId ?? t.seriesId);
|
||||
t.reminder = row.reminder ?? t.reminder;
|
||||
t.reminders = String(row.reminders ?? t.reminders ?? '');
|
||||
t.assigneeId = row.assigneeId ?? t.assigneeId;
|
||||
t.completedAt = row.completedAt == null ? null : Number(row.completedAt ?? t.completedAt);
|
||||
t.updatedAt = new Date(row.updatedAt ?? Date.now());
|
||||
});
|
||||
return true;
|
||||
@@ -410,6 +528,7 @@ async function upsertSubtask(row: ServerRow, force: boolean): Promise<boolean> {
|
||||
if (!local) {
|
||||
await collections.subtasks.create((s) => {
|
||||
s.taskId = String(row.taskId ?? '');
|
||||
s.parentSubtaskId = row.parentSubtaskId == null ? null : String(row.parentSubtaskId);
|
||||
s.title = String(row.title ?? '');
|
||||
s.description = String(row.description ?? '');
|
||||
s.priority = row.priority ?? 'none';
|
||||
@@ -435,6 +554,7 @@ async function upsertSubtask(row: ServerRow, force: boolean): Promise<boolean> {
|
||||
}
|
||||
await local.update((s) => {
|
||||
s.taskId = String(row.taskId ?? s.taskId);
|
||||
s.parentSubtaskId = row.parentSubtaskId == null ? null : String(row.parentSubtaskId);
|
||||
s.title = String(row.title ?? s.title);
|
||||
s.description = String(row.description ?? s.description);
|
||||
s.priority = row.priority ?? s.priority;
|
||||
@@ -572,4 +692,4 @@ export function isSyncInFlight(): boolean {
|
||||
|
||||
export function isWatchingForUpdates(): boolean {
|
||||
return watcherActive;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { database, collections } from './index';
|
||||
import { Q } from '@nozbe/watermelondb';
|
||||
import Tombstone, { TombstoneEntity } from '@/models/Tombstone';
|
||||
|
||||
// Batch variant: must be called from inside an existing database.write().
|
||||
export async function recordTombstonesInBatch(entity: TombstoneEntity, ids: string[]): Promise<void> {
|
||||
const uniqueIds = [...new Set(ids)].filter(Boolean);
|
||||
for (const id of uniqueIds) {
|
||||
const existing = await collections.tombstones
|
||||
.query(Q.where('entity', entity), Q.where('entity_id', id))
|
||||
.fetch();
|
||||
const now = new Date();
|
||||
if (existing.length > 0) {
|
||||
await existing[0].update((t) => {
|
||||
t.deletedAt = now;
|
||||
});
|
||||
} else {
|
||||
await collections.tombstones.create((t) => {
|
||||
t.entity = entity;
|
||||
t.entityId = id;
|
||||
t.deletedAt = now;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Batch variant: must be called from inside an existing database.write().
|
||||
export async function removeTombstonesInBatch(entity: TombstoneEntity, ids: string[]): Promise<void> {
|
||||
for (const id of ids) {
|
||||
const existing = await collections.tombstones
|
||||
.query(Q.where('entity', entity), Q.where('entity_id', id))
|
||||
.fetch();
|
||||
for (const row of existing) {
|
||||
await row.destroyPermanently();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function recordTombstone(entity: TombstoneEntity, id: string): Promise<void> {
|
||||
await database.write(async () => {
|
||||
await recordTombstonesInBatch(entity, [id]);
|
||||
});
|
||||
}
|
||||
|
||||
export async function recordTombstones(entity: TombstoneEntity, ids: string[]): Promise<void> {
|
||||
await database.write(async () => {
|
||||
await recordTombstonesInBatch(entity, ids);
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeTombstone(entity: TombstoneEntity, id: string): Promise<void> {
|
||||
await database.write(async () => {
|
||||
await removeTombstonesInBatch(entity, [id]);
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeTombstones(entity: TombstoneEntity, ids: string[]): Promise<void> {
|
||||
await database.write(async () => {
|
||||
await removeTombstonesInBatch(entity, ids);
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchPendingTombstones(sinceMs: number): Promise<Tombstone[]> {
|
||||
return collections.tombstones.query(Q.where('deleted_at', Q.gt(sinceMs))).fetch();
|
||||
}
|
||||
@@ -23,7 +23,6 @@ export default class Task extends Model {
|
||||
@field('repeat') repeat!: Repeat;
|
||||
@field('repeat_interval') repeatInterval!: number;
|
||||
@field('repeat_days') repeatDays!: string;
|
||||
@field('color') color!: string;
|
||||
@field('series_id') seriesId!: string;
|
||||
@field('reminder') reminder!: Reminder;
|
||||
@field('reminders') reminders!: string;
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Model } from '@nozbe/watermelondb';
|
||||
import { field, date } from '@nozbe/watermelondb/decorators';
|
||||
|
||||
export type TombstoneEntity = 'categories' | 'tasks' | 'subtasks' | 'repeatProfiles' | 'friendships';
|
||||
|
||||
export default class Tombstone extends Model {
|
||||
static table = 'tombstones';
|
||||
|
||||
@field('entity') entity!: TombstoneEntity;
|
||||
@field('entity_id') entityId!: string;
|
||||
@date('deleted_at') deletedAt!: Date;
|
||||
}
|
||||
@@ -97,15 +97,22 @@ export async function apiFetch(path: string, options: RequestInit = {}): Promise
|
||||
const response = await fetch(`${apiBaseUrl}${path}`, { ...options, headers });
|
||||
if (!response.ok) {
|
||||
let message = `Request failed (${response.status})`;
|
||||
let details: any = null;
|
||||
try {
|
||||
const body = await response.json();
|
||||
if (body && typeof body.message === 'string') {
|
||||
message = body.message;
|
||||
}
|
||||
if (body && body.error && body.error.details) {
|
||||
details = body.error.details;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
throw new Error(message);
|
||||
const error = new Error(message);
|
||||
(error as any).details = details;
|
||||
(error as any).status = response.status;
|
||||
throw error;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface ThemeColors {
|
||||
textFaint: string;
|
||||
textMuted: string;
|
||||
accent: string;
|
||||
accentStrong: string;
|
||||
accentSoft: string;
|
||||
accentBorder: string;
|
||||
accentText: string;
|
||||
@@ -74,6 +75,13 @@ export function mixHex(color: string, target: string, ratio: number): string {
|
||||
return `#${toHexByte(r1 + (r2 - r1) * ratio)}${toHexByte(g1 + (g2 - g1) * ratio)}${toHexByte(b1 + (b2 - b1) * ratio)}`.toUpperCase();
|
||||
}
|
||||
|
||||
export function desaturate(hex: string, amount: number): string {
|
||||
const [r, g, b] = parseHex(hex);
|
||||
const gray = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
const shift = 1 - amount;
|
||||
return `#${toHexByte(gray + (r - gray) * shift)}${toHexByte(gray + (g - gray) * shift)}${toHexByte(gray + (b - gray) * shift)}`.toUpperCase();
|
||||
}
|
||||
|
||||
function accentTextColor(accent: string): string {
|
||||
const [r, g, b] = parseHex(accent).map((v) => v / 255);
|
||||
const linear = (v: number) => (v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4));
|
||||
@@ -82,24 +90,26 @@ function accentTextColor(accent: string): string {
|
||||
}
|
||||
|
||||
export function colors(accent: string = DEFAULT_ACCENT): ThemeColors {
|
||||
const softAccent = desaturate(accent, 0.35);
|
||||
return {
|
||||
background: '#121212',
|
||||
card: '#1E1E1E',
|
||||
cardAlt: '#262626',
|
||||
border: '#2A2A2A',
|
||||
borderStrong: '#3A3A3A',
|
||||
text: '#F5F5F5',
|
||||
textSecondary: '#E0E0E0',
|
||||
textFaint: '#BDBDBD',
|
||||
textMuted: '#8E8E8E',
|
||||
cardAlt: '#28292C',
|
||||
border: '#2C2E33',
|
||||
borderStrong: '#3C3E45',
|
||||
text: '#F2F3F5',
|
||||
textSecondary: '#E3E4E8',
|
||||
textFaint: '#C4C6CC',
|
||||
textMuted: '#9C9FA7',
|
||||
accent,
|
||||
accentSoft: mixHex(accent, '#121212', 0.12),
|
||||
accentBorder: mixHex(accent, '#121212', 0.25),
|
||||
accentStrong: mixHex(accent, '#FFFFFF', 0.55),
|
||||
accentSoft: mixHex(softAccent, '#121212', 0.18),
|
||||
accentBorder: mixHex(desaturate(accent, 0.15), '#121212', 0.5),
|
||||
accentText: accentTextColor(accent),
|
||||
inputBg: '#1A1A1A',
|
||||
inputBg: '#1A1B1E',
|
||||
overlay: 'rgba(0,0,0,0.6)',
|
||||
sheetBg: '#242424',
|
||||
tabBarBg: '#1A1A1A',
|
||||
sheetBg: '#26282D',
|
||||
tabBarBg: '#16171A',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -225,3 +235,5 @@ export function useSettings() {
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export * from './theme/tokens';
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
export const SPACING = {
|
||||
xs: 4,
|
||||
sm: 8,
|
||||
md: 12,
|
||||
lg: 16,
|
||||
xl: 20,
|
||||
xxl: 24,
|
||||
xxxl: 32,
|
||||
} as const;
|
||||
|
||||
export const BORDER_RADIUS = {
|
||||
xs: 6,
|
||||
sm: 8,
|
||||
md: 10,
|
||||
lg: 12,
|
||||
xl: 16,
|
||||
xxl: 20,
|
||||
round: 999,
|
||||
circle: 50,
|
||||
} as const;
|
||||
|
||||
export const SHADOWS = {
|
||||
none: {
|
||||
shadowColor: 'transparent',
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0,
|
||||
shadowRadius: 0,
|
||||
elevation: 0,
|
||||
},
|
||||
xs: {
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.04,
|
||||
shadowRadius: 2,
|
||||
elevation: 1,
|
||||
},
|
||||
sm: {
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.06,
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
},
|
||||
md: {
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 8,
|
||||
elevation: 4,
|
||||
},
|
||||
lg: {
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.12,
|
||||
shadowRadius: 16,
|
||||
elevation: 8,
|
||||
},
|
||||
xl: {
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 12 },
|
||||
shadowOpacity: 0.16,
|
||||
shadowRadius: 24,
|
||||
elevation: 12,
|
||||
},
|
||||
inner: {
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.03,
|
||||
shadowRadius: 2,
|
||||
elevation: 0,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const TYPOGRAPHY = {
|
||||
display: {
|
||||
fontSize: 34,
|
||||
fontWeight: '700' as const,
|
||||
lineHeight: 42,
|
||||
letterSpacing: -0.5,
|
||||
},
|
||||
h1: {
|
||||
fontSize: 28,
|
||||
fontWeight: '700' as const,
|
||||
lineHeight: 36,
|
||||
letterSpacing: -0.3,
|
||||
},
|
||||
h2: {
|
||||
fontSize: 22,
|
||||
fontWeight: '700' as const,
|
||||
lineHeight: 30,
|
||||
letterSpacing: -0.2,
|
||||
},
|
||||
h3: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600' as const,
|
||||
lineHeight: 24,
|
||||
letterSpacing: 0,
|
||||
},
|
||||
h4: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600' as const,
|
||||
lineHeight: 22,
|
||||
letterSpacing: 0,
|
||||
},
|
||||
body: {
|
||||
fontSize: 16,
|
||||
fontWeight: '400' as const,
|
||||
lineHeight: 24,
|
||||
letterSpacing: 0,
|
||||
},
|
||||
bodyStrong: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600' as const,
|
||||
lineHeight: 24,
|
||||
letterSpacing: 0,
|
||||
},
|
||||
bodySmall: {
|
||||
fontSize: 14,
|
||||
fontWeight: '400' as const,
|
||||
lineHeight: 20,
|
||||
letterSpacing: 0,
|
||||
},
|
||||
bodySmallStrong: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600' as const,
|
||||
lineHeight: 20,
|
||||
letterSpacing: 0,
|
||||
},
|
||||
caption: {
|
||||
fontSize: 12,
|
||||
fontWeight: '400' as const,
|
||||
lineHeight: 16,
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
captionStrong: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600' as const,
|
||||
lineHeight: 16,
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
overline: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600' as const,
|
||||
lineHeight: 14,
|
||||
letterSpacing: 0.8,
|
||||
textTransform: 'uppercase' as const,
|
||||
},
|
||||
button: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600' as const,
|
||||
lineHeight: 20,
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
buttonSmall: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600' as const,
|
||||
lineHeight: 18,
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const TRANSITIONS = {
|
||||
fast: 120,
|
||||
normal: 200,
|
||||
slow: 300,
|
||||
slower: 400,
|
||||
} as const;
|
||||
|
||||
export const EASING = {
|
||||
easeOut: 'ease-out',
|
||||
easeInOut: 'ease-in-out',
|
||||
spring: 'spring',
|
||||
} as const;
|
||||
|
||||
export const LAYOUT = {
|
||||
screenPadding: SPACING.lg,
|
||||
cardPadding: SPACING.lg,
|
||||
sectionGap: SPACING.xl,
|
||||
itemGap: SPACING.md,
|
||||
inlineGap: SPACING.sm,
|
||||
} as const;
|
||||
|
||||
export const TOUCH_TARGET = {
|
||||
minSize: 44,
|
||||
comfortableSize: 48,
|
||||
} as const;
|
||||
|
||||
export const Z_INDEX = {
|
||||
base: 0,
|
||||
dropdown: 100,
|
||||
modal: 200,
|
||||
toast: 300,
|
||||
tooltip: 400,
|
||||
} as const;
|
||||
@@ -12,6 +12,12 @@ export const REPEAT_OPTIONS: { value: Repeat; label: string }[] = [
|
||||
|
||||
export const WEEKDAY_LABELS = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
|
||||
|
||||
export const WEEKDAY_ORDER = [1, 2, 3, 4, 5, 6, 0];
|
||||
|
||||
export function weekdaysInDisplayOrder(days: number[]): number[] {
|
||||
return WEEKDAY_ORDER.filter((day) => days.includes(day));
|
||||
}
|
||||
|
||||
export function repeatDaysToString(days: number[]): string {
|
||||
return [...days].sort().join(',');
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { database, collections } from '@/database';
|
||||
import { Q } from '@nozbe/watermelondb';
|
||||
import { recordTombstonesInBatch } from '@/database/tombstones';
|
||||
|
||||
export async function createCategory(name: string, color: string): Promise<void> {
|
||||
await database.write(async () => {
|
||||
@@ -42,5 +43,6 @@ export async function deleteCategory(categoryId: string): Promise<void> {
|
||||
|
||||
const category = await collections.categories.find(categoryId);
|
||||
await category.destroyPermanently();
|
||||
await recordTombstonesInBatch('categories', [categoryId]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { database, collections } from '../database';
|
||||
import { Repeat } from '@/types';
|
||||
import { recordTombstonesInBatch } from '../database/tombstones';
|
||||
|
||||
export async function createRepeatProfile(
|
||||
name: string,
|
||||
@@ -24,5 +25,6 @@ export async function deleteRepeatProfile(id: string): Promise<void> {
|
||||
await database.write(async () => {
|
||||
const profile = await collections.repeatProfiles.find(id);
|
||||
await profile.destroyPermanently();
|
||||
await recordTombstonesInBatch('repeatProfiles', [id]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
export const TAB_BAR_HEIGHT = 62;
|
||||
|
||||
let lastVisitedTab = 'index';
|
||||
|
||||
export function setLastVisitedTab(tab: string): void {
|
||||
lastVisitedTab = tab;
|
||||
}
|
||||
|
||||
export function getLastVisitedTab(): string {
|
||||
return lastVisitedTab;
|
||||
}
|
||||
|
||||
export function tabHref(tab: string): string {
|
||||
if (tab === 'index') return '/';
|
||||
return `/${tab}`;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { database, collections } from '@/database';
|
||||
import { Q } from '@nozbe/watermelondb';
|
||||
import { Priority, Repeat, Reminder, SubtaskData } from '@/types';
|
||||
import { scheduleTaskReminder, cancelTaskReminder } from '@/services/notifications';
|
||||
import { recordTombstonesInBatch } from '@/database/tombstones';
|
||||
|
||||
function mapSubtaskRow(s: any, taskId?: string): SubtaskData {
|
||||
return {
|
||||
@@ -147,6 +148,10 @@ export async function deleteTaskWithSubtasks(taskId: string): Promise<void> {
|
||||
}
|
||||
const task = await collections.tasks.find(taskId);
|
||||
await task.destroyPermanently();
|
||||
if (subtasks.length > 0) {
|
||||
await recordTombstonesInBatch('subtasks', subtasks.map((s) => s.id));
|
||||
}
|
||||
await recordTombstonesInBatch('tasks', [taskId]);
|
||||
}
|
||||
|
||||
export type TaskDeleteScope = 'this' | 'future' | 'all';
|
||||
@@ -200,7 +205,7 @@ export async function toggleTaskComplete(taskId: string): Promise<string | null>
|
||||
t.updatedAt = new Date();
|
||||
});
|
||||
if (completing && task.repeat !== 'none' && task.dueDate) {
|
||||
nextOccurrence = await createNextOccurrence(task);
|
||||
nextOccurrence = await createNextOccurrenceIfNeeded(task);
|
||||
}
|
||||
});
|
||||
if (nextOccurrence) await scheduleTaskReminder(nextOccurrence);
|
||||
@@ -275,7 +280,15 @@ export async function deleteSubtask(subtaskId: string): Promise<void> {
|
||||
await database.write(async () => {
|
||||
const subtask = await collections.subtasks.find(subtaskId);
|
||||
parentTaskId = subtask.taskId;
|
||||
const children = await collections.subtasks.query(Q.where('parent_subtask_id', subtaskId)).fetch();
|
||||
for (const child of children) {
|
||||
await child.update((c) => {
|
||||
c.parentSubtaskId = null;
|
||||
c.updatedAt = new Date();
|
||||
});
|
||||
}
|
||||
await subtask.destroyPermanently();
|
||||
await recordTombstonesInBatch('subtasks', [subtaskId]);
|
||||
});
|
||||
await cancelTaskReminder(subtaskId);
|
||||
if (parentTaskId) {
|
||||
@@ -379,17 +392,21 @@ export async function deleteTask(taskId: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
export async function convertTaskToSubtask(taskId: string, parentTaskId: string): Promise<void> {
|
||||
export async function convertTaskToSubtask(taskId: string, parentTaskId: string, parentSubtaskId: string | null = null): Promise<void> {
|
||||
if (taskId === parentTaskId) return;
|
||||
await database.write(async () => {
|
||||
const task = await collections.tasks.find(taskId);
|
||||
const ownSubtasks = await collections.subtasks.query(Q.where('task_id', taskId)).fetch();
|
||||
const existing = await collections.subtasks.query(Q.where('task_id', parentTaskId)).fetch();
|
||||
const existing = await collections.subtasks.query(
|
||||
Q.where('task_id', parentTaskId),
|
||||
parentSubtaskId ? Q.where('parent_subtask_id', parentSubtaskId) : Q.where('parent_subtask_id', null)
|
||||
).fetch();
|
||||
const now = new Date();
|
||||
|
||||
let order = existing.length;
|
||||
await collections.subtasks.create((s) => {
|
||||
const created = await collections.subtasks.create((s) => {
|
||||
s.taskId = parentTaskId;
|
||||
s.parentSubtaskId = parentSubtaskId;
|
||||
s.title = task.title;
|
||||
s.description = task.description;
|
||||
s.priority = task.priority;
|
||||
@@ -412,6 +429,7 @@ export async function convertTaskToSubtask(taskId: string, parentTaskId: string)
|
||||
for (const subtask of ownSubtasks) {
|
||||
await collections.subtasks.create((s) => {
|
||||
s.taskId = parentTaskId;
|
||||
s.parentSubtaskId = created.id;
|
||||
s.title = subtask.title;
|
||||
s.description = subtask.description;
|
||||
s.priority = subtask.priority;
|
||||
@@ -432,7 +450,13 @@ export async function convertTaskToSubtask(taskId: string, parentTaskId: string)
|
||||
});
|
||||
}
|
||||
|
||||
const parent = await collections.tasks.find(parentTaskId);
|
||||
await parent.update((p) => {
|
||||
p.updatedAt = now;
|
||||
});
|
||||
|
||||
await task.destroyPermanently();
|
||||
await recordTombstonesInBatch('tasks', [taskId]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -464,7 +488,16 @@ export async function convertSubtaskToTask(subtaskId: string): Promise<void> {
|
||||
});
|
||||
newTaskId = task.id;
|
||||
|
||||
const children = await collections.subtasks.query(Q.where('parent_subtask_id', subtaskId)).fetch();
|
||||
for (const child of children) {
|
||||
await child.update((c) => {
|
||||
c.parentSubtaskId = null;
|
||||
c.updatedAt = now;
|
||||
});
|
||||
}
|
||||
|
||||
await subtask.destroyPermanently();
|
||||
await recordTombstonesInBatch('subtasks', [subtaskId]);
|
||||
await parent.update((p) => {
|
||||
p.updatedAt = now;
|
||||
});
|
||||
@@ -487,6 +520,7 @@ export async function moveSubtaskToTask(subtaskId: string, toTaskId: string): Pr
|
||||
|
||||
await subtask.update((s) => {
|
||||
s.taskId = toTaskId;
|
||||
s.parentSubtaskId = null;
|
||||
s.order = siblings.length;
|
||||
s.updatedAt = now;
|
||||
});
|
||||
@@ -505,6 +539,44 @@ export async function moveSubtaskToTask(subtaskId: string, toTaskId: string): Pr
|
||||
});
|
||||
}
|
||||
|
||||
export async function setSubtaskParent(subtaskId: string, newParentId: string): Promise<void> {
|
||||
if (subtaskId === newParentId) return;
|
||||
await database.write(async () => {
|
||||
const subtask = await collections.subtasks.find(subtaskId);
|
||||
if (subtask.parentSubtaskId === newParentId) return;
|
||||
|
||||
let cursor: any = null;
|
||||
try {
|
||||
cursor = await collections.subtasks.find(newParentId);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const now = new Date();
|
||||
let guard = 0;
|
||||
while (cursor && guard++ < 200) {
|
||||
if (cursor.id === subtaskId) return;
|
||||
if (!cursor.parentSubtaskId) break;
|
||||
cursor = await collections.subtasks.find(cursor.parentSubtaskId);
|
||||
}
|
||||
|
||||
const siblings = await collections.subtasks.query(
|
||||
Q.where('task_id', subtask.taskId),
|
||||
Q.where('parent_subtask_id', newParentId)
|
||||
).fetch();
|
||||
|
||||
await subtask.update((s) => {
|
||||
s.parentSubtaskId = newParentId;
|
||||
s.order = siblings.length;
|
||||
s.updatedAt = now;
|
||||
});
|
||||
|
||||
const task = await collections.tasks.find(subtask.taskId);
|
||||
await task.update((t) => {
|
||||
t.updatedAt = now;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function setTaskCompleted(taskId: string, completed: boolean): Promise<void> {
|
||||
let nextOccurrence: any = null;
|
||||
await database.write(async () => {
|
||||
@@ -515,7 +587,7 @@ export async function setTaskCompleted(taskId: string, completed: boolean): Prom
|
||||
t.updatedAt = new Date();
|
||||
});
|
||||
if (completed && task.repeat !== 'none' && task.dueDate) {
|
||||
nextOccurrence = await createNextOccurrence(task);
|
||||
nextOccurrence = await createNextOccurrenceIfNeeded(task);
|
||||
}
|
||||
});
|
||||
if (nextOccurrence) await scheduleTaskReminder(nextOccurrence);
|
||||
@@ -524,7 +596,23 @@ export async function setTaskCompleted(taskId: string, completed: boolean): Prom
|
||||
else await scheduleTaskReminder(task);
|
||||
}
|
||||
|
||||
async function createNextOccurrence(task: any): Promise<any> {
|
||||
async function createNextOccurrenceIfNeeded(task: any): Promise<any> {
|
||||
const seriesId = task.seriesId || newSeriesId();
|
||||
const existing = await collections.tasks.query(
|
||||
Q.where('series_id', seriesId),
|
||||
Q.where('completed', false)
|
||||
).fetch();
|
||||
if (existing.length > 0) return null;
|
||||
const next = await createNextOccurrence(task, seriesId);
|
||||
if (!task.seriesId) {
|
||||
await task.update((t: any) => {
|
||||
t.seriesId = seriesId;
|
||||
});
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
async function createNextOccurrence(task: any, seriesId: string): Promise<any> {
|
||||
const nextDate = computeNextOccurrence(task.dueDate, task.repeat, task.repeatInterval, task.repeatDays);
|
||||
const subtasks = await collections.subtasks.query(Q.where('task_id', task.id)).fetch();
|
||||
const now = new Date();
|
||||
@@ -542,7 +630,7 @@ async function createNextOccurrence(task: any): Promise<any> {
|
||||
t.repeat = task.repeat;
|
||||
t.repeatInterval = task.repeatInterval || 1;
|
||||
t.repeatDays = task.repeatDays || '';
|
||||
t.seriesId = task.seriesId || newSeriesId();
|
||||
t.seriesId = seriesId;
|
||||
t.reminder = task.reminder || 'none';
|
||||
t.assigneeId = task.assigneeId ?? null;
|
||||
t.createdAt = now;
|
||||
|
||||
Reference in New Issue
Block a user