basic working app currently wworking on the sync to work
Build APK / build (push) Canceled after 0s

This commit is contained in:
2026-08-06 11:16:47 +02:00
commit 793e2d9044
192 changed files with 42523 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
{
"enabledPlugins": {
"expo@claude-plugins-official": true
}
}
+15
View File
@@ -0,0 +1,15 @@
# API Configuration
EXPO_PUBLIC_API_URL=http://localhost:3000/api
# Optional: Analytics & Monitoring
# EXPO_PUBLIC_SENTRY_DSN=
# EXPO_PUBLIC_AMPLITUDE_KEY=
# EXPO_PUBLIC_POSTHOG_KEY=
# Optional: Feature Flags
# EXPO_PUBLIC_ENABLE_SYNC=true
# EXPO_PUBLIC_ENABLE_NOTIFICATIONS=true
# Optional: Build Configuration
# EXPO_PUBLIC_APP_VERSION=1.0.0
# EXPO_PUBLIC_BUILD_NUMBER=1
+41
View File
@@ -0,0 +1,41 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
# generated native folders
/ios
/android
+26
View File
@@ -0,0 +1,26 @@
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100,
"bracketSpacing": true,
"arrowParens": "avoid",
"endOfLine": "lf",
"plugins": [],
"overrides": [
{
"files": "*.md",
"options": {
"printWidth": 120,
"proseWrap": "always"
}
},
{
"files": "*.json",
"options": {
"printWidth": 200
}
}
]
}
+3
View File
@@ -0,0 +1,3 @@
# Expo HAS CHANGED
Read the exact versioned docs at https://docs.expo.dev/versions/v57.0.0/ before writing any code.
+20
View File
@@ -0,0 +1,20 @@
import { StatusBar } from 'expo-status-bar';
import { StyleSheet, Text, View } from 'react-native';
export default function App() {
return (
<View style={styles.container}>
<Text>Open up App.tsx to start working on your app!</Text>
<StatusBar style="auto" />
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
alignItems: 'center',
justifyContent: 'center',
},
});
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+41
View File
@@ -0,0 +1,41 @@
{
"expo": {
"name": "carry-your-live",
"slug": "carry-your-live",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "dark",
"ios": {
"supportsTablet": true
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/android-icon-foreground.png",
"backgroundImage": "./assets/android-icon-background.png",
"monochromeImage": "./assets/android-icon-monochrome.png"
},
"predictiveBackGestureEnabled": false,
"package": "com.anonymous.carryyourlive"
},
"web": {
"favicon": "./assets/favicon.png"
},
"plugins": [
"@react-native-community/datetimepicker",
[
"expo-notifications",
{
"icon": "./assets/android-icon-foreground.png",
"color": "#1E88E5"
}
]
],
"extra": {
"eas": {
"projectId": "93a56631-01e5-45ab-9de1-7e6fb863c0a9"
}
}
}
}
+57
View File
@@ -0,0 +1,57 @@
import { Tabs } from 'expo-router';
import React from 'react';
import { TabBarIcon } from '@/components/TabBarIcon';
import { useSettings } from '@/theme';
export default function TabLayout() {
const { theme } = useSettings();
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: theme.accent,
tabBarInactiveTintColor: '#8E8E8E',
tabBarStyle: {
backgroundColor: theme.tabBarBg,
borderTopWidth: 1,
borderTopColor: theme.border,
height: 64,
paddingBottom: 0,
},
tabBarLabelStyle: {
fontSize: 11,
fontWeight: '500',
},
headerShown: false,
}}
>
<Tabs.Screen
name="index"
options={{
title: 'Tasks',
tabBarIcon: ({ focused, color }) => (
<TabBarIcon name="checklist" focused={focused} color={color} />
),
}}
/>
<Tabs.Screen
name="calendar"
options={{
title: 'Calendar',
tabBarIcon: ({ focused, color }) => (
<TabBarIcon name="calendar" focused={focused} color={color} />
),
}}
/>
<Tabs.Screen
name="settings"
options={{
title: 'Settings',
tabBarIcon: ({ focused, color }) => (
<TabBarIcon name="gear" focused={focused} color={color} />
),
}}
/>
</Tabs>
);
}
+231
View File
@@ -0,0 +1,231 @@
import React, { useMemo, useRef, useState } from 'react';
import { View, Text, StyleSheet, SafeAreaView, ScrollView, TouchableOpacity, FlatList } from 'react-native';
import { useRouter } from 'expo-router';
import { Header } from '@/components/Header';
import { useTasksByDate } from '@/hooks/useTasks';
import { toggleTaskComplete } from '@/utils/taskActions';
import { useSettings } from '@/theme';
import { TaskItem } from '@/components/TaskItem';
import { QuickAddBar } from '@/components/QuickAddBar';
import { TaskData } from '@/types';
import { format, startOfMonth, endOfMonth, eachDayOfInterval, isSameDay, isSameMonth, addMonths, isToday } from 'date-fns';
import Svg, { Path } from 'react-native-svg';
const DAY_WIDTH = 44;
const DAY_GAP = 6;
export default function CalendarScreen() {
const router = useRouter();
const { theme } = useSettings();
const [visibleMonth, setVisibleMonth] = useState(() => new Date());
const [selectedDate, setSelectedDate] = useState(() => new Date());
const stripRef = useRef<ScrollView>(null);
const days = useMemo(
() => eachDayOfInterval({ start: startOfMonth(visibleMonth), end: endOfMonth(visibleMonth) }),
[visibleMonth]
);
const { tasks, loading } = useTasksByDate(selectedDate);
const handleDayPress = (day: Date) => {
setSelectedDate(day);
if (!isSameMonth(day, visibleMonth)) {
setVisibleMonth(day);
}
};
const handlePrevMonth = () => {
const prev = addMonths(visibleMonth, -1);
setVisibleMonth(prev);
if (!isSameMonth(selectedDate, prev)) {
setSelectedDate(startOfMonth(prev));
}
};
const handleNextMonth = () => {
const next = addMonths(visibleMonth, 1);
setVisibleMonth(next);
if (!isSameMonth(selectedDate, next)) {
setSelectedDate(startOfMonth(next));
}
};
const handleToggleComplete = async (taskId: string) => {
await toggleTaskComplete(taskId);
};
const today = new Date();
const scrollToDay = (day: Date) => {
const index = days.findIndex((d) => isSameDay(d, day));
if (index >= 0) {
stripRef.current?.scrollTo({ x: Math.max(0, index * (DAY_WIDTH + DAY_GAP) - 24), animated: true });
}
};
React.useEffect(() => {
scrollToDay(isSameMonth(selectedDate, visibleMonth) ? selectedDate : today);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visibleMonth]);
return (
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
<Header title="Calendar" showLogo={false} />
<ScrollView
ref={stripRef}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.dateStrip}
>
{days.map((day) => {
const isSelected = isSameDay(day, selectedDate);
const isCurrent = isToday(day);
return (
<TouchableOpacity
key={day.toISOString()}
style={[
styles.dayButton,
{ backgroundColor: theme.card, borderColor: theme.borderStrong },
isCurrent && !isSelected && { borderColor: theme.accent, borderWidth: 1.5 },
isSelected && { backgroundColor: theme.accent, borderColor: theme.accent },
]}
onPress={() => handleDayPress(day)}
activeOpacity={0.7}
>
<Text style={[styles.dayWeekday, { color: theme.textMuted }, isSelected && styles.dayTextSelected]}>
{format(day, 'EEE').charAt(0)}
</Text>
<Text style={[styles.dayNumber, { color: theme.text }, isSelected && styles.dayTextSelected]}>
{format(day, 'd')}
</Text>
</TouchableOpacity>
);
})}
</ScrollView>
<View style={styles.monthRow}>
<TouchableOpacity onPress={handlePrevMonth} style={[styles.monthNav, { borderColor: theme.borderStrong, backgroundColor: theme.card }]} 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" />
</Svg>
</TouchableOpacity>
<Text style={[styles.monthLabel, { color: theme.text }]}>{format(visibleMonth, 'MMM yyyy').toUpperCase()}</Text>
<TouchableOpacity onPress={handleNextMonth} style={[styles.monthNav, { borderColor: theme.borderStrong, backgroundColor: theme.card }]} 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" />
</Svg>
</TouchableOpacity>
</View>
<FlatList
data={tasks}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<TaskItem
task={item as TaskData}
onToggle={() => handleToggleComplete(item.id)}
onPress={() => router.push({ pathname: '/task-detail', params: { id: item.id } })}
/>
)}
ItemSeparatorComponent={() => <View style={styles.separator} />}
ListEmptyComponent={
loading ? (
<View style={styles.emptyState}>
<Text style={[styles.emptyText, { color: theme.textFaint }]}>Loading...</Text>
</View>
) : (
<View style={styles.emptyState}>
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>No tasks scheduled.</Text>
<Text style={[styles.emptySubtext, { color: theme.textMuted }]}>Use the bar below to add one</Text>
</View>
)
}
contentContainerStyle={styles.listContent}
/>
<QuickAddBar
dueDate={selectedDate.getTime()}
placeholder={`Add task for ${format(selectedDate, 'MMM d')}`}
/>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
dateStrip: {
paddingHorizontal: 16,
paddingTop: 12,
gap: DAY_GAP,
},
dayButton: {
width: DAY_WIDTH,
height: 60,
borderRadius: 16,
borderWidth: 1,
alignItems: 'center',
justifyContent: 'center',
gap: 2,
},
dayTextSelected: {
color: '#FFFFFF',
},
dayWeekday: {
fontSize: 11,
fontWeight: '600',
textTransform: 'uppercase',
},
dayNumber: {
fontSize: 16,
fontWeight: '600',
},
monthRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 24,
paddingVertical: 12,
},
monthNav: {
width: 36,
height: 36,
borderRadius: 12,
borderWidth: 1,
alignItems: 'center',
justifyContent: 'center',
},
monthLabel: {
fontSize: 15,
fontWeight: '700',
letterSpacing: 1,
minWidth: 120,
textAlign: 'center',
},
listContent: {
paddingHorizontal: 16,
paddingTop: 4,
paddingBottom: 100,
flexGrow: 1,
},
separator: {
height: 8,
},
emptyState: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 64,
},
emptyText: {
fontSize: 16,
fontWeight: '600',
marginBottom: 4,
},
emptySubtext: {
fontSize: 13,
},
});
+43
View File
@@ -0,0 +1,43 @@
import React from 'react';
import { View, Text, StyleSheet, SafeAreaView } from 'react-native';
import { Header } from '@/components/Header';
import { CategoryFilter } from '@/components/CategoryFilter';
import { TaskList } from '@/components/TaskList';
import { QuickAddBar } from '@/components/QuickAddBar';
import { useDatabase } from '@/hooks/useDatabase';
import { useSettings } from '@/theme';
export default function TasksScreen() {
const { isReady } = useDatabase();
const { theme } = useSettings();
const [selectedCategory, setSelectedCategory] = React.useState<string>('all');
const [selectionActive, setSelectionActive] = React.useState(false);
if (!isReady) {
return (
<View style={[styles.container, { backgroundColor: theme.background }]}>
<Text style={{ color: theme.textFaint }}>Loading...</Text>
</View>
);
}
return (
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
<Header title="TODO" showLogo={true} />
<CategoryFilter selected={selectedCategory} onSelect={setSelectedCategory} />
<TaskList categoryId={selectedCategory} onSelectionChange={setSelectionActive} />
<QuickAddBar />
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
});
+256
View File
@@ -0,0 +1,256 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet, SafeAreaView, ScrollView, Switch, TouchableOpacity } from 'react-native';
import { Header } from '@/components/Header';
import { ListItem } from '@/components/ListItem';
import { OptionPickerModal } from '@/components/OptionPickerModal';
import { CategoryEditorModal } from '@/components/CategoryEditorModal';
import { SyncModal } from '@/components/SyncModal';
import { FriendsModal } from '@/components/FriendsModal';
import { LegalModal } from '@/components/LegalModal';
import SyncStatus from '@/components/SyncStatus';
import { useSettings, SORT_OPTIONS, REMINDER_OPTIONS } from '@/theme';
import { useCategories } from '@/hooks/useDatabase';
import { getAuthUser, getAuthToken } from '@/services/auth';
import { getLastSyncTime } from '@/database/sync';
import Category from '@/models/Category';
import Svg, { Path } from 'react-native-svg';
export default function SettingsScreen() {
const { theme, notifications, setNotifications, defaultCategoryId, setDefaultCategoryId, sortBy, setSortBy, reminderPreference, setReminderPreference } = useSettings();
const categories = useCategories();
const [picker, setPicker] = useState<null | 'category' | 'sort' | 'reminder'>(null);
const [editingCategory, setEditingCategory] = useState<Category | null | 'new'>(null);
const [syncVisible, setSyncVisible] = useState(false);
const [friendsVisible, setFriendsVisible] = useState(false);
const [legalVisible, setLegalVisible] = useState<null | 'privacy' | 'terms'>(null);
const [syncSubtitle, setSyncSubtitle] = useState('Checking...');
const refreshSyncStatus = async () => {
const token = await getAuthToken();
if (!token) {
setSyncSubtitle('Not signed in');
return;
}
const user = await getAuthUser();
const lastSync = await getLastSyncTime();
if (user) {
setSyncSubtitle(
lastSync ? `Signed in · synced ${formatSyncTime(lastSync)}` : `Signed in as ${user.username}`
);
} else {
setSyncSubtitle('Not signed in');
}
};
React.useEffect(() => {
refreshSyncStatus();
}, []);
const defaultCategory = categories.find((c) => c.id === defaultCategoryId);
const defaultCategoryLabel = defaultCategory?.name ?? (categories.length > 0 ? categories[0].name : 'None');
const reminderLabel = REMINDER_OPTIONS.find((o) => o.value === reminderPreference)?.label ?? 'No reminder';
const handleDefaultCategory = (value: string) => {
setDefaultCategoryId(value);
};
const handleReminderPreference = (value: string) => {
setReminderPreference(value as typeof reminderPreference);
};
const editorVisible = editingCategory !== null;
const editorCategory = editingCategory === 'new' ? null : editingCategory;
return (
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
<Header title="Settings" showLogo={false} />
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>Categories</Text>
{categories.map((category) => (
<ListItem
key={category.id}
title={category.name}
subtitle={category.id === defaultCategoryId ? 'Default category' : undefined}
leftElement={
<View style={[styles.categoryDot, { backgroundColor: category.color }]} />
}
onPress={() => setEditingCategory(category)}
showChevron
/>
))}
<TouchableOpacity
style={[styles.addCategoryRow, { borderColor: theme.borderStrong }]}
onPress={() => setEditingCategory('new')}
activeOpacity={0.7}
>
<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" />
</Svg>
</View>
<Text style={[styles.addCategoryText, { color: theme.accent }]}>Add Category</Text>
</TouchableOpacity>
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>Notifications</Text>
<ListItem
title="Push Notifications"
subtitle="Receive task reminders"
rightElement={
<Switch
value={notifications}
onValueChange={setNotifications}
thumbColor="#FFFFFF"
trackColor={{ false: theme.borderStrong, true: theme.accent }}
/>
}
/>
<ListItem
title="Reminder Preferences"
subtitle={notifications ? reminderLabel : 'Enable notifications first'}
onPress={() => notifications && setPicker('reminder')}
showChevron
/>
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>Tasks</Text>
<ListItem
title="Default Category"
subtitle={defaultCategoryLabel}
onPress={() => setPicker('category')}
showChevron
/>
<ListItem
title="Sort Tasks By"
subtitle={SORT_OPTIONS.find((o) => o.value === sortBy)?.label ?? 'Date'}
onPress={() => setPicker('sort')}
showChevron
/>
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>Data</Text>
<SyncStatus />
<ListItem
title="Backup & Sync"
subtitle={syncSubtitle}
onPress={() => setSyncVisible(true)}
showChevron
/>
<ListItem
title="Friends"
subtitle="Manage friends and requests"
onPress={() => setFriendsVisible(true)}
showChevron
/>
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>About</Text>
<ListItem
title="Version"
subtitle="1.0.0"
/>
<ListItem
title="Privacy Policy"
onPress={() => setLegalVisible('privacy')}
showChevron
/>
<ListItem
title="Terms of Service"
onPress={() => setLegalVisible('terms')}
showChevron
/>
</ScrollView>
<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}
onSelect={handleDefaultCategory}
onClose={() => setPicker(null)}
/>
<OptionPickerModal
visible={picker === 'sort'}
title="Sort Tasks By"
options={SORT_OPTIONS}
selectedValue={sortBy}
onSelect={(value) => setSortBy(value as typeof sortBy)}
onClose={() => setPicker(null)}
/>
<OptionPickerModal
visible={picker === 'reminder'}
title="Reminder Preferences"
options={REMINDER_OPTIONS}
selectedValue={reminderPreference}
onSelect={handleReminderPreference}
onClose={() => setPicker(null)}
/>
<CategoryEditorModal
visible={editorVisible}
category={editorCategory}
categoryCount={categories.length}
onClose={() => setEditingCategory(null)}
/>
<SyncModal visible={syncVisible} onClose={() => { setSyncVisible(false); refreshSyncStatus(); }} />
<FriendsModal visible={friendsVisible} onClose={() => setFriendsVisible(false)} />
<LegalModal
visible={legalVisible !== null}
type={legalVisible}
onClose={() => setLegalVisible(null)}
/>
</SafeAreaView>
);
}
function formatSyncTime(timestamp: number): string {
const seconds = Math.floor((Date.now() - timestamp) / 1000);
if (seconds < 60) return 'just now';
if (seconds < 3600) return `${Math.floor(seconds / 60)} min ago`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)} h ago`;
return new Date(timestamp).toLocaleDateString();
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
content: {
flex: 1,
paddingHorizontal: 16,
paddingTop: 8,
},
sectionTitle: {
fontSize: 13,
fontWeight: '600',
textTransform: 'uppercase',
letterSpacing: 0.5,
marginTop: 24,
marginBottom: 8,
marginLeft: 4,
},
categoryDot: {
width: 20,
height: 20,
borderRadius: 10,
},
addCategoryRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
paddingVertical: 12,
paddingHorizontal: 16,
borderRadius: 12,
borderWidth: 1,
borderStyle: 'dashed',
marginBottom: 8,
},
addIcon: {
width: 28,
height: 28,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
},
addCategoryText: {
fontSize: 15,
fontWeight: '600',
},
});
+31
View File
@@ -0,0 +1,31 @@
import { Stack } from 'expo-router';
import { DatabaseProvider } from '@/hooks/useDatabase';
import { SettingsProvider } from '@/theme';
import { FriendsProvider } from '@/hooks/useFriends';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { StatusBar } from 'expo-status-bar';
function RootNavigator() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<StatusBar style="light" />
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="(tabs)" />
<Stack.Screen name="add-task" />
<Stack.Screen name="task-detail" />
</Stack>
</GestureHandlerRootView>
);
}
export default function RootLayout() {
return (
<SettingsProvider>
<DatabaseProvider>
<FriendsProvider>
<RootNavigator />
</FriendsProvider>
</DatabaseProvider>
</SettingsProvider>
);
}
+262
View File
@@ -0,0 +1,262 @@
import React, { useMemo } from 'react';
import { View, Text, StyleSheet, SafeAreaView, ScrollView, KeyboardAvoidingView, Platform } from 'react-native';
import { Header } from '@/components/Header';
import { CategorySelector } from '@/components/CategorySelector';
import { TaskNameInput } from '@/components/TaskNameInput';
import { SubtasksSection } from '@/components/SubtasksSection';
import { DateTimePickerComponent } from '@/components/DateTimePicker';
import { useRouter, useLocalSearchParams } from 'expo-router';
import { PrioritySelector } from '@/components/PrioritySelector';
import { RepeatSelector } from '@/components/RepeatSelector';
import { ReminderSelector } from '@/components/ReminderSelector';
import { DescriptionInput } from '@/components/DescriptionInput';
import { FormButtons } from '@/components/FormButtons';
import { AssigneeSelector } from '@/components/AssigneeSelector';
import { useForm, FormProvider, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useDatabase, useCategories } from '@/hooks/useDatabase';
import { database, collections } from '@/database';
import { TaskFormData } from '@/types';
import { useSettings } from '@/theme';
import { scheduleTaskReminder } from '@/services/notifications';
import { useFriends } from '@/hooks/useFriends';
const taskSchema = z.object({
title: z.string().trim().min(1, 'Task name is required').max(100),
description: z.string().max(1000).optional(),
categoryId: z.string().min(1, 'Category is required'),
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']),
dueDate: z.date().nullable().optional(),
dueTime: z.string().optional(),
endTime: z.string().optional(),
allDay: z.boolean().optional(),
repeat: z.enum(['none', 'daily', 'weekly', 'monthly', 'custom']),
repeatInterval: z.number().int().min(1).max(30).optional(),
repeatDays: z.array(z.number().int().min(0).max(6)).optional(),
reminder: z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']),
assigneeId: z.string().nullable().optional(),
subtasks: z.array(z.object({ title: z.string(), _key: z.string().optional() })).optional(),
});
export default function AddTaskScreen() {
const { isReady } = useDatabase();
const categories = useCategories();
const { defaultCategoryId } = useSettings();
const router = useRouter();
const { theme } = useSettings();
const { date: dateParam } = useLocalSearchParams<{ date?: string }>();
const { friends } = useFriends();
const initialCategory = defaultCategoryId || categories[0]?.id || '';
const initialDate = useMemo(() => {
if (!dateParam) return null;
const parsed = new Date(Array.isArray(dateParam) ? dateParam[0] : dateParam);
return Number.isNaN(parsed.getTime()) ? null : parsed;
}, [dateParam]);
const methods = useForm<TaskFormData>({
resolver: zodResolver(taskSchema),
defaultValues: {
title: '',
description: '',
categoryId: initialCategory,
priority: 'none',
dueDate: initialDate,
dueTime: '',
endTime: '',
allDay: false,
repeat: 'none',
repeatInterval: 1,
repeatDays: [],
reminder: 'none',
assigneeId: null,
subtasks: [],
},
});
const {
handleSubmit,
watch,
setValue,
control,
formState: { errors },
} = methods;
const categoryId = watch('categoryId');
const priority = watch('priority');
const repeat = watch('repeat');
const repeatInterval = watch('repeatInterval') ?? 1;
const repeatDays = watch('repeatDays') ?? [];
const reminder = watch('reminder');
const dueDate = watch('dueDate');
const assigneeId = watch('assigneeId');
React.useEffect(() => {
if (!categoryId && initialCategory) {
setValue('categoryId', initialCategory);
}
}, [initialCategory, categoryId, setValue]);
const onSubmit = async (data: TaskFormData) => {
if (!isReady) return;
const now = new Date();
const dueDateTimestamp = data.dueDate ? data.dueDate.getTime() : 0;
const seriesId = data.repeat !== 'none'
? `series_${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}`
: '';
let createdTask: any = null;
await database.write(async () => {
const task = await collections.tasks.create((t) => {
t.title = data.title.trim();
t.description = data.description || '';
t.categoryId = data.categoryId;
t.priority = data.priority;
t.completed = false;
t.dueDate = dueDateTimestamp;
t.dueTime = data.dueTime || '';
t.endTime = data.endTime || '';
t.allDay = data.allDay ?? false;
t.repeat = data.repeat;
t.repeatInterval = data.repeatInterval || 1;
t.repeatDays = (data.repeatDays || []).join(',');
t.seriesId = seriesId;
t.reminder = data.reminder || 'none';
t.assigneeId = data.assigneeId ?? null;
t.createdAt = now;
t.updatedAt = now;
});
createdTask = task;
if (data.subtasks && data.subtasks.length > 0) {
for (let i = 0; i < data.subtasks.length; i++) {
const subtask = data.subtasks[i];
if (subtask.title.trim()) {
await collections.subtasks.create((s) => {
s.taskId = task.id;
s.title = subtask.title.trim();
s.description = '';
s.priority = 'none';
s.completed = false;
s.dueDate = 0;
s.dueTime = '';
s.endTime = '';
s.reminder = 'none';
s.assigneeId = null;
s.order = i;
s.createdAt = now;
s.updatedAt = now;
});
}
}
}
});
await scheduleTaskReminder(createdTask);
router.back();
};
if (!isReady) {
return (
<View style={[styles.loadingContainer, { backgroundColor: theme.background }]}>
<Text style={{ color: theme.textFaint }}>Loading...</Text>
</View>
);
}
return (
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
<Header title="Add New Task" showLogo={true} />
<FormProvider {...methods}>
<KeyboardAvoidingView
style={styles.keyboardAvoiding}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
>
<CategorySelector
value={categoryId}
onChange={(value) => setValue('categoryId', value)}
error={errors.categoryId?.message}
/>
<Controller
control={control}
name="title"
render={({ field }) => (
<TaskNameInput
value={field.value}
onChangeText={field.onChange}
onBlur={field.onBlur}
error={errors.title?.message}
/>
)}
/>
<SubtasksSection control={control} />
<PrioritySelector
value={priority}
onChange={(value) => setValue('priority', value)}
/>
<DateTimePickerComponent control={control} />
<RepeatSelector
value={repeat}
interval={repeatInterval}
days={repeatDays}
onChange={(nextRepeat, nextInterval, nextDays) => {
setValue('repeat', nextRepeat);
setValue('repeatInterval', nextInterval);
setValue('repeatDays', nextDays);
}}
/>
<ReminderSelector
value={reminder}
hasDueDate={!!dueDate}
onChange={(value) => setValue('reminder', value)}
/>
<AssigneeSelector
value={assigneeId}
onChange={(value: string | null) => setValue('assigneeId', value)}
friends={friends.map((f) => f.username)}
/>
<Controller
control={control}
name="description"
render={({ field }) => (
<DescriptionInput
value={field.value ?? ''}
onChangeText={field.onChange}
onBlur={field.onBlur}
/>
)}
/>
</ScrollView>
</KeyboardAvoidingView>
<FormButtons onSubmit={handleSubmit(onSubmit)} submitLabel="Save" />
</FormProvider>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
keyboardAvoiding: {
flex: 1,
},
scrollContent: {
paddingHorizontal: 16,
paddingTop: 8,
paddingBottom: 100,
gap: 24,
},
});
+331
View File
@@ -0,0 +1,331 @@
import React from 'react';
import { View, Text, StyleSheet, SafeAreaView, ScrollView, KeyboardAvoidingView, Platform, TouchableOpacity } from 'react-native';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { Header } from '@/components/Header';
import { CategorySelector } from '@/components/CategorySelector';
import { TaskNameInput } from '@/components/TaskNameInput';
import { SubtasksSection } from '@/components/SubtasksSection';
import { DateTimePickerComponent } from '@/components/DateTimePicker';
import { PrioritySelector } from '@/components/PrioritySelector';
import { RepeatSelector } from '@/components/RepeatSelector';
import { ReminderSelector } from '@/components/ReminderSelector';
import { DescriptionInput } from '@/components/DescriptionInput';
import { FormButtons } from '@/components/FormButtons';
import { TaskDeleteModal } from '@/components/TaskDeleteModal';
import { AssigneeSelector } from '@/components/AssigneeSelector';
import { useForm, FormProvider, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useDatabase } from '@/hooks/useDatabase';
import { database, collections } from '@/database';
import { Q } from '@nozbe/watermelondb';
import { TaskFormData } from '@/types';
import { useSettings } from '@/theme';
import { deleteTaskOccurrences } from '@/utils/taskActions';
import { scheduleTaskReminder, cancelTaskReminder } from '@/services/notifications';
import { useFriends } from '@/hooks/useFriends';
import Svg, { Path } from 'react-native-svg';
const taskSchema = z.object({
title: z.string().trim().min(1, 'Task name is required').max(100),
description: z.string().max(1000).optional(),
categoryId: z.string().min(1, 'Category is required'),
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']),
dueDate: z.date().nullable().optional(),
dueTime: z.string().optional(),
endTime: z.string().optional(),
allDay: z.boolean().optional(),
repeat: z.enum(['none', 'daily', 'weekly', 'monthly', 'custom']),
repeatInterval: z.number().int().min(1).max(30).optional(),
repeatDays: z.array(z.number().int().min(0).max(6)).optional(),
reminder: z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']),
assigneeId: z.string().nullable().optional(),
subtasks: z.array(z.object({ title: z.string(), _key: z.string().optional() })).optional(),
});
export default function TaskDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const { isReady } = useDatabase();
const router = useRouter();
const { theme } = useSettings();
const { friends } = useFriends();
const [loaded, setLoaded] = React.useState(false);
const [notFound, setNotFound] = React.useState(false);
const methods = useForm<TaskFormData>({
resolver: zodResolver(taskSchema),
defaultValues: {
title: '',
description: '',
categoryId: '',
priority: 'none',
dueDate: null,
dueTime: '',
endTime: '',
allDay: false,
repeat: 'none',
repeatInterval: 1,
repeatDays: [],
reminder: 'none',
assigneeId: null,
subtasks: [],
},
});
const { handleSubmit, watch, setValue, control, reset, formState: { errors } } = methods;
const categoryId = watch('categoryId');
const priority = watch('priority');
const repeat = watch('repeat');
const repeatInterval = watch('repeatInterval') ?? 1;
const repeatDays = watch('repeatDays') ?? [];
const reminder = watch('reminder');
const dueDate = watch('dueDate');
const assigneeId = watch('assigneeId');
const [deleteModalVisible, setDeleteModalVisible] = React.useState(false);
React.useEffect(() => {
if (!id || !isReady) return;
let mounted = true;
(async () => {
try {
const task = await collections.tasks.find(id);
const subtasks = await collections.subtasks.query(Q.where('task_id', id)).fetch();
if (!mounted) return;
reset({
title: task.title,
description: task.description,
categoryId: task.categoryId,
priority: task.priority,
dueDate: task.dueDate ? new Date(task.dueDate) : null,
dueTime: task.dueTime,
endTime: task.endTime || '',
allDay: task.allDay ?? false,
repeat: task.repeat,
repeatInterval: task.repeatInterval || 1,
repeatDays: (task.repeatDays || '').split(',').map(Number).filter((d) => !Number.isNaN(d)),
reminder: (task.reminder || 'none') as TaskFormData['reminder'],
assigneeId: task.assigneeId ?? null,
subtasks: subtasks.map((s) => ({ title: s.title, _key: s.id })),
});
setLoaded(true);
} catch {
if (mounted) setNotFound(true);
}
})();
return () => { mounted = false; };
}, [id, isReady, reset]);
const onSubmit = async (data: TaskFormData) => {
if (!id || !isReady) return;
const now = new Date();
const dueDateTimestamp = data.dueDate ? data.dueDate.getTime() : 0;
let savedTask: any = null;
await database.write(async () => {
const task = await collections.tasks.find(id);
savedTask = task;
const existingSubtasks = await collections.subtasks.query(Q.where('task_id', id)).fetch();
for (const subtask of existingSubtasks) {
await subtask.destroyPermanently();
}
await task.update((t) => {
t.title = data.title.trim();
t.description = data.description || '';
t.categoryId = data.categoryId;
t.priority = data.priority;
t.dueDate = dueDateTimestamp;
t.dueTime = data.dueTime || '';
t.endTime = data.endTime || '';
t.allDay = data.allDay ?? false;
t.repeat = data.repeat;
t.repeatInterval = data.repeatInterval || 1;
t.repeatDays = (data.repeatDays || []).join(',');
t.reminder = data.reminder || 'none';
t.assigneeId = data.assigneeId ?? null;
if (data.repeat !== 'none' && !t.seriesId) {
t.seriesId = `series_${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}`;
}
t.updatedAt = now;
});
if (data.subtasks && data.subtasks.length > 0) {
for (let i = 0; i < data.subtasks.length; i++) {
const subtask = data.subtasks[i];
if (subtask.title.trim()) {
await collections.subtasks.create((s) => {
s.taskId = task.id;
s.title = subtask.title.trim();
s.description = '';
s.priority = 'none';
s.completed = false;
s.dueDate = 0;
s.dueTime = '';
s.endTime = '';
s.allDay = false;
s.repeat = 'none';
s.repeatInterval = 1;
s.repeatDays = '';
s.seriesId = '';
s.reminder = 'none';
s.assigneeId = null;
s.order = i;
s.createdAt = now;
s.updatedAt = now;
});
}
}
}
});
await scheduleTaskReminder(savedTask);
router.back();
};
const handleDelete = () => {
setDeleteModalVisible(true);
};
const doDelete = async (scope: 'this' | 'future' | 'all') => {
if (!id) return;
setDeleteModalVisible(false);
await deleteTaskOccurrences(id, scope);
router.back();
};
if (!isReady || !loaded) {
return (
<View style={[styles.loadingContainer, { backgroundColor: theme.background }]}>
<Text style={{ color: theme.textFaint }}>{notFound ? 'Task not found' : 'Loading...'}</Text>
</View>
);
}
return (
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
<Header
title="Edit Task"
showLogo={true}
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" />
</Svg>
</TouchableOpacity>
}
/>
<FormProvider {...methods}>
<KeyboardAvoidingView
style={styles.keyboardAvoiding}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
>
<CategorySelector
value={categoryId}
onChange={(value) => setValue('categoryId', value)}
error={errors.categoryId?.message}
/>
<Controller
control={control}
name="title"
render={({ field }) => (
<TaskNameInput
value={field.value}
onChangeText={field.onChange}
onBlur={field.onBlur}
error={errors.title?.message}
/>
)}
/>
<SubtasksSection control={control} />
<PrioritySelector
value={priority}
onChange={(value) => setValue('priority', value)}
/>
<DateTimePickerComponent control={control} />
<RepeatSelector
value={repeat}
interval={repeatInterval}
days={repeatDays}
onChange={(nextRepeat, nextInterval, nextDays) => {
setValue('repeat', nextRepeat);
setValue('repeatInterval', nextInterval);
setValue('repeatDays', nextDays);
}}
/>
<ReminderSelector
value={reminder}
hasDueDate={!!dueDate}
onChange={(value) => setValue('reminder', value)}
/>
<AssigneeSelector
value={assigneeId}
onChange={(value: string | null) => setValue('assigneeId', value)}
friends={friends.map((f) => f.username)}
/>
<Controller
control={control}
name="description"
render={({ field }) => (
<DescriptionInput
value={field.value ?? ''}
onChangeText={field.onChange}
onBlur={field.onBlur}
/>
)}
/>
</ScrollView>
</KeyboardAvoidingView>
<FormButtons onSubmit={handleSubmit(onSubmit)} submitLabel="Save" />
</FormProvider>
<TaskDeleteModal
visible={deleteModalVisible}
taskId={id}
taskTitle={methods.getValues('title')}
isRepeating={repeat !== 'none'}
onClose={() => setDeleteModalVisible(false)}
onDelete={doDelete}
/>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
keyboardAvoiding: {
flex: 1,
},
scrollContent: {
paddingHorizontal: 16,
paddingTop: 8,
paddingBottom: 100,
gap: 24,
},
deleteButton: {
width: 36,
height: 36,
borderRadius: 12,
borderWidth: 1,
alignItems: 'center',
justifyContent: 'center',
},
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+25
View File
@@ -0,0 +1,25 @@
module.exports = function (api) {
api.cache(true);
return {
presets: [
[
'babel-preset-expo',
{
// babel-preset-expo disables the class-properties transform on web
// (modern browsers support class fields natively), but WatermelonDB's
// legacy decorators require class properties to be transformed AFTER
// decorators. Disable the preset's own decorators transform and apply
// both transforms here with the correct ordering.
decorators: false,
},
],
],
plugins: [
['@babel/plugin-proposal-decorators', { legacy: true }],
['@babel/plugin-transform-class-properties', { loose: true }],
['@babel/plugin-transform-class-static-block', { loose: true }],
['@babel/plugin-transform-private-methods', { loose: true }],
['@babel/plugin-transform-private-property-in-object', { loose: true }],
],
};
};
+21
View File
@@ -0,0 +1,21 @@
{
"cli": {
"version": ">= 21.5.0",
"appVersionSource": "remote"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal"
},
"preview": {
"distribution": "internal"
},
"production": {
"autoIncrement": true
}
},
"submit": {
"production": {}
}
}
+10
View File
@@ -0,0 +1,10 @@
// https://docs.expo.dev/guides/using-eslint/
const { defineConfig } = require('eslint/config');
const expoConfig = require("eslint-config-expo/flat");
module.exports = defineConfig([
expoConfig,
{
ignores: ["dist/*"],
}
]);
+8
View File
@@ -0,0 +1,8 @@
import { registerRootComponent } from 'expo';
import App from './App';
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
// It also ensures that whether you load the app in Expo Go or in a native build,
// the environment is set up appropriately
registerRootComponent(App);
+12226
View File
File diff suppressed because it is too large Load Diff
+56
View File
@@ -0,0 +1,56 @@
{
"name": "carry-your-live",
"version": "1.0.0",
"main": "expo-router/entry",
"dependencies": {
"@hookform/resolvers": "^3.3.4",
"@nozbe/watermelondb": "^0.28.1-0",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-community/datetimepicker": "9.1.0",
"date-fns": "^3.6.0",
"expo": "~57.0.10",
"expo-constants": "~57.0.9",
"expo-dev-client": "~57.0.1",
"expo-linking": "~57.0.5",
"expo-notifications": "~57.0.8",
"expo-router": "~57.0.10",
"expo-sqlite": "~57.0.1",
"expo-status-bar": "~57.0.1",
"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-paper": "^5.12.3",
"react-native-reanimated": "4.5.1",
"react-native-safe-area-context": "5.7.0",
"react-native-screens": "4.26.0",
"react-native-svg": "^15.15.4",
"react-native-web": "^0.21.2",
"react-native-worklets": "0.10.1",
"zod": "^3.23.8"
},
"devDependencies": {
"@babel/core": "^7.20.0",
"@types/react": "~19.2.2",
"eslint": "^9.0.0",
"eslint-config-expo": "~57.0.1",
"prettier": "^3.9.6",
"typescript": "~6.0.3"
},
"scripts": {
"start": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web",
"lint": "expo lint",
"typecheck": "tsc --noEmit",
"format": "prettier --write \"**/*.{ts,tsx,json,md}\"",
"build:ios": "eas build --platform ios",
"build:android": "eas build --platform android",
"build:web": "eas build --platform web",
"submit:ios": "eas submit --platform ios",
"submit:android": "eas submit --platform android"
},
"private": true
}
@@ -0,0 +1,316 @@
import React, { useRef, useState } from 'react';
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import Svg, { Circle, Line, G, Text as SvgText } from 'react-native-svg';
import { useSettings } from '@/theme';
interface AnalogClockProps {
value: Date;
is24Hour?: boolean;
onChange: (date: Date) => void;
}
const SIZE = 240;
const CENTER = SIZE / 2;
const RADIUS = CENTER - 8;
const NUM_RADIUS = RADIUS * 0.72;
const INNER_NUM_RADIUS = RADIUS * 0.52;
function pad(n: number): string {
return String(n).padStart(2, '0');
}
export function AnalogClock({ value, is24Hour = false, onChange }: AnalogClockProps) {
const { theme } = useSettings();
const [mode, setMode] = useState<'hour' | 'minute'>('hour');
const movedRef = useRef(false);
const hours = value.getHours();
const minutes = value.getMinutes();
const isPM = hours >= 12;
const display12 = ((hours % 12) + 12) % 12;
const displayHour = display12 === 0 ? 12 : display12;
const emit = (hour: number, minute: number) => {
const next = new Date(value);
next.setHours(hour, minute, 0, 0);
onChange(next);
};
const handleSelect = (dx: number, dy: number, dragging: boolean) => {
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance > RADIUS) return;
const deg = (Math.atan2(dx, dy) * 180) / Math.PI;
const normDeg = ((deg % 360) + 360) % 360;
if (mode === 'hour') {
if (dragging) return;
const base = Math.round(normDeg / 30) % 12;
let hour: number;
if (is24Hour) {
const inner = distance < INNER_NUM_RADIUS + 14;
hour = inner ? base + 12 : (base === 0 ? 12 : base);
} else {
hour = isPM ? base + 12 : (base === 0 ? 0 : base);
}
emit(hour, minutes);
setMode('minute');
} else {
const minute = Math.round(normDeg / 6) % 60;
emit(hours, minute);
}
};
const onLocation = (event: any, dragging: boolean) => {
const { locationX, locationY } = event.nativeEvent;
if (typeof locationX !== 'number' || typeof locationY !== 'number') return;
handleSelect(locationX - CENTER, locationY - CENTER, dragging);
};
const hourAngle = ((hours % 12) + minutes / 60) * 30;
const minuteAngle = minutes * 6;
const ticks = Array.from({ length: 60 }, (_, i) => {
const angle = (i * 6 * Math.PI) / 180;
const major = i % 5 === 0;
const r1 = major ? RADIUS * 0.86 : RADIUS * 0.93;
const r2 = RADIUS * 0.985;
return (
<Line
key={`tick-${i}`}
x1={CENTER + r1 * Math.sin(angle)}
y1={CENTER - r1 * Math.cos(angle)}
x2={CENTER + r2 * Math.sin(angle)}
y2={CENTER - r2 * Math.cos(angle)}
stroke={major ? theme.textMuted : theme.borderStrong}
strokeWidth={major ? 2 : 1}
strokeLinecap="round"
/>
);
});
const numbers = Array.from({ length: 12 }, (_, i) => {
const hour = i === 0 ? 12 : i;
const angle = (i * 30 * Math.PI) / 180;
const x = CENTER + NUM_RADIUS * Math.sin(angle);
const y = CENTER - NUM_RADIUS * Math.cos(angle);
const active = mode === 'hour' && display12 === i;
return (
<SvgText
key={`num-${hour}`}
x={x}
y={y}
fontSize={16}
fontWeight={active ? '700' : '500'}
fill={active ? theme.accent : mode === 'minute' ? theme.textMuted : theme.text}
textAnchor="middle"
alignmentBaseline="central"
>
{hour}
</SvgText>
);
});
const innerNumbers = !is24Hour
? null
: Array.from({ length: 11 }, (_, i) => {
const hour = i + 13;
const angle = (i * 30 * Math.PI) / 180;
const x = CENTER + INNER_NUM_RADIUS * Math.sin(angle);
const y = CENTER - INNER_NUM_RADIUS * Math.cos(angle);
const active = mode === 'hour' && display12 === i + 1;
return (
<SvgText
key={`inner-${hour}`}
x={x}
y={y}
fontSize={12}
fontWeight={active ? '700' : '400'}
fill={active ? theme.accent : theme.textFaint}
textAnchor="middle"
alignmentBaseline="central"
>
{hour}
</SvgText>
);
});
const minuteDots = mode === 'minute'
? Array.from({ length: 12 }, (_, i) => {
const angle = (i * 30 * Math.PI) / 180;
const x = CENTER + RADIUS * 0.82 * Math.sin(angle);
const y = CENTER - RADIUS * 0.82 * Math.cos(angle);
const active = Math.round(minutes / 5) % 12 === i;
return (
<Circle
key={`min-dot-${i}`}
cx={x}
cy={y}
r={active ? 5 : 3}
fill={active ? theme.accent : theme.accentBorder}
/>
);
})
: null;
return (
<View style={styles.container}>
<View style={styles.displayRow}>
<TouchableOpacity onPress={() => setMode('hour')} activeOpacity={0.7}>
<Text
style={[
styles.displayValue,
{ color: mode === 'hour' ? theme.accent : theme.textMuted },
{ fontSize: mode === 'hour' ? 30 : 22 },
]}
>
{pad(displayHour)}
</Text>
</TouchableOpacity>
<Text style={[styles.displayColon, { color: theme.text }]}>:</Text>
<TouchableOpacity onPress={() => setMode('minute')} activeOpacity={0.7}>
<Text
style={[
styles.displayValue,
{ color: mode === 'minute' ? theme.accent : theme.textMuted },
{ fontSize: mode === 'minute' ? 30 : 22 },
]}
>
{pad(minutes)}
</Text>
</TouchableOpacity>
{!is24Hour && (
<View style={styles.periodGroup}>
{(['AM', 'PM'] as const).map((period) => {
const active = isPM === (period === 'PM');
return (
<TouchableOpacity
key={period}
onPress={() => {
const hour = active ? hours : hours + (period === 'PM' ? 12 : -12);
emit(((hour % 24) + 24) % 24, minutes);
}}
activeOpacity={0.7}
>
<Text
style={[
styles.periodText,
{ color: active ? theme.accent : theme.textFaint },
active && styles.periodTextActive,
]}
>
{period}
</Text>
</TouchableOpacity>
);
})}
</View>
)}
</View>
<Text style={[styles.hint, { color: theme.textFaint }]}>
{mode === 'hour' ? 'Select the hour' : 'Select the minute'}
</Text>
<View
style={styles.clockSurface}
onStartShouldSetResponder={() => true}
onMoveShouldSetResponder={() => true}
onResponderGrant={(e) => {
movedRef.current = false;
onLocation(e, false);
}}
onResponderMove={(e) => {
movedRef.current = true;
onLocation(e, true);
}}
onResponderRelease={(e) => {
if (!movedRef.current) {
onLocation(e, false);
}
movedRef.current = false;
}}
onResponderTerminationRequest={() => false}
>
<Svg width={SIZE} height={SIZE}>
<Circle
cx={CENTER}
cy={CENTER}
r={RADIUS}
fill={theme.inputBg}
stroke={theme.borderStrong}
strokeWidth={1.5}
/>
{ticks}
{minuteDots}
{numbers}
{innerNumbers}
<G rotation={hourAngle} origin={`${CENTER}, ${CENTER}`}>
<Line
x1={CENTER}
y1={CENTER + 12}
x2={CENTER}
y2={CENTER - RADIUS * 0.45}
stroke={theme.text}
strokeWidth={4}
strokeLinecap="round"
/>
</G>
<G rotation={minuteAngle} origin={`${CENTER}, ${CENTER}`}>
<Line
x1={CENTER}
y1={CENTER + 14}
x2={CENTER}
y2={CENTER - RADIUS * 0.66}
stroke={theme.accent}
strokeWidth={3}
strokeLinecap="round"
/>
</G>
<Circle cx={CENTER} cy={CENTER} r={5} fill={theme.accent} />
<Circle cx={CENTER} cy={CENTER} r={2} fill={theme.inputBg} />
</Svg>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
alignItems: 'center',
gap: 4,
},
displayRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
displayValue: {
fontWeight: '700',
fontVariant: ['tabular-nums'],
},
displayColon: {
fontSize: 30,
fontWeight: '700',
},
periodGroup: {
marginLeft: 8,
gap: 0,
},
periodText: {
fontSize: 14,
fontWeight: '600',
paddingVertical: 2,
paddingHorizontal: 4,
},
periodTextActive: {
textDecorationLine: 'underline',
},
hint: {
fontSize: 12,
marginBottom: 6,
},
clockSurface: {
width: SIZE,
height: SIZE,
},
});
@@ -0,0 +1,339 @@
import React from 'react';
import { View, Text, TouchableOpacity, StyleSheet, Modal, FlatList, ActivityIndicator, TextInput } from 'react-native';
import { useSettings } from '@/theme';
import { useFriends } from '@/hooks/useFriends';
interface AssigneeSelectorProps {
value: string | null;
onChange: (assigneeId: string | null) => void;
disabled?: boolean;
friends: string[];
}
export function AssigneeSelector({ value, onChange, disabled = false }: AssigneeSelectorProps) {
const { theme } = useSettings();
const { friends, searchUsers, loading: friendsLoading } = useFriends();
const [showModal, setShowModal] = React.useState(false);
const [searchQuery, setSearchQuery] = React.useState('');
const [searchResults, setSearchResults] = React.useState<Array<{ id: string; username: string }>>([]);
const [searching, setSearching] = React.useState(false);
const selectedFriend = value ? friends.find(f => f.id === value) : null;
React.useEffect(() => {
if (showModal && searchQuery.length >= 2) {
const timeout = setTimeout(async () => {
setSearching(true);
try {
const results = await searchUsers(searchQuery);
setSearchResults(results.filter(r => !friends.some(f => f.id === r.id)));
} catch (err) {
console.error('Search failed:', err);
} finally {
setSearching(false);
}
}, 300);
return () => clearTimeout(timeout);
} else {
setSearchResults([]);
}
}, [searchQuery, showModal, friends, searchUsers]);
const handleSelectFriend = (friendId: string) => {
onChange(friendId);
setShowModal(false);
};
const handleClear = () => {
onChange(null);
};
const handleSearchAdd = async (friendId: string, username: string) => {
try {
const { sendFriendRequest } = await import('@/services/friends');
await sendFriendRequest(username);
onChange(friendId);
setShowModal(false);
} catch (err) {
console.error('Failed to add friend:', err);
}
};
return (
<View style={styles.container}>
<TouchableOpacity
style={[
styles.selectorButton,
{ backgroundColor: theme.inputBg, borderColor: theme.borderStrong },
disabled && { opacity: 0.6 },
]}
onPress={() => !disabled && setShowModal(true)}
activeOpacity={0.8}
>
<Text style={[styles.selectorIcon, { color: theme.accent }]}>👤</Text>
<View style={styles.selectorContent}>
<Text style={[styles.selectorLabel, { color: theme.textMuted }]}>Assignee</Text>
<Text
style={[
styles.selectorValue,
{ color: value ? theme.text : theme.textMuted },
]}
>
{selectedFriend ? selectedFriend.username : 'Unassigned'}
</Text>
</View>
{value && !disabled && (
<TouchableOpacity
style={styles.clearButton}
onPress={(e) => {
e.stopPropagation();
handleClear();
}}
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
>
<Text style={[styles.clearText, { color: theme.textMuted }]}>✕</Text>
</TouchableOpacity>
)}
</TouchableOpacity>
<Modal visible={showModal} transparent animationType="fade" onRequestClose={() => setShowModal(false)}>
<View style={[styles.modalOverlay, { backgroundColor: 'rgba(0,0,0,0.5)' }]}>
<View style={[styles.modalSheet, { backgroundColor: theme.sheetBg }]}>
<View style={styles.modalHeader}>
<Text style={[styles.modalTitle, { color: theme.text }]}>Assign Task</Text>
<TouchableOpacity onPress={() => setShowModal(false)} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
<Text style={[styles.closeText, { color: theme.textMuted }]}>✕</Text>
</TouchableOpacity>
</View>
<View style={styles.modalSection}>
<TouchableOpacity
style={[
styles.optionRow,
{ backgroundColor: selectedFriend ? theme.accentSoft : 'transparent', borderColor: selectedFriend ? theme.accent : 'transparent' },
]}
onPress={() => handleClear()}
>
<View style={styles.optionIcon}>
<Text style={styles.optionIconText}>🚫</Text>
</View>
<Text style={[styles.optionText, { color: theme.text }]}>Unassigned</Text>
{selectedFriend && (
<Text style={[styles.checkmark, { color: theme.accent }]}>✓</Text>
)}
</TouchableOpacity>
</View>
<View style={styles.modalSection}>
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>Friends</Text>
{friendsLoading ? (
<View style={styles.loading}>
<ActivityIndicator color={theme.accent} size="large" />
</View>
) : friends.length === 0 ? (
<Text style={[styles.emptyText, { color: theme.textFaint }]}>No friends yet. Add friends to assign tasks.</Text>
) : (
<FlatList
data={friends}
keyExtractor={f => f.id}
renderItem={({ item }) => (
<TouchableOpacity
style={[
styles.optionRow,
{ backgroundColor: value === item.id ? theme.accentSoft : 'transparent', borderColor: value === item.id ? theme.accent : 'transparent' },
]}
onPress={() => handleSelectFriend(item.id)}
>
<View style={styles.optionIcon}>
<Text style={styles.optionIconText}>👤</Text>
</View>
<Text style={[styles.optionText, { color: theme.text }]}>{item.username}</Text>
{value === item.id && (
<Text style={[styles.checkmark, { color: theme.accent }]}>✓</Text>
)}
</TouchableOpacity>
)}
/>
)}
</View>
<View style={styles.modalSection}>
<View style={styles.searchContainer}>
<TextInput
style={[styles.searchInput, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text }]}
placeholder="Search users by username"
placeholderTextColor={theme.textMuted}
value={searchQuery}
onChangeText={setSearchQuery}
autoCapitalize="none"
autoCorrect={false}
/>
</View>
{searching ? (
<View style={styles.loading}>
<ActivityIndicator color={theme.accent} size="large" />
</View>
) : searchQuery.length >= 2 && searchResults.length > 0 ? (
<FlatList
data={searchResults}
keyExtractor={u => u.id}
renderItem={({ item }) => (
<TouchableOpacity
style={styles.optionRow}
onPress={() => handleSearchAdd(item.id, item.username)}
>
<View style={styles.optionIcon}>
<Text style={styles.optionIconText}>➕</Text>
</View>
<Text style={[styles.optionText, { color: theme.text }]}>{item.username}</Text>
<Text style={[styles.optionSubtext, { color: theme.textMuted }]}>Add as friend & assign</Text>
</TouchableOpacity>
)}
/>
) : searchQuery.length >= 2 && searchResults.length === 0 && !searching ? (
<Text style={[styles.emptyText, { color: theme.textFaint }]}>No users found</Text>
) : searchQuery.length < 2 ? (
<Text style={[styles.emptyText, { color: theme.textFaint }]}>Type at least 2 characters to search</Text>
) : null}
</View>
</View>
</View>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
container: {
width: '100%',
},
selectorButton: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
paddingVertical: 14,
paddingHorizontal: 12,
borderRadius: 12,
borderWidth: 1,
minHeight: 52,
},
selectorIcon: {
fontSize: 20,
},
selectorContent: {
flex: 1,
justifyContent: 'center',
},
selectorLabel: {
fontSize: 11,
fontWeight: '600',
textTransform: 'uppercase',
letterSpacing: 0.5,
marginBottom: 2,
},
selectorValue: {
fontSize: 15,
fontWeight: '500',
},
clearButton: {
padding: 4,
},
clearText: {
fontSize: 18,
fontWeight: '300',
},
modalOverlay: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 24,
},
modalSheet: {
width: '100%',
maxWidth: 400,
borderRadius: 20,
overflow: 'hidden',
maxHeight: '85%',
},
modalHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: 20,
borderBottomWidth: 1,
},
modalTitle: {
fontSize: 18,
fontWeight: '700',
},
closeText: {
fontSize: 22,
fontWeight: '300',
},
modalSection: {
padding: 12,
paddingBottom: 20,
borderBottomWidth: 1,
},
sectionTitle: {
fontSize: 12,
fontWeight: '600',
textTransform: 'uppercase',
letterSpacing: 0.5,
marginBottom: 8,
paddingHorizontal: 8,
},
optionRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
paddingVertical: 12,
paddingHorizontal: 16,
borderRadius: 10,
borderWidth: 1,
},
optionIcon: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0,0,0,0.05)',
},
optionIconText: {
fontSize: 16,
},
optionText: {
fontSize: 16,
fontWeight: '500',
flex: 1,
},
optionSubtext: {
fontSize: 12,
marginTop: 2,
},
checkmark: {
fontSize: 18,
fontWeight: '700',
},
loading: {
padding: 20,
alignItems: 'center',
},
emptyText: {
fontSize: 14,
textAlign: 'center',
paddingHorizontal: 20,
},
searchContainer: {
padding: 12,
paddingBottom: 8,
},
searchInput: {
fontSize: 16,
paddingVertical: 12,
paddingHorizontal: 16,
borderRadius: 12,
borderWidth: 1,
},
});
@@ -0,0 +1,229 @@
import React, { useEffect, useState } from 'react';
import { Modal, View, Text, StyleSheet, TextInput, TouchableOpacity, ScrollView } from 'react-native';
import Category from '@/models/Category';
import { useSettings } from '@/theme';
import { CATEGORY_COLORS } from '@/constants';
import { createCategory, updateCategory, deleteCategory } from '@/utils/categoryActions';
import { confirmAction } from '@/utils/confirm';
import ColorPickerInput from '@/components/ColorPickerInput';
import Svg, { Path } from 'react-native-svg';
interface CategoryEditorModalProps {
visible: boolean;
category: Category | null;
categoryCount: number;
onClose: () => void;
}
export function CategoryEditorModal({ visible, category, categoryCount, onClose }: CategoryEditorModalProps) {
const { theme } = useSettings();
const [name, setName] = useState('');
const [color, setColor] = useState(CATEGORY_COLORS[0]);
useEffect(() => {
if (visible) {
setName(category?.name ?? '');
setColor(category?.color ?? CATEGORY_COLORS[0]);
}
}, [visible, category]);
const canDelete = category !== null && categoryCount > 1;
const handleSave = async () => {
const trimmed = name.trim();
if (!trimmed) return;
if (category) {
await updateCategory(category.id, trimmed, color);
} else {
await createCategory(trimmed, color);
}
onClose();
};
const handleDelete = () => {
if (!category || !canDelete) return;
confirmAction({
title: 'Delete Category',
message: `Tasks in "${category.name}" will be moved to another category.`,
confirmLabel: 'Delete',
destructive: true,
onConfirm: async () => {
await deleteCategory(category.id);
onClose();
},
});
};
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<View style={styles.overlay}>
<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 }}>
<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}
>
{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}>Save</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.4)',
alignItems: 'center',
justifyContent: 'center',
padding: 24,
},
sheet: {
width: '100%',
maxWidth: 380,
borderRadius: 16,
padding: 20,
gap: 8,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 4,
},
title: {
fontSize: 18,
fontWeight: '700',
},
closeButton: {
padding: 4,
},
label: {
fontSize: 13,
fontWeight: '600',
marginTop: 8,
},
input: {
height: 48,
paddingHorizontal: 14,
borderRadius: 12,
borderWidth: 1,
fontSize: 16,
},
colorGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 10,
paddingVertical: 4,
},
colorOption: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
},
colorOptionSelected: {
borderWidth: 2,
borderColor: '#FFFFFF',
shadowColor: '#000',
shadowOpacity: 0.3,
shadowRadius: 3,
elevation: 3,
},
actions: {
flexDirection: 'row',
gap: 12,
marginTop: 16,
},
deleteButton: {
flex: 1,
paddingVertical: 12,
borderRadius: 12,
borderWidth: 1,
alignItems: 'center',
},
deleteButtonText: {
fontSize: 15,
fontWeight: '600',
},
saveButton: {
flex: 1,
paddingVertical: 12,
borderRadius: 12,
alignItems: 'center',
},
saveButtonDisabled: {
opacity: 0.5,
},
saveButtonText: {
fontSize: 15,
fontWeight: '600',
color: '#FFFFFF',
},
});
@@ -0,0 +1,195 @@
import React from 'react';
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, Animated, Easing } from 'react-native';
import { useCategories } from '@/hooks/useDatabase';
import { useSettings, ThemeColors } from '@/theme';
import Category from '@/models/Category';
interface CategoryFilterProps {
selected: string;
onSelect: (categoryId: string) => void;
}
export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) {
const categories = useCategories();
const { theme } = useSettings();
if (categories.length === 0) {
return null;
}
return (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.container}
style={styles.scrollView}
>
<CategoryButton
id="all"
name="All"
color="#9E9E9E"
selected={selected === 'all'}
onPress={() => onSelect('all')}
theme={theme}
/>
{categories.map((category) => (
<AnimatedCategoryButton
key={category.id}
category={category}
selected={selected === category.id}
onPress={() => onSelect(category.id)}
theme={theme}
/>
))}
</ScrollView>
);
}
interface CategoryButtonProps {
id: string;
name: string;
color: string;
selected: boolean;
onPress: () => void;
theme: ThemeColors;
}
function CategoryButton({ id, name, color, selected, onPress, theme }: CategoryButtonProps) {
return (
<TouchableOpacity
style={[
styles.button,
{ backgroundColor: theme.card, borderColor: theme.borderStrong },
selected && { borderColor: theme.accent, backgroundColor: theme.accentSoft },
]}
onPress={onPress}
activeOpacity={0.8}
>
<View
style={[
styles.colorDot,
{ backgroundColor: color },
selected && styles.colorDotSelected,
]}
/>
<Text style={[
styles.buttonText,
{ color: theme.textSecondary },
selected && { color: theme.accent, fontWeight: '600' },
]}>
{name}
</Text>
</TouchableOpacity>
);
}
interface AnimatedCategoryButtonProps {
category: Category;
selected: boolean;
onPress: () => void;
theme: ThemeColors;
}
function AnimatedCategoryButton({ category, selected, onPress, theme }: AnimatedCategoryButtonProps) {
const scaleAnim = React.useRef(new Animated.Value(selected ? 1.05 : 1)).current;
const borderWidthAnim = React.useRef(new Animated.Value(selected ? 2 : 1)).current;
const shadowOpacityAnim = React.useRef(new Animated.Value(selected ? 0.15 : 0)).current;
React.useEffect(() => {
Animated.timing(scaleAnim, {
toValue: selected ? 1.05 : 1,
duration: 150,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}).start();
Animated.timing(borderWidthAnim, {
toValue: selected ? 2 : 1,
duration: 150,
useNativeDriver: false,
}).start();
Animated.timing(shadowOpacityAnim, {
toValue: selected ? 0.15 : 0,
duration: 150,
useNativeDriver: false,
}).start();
}, [selected, scaleAnim, borderWidthAnim, shadowOpacityAnim]);
const animatedStyle = {
transform: [{ scale: scaleAnim }],
borderWidth: borderWidthAnim,
shadowOpacity: shadowOpacityAnim,
};
return (
<Animated.View style={[styles.button, styles.animatedButton, { backgroundColor: theme.card, borderColor: theme.borderStrong }, animatedStyle]}>
<TouchableOpacity
style={styles.buttonInner}
onPress={onPress}
activeOpacity={0.8}
>
<View
style={[
styles.colorDot,
{ backgroundColor: category.color },
selected && styles.colorDotSelected,
]}
/>
<Text style={[
styles.buttonText,
{ color: theme.textSecondary },
selected && { color: theme.accent, fontWeight: '600' },
]}>
{category.name}
</Text>
</TouchableOpacity>
</Animated.View>
);
}
const styles = StyleSheet.create({
scrollView: {
paddingVertical: 8,
},
container: {
paddingHorizontal: 16,
gap: 8,
alignItems: 'center',
},
button: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 20,
borderWidth: 1,
minWidth: 72,
justifyContent: 'center',
},
animatedButton: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowRadius: 8,
elevation: 3,
},
buttonInner: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
colorDot: {
width: 10,
height: 10,
borderRadius: 5,
},
colorDotSelected: {
width: 12,
height: 12,
borderRadius: 6,
},
buttonText: {
fontSize: 13,
fontWeight: '500',
},
});
@@ -0,0 +1,111 @@
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity, ScrollView } from 'react-native';
import { useCategories } from '@/hooks/useDatabase';
import { useSettings } from '@/theme';
interface CategorySelectorProps {
value: string;
onChange: (value: string) => void;
error?: string;
}
export function CategorySelector({ value, onChange, error }: CategorySelectorProps) {
const categories = useCategories();
const { theme } = useSettings();
return (
<View style={styles.container}>
<Text style={[styles.label, { color: theme.text }]}>Category</Text>
<View style={styles.requiredIndicator} />
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
style={styles.scrollView}
>
{categories.map((category) => (
<TouchableOpacity
key={category.id}
style={[
styles.categoryButton,
{ backgroundColor: theme.card, borderColor: theme.borderStrong },
value === category.id && { borderColor: theme.accent, backgroundColor: theme.accentSoft, borderWidth: 2 },
]}
onPress={() => onChange(category.id)}
activeOpacity={0.8}
>
<View
style={[
styles.colorCircle,
{ backgroundColor: category.color },
value === category.id && styles.colorCircleSelected,
]}
/>
<Text style={[
styles.categoryName,
{ color: theme.textSecondary },
value === category.id && { color: theme.accent, fontWeight: '600' },
]}>
{category.name}
</Text>
</TouchableOpacity>
))}
</ScrollView>
{error && <Text style={styles.errorText}>{error}</Text>}
</View>
);
}
const styles = StyleSheet.create({
container: {
gap: 8,
},
label: {
fontSize: 14,
fontWeight: '600',
},
requiredIndicator: {
position: 'absolute',
top: 0,
right: 0,
color: '#E53935',
fontSize: 14,
},
scrollView: {
paddingVertical: 4,
},
scrollContent: {
paddingHorizontal: 16,
gap: 10,
},
categoryButton: {
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 24,
borderWidth: 1,
flexDirection: 'row',
alignItems: 'center',
gap: 8,
minWidth: 90,
justifyContent: 'center',
},
colorCircle: {
width: 12,
height: 12,
borderRadius: 6,
},
colorCircleSelected: {
width: 14,
height: 14,
borderRadius: 7,
},
categoryName: {
fontSize: 13,
fontWeight: '500',
},
errorText: {
fontSize: 12,
color: '#E53935',
marginLeft: 16,
},
});
@@ -0,0 +1,64 @@
import React from 'react';
import { View, Text, StyleSheet, TextInput } from 'react-native';
import { useSettings } from '@/theme';
interface ColorPickerInputProps {
value: string;
onChange: (color: string) => void;
}
function normalizeHex(input: string): string {
const cleaned = input.replace(/[^0-9a-fA-F]/g, '').slice(0, 6);
return cleaned ? `#${cleaned}` : '';
}
export default function NativeColorPickerInput({ value, onChange }: ColorPickerInputProps) {
const { theme } = useSettings();
return (
<View style={styles.row}>
<View style={[styles.preview, { backgroundColor: value }]} />
<TextInput
style={[
styles.input,
{ backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text },
]}
value={value}
onChangeText={(text) => onChange(normalizeHex(text))}
placeholder="#E53935"
placeholderTextColor={theme.textMuted}
autoCapitalize="characters"
autoCorrect={false}
maxLength={7}
/>
<Text style={[styles.hint, { color: theme.textMuted }]}>Hex code</Text>
</View>
);
}
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
preview: {
width: 36,
height: 36,
borderRadius: 18,
borderWidth: 1,
borderColor: '#E0E0E0',
},
input: {
flex: 1,
height: 44,
paddingHorizontal: 14,
borderRadius: 10,
borderWidth: 1,
fontSize: 15,
fontWeight: '500',
},
hint: {
fontSize: 12,
},
});
@@ -0,0 +1,44 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { useSettings } from '@/theme';
interface ColorPickerInputProps {
value: string;
onChange: (color: string) => void;
}
export default function WebColorPickerInput({ value, onChange }: ColorPickerInputProps) {
const { theme } = useSettings();
return (
<View style={styles.row}>
{React.createElement('input', {
type: 'color',
value: value.toUpperCase(),
onChange: (e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.value),
style: {
width: 44,
height: 44,
padding: 0,
border: `1px solid ${theme.borderStrong}`,
borderRadius: 12,
backgroundColor: 'transparent',
cursor: 'pointer',
},
})}
<Text style={[styles.hexText, { color: theme.textSecondary }]}>{value.toUpperCase()}</Text>
</View>
);
}
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
hexText: {
fontSize: 15,
fontWeight: '500',
},
});
@@ -0,0 +1,51 @@
import React from 'react';
import { View, StyleSheet } from 'react-native';
import DateTimePicker, { DateTimePickerEvent } from '@react-native-community/datetimepicker';
interface NativeDateTimeInputProps {
value: Date | null;
mode: 'date' | 'time';
isVisible: boolean;
onChange: (event: DateTimePickerEvent, date?: Date) => void;
onDismiss: () => void;
testID?: string;
is24Hour?: boolean;
}
export default function NativeDateTimeInput({
value,
mode,
isVisible,
onChange,
onDismiss,
is24Hour,
}: NativeDateTimeInputProps) {
if (!isVisible) return null;
const handleChange = (event: DateTimePickerEvent, date?: Date) => {
if (event.type === 'set' && date) {
onChange(event, date);
}
onDismiss();
};
return (
<DateTimePicker
testID="date-time-picker"
value={value ?? new Date()}
mode={mode}
display="default"
is24Hour={is24Hour ?? false}
themeVariant="dark"
onChange={handleChange}
style={styles.picker}
/>
);
}
const styles = StyleSheet.create({
picker: {
width: 0,
height: 0,
},
});
@@ -0,0 +1,167 @@
import React, { useEffect, useState } from 'react';
import { View, Text, TouchableOpacity, StyleSheet, Modal } from 'react-native';
import { DateTimePickerEvent } from '@react-native-community/datetimepicker';
interface WebDateTimeInputProps {
value: Date;
mode: 'date' | 'time';
isVisible: boolean;
onChange: (event: DateTimePickerEvent, date?: Date) => void;
onDismiss: () => void;
testID?: string;
is24Hour?: boolean;
}
function pad(n: number): string {
return String(n).padStart(2, '0');
}
function dateInputValue(value: Date): string {
return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}`;
}
function timeInputValue(value: Date): string {
return `${pad(value.getHours())}:${pad(value.getMinutes())}`;
}
export default function WebDateTimeInput({
value,
mode,
isVisible,
onChange,
onDismiss,
is24Hour,
}: WebDateTimeInputProps) {
const [inputValue, setInputValue] = useState(() => dateInputValue(value));
useEffect(() => {
setInputValue(dateInputValue(value));
}, [value]);
const emit = (raw: string) => {
if (!raw) return;
let date: Date;
if (mode === 'date') {
date = new Date(`${raw}T00:00:00`);
} else {
const [hours, minutes] = raw.split(':').map(Number);
date = new Date();
date.setHours(hours, minutes, 0, 0);
}
const event: DateTimePickerEvent = {
type: 'set',
nativeEvent: { timestamp: date.getTime(), utcOffset: date.getTimezoneOffset() },
};
onChange(event, date);
};
const bgColor = '#1E1E1E';
const textColor = '#FFFFFF';
const borderColor = '#333333';
const overlayColor = 'rgba(0,0,0,0.6)';
const mutedColor = '#9E9E9E';
return (
<Modal visible={isVisible} transparent animationType="fade" onRequestClose={onDismiss}>
<View style={[styles.overlay, { backgroundColor: overlayColor }]}>
<View style={[styles.sheet, { backgroundColor: bgColor }]}>
<Text style={[styles.title, { color: textColor }]}>
{mode === 'date' ? 'Choose Date' : 'Choose Time'}
</Text>
<View style={styles.inputWrapper}>
{React.createElement('input', {
type: mode,
value: mode === 'date' ? inputValue : timeInputValue(value),
onChange: (e: React.ChangeEvent<HTMLInputElement>) => {
const raw = e.target.value;
if (mode === 'date') {
setInputValue(raw);
emit(raw);
} else {
emit(raw);
}
},
autoFocus: true,
style: {
fontSize: 16,
padding: 12,
borderRadius: 10,
border: `1px solid ${borderColor}`,
outline: 'none',
backgroundColor: '#2C2C2C',
color: textColor,
fontFamily: 'inherit',
marginVertical: 16,
width: '100%',
boxSizing: 'border-box',
},
})}
</View>
<View style={styles.actions}>
<TouchableOpacity
style={[styles.clearButton, { borderColor, backgroundColor: '#2C2C2C' }]}
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>
</View>
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 24,
},
sheet: {
width: '100%',
maxWidth: 360,
borderRadius: 16,
padding: 20,
alignItems: 'center',
},
title: {
fontSize: 16,
fontWeight: '600',
marginBottom: 8,
},
inputWrapper: {
width: '100%',
},
actions: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: 12,
width: '100%',
},
clearButton: {
paddingVertical: 10,
paddingHorizontal: 16,
borderRadius: 10,
borderWidth: 1,
},
clearButtonText: {
fontSize: 15,
fontWeight: '500',
},
doneButton: {
paddingVertical: 10,
paddingHorizontal: 16,
borderRadius: 10,
backgroundColor: '#E53935',
},
doneButtonText: {
fontSize: 15,
fontWeight: '600',
color: '#FFFFFF',
},
});
@@ -0,0 +1,312 @@
import React, { useRef } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Switch } from 'react-native';
import { Controller, Control, useWatch } from 'react-hook-form';
import { TaskFormData } from '@/types';
import { useSettings } from '@/theme';
import DateTimeInput from '@/components/DateTimeInput';
import { WheelTimePicker } from '@/components/WheelTimePicker';
import Svg, { Path, Circle } from 'react-native-svg';
import { format } from 'date-fns';
interface DateTimePickerComponentProps {
control: Control<TaskFormData>;
}
export function DateTimePickerComponent({ control }: DateTimePickerComponentProps) {
const { theme } = useSettings();
const [picker, setPicker] = React.useState<'date' | 'time' | 'endTime' | null>(null);
const dateValueRef = useRef<Date | null>(null);
const timeValueRef = useRef<string>('');
const endTimeValueRef = useRef<string>('');
const dateRef = useRef<((value: Date | null) => void) | null>(null);
const timeRef = useRef<((value: string) => void) | null>(null);
const endTimeRef = useRef<((value: string) => void) | null>(null);
const allDay = useWatch({ control, name: 'allDay' }) ?? false;
const startTime = useWatch({ control, name: 'dueTime' }) ?? '';
const endTime = useWatch({ control, name: 'endTime' }) ?? '';
const endTimeInvalid = startTime !== '' && endTime !== '' && endTime <= startTime;
const setDateValue = (value: Date | null) => dateRef.current?.(value);
const setTimeValue = (value: string) => timeRef.current?.(value);
const setEndTimeValue = (value: string) => endTimeRef.current?.(value);
const handlePickerDismiss = () => setPicker(null);
const renderTimeButton = (
fieldName: 'dueTime' | 'endTime',
ref: React.MutableRefObject<((value: string) => void) | null>,
valueRef: React.MutableRefObject<string>,
placeholder: string,
onPress: () => void
) => (
<Controller
control={control}
name={fieldName}
render={({ field }) => {
ref.current = field.onChange;
valueRef.current = field.value ?? '';
return (
<TouchableOpacity
style={[
styles.pickerButton,
{ backgroundColor: theme.inputBg, borderColor: theme.borderStrong },
field.value && { borderColor: theme.accent, backgroundColor: theme.accentSoft },
fieldName === 'endTime' && endTimeInvalid && { borderColor: '#E53935', backgroundColor: theme.accentSoft },
]}
onPress={onPress}
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" />
</Svg>
<Text
style={[
styles.pickerText,
{ color: field.value ? theme.text : theme.textMuted },
field.value && styles.pickerTextFilled,
]}
numberOfLines={1}
>
{field.value ? formatTime12(field.value) : placeholder}
</Text>
{field.value && (
<TouchableOpacity
style={styles.clearButton}
onPress={() => {
if (fieldName === 'dueTime') {
field.onChange('');
endTimeRef.current?.('');
} else {
field.onChange('');
}
}}
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" />
</Svg>
</TouchableOpacity>
)}
</TouchableOpacity>
);
}}
/>
);
return (
<View style={styles.container}>
<Text style={[styles.label, { color: theme.text }]}>Date & Time</Text>
<View style={styles.dateRow}>
<Controller
control={control}
name="dueDate"
render={({ field }) => {
dateRef.current = field.onChange;
dateValueRef.current = field.value;
return (
<TouchableOpacity
style={[
styles.pickerButton,
{ backgroundColor: theme.inputBg, borderColor: theme.borderStrong },
field.value && { borderColor: theme.accent, backgroundColor: theme.accentSoft },
]}
onPress={() => setPicker('date')}
activeOpacity={0.8}
>
<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}
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}
fill="none"
/>
</Svg>
<Text
style={[
styles.pickerText,
{ color: field.value ? theme.text : theme.textMuted },
field.value && styles.pickerTextFilled,
]}
numberOfLines={1}
>
{field.value ? format(field.value, 'MMM d, yyyy') : 'Due Date'}
</Text>
{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" />
</Svg>
</TouchableOpacity>
)}
</TouchableOpacity>
);
}}
/>
</View>
<Controller
control={control}
name="allDay"
render={({ field }) => (
<View style={styles.allDayRow}>
<Text style={[styles.allDayLabel, { color: theme.text }]}>All Day</Text>
<Switch
trackColor={{ false: theme.border, true: theme.accent }}
thumbColor={field.value ? theme.accent : theme.textMuted}
ios_backgroundColor={theme.border}
value={field.value ?? false}
onValueChange={(value) => {
field.onChange(value);
if (value) {
timeRef.current?.('');
endTimeRef.current?.('');
}
}}
/>
</View>
)}
/>
{!allDay && (
<>
<View style={styles.timeRow}>
{renderTimeButton('dueTime', timeRef, timeValueRef, 'Start Time', () => setPicker('time'))}
{renderTimeButton('endTime', endTimeRef, endTimeValueRef, 'End Time', () => setPicker('endTime'))}
</View>
{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" />
<Path d="M12 8v5M12 16.5v.5" stroke="#E53935" strokeWidth={1.8} strokeLinecap="round" />
</Svg>
<Text style={[styles.warningText, { color: '#E53935' }]}>
{endTime === startTime
? 'The end time is the same as the start time.'
: 'The end time is before the start time.'}
</Text>
</View>
)}
</>
)}
<DateTimeInput
testID="date-picker"
value={dateValueRef.current ?? new Date()}
mode="date"
is24Hour={false}
isVisible={picker === 'date'}
onChange={(_event, selectedDate) => {
if (selectedDate) {
setDateValue(selectedDate);
}
}}
onDismiss={handlePickerDismiss}
/>
<WheelTimePicker
visible={picker === 'time' || picker === 'endTime'}
title={picker === 'endTime' ? 'End Time' : 'Start Time'}
initialTime={
picker === 'endTime'
? endTimeValueRef.current || timeValueRef.current
: timeValueRef.current
}
onConfirm={(time) => {
if (picker === 'endTime') {
setEndTimeValue(time);
} else {
setTimeValue(time);
}
handlePickerDismiss();
}}
onDismiss={handlePickerDismiss}
/>
</View>
);
}
function formatTime12(time: string): string {
const [hours, minutes] = time.split(':').map(Number);
if (Number.isNaN(hours) || Number.isNaN(minutes)) return time;
const period = hours >= 12 ? 'PM' : 'AM';
const h = hours % 12 === 0 ? 12 : hours % 12;
return `${h}:${String(minutes).padStart(2, '0')} ${period}`;
}
const styles = StyleSheet.create({
container: {
gap: 8,
},
label: {
fontSize: 14,
fontWeight: '600',
},
dateRow: {
flexDirection: 'row',
alignItems: 'center',
},
allDayRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 12,
},
allDayLabel: {
fontSize: 15,
fontWeight: '500',
},
timeRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
pickerButton: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
paddingVertical: 14,
paddingHorizontal: 12,
borderRadius: 12,
borderWidth: 1,
minHeight: 52,
},
pickerText: {
fontSize: 15,
flex: 1,
flexShrink: 1,
},
pickerTextFilled: {
fontWeight: '500',
},
clearButton: {
padding: 2,
},
warningRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
paddingVertical: 8,
paddingHorizontal: 12,
borderRadius: 10,
borderWidth: 1,
},
warningText: {
fontSize: 13,
fontWeight: '500',
flex: 1,
},
});
@@ -0,0 +1,68 @@
import React from 'react';
import { View, Text, StyleSheet, TextInput } from 'react-native';
import { TextInputProps } from 'react-native';
import { useSettings } from '@/theme';
interface DescriptionInputProps extends TextInputProps {
error?: string;
}
export function DescriptionInput({ error, ...props }: DescriptionInputProps) {
const { theme } = useSettings();
return (
<View style={styles.container}>
<Text style={[styles.label, { color: theme.text }]}>Description</Text>
<TextInput
style={[
styles.input,
{ backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text },
error && styles.inputError,
]}
placeholder="Add description..."
placeholderTextColor={theme.textMuted}
multiline
numberOfLines={5}
maxLength={1000}
textAlignVertical="top"
autoCapitalize="sentences"
{...props}
/>
<Text style={[styles.charCount, { color: theme.textMuted }]}>
{props.value?.length || 0}/1000
</Text>
{error && <Text style={styles.errorText}>{error}</Text>}
</View>
);
}
const styles = StyleSheet.create({
container: {
gap: 6,
},
label: {
fontSize: 14,
fontWeight: '600',
},
input: {
minHeight: 100,
padding: 16,
borderRadius: 12,
borderWidth: 1,
fontSize: 15,
},
inputError: {
borderColor: '#E53935',
borderWidth: 1.5,
},
charCount: {
fontSize: 11,
textAlign: 'right',
marginTop: -4,
},
errorText: {
fontSize: 12,
color: '#E53935',
marginLeft: 4,
},
});
@@ -0,0 +1,100 @@
import React, { useState } from 'react';
import { View, StyleSheet, TouchableOpacity, Animated, Easing } from 'react-native';
import { useRouter } from 'expo-router';
import Svg, { Path } from 'react-native-svg';
export function FloatingActionButton() {
const router = useRouter();
const [scaleAnim] = React.useState(new Animated.Value(1));
const [rotateAnim] = React.useState(new Animated.Value(0));
const [rotation, setRotation] = useState(0);
const handlePressIn = () => {
Animated.timing(scaleAnim, {
toValue: 0.9,
duration: 100,
useNativeDriver: true,
}).start();
};
const handlePressOut = () => {
Animated.spring(scaleAnim, {
toValue: 1,
friction: 5,
tension: 200,
useNativeDriver: true,
}).start();
};
const handlePress = () => {
const nextRotation = rotation + 90;
setRotation(nextRotation);
Animated.timing(rotateAnim, {
toValue: nextRotation / 90,
duration: 200,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}).start(() => {
router.push('/add-task');
});
};
const rotate = rotateAnim.interpolate({
inputRange: [0, 1],
outputRange: ['0deg', '90deg'],
});
return (
<Animated.View
style={[
styles.container,
{ transform: [{ scale: scaleAnim }] },
]}
>
<TouchableOpacity
style={styles.button}
onPress={handlePress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
activeOpacity={1}
>
<Animated.View
style={{
transform: [{ rotate }],
}}
>
<Svg width={24} height={24} viewBox="0 0 24 24">
<Path
d="M12 5v14M5 12h14"
stroke="#FFFFFF"
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
</Animated.View>
</TouchableOpacity>
</Animated.View>
);
}
const styles = StyleSheet.create({
container: {
position: 'absolute',
bottom: 24,
right: 24,
shadowColor: '#E53935',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 12,
elevation: 6,
},
button: {
width: 56,
height: 56,
borderRadius: 16,
backgroundColor: '#E53935',
alignItems: 'center',
justifyContent: 'center',
},
});
@@ -0,0 +1,100 @@
import React from 'react';
import { View, StyleSheet, TouchableOpacity, Text } from 'react-native';
import { useFormContext } from 'react-hook-form';
import { useRouter } from 'expo-router';
import { useSettings } from '@/theme';
interface FormButtonsProps {
onSubmit: (data: any) => void;
submitLabel?: string;
}
export function FormButtons({ onSubmit, submitLabel = 'Submit' }: FormButtonsProps) {
const router = useRouter();
const { theme } = useSettings();
const { handleSubmit, formState: { isSubmitting } } = useFormContext();
const cancel = () => {
if (router.canGoBack()) {
router.back();
} else {
router.replace('/');
}
};
return (
<View style={[styles.container, { backgroundColor: theme.background, borderTopColor: theme.border }]}>
<TouchableOpacity
style={[styles.cancelButton, { borderColor: theme.borderStrong, backgroundColor: theme.card }]}
onPress={cancel}
disabled={isSubmitting}
activeOpacity={0.7}
>
<Text style={[styles.cancelButtonText, { color: theme.textSecondary }]}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.submitButton, isSubmitting && styles.buttonDisabled]}
onPress={handleSubmit(onSubmit)}
disabled={isSubmitting}
activeOpacity={0.8}
>
<Text style={styles.submitButtonText}>
{isSubmitting ? 'Saving...' : submitLabel}
</Text>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
paddingHorizontal: 16,
paddingVertical: 16,
borderTopWidth: 1,
flexDirection: 'row',
justifyContent: 'space-between',
gap: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.05,
shadowRadius: 8,
elevation: 4,
},
cancelButton: {
flex: 1,
paddingVertical: 14,
borderRadius: 12,
borderWidth: 1,
alignItems: 'center',
justifyContent: 'center',
},
cancelButtonText: {
fontSize: 16,
fontWeight: '600',
},
submitButton: {
flex: 1,
paddingVertical: 14,
borderRadius: 12,
backgroundColor: '#E53935',
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#E53935',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 3,
},
submitButtonText: {
fontSize: 16,
fontWeight: '600',
color: '#FFFFFF',
},
buttonDisabled: {
opacity: 0.6,
},
});
@@ -0,0 +1,442 @@
import React, { useState, useEffect } from 'react';
import {
Modal,
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
FlatList,
ActivityIndicator,
Alert,
} from 'react-native';
import { useSettings } from '@/theme';
import { useFriends } from '@/hooks/useFriends';
interface FriendsModalProps {
visible: boolean;
onClose: () => void;
}
export function FriendsModal({ visible, onClose }: FriendsModalProps) {
const { theme } = useSettings();
const { friends, incoming, outgoing, loading, sendRequest, acceptRequest, declineRequest, removeFriend, searchUsers } = useFriends();
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<Array<{ id: string; username: string }>>([]);
const [searching, setSearching] = useState(false);
const [selectedTab, setSelectedTab] = useState<'friends' | 'incoming' | 'outgoing' | 'add'>('friends');
useEffect(() => {
if (selectedTab === 'add' && searchQuery.length >= 2) {
const timeout = setTimeout(async () => {
setSearching(true);
try {
const results = await searchUsers(searchQuery);
setSearchResults(results.filter(r => !friends.some(f => f.id === r.id) && !outgoing.some(o => o.id === r.id)));
} catch (err) {
console.error('Search failed:', err);
} finally {
setSearching(false);
}
}, 300);
return () => clearTimeout(timeout);
} else {
setSearchResults([]);
}
}, [searchQuery, selectedTab, friends, outgoing, searchUsers]);
const handleAddFriend = async () => {
if (!searchQuery.trim()) return;
try {
await sendRequest(searchQuery.trim());
setSearchQuery('');
setSearchResults([]);
setSelectedTab('friends');
} catch (err) {
Alert.alert('Error', err instanceof Error ? err.message : 'Failed to send request');
}
};
const handleAccept = async (requestId: string) => {
try {
await acceptRequest(requestId);
} catch (err) {
Alert.alert('Error', err instanceof Error ? err.message : 'Failed to accept request');
}
};
const handleDecline = async (requestId: string) => {
try {
await declineRequest(requestId);
} catch (err) {
Alert.alert('Error', err instanceof Error ? err.message : 'Failed to decline request');
}
};
const handleRemove = async (friendId: string) => {
Alert.alert('Remove Friend', 'Are you sure you want to remove this friend?', [
{ text: 'Cancel', style: 'cancel' },
{ text: 'Remove', style: 'destructive', onPress: async () => {
try {
await removeFriend(friendId);
} catch (err) {
Alert.alert('Error', err instanceof Error ? err.message : 'Failed to remove friend');
}
}},
]);
};
if (!visible) return null;
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<View style={[styles.overlay, { backgroundColor: 'rgba(0,0,0,0.5)' }]}>
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
<View style={styles.header}>
<Text style={[styles.title, { color: theme.text }]}>Friends</Text>
<TouchableOpacity onPress={onClose} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
<Text style={[styles.closeText, { color: theme.textMuted }]}>✕</Text>
</TouchableOpacity>
</View>
<View style={styles.tabs}>
{(['friends', 'incoming', 'outgoing', 'add'] as const).map((tab) => (
<TouchableOpacity
key={tab}
style={[
styles.tab,
selectedTab === tab ? styles.tabActive : {},
{ backgroundColor: selectedTab === tab ? theme.accent : 'transparent' },
]}
onPress={() => setSelectedTab(tab)}
>
<Text
style={[
styles.tabText,
{ color: selectedTab === tab ? '#FFFFFF' : theme.textMuted },
]}
>
{tab.charAt(0).toUpperCase() + tab.slice(1)}
{tab === 'friends' && friends.length > 0 && (
<Text style={[styles.badge, { color: '#FFFFFF' }]}>{friends.length}</Text>
)}
{tab === 'incoming' && incoming.length > 0 && (
<Text style={[styles.badge, { color: '#FFFFFF' }]}>{incoming.length}</Text>
)}
</Text>
</TouchableOpacity>
))}
</View>
{selectedTab === 'friends' && (
<View style={styles.content}>
{loading ? (
<View style={styles.loading}>
<ActivityIndicator color={theme.accent} size="large" />
</View>
) : friends.length === 0 ? (
<View style={styles.empty}>
<Text style={[styles.emptyText, { color: theme.textMuted }]}>No friends yet</Text>
<Text style={[styles.emptyHint, { color: theme.textFaint }]}>Tap "Add" to find friends by username</Text>
</View>
) : (
<FlatList
data={friends}
keyExtractor={f => f.id}
renderItem={({ item }) => (
<View style={[styles.friendRow, { borderBottomColor: theme.border }]}>
<Text style={[styles.friendName, { color: theme.text }]}>{item.username}</Text>
<TouchableOpacity
style={[styles.removeBtn, { backgroundColor: theme.accentSoft, borderColor: theme.accent }]}
onPress={() => handleRemove(item.id)}
>
<Text style={[styles.removeBtnText, { color: theme.accent }]}>Remove</Text>
</TouchableOpacity>
</View>
)}
/>
)}
</View>
)}
{selectedTab === 'incoming' && (
<View style={styles.content}>
{loading ? (
<View style={styles.loading}>
<ActivityIndicator color={theme.accent} size="large" />
</View>
) : incoming.length === 0 ? (
<View style={styles.empty}>
<Text style={[styles.emptyText, { color: theme.textMuted }]}>No incoming requests</Text>
</View>
) : (
<FlatList
data={incoming}
keyExtractor={r => r.requestId}
renderItem={({ item }) => (
<View style={[styles.requestRow, { borderBottomColor: theme.border }]}>
<Text style={[styles.friendName, { color: theme.text }]}>{item.username}</Text>
<View style={styles.requestActions}>
<TouchableOpacity
style={[styles.actionBtn, { backgroundColor: theme.accent }]}
onPress={() => handleAccept(item.requestId)}
>
<Text style={styles.actionBtnText}>Accept</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionBtn, { backgroundColor: 'transparent', borderColor: theme.borderStrong, borderWidth: 1 }]}
onPress={() => handleDecline(item.requestId)}
>
<Text style={[styles.actionBtnText, { color: theme.textMuted }]}>Decline</Text>
</TouchableOpacity>
</View>
</View>
)}
/>
)}
</View>
)}
{selectedTab === 'outgoing' && (
<View style={styles.content}>
{loading ? (
<View style={styles.loading}>
<ActivityIndicator color={theme.accent} size="large" />
</View>
) : outgoing.length === 0 ? (
<View style={styles.empty}>
<Text style={[styles.emptyText, { color: theme.textMuted }]}>No outgoing requests</Text>
</View>
) : (
<FlatList
data={outgoing}
keyExtractor={r => r.requestId}
renderItem={({ item }) => (
<View style={[styles.requestRow, { borderBottomColor: theme.border }]}>
<Text style={[styles.friendName, { color: theme.textMuted }]}>{item.username}</Text>
<Text style={[styles.pendingText, { color: theme.textFaint }]}>Pending</Text>
<TouchableOpacity
style={[styles.cancelBtn, { backgroundColor: 'transparent', borderColor: theme.borderStrong, borderWidth: 1 }]}
onPress={() => handleDecline(item.requestId)}
>
<Text style={[styles.cancelBtnText, { color: theme.textMuted }]}>Cancel</Text>
</TouchableOpacity>
</View>
)}
/>
)}
</View>
)}
{selectedTab === 'add' && (
<View style={styles.content}>
<View style={styles.searchContainer}>
<TextInput
style={[styles.searchInput, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text }]}
placeholder="Search by username"
placeholderTextColor={theme.textMuted}
value={searchQuery}
onChangeText={setSearchQuery}
autoCapitalize="none"
autoCorrect={false}
/>
</View>
{searching ? (
<View style={styles.loading}>
<ActivityIndicator color={theme.accent} size="large" />
</View>
) : searchQuery.length >= 2 && searchResults.length === 0 ? (
<View style={styles.empty}>
<Text style={[styles.emptyText, { color: theme.textMuted }]}>No users found</Text>
</View>
) : searchQuery.length >= 2 && searchResults.length > 0 ? (
<FlatList
data={searchResults}
keyExtractor={u => u.id}
renderItem={({ item }) => (
<View style={[styles.friendRow, { borderBottomColor: theme.border }]}>
<Text style={[styles.friendName, { color: theme.text }]}>{item.username}</Text>
<TouchableOpacity
style={[styles.addBtn, { backgroundColor: theme.accent }]}
onPress={async () => {
try {
await sendRequest(item.username);
setSearchQuery('');
setSearchResults([]);
setSelectedTab('friends');
} catch (err) {
Alert.alert('Error', err instanceof Error ? err.message : 'Failed to send request');
}
}}
>
<Text style={styles.addBtnText}>Add</Text>
</TouchableOpacity>
</View>
)}
/>
) : searchQuery.length < 2 ? (
<View style={styles.empty}>
<Text style={[styles.emptyHint, { color: theme.textFaint }]}>Type at least 2 characters to search</Text>
</View>
) : null}
</View>
)}
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 24,
},
sheet: {
width: '100%',
maxWidth: 400,
borderRadius: 20,
overflow: 'hidden',
maxHeight: '85%',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: 20,
borderBottomWidth: 1,
},
title: {
fontSize: 18,
fontWeight: '700',
},
closeText: {
fontSize: 22,
fontWeight: '300',
},
tabs: {
flexDirection: 'row',
borderBottomWidth: 1,
},
tab: {
flex: 1,
paddingVertical: 14,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 0,
},
tabActive: {
borderBottomWidth: 0,
},
tabText: {
fontSize: 14,
fontWeight: '600',
},
badge: {
fontSize: 11,
fontWeight: '700',
marginLeft: 6,
backgroundColor: 'rgba(255,255,255,0.2)',
paddingHorizontal: 6,
paddingVertical: 1,
borderRadius: 8,
},
content: {
maxHeight: 400,
},
loading: {
padding: 40,
alignItems: 'center',
},
empty: {
padding: 40,
alignItems: 'center',
},
emptyText: {
fontSize: 16,
fontWeight: '500',
marginBottom: 4,
},
emptyHint: {
fontSize: 13,
},
friendRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 14,
paddingHorizontal: 20,
},
requestRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 14,
paddingHorizontal: 20,
},
friendName: {
fontSize: 16,
flex: 1,
},
pendingText: {
fontSize: 13,
fontWeight: '500',
marginRight: 12,
},
removeBtn: {
paddingVertical: 6,
paddingHorizontal: 12,
borderRadius: 8,
borderWidth: 1,
},
removeBtnText: {
fontSize: 13,
fontWeight: '600',
},
addBtn: {
paddingVertical: 6,
paddingHorizontal: 16,
borderRadius: 8,
},
addBtnText: {
fontSize: 13,
fontWeight: '600',
color: '#FFFFFF',
},
requestActions: {
flexDirection: 'row',
gap: 8,
},
actionBtn: {
paddingVertical: 6,
paddingHorizontal: 14,
borderRadius: 8,
},
actionBtnText: {
fontSize: 13,
fontWeight: '600',
color: '#FFFFFF',
},
cancelBtn: {
paddingVertical: 6,
paddingHorizontal: 14,
borderRadius: 8,
},
cancelBtnText: {
fontSize: 13,
fontWeight: '600',
},
searchContainer: {
padding: 20,
paddingBottom: 12,
},
searchInput: {
fontSize: 16,
paddingVertical: 12,
paddingHorizontal: 16,
borderRadius: 12,
borderWidth: 1,
},
});
+80
View File
@@ -0,0 +1,80 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useSettings } from '@/theme';
interface HeaderProps {
title: string;
showLogo: boolean;
rightAction?: React.ReactNode;
}
export function Header({ title, showLogo, rightAction }: HeaderProps) {
const { theme } = useSettings();
return (
<SafeAreaView style={[styles.header, { backgroundColor: theme.background }]}>
<View style={styles.headerContent}>
{showLogo && (
<View style={styles.logoContainer}>
<Text style={styles.logoText}>✓</Text>
</View>
)}
<Text style={[styles.title, { color: theme.text }]}>{title}</Text>
<View style={styles.spacer}>{rightAction}</View>
</View>
<View style={[styles.bottomRounded, { backgroundColor: theme.background }]} />
</SafeAreaView>
);
}
const styles = StyleSheet.create({
header: {
borderBottomLeftRadius: 24,
borderBottomRightRadius: 24,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.05,
shadowRadius: 8,
elevation: 2,
},
headerContent: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 20,
paddingTop: 8,
paddingBottom: 16,
height: 80,
},
logoContainer: {
width: 36,
height: 36,
borderRadius: 10,
backgroundColor: '#E53935',
alignItems: 'center',
justifyContent: 'center',
},
logoText: {
color: '#FFFFFF',
fontSize: 20,
fontWeight: '700',
},
title: {
fontSize: 20,
fontWeight: '700',
position: 'absolute',
left: '50%',
marginLeft: -30,
},
spacer: {
width: 36,
alignItems: 'flex-end',
},
bottomRounded: {
height: 24,
borderBottomLeftRadius: 24,
borderBottomRightRadius: 24,
marginTop: -24,
},
});
@@ -0,0 +1,197 @@
import React from 'react';
import { View, Text, StyleSheet, SafeAreaView, ScrollView, TouchableOpacity } from 'react-native';
import Svg, { Path } from 'react-native-svg';
import { useSettings } from '@/theme';
interface LegalModalProps {
visible: boolean;
type: 'privacy' | 'terms' | null;
onClose: () => void;
}
const PRIVACY_POLICY = `
Privacy Policy for Carry Your Live
Last updated: ${new Date().toLocaleDateString()}
1. Information We Collect
We collect information you provide directly to us, such as when you create an account, add tasks, or interact with other users.
- Account Information: Username and password (securely hashed)
- Task Data: Tasks, subtasks, categories, due dates, reminders, and related metadata
- Social Data: Friend requests, accepted friendships, and assigned tasks
- Usage Data: App interactions, sync timestamps, and error logs
2. How We Use Your Information
- Provide and maintain the service
- Sync your data across devices
- Enable social features (friend requests, task assignment)
- Send notifications (with your permission)
- Improve the app experience
3. Data Storage & Security
- Passwords are hashed using bcrypt (never stored in plain text)
- Data is stored locally on your device using SQLite
- Optional cloud sync uses encrypted HTTPS connections
- You can delete your account and all data at any time
4. Data Sharing
We do not sell your personal information. Data is shared only:
- With friends you explicitly connect with (task assignments, shared lists)
- With our backend service for sync functionality
- When required by law
5. Your Rights
- Access: View all data associated with your account
- Rectification: Correct inaccurate data
- Erasure: Delete your account and all data
- Portability: Export your data (via sync)
- Restrict Processing: Disable sync or notifications
6. Children's Privacy
This app is not intended for users under 13. We do not knowingly collect data from children under 13.
7. Changes to This Policy
We may update this policy. Continued use constitutes acceptance.
8. Contact
For privacy questions, contact us through the app's feedback channel.
`;
const TERMS_OF_SERVICE = `
Terms of Service for Carry Your Live
Last updated: ${new Date().toLocaleDateString()}
1. Acceptance of Terms
By using Carry Your Live, you agree to these Terms. If you disagree, do not use the app.
2. Account Registration
- You must provide accurate information
- You are responsible for your password security
- One account per person
- You must be 13+ years old
3. User Content
You retain ownership of your tasks, categories, and data. You grant us a license to store, sync, and display your content as needed to provide the service.
4. Social Features
- Friend requests require mutual acceptance
- You can remove friends at any time
- Assigned tasks are visible to both parties
- Harassment or abuse will result in account termination
5. Service Availability
We strive for high availability but do not guarantee 100% uptime. The service may be temporarily unavailable for maintenance.
6. Disclaimers
THE SERVICE IS PROVIDED "AS IS" WITHOUT WARRANTIES OF ANY KIND. WE DO NOT GUARANTEE THAT THE SERVICE WILL BE UNINTERRUPTED, ERROR-FREE, OR SECURE.
7. Limitation of Liability
TO THE MAXIMUM EXTENT PERMITTED BY LAW, WE ARE NOT LIABLE FOR INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES.
8. Termination
You may delete your account at any time. We may terminate accounts that violate these Terms.
9. Governing Law
These Terms are governed by the laws of your country of residence.
10. Changes
We may modify these Terms. Continued use after changes constitutes acceptance.
11. Contact
For questions about these Terms, contact us through the app's feedback channel.
`;
export function LegalModal({ visible, type, onClose }: LegalModalProps) {
if (!visible || !type) return null;
const { theme } = useSettings();
const content = type === 'privacy' ? PRIVACY_POLICY : TERMS_OF_SERVICE;
const title = type === 'privacy' ? 'Privacy Policy' : 'Terms of Service';
return (
<SafeAreaView style={[styles.overlay, { backgroundColor: theme.overlay }]}>
<View style={[styles.modal, { backgroundColor: theme.card }]}>
<View style={[styles.header, { borderBottomColor: theme.border }]}>
<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" />
</Svg>
</TouchableOpacity>
</View>
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
<Text style={[styles.body, { color: theme.text }]}>{content}</Text>
</ScrollView>
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
overlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
justifyContent: 'center',
padding: 16,
zIndex: 100,
},
modal: {
borderRadius: 20,
maxHeight: '90%',
overflow: 'hidden',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 20,
paddingVertical: 16,
borderBottomWidth: 1,
},
title: {
fontSize: 18,
fontWeight: '700',
flex: 1,
textAlign: 'center',
marginRight: 24,
},
closeButton: {
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
},
content: {
padding: 20,
maxHeight: '70%',
},
body: {
fontSize: 14,
lineHeight: 22,
whiteSpace: 'pre-wrap' as const,
},
});
@@ -0,0 +1,85 @@
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import { useSettings } from '@/theme';
import Svg, { Path } from 'react-native-svg';
interface ListItemProps {
title: string;
subtitle?: string;
leftElement?: React.ReactNode;
rightElement?: React.ReactNode;
onPress?: () => void;
showChevron?: boolean;
}
export function ListItem({ title, subtitle, leftElement, rightElement, onPress, showChevron }: ListItemProps) {
const { theme } = useSettings();
const isInteractive = onPress || showChevron;
return (
<TouchableOpacity
style={[
styles.container,
{ backgroundColor: theme.card, borderColor: theme.border },
isInteractive && styles.interactive,
]}
onPress={onPress}
activeOpacity={0.7}
disabled={!isInteractive}
>
{leftElement && <View style={styles.leftElement}>{leftElement}</View>}
<View style={styles.leftContent}>
<Text style={[styles.title, { color: theme.text }]}>{title}</Text>
{subtitle && <Text style={[styles.subtitle, { color: theme.textMuted }]}>{subtitle}</Text>}
</View>
<View style={styles.rightContent}>
{rightElement}
{showChevron && !rightElement && (
<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"
/>
</Svg>
)}
</View>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 14,
paddingHorizontal: 16,
borderRadius: 12,
borderWidth: 1,
marginBottom: 8,
},
interactive: {},
leftElement: {
marginRight: 12,
},
leftContent: {
flex: 1,
gap: 2,
},
title: {
fontSize: 16,
fontWeight: '500',
},
subtitle: {
fontSize: 13,
},
rightContent: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
});
@@ -0,0 +1,130 @@
import React from 'react';
import { View, Text, StyleSheet, Modal, TouchableOpacity, FlatList } from 'react-native';
import { useSettings } from '@/theme';
import Svg, { Path } from 'react-native-svg';
export interface PickerOption {
value: string;
label: string;
color?: string;
}
interface OptionPickerModalProps {
visible: boolean;
title: string;
options: PickerOption[];
selectedValue?: string;
onSelect: (value: string) => void;
onClose: () => void;
}
export function OptionPickerModal({ visible, title, options, selectedValue, onSelect, onClose }: OptionPickerModalProps) {
const { theme } = useSettings();
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<View style={[styles.overlay, { backgroundColor: theme.overlay }]}>
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
<Text style={[styles.title, { color: theme.text }]}>{title}</Text>
<FlatList
data={options}
keyExtractor={(item) => item.value}
renderItem={({ item }) => {
const selected = item.value === selectedValue;
return (
<TouchableOpacity
style={[
styles.option,
{ borderColor: selected ? theme.accent : theme.borderStrong },
selected && { backgroundColor: theme.accentSoft },
]}
onPress={() => {
onSelect(item.value);
onClose();
}}
activeOpacity={0.7}
>
{item.color && (
<View style={[styles.dot, { backgroundColor: item.color }]} />
)}
<Text style={[styles.optionText, { color: selected ? theme.accent : theme.textSecondary }]}>
{item.label}
</Text>
{selected && (
<Svg width={18} height={18} viewBox="0 0 24 24">
<Path d="M5 12l5 5 9-10" stroke={theme.accent} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
</Svg>
)}
</TouchableOpacity>
);
}}
ItemSeparatorComponent={() => <View style={{ height: 8 }} />}
ListFooterComponent={
<TouchableOpacity
style={[styles.cancelButton, { borderColor: theme.borderStrong }]}
onPress={onClose}
activeOpacity={0.7}
>
<Text style={[styles.cancelText, { color: theme.textFaint }]}>Cancel</Text>
</TouchableOpacity>
}
contentContainerStyle={styles.listContent}
/>
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
justifyContent: 'flex-end',
},
sheet: {
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
paddingTop: 16,
paddingBottom: 32,
maxHeight: '75%',
},
title: {
fontSize: 16,
fontWeight: '700',
textAlign: 'center',
marginBottom: 16,
},
listContent: {
paddingHorizontal: 16,
},
option: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
paddingHorizontal: 16,
paddingVertical: 14,
borderRadius: 12,
borderWidth: 1,
},
dot: {
width: 14,
height: 14,
borderRadius: 7,
},
optionText: {
fontSize: 15,
fontWeight: '500',
flex: 1,
},
cancelButton: {
marginTop: 12,
paddingVertical: 14,
borderRadius: 12,
borderWidth: 1,
alignItems: 'center',
},
cancelText: {
fontSize: 15,
fontWeight: '600',
},
});
@@ -0,0 +1,97 @@
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import { PRIORITY_COLORS } from '@/constants';
import { Priority } from '@/types';
import { useSettings } from '@/theme';
import Svg, { Circle } from 'react-native-svg';
interface PrioritySelectorProps {
value: Priority;
onChange: (value: Priority) => void;
}
const priorities: Array<{ value: Priority; label: string }> = [
{ value: 'none', label: 'None' },
{ value: 'low', label: 'Low' },
{ value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' },
{ value: 'critical', label: 'Critical' },
];
export function PrioritySelector({ value, onChange }: PrioritySelectorProps) {
const { theme } = useSettings();
return (
<View style={styles.container}>
<Text style={[styles.label, { color: theme.text }]}>Priority</Text>
<View style={styles.options}>
{priorities.map((priority) => (
<TouchableOpacity
key={priority.value}
style={[
styles.option,
{ backgroundColor: theme.card, borderColor: theme.borderStrong },
value === priority.value && { borderColor: theme.accent, backgroundColor: theme.accentSoft, borderWidth: 2 },
]}
onPress={() => onChange(priority.value)}
activeOpacity={0.8}
>
<View style={[
styles.colorIndicator,
{ backgroundColor: PRIORITY_COLORS[priority.value] },
value === priority.value && styles.colorIndicatorSelected,
]} />
<Text style={[
styles.optionText,
{ color: theme.textFaint },
value === priority.value && { color: theme.accent, fontWeight: '600' },
]}>
{priority.label}
</Text>
</TouchableOpacity>
))}
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
gap: 10,
},
label: {
fontSize: 14,
fontWeight: '600',
},
options: {
flexDirection: 'row',
gap: 8,
},
option: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
paddingVertical: 12,
paddingHorizontal: 16,
borderRadius: 12,
borderWidth: 1,
},
colorIndicator: {
width: 10,
height: 10,
borderRadius: 5,
opacity: 0.5,
},
colorIndicatorSelected: {
opacity: 1,
width: 12,
height: 12,
borderRadius: 6,
},
optionText: {
fontSize: 13,
fontWeight: '500',
},
});
@@ -0,0 +1,164 @@
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, TextInput, TouchableOpacity, KeyboardAvoidingView, Platform } from 'react-native';
import { database, collections } from '@/database';
import { useCategories } from '@/hooks/useDatabase';
import { useSettings } from '@/theme';
import { OptionPickerModal } from '@/components/OptionPickerModal';
import Svg, { Path } from 'react-native-svg';
interface QuickAddBarProps {
dueDate?: number;
placeholder?: string;
}
export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
const { theme, defaultCategoryId } = useSettings();
const categories = useCategories();
const [title, setTitle] = useState('');
const [categoryId, setCategoryId] = useState(defaultCategoryId || categories[0]?.id || '');
const [categoryPickerVisible, setCategoryPickerVisible] = useState(false);
const categoryColor = categories.find((c) => c.id === categoryId)?.color ?? '#9E9E9E';
useEffect(() => {
if (!categoryId) {
setCategoryId(defaultCategoryId || categories[0]?.id || '');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [categories, defaultCategoryId]);
const handleAdd = async () => {
const trimmed = title.trim();
if (!trimmed || !categoryId) return;
const now = new Date();
await database.write(async () => {
await collections.tasks.create((t) => {
t.title = trimmed;
t.description = '';
t.categoryId = categoryId;
t.priority = 'none';
t.completed = false;
t.dueDate = dueDate;
t.dueTime = '';
t.endTime = '';
t.repeat = 'none';
t.repeatInterval = 1;
t.repeatDays = '';
t.reminder = 'none';
t.seriesId = '';
t.createdAt = now;
t.updatedAt = now;
});
});
setTitle('');
};
return (
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
style={styles.wrapper}
>
<View style={[styles.bar, { backgroundColor: theme.card, borderColor: theme.border }]}>
<TouchableOpacity
style={[styles.categoryButton, { borderColor: theme.borderStrong, backgroundColor: theme.cardAlt }]}
onPress={() => setCategoryPickerVisible(true)}
activeOpacity={0.7}
accessibilityLabel="Select category"
>
<View style={[styles.categoryButtonDot, { backgroundColor: categoryColor }]} />
<Svg width={12} height={12} viewBox="0 0 24 24">
<Path d="M6 9l6 6 6-6" stroke={theme.textFaint} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" fill="none" />
</Svg>
</TouchableOpacity>
<TextInput
style={[styles.input, { color: theme.text }]}
placeholder={placeholder ?? 'Add a task'}
placeholderTextColor={theme.textMuted}
value={title}
onChangeText={setTitle}
onSubmitEditing={handleAdd}
returnKeyType="done"
/>
<TouchableOpacity
style={[styles.submit, { backgroundColor: theme.accent }, !title.trim() && styles.submitDisabled]}
onPress={handleAdd}
disabled={!title.trim()}
activeOpacity={0.8}
accessibilityLabel="Add task"
>
<Svg width={20} height={20} viewBox="0 0 24 24">
<Path
d="M12 5v14M5 12h14"
stroke="#FFFFFF"
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
</TouchableOpacity>
</View>
<OptionPickerModal
visible={categoryPickerVisible}
title="Select Category"
options={categories.map((c) => ({ value: c.id, label: c.name, color: c.color }))}
selectedValue={categoryId}
onSelect={(value) => setCategoryId(value)}
onClose={() => setCategoryPickerVisible(false)}
/>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
wrapper: {
position: 'absolute',
left: 16,
right: 16,
bottom: 24,
},
bar: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
paddingHorizontal: 10,
paddingVertical: 10,
borderRadius: 16,
borderWidth: 1,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.12,
shadowRadius: 12,
elevation: 8,
},
categoryButton: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingVertical: 10,
paddingHorizontal: 12,
borderRadius: 12,
borderWidth: 1,
},
categoryButtonDot: {
width: 12,
height: 12,
borderRadius: 6,
},
input: {
flex: 1,
fontSize: 15,
paddingVertical: 10,
},
submit: {
width: 40,
height: 40,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
},
submitDisabled: {
opacity: 0.5,
},
});
@@ -0,0 +1,108 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import { Reminder, REMINDER_OPTIONS } from '@/types';
import { useSettings } from '@/theme';
import { OptionPickerModal } from '@/components/OptionPickerModal';
import Svg, { Path, Circle } from 'react-native-svg';
interface ReminderSelectorProps {
value: Reminder;
onChange: (value: Reminder) => void;
hasDueDate: boolean;
}
export function ReminderSelector({ value, onChange, hasDueDate }: ReminderSelectorProps) {
const { theme } = useSettings();
const [showPicker, setShowPicker] = useState(false);
const selected = REMINDER_OPTIONS.find((o) => o.value === value) ?? REMINDER_OPTIONS[0];
const disabled = !hasDueDate;
return (
<View style={styles.container}>
<Text style={[styles.label, { color: theme.text }]}>Reminder</Text>
<TouchableOpacity
style={[
styles.row,
{ backgroundColor: theme.card, borderColor: theme.borderStrong },
value !== 'none' && { borderColor: theme.accent, backgroundColor: theme.accentSoft },
disabled && { opacity: 0.5 },
]}
onPress={() => setShowPicker(true)}
disabled={disabled}
activeOpacity={0.8}
>
<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={value !== 'none' ? theme.accent : theme.textMuted}
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
<Circle cx={18.5} cy={5.5} r={3.5} fill={value !== 'none' ? theme.accent : 'transparent'} stroke={value !== 'none' ? theme.accent : theme.textMuted} strokeWidth={1.5} />
</Svg>
<Text
style={[
styles.valueText,
{ color: value !== 'none' ? theme.text : theme.textMuted },
value !== 'none' && styles.valueTextFilled,
]}
>
{value !== 'none' ? selected.label : 'No reminder'}
</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" />
</Svg>
</View>
</TouchableOpacity>
{!hasDueDate && (
<Text style={[styles.hint, { color: theme.textMuted }]}>Set a due date to add a reminder.</Text>
)}
<OptionPickerModal
visible={showPicker}
title="Reminder"
options={REMINDER_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
selectedValue={value}
onSelect={(v) => onChange(v as Reminder)}
onClose={() => setShowPicker(false)}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
gap: 8,
},
label: {
fontSize: 14,
fontWeight: '600',
},
row: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
paddingVertical: 12,
paddingHorizontal: 14,
borderRadius: 12,
borderWidth: 1,
},
valueText: {
flex: 1,
fontSize: 15,
},
valueTextFilled: {
fontWeight: '500',
},
chevron: {
transform: [{ rotate: '-90deg' }],
},
hint: {
fontSize: 12,
marginLeft: 4,
},
});
@@ -0,0 +1,465 @@
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Modal, TextInput, Pressable } from 'react-native';
import { Repeat, REPEAT_OPTIONS, WEEKDAY_LABELS, repeatDaysFromString } from '@/types';
import { useSettings } from '@/theme';
import { useRepeatProfiles } from '@/hooks/useDatabase';
import { createRepeatProfile, deleteRepeatProfile } from '@/utils/repeatProfileActions';
import { confirmAction } from '@/utils/confirm';
import Svg, { Path } from 'react-native-svg';
interface RepeatSelectorProps {
value: Repeat;
interval: number;
days: number[];
onChange: (value: Repeat, interval: number, days: number[]) => void;
}
function RepeatIcon({ repeat, color }: { repeat: Repeat; color: string }) {
if (repeat === 'none') {
return (
<Svg width={14} height={14} viewBox="0 0 24 24">
<Path d="M4 4l16 16" stroke={color} strokeWidth={2} strokeLinecap="round" />
<Path
d="M20 12a8 8 0 0 0-14.5-5M4 12a8 8 0 0 0 14.5 5"
stroke={color}
strokeWidth={2}
strokeLinecap="round"
fill="none"
/>
</Svg>
);
}
return (
<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={color}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</Svg>
);
}
function unitLabel(value: Repeat): string {
switch (value) {
case 'daily':
return 'day';
case 'monthly':
return 'month';
default:
return 'week';
}
}
function isDaysBased(value: Repeat): boolean {
return value === 'weekly' || value === 'custom';
}
export function RepeatSelector({ value, interval, days, onChange }: RepeatSelectorProps) {
const { theme } = useSettings();
const profiles = useRepeatProfiles();
const [saveModalVisible, setSaveModalVisible] = React.useState(false);
const [profileName, setProfileName] = React.useState('');
const selectRepeat = (next: Repeat) => {
if (next === value) return;
const nextDays = next === 'custom' && days.length === 0 ? [new Date().getDay()] : days;
onChange(next, interval, nextDays);
};
const bumpInterval = (delta: number) => {
onChange(value, Math.min(30, Math.max(1, interval + delta)), days);
};
const toggleDay = (day: number) => {
const nextDays = days.includes(day) ? days.filter((d) => d !== day) : [...days, day].sort();
if (value === 'custom' && nextDays.length === 0) return;
onChange(value, interval, nextDays);
};
const applyProfile = (profile: { repeat: Repeat; repeatInterval: number; repeatDays: string }) => {
onChange(profile.repeat, profile.repeatInterval, repeatDaysFromString(profile.repeatDays));
};
const handleDeleteProfile = (id: string, name: string) => {
confirmAction({
title: 'Delete repeat profile',
message: `"${name}" will be removed. Existing tasks are not affected.`,
destructive: true,
onConfirm: () => {
deleteRepeatProfile(id).catch(() => {});
},
});
};
const handleSaveProfile = () => {
const name = profileName.trim();
if (!name || value === 'none') return;
createRepeatProfile(name, value, interval, days)
.then(() => {
setSaveModalVisible(false);
setProfileName('');
})
.catch(() => {});
};
return (
<View style={styles.container}>
<Text style={[styles.label, { color: theme.text }]}>Repeat</Text>
{profiles.length > 0 && (
<View style={styles.profileRow}>
<Text style={[styles.profileLabel, { color: theme.textFaint }]}>Profiles</Text>
<View style={styles.profileChips}>
{profiles.map((profile) => (
<View
key={profile.id}
style={[
styles.profileChip,
{ backgroundColor: theme.card, borderColor: theme.borderStrong },
profile.repeat === value &&
profile.repeatInterval === interval &&
profile.repeatDays === [...days].sort().join(',') && {
borderColor: theme.accent,
backgroundColor: theme.accentSoft,
borderWidth: 2,
},
]}
>
<TouchableOpacity onPress={() => applyProfile(profile)} activeOpacity={0.8}>
<Text style={[styles.profileChipText, { color: theme.textSecondary }]}>{profile.name}</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => handleDeleteProfile(profile.id, profile.name)}
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
>
<Text style={[styles.profileChipX, { color: theme.textFaint }]}>×</Text>
</TouchableOpacity>
</View>
))}
</View>
</View>
)}
<View style={styles.chipRow}>
{REPEAT_OPTIONS.map((option) => (
<TouchableOpacity
key={option.value}
style={[
styles.chip,
{ backgroundColor: theme.card, borderColor: theme.borderStrong },
value === option.value && { borderColor: theme.accent, backgroundColor: theme.accentSoft, borderWidth: 2 },
]}
onPress={() => selectRepeat(option.value)}
activeOpacity={0.8}
>
<RepeatIcon repeat={option.value} color={value === option.value ? theme.accent : theme.textFaint} />
<Text
style={[
styles.chipText,
{ color: theme.textFaint },
value === option.value && { color: theme.accent, fontWeight: '600' },
]}
>
{option.label.replace('No Repeat', 'None')}
</Text>
</TouchableOpacity>
))}
</View>
{value !== 'none' && (
<View style={styles.settings}>
<View style={[styles.intervalRow, { backgroundColor: theme.card, borderColor: theme.borderStrong }]}>
<Text style={[styles.intervalLabel, { color: theme.textSecondary }]}>Every</Text>
<TouchableOpacity
style={[styles.stepButton, { borderColor: theme.borderStrong }]}
onPress={() => bumpInterval(-1)}
disabled={interval <= 1}
activeOpacity={0.7}
>
<Text style={[styles.stepButtonText, { color: interval <= 1 ? theme.textMuted : theme.text }]}>−</Text>
</TouchableOpacity>
<Text style={[styles.intervalValue, { color: theme.text }]}>{interval}</Text>
<TouchableOpacity
style={[styles.stepButton, { borderColor: theme.borderStrong }]}
onPress={() => bumpInterval(1)}
disabled={interval >= 30}
activeOpacity={0.7}
>
<Text style={[styles.stepButtonText, { color: interval >= 30 ? theme.textMuted : theme.text }]}>+</Text>
</TouchableOpacity>
<Text style={[styles.intervalLabel, { color: theme.textSecondary }]}>
{unitLabel(value)}
{interval > 1 ? 's' : ''}
</Text>
</View>
{isDaysBased(value) && (
<View style={styles.dayRow}>
{WEEKDAY_LABELS.map((label, day) => (
<TouchableOpacity
key={`${label}-${day}`}
style={[
styles.dayChip,
{ backgroundColor: theme.card, borderColor: theme.borderStrong },
days.includes(day) && { backgroundColor: theme.accent, borderColor: theme.accent },
value === 'custom' && days.length === 1 && days.includes(day) && styles.dayChipLast,
]}
onPress={() => toggleDay(day)}
activeOpacity={0.8}
>
<Text
style={[
styles.dayChipText,
{ color: theme.textFaint },
days.includes(day) && { color: '#FFFFFF', fontWeight: '700' },
]}
>
{label}
</Text>
</TouchableOpacity>
))}
</View>
)}
</View>
)}
{value !== 'none' && (
<TouchableOpacity
style={[styles.saveProfileButton, { borderColor: theme.borderStrong }]}
onPress={() => setSaveModalVisible(true)}
activeOpacity={0.8}
>
<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>
</TouchableOpacity>
)}
<Modal
visible={saveModalVisible}
transparent
animationType="fade"
onRequestClose={() => setSaveModalVisible(false)}
>
<Pressable style={styles.modalBackdrop} onPress={() => setSaveModalVisible(false)}>
<Pressable style={[styles.modalCard, { backgroundColor: theme.card, borderColor: theme.border }]}>
<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(' ')}` : ''}
</Text>
<TextInput
style={[styles.modalInput, { backgroundColor: theme.cardAlt, borderColor: theme.borderStrong, color: theme.text }]}
value={profileName}
onChangeText={setProfileName}
placeholder="Profile name (e.g. Every weekday)"
placeholderTextColor={theme.textFaint}
autoFocus
returnKeyType="done"
onSubmitEditing={handleSaveProfile}
maxLength={50}
/>
<View style={styles.modalButtons}>
<TouchableOpacity
style={[styles.modalButton, { borderColor: theme.borderStrong }]}
onPress={() => setSaveModalVisible(false)}
activeOpacity={0.8}
>
<Text style={[styles.modalButtonText, { color: theme.textSecondary }]}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.modalButton, styles.modalButtonPrimary, { backgroundColor: theme.accent }]}
onPress={handleSaveProfile}
disabled={!profileName.trim()}
activeOpacity={0.8}
>
<Text style={[styles.modalButtonText, { color: '#FFFFFF', fontWeight: '600' }]}>Save</Text>
</TouchableOpacity>
</View>
</Pressable>
</Pressable>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
container: {
gap: 10,
},
label: {
fontSize: 14,
fontWeight: '600',
},
chipRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
},
chip: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingVertical: 10,
paddingHorizontal: 12,
borderRadius: 20,
borderWidth: 1,
},
chipText: {
fontSize: 13,
fontWeight: '500',
},
settings: {
gap: 10,
},
intervalRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
paddingHorizontal: 14,
paddingVertical: 10,
borderRadius: 12,
borderWidth: 1,
},
intervalLabel: {
fontSize: 14,
fontWeight: '500',
},
intervalValue: {
fontSize: 16,
fontWeight: '700',
minWidth: 24,
textAlign: 'center',
},
stepButton: {
width: 32,
height: 32,
borderRadius: 8,
borderWidth: 1,
alignItems: 'center',
justifyContent: 'center',
},
stepButtonText: {
fontSize: 18,
fontWeight: '600',
lineHeight: 20,
},
dayRow: {
flexDirection: 'row',
justifyContent: 'space-between',
gap: 6,
},
dayChip: {
flex: 1,
height: 40,
borderRadius: 10,
borderWidth: 1,
alignItems: 'center',
justifyContent: 'center',
},
dayChipLast: {},
dayChipText: {
fontSize: 13,
fontWeight: '600',
},
profileRow: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: 8,
},
profileLabel: {
fontSize: 12,
fontWeight: '600',
paddingTop: 8,
},
profileChips: {
flex: 1,
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
},
profileChip: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingVertical: 6,
paddingHorizontal: 10,
borderRadius: 12,
borderWidth: 1,
},
profileChipText: {
fontSize: 13,
fontWeight: '500',
},
profileChipX: {
fontSize: 14,
lineHeight: 16,
fontWeight: '700',
paddingHorizontal: 2,
},
saveProfileButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
paddingVertical: 10,
borderRadius: 12,
borderWidth: 1,
borderStyle: 'dashed',
},
saveProfileText: {
fontSize: 13,
fontWeight: '600',
},
modalBackdrop: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.5)',
alignItems: 'center',
justifyContent: 'center',
padding: 24,
},
modalCard: {
width: '100%',
maxWidth: 400,
borderRadius: 16,
borderWidth: 1,
padding: 20,
gap: 12,
},
modalTitle: {
fontSize: 16,
fontWeight: '700',
},
modalHint: {
fontSize: 13,
},
modalInput: {
borderRadius: 10,
borderWidth: 1,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 14,
},
modalButtons: {
flexDirection: 'row',
gap: 10,
},
modalButton: {
flex: 1,
alignItems: 'center',
paddingVertical: 11,
borderRadius: 10,
borderWidth: 1,
},
modalButtonPrimary: {
borderWidth: 0,
},
modalButtonText: {
fontSize: 14,
},
});
@@ -0,0 +1,145 @@
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import { SubtaskData } from '@/types';
import { PRIORITY_COLORS } from '@/constants';
import { useSettings } from '@/theme';
import Svg, { Path } from 'react-native-svg';
interface SubtaskItemProps {
subtask: SubtaskData;
onToggle: () => void;
}
export function SubtaskItem({ subtask, onToggle }: SubtaskItemProps) {
const { theme } = useSettings();
return (
<View style={[styles.container, { backgroundColor: theme.cardAlt }]}>
<TouchableOpacity
style={styles.checkCircle}
onPress={onToggle}
activeOpacity={0.7}
accessibilityLabel={subtask.completed ? 'Mark incomplete' : 'Mark complete'}
>
<Svg width={20} height={20} viewBox="0 0 24 24">
{subtask.completed ? (
<>
<Path
d="M20 6L9 17l-5-5"
stroke={theme.accent}
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</>
) : (
<Path
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2"
stroke={theme.borderStrong}
strokeWidth={2}
fill="none"
/>
)}
</Svg>
</TouchableOpacity>
<Text
style={[
styles.title,
{ color: theme.text },
subtask.completed && styles.titleCompleted,
]}
numberOfLines={1}
>
{subtask.title}
</Text>
{subtask.priority && subtask.priority !== 'none' && (
<View style={[styles.priorityBadge, { backgroundColor: PRIORITY_COLORS[subtask.priority] }]}>
<Text style={styles.priorityText}>{subtask.priority.charAt(0).toUpperCase()}</Text>
</View>
)}
{subtask.dueDate && subtask.dueDate > 0 && (
<Text style={[styles.dueText, { color: theme.textFaint }]} numberOfLines={1}>
{formatDueDate(subtask.dueDate, subtask.dueTime, subtask.endTime || '')}
</Text>
)}
</View>
);
}
function formatDueDate(dueDate: number, dueTime: string, endTime?: string): string {
const date = new Date(dueDate);
const today = new Date();
today.setHours(0, 0, 0, 0);
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
let dateStr = '';
if (date.toDateString() === today.toDateString()) {
dateStr = 'Today';
} else if (date.toDateString() === tomorrow.toDateString()) {
dateStr = 'Tomorrow';
} else {
dateStr = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
if (dueTime && endTime) {
return `${dateStr} · ${formatTime12(dueTime)} – ${formatTime12(endTime)}`;
}
return dueTime ? `${dateStr} at ${formatTime12(dueTime)}` : dateStr;
}
function formatTime12(time: string): string {
const [hours, minutes] = time.split(':').map(Number);
if (Number.isNaN(hours) || Number.isNaN(minutes)) return time;
const period = hours >= 12 ? 'PM' : 'AM';
const h = hours % 12 === 0 ? 12 : hours % 12;
return `${h}:${String(minutes).padStart(2, '0')} ${period}`;
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
paddingHorizontal: 12,
paddingVertical: 10,
borderRadius: 10,
marginLeft: 32,
marginBottom: 4,
},
checkCircle: {
width: 20,
height: 20,
borderRadius: 10,
alignItems: 'center',
justifyContent: 'center',
},
title: {
fontSize: 14,
flex: 1,
},
titleCompleted: {
textDecorationLine: 'line-through',
color: '#9E9E9E',
},
priorityBadge: {
paddingHorizontal: 5,
paddingVertical: 1,
borderRadius: 6,
minWidth: 18,
alignItems: 'center',
},
priorityText: {
fontSize: 8,
fontWeight: '700',
color: '#FFFFFF',
},
dueText: {
fontSize: 12,
maxWidth: '30%',
},
});
@@ -0,0 +1,237 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet, TextInput, TouchableOpacity, Animated } from 'react-native';
import { Controller, Control } from 'react-hook-form';
import { TaskFormData, SubtaskFormValue } from '@/types';
import { useSettings } from '@/theme';
import Svg, { Path } from 'react-native-svg';
interface SubtasksSectionProps {
control: Control<TaskFormData>;
}
function makeKey(): string {
return Math.random().toString(36).slice(2, 10);
}
export function SubtasksSection({ control }: SubtasksSectionProps) {
const { theme } = useSettings();
const [expanded, setExpanded] = useState(false);
return (
<Controller
control={control}
name="subtasks"
render={({ field }) => {
const items: SubtaskFormValue[] = field.value ?? [];
const addSubtask = () => {
field.onChange([...items, { title: '', _key: makeKey() }]);
};
const updateSubtask = (index: number, title: string) => {
const next = items.map((s, i) => (i === index ? { ...s, title } : s));
field.onChange(next);
};
const removeSubtask = (index: number) => {
field.onChange(items.filter((_, i) => i !== index));
};
return (
<View style={styles.container}>
<TouchableOpacity
style={styles.header}
onPress={() => setExpanded(!expanded)}
activeOpacity={0.7}
>
<View style={styles.headerLeft}>
<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>
</View>
)}
</View>
<Animated.View
style={[
styles.chevron,
{
transform: [
{
rotate: expanded ? '180deg' : '0deg',
},
],
},
]}
>
<Svg width={16} height={16} viewBox="0 0 24 24">
<Path
d="M6 9l6 6 6-6"
stroke={theme.textMuted}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</Svg>
</Animated.View>
</TouchableOpacity>
<Animated.View
style={[
styles.content,
{
maxHeight: expanded ? items.length * 60 + 80 : 0,
opacity: expanded ? 1 : 0,
},
]}
>
<View style={styles.list}>
{items.map((item, index) => (
<SubtaskItem
key={item._key ?? `subtask-${index}`}
index={index}
value={item.title}
onChange={(value) => updateSubtask(index, value)}
onRemove={() => removeSubtask(index)}
/>
))}
</View>
<TouchableOpacity
style={[styles.addButton, { backgroundColor: theme.accentSoft, borderColor: theme.accentBorder }]}
onPress={addSubtask}
activeOpacity={0.8}
>
<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>
</TouchableOpacity>
</Animated.View>
</View>
);
}}
/>
);
}
interface SubtaskItemProps {
index: number;
value: string;
onChange: (value: string) => void;
onRemove: () => void;
}
function SubtaskItem({ index, value, onChange, onRemove }: SubtaskItemProps) {
const { theme } = useSettings();
return (
<View style={[styles.item, { backgroundColor: theme.cardAlt, borderColor: theme.border }]}>
<View style={styles.checkbox}>
<Svg width={22} height={22} viewBox="0 0 24 24">
<Path
d="M4 12.5l5 5 10-10"
stroke={theme.borderStrong}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</Svg>
</View>
<TextInput
style={[styles.input, { color: theme.text }]}
placeholder={`Subtask ${index + 1}`}
placeholderTextColor={theme.textMuted}
value={value}
onChangeText={onChange}
autoCapitalize="sentences"
maxLength={100}
/>
<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" />
</Svg>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: {
gap: 8,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 4,
},
headerLeft: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
label: {
fontSize: 14,
fontWeight: '600',
},
badge: {
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 10,
},
badgeText: {
fontSize: 12,
fontWeight: '700',
},
chevron: {
width: 16,
height: 16,
alignItems: 'center',
justifyContent: 'center',
},
content: {
overflow: 'hidden',
},
list: {
gap: 8,
paddingBottom: 8,
},
item: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
paddingHorizontal: 12,
paddingVertical: 10,
borderRadius: 12,
borderWidth: 1,
},
checkbox: {
width: 22,
height: 22,
alignItems: 'center',
justifyContent: 'center',
},
input: {
flex: 1,
fontSize: 15,
paddingVertical: 4,
},
removeButton: {
padding: 4,
},
addButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
paddingVertical: 10,
borderRadius: 12,
borderWidth: 1,
},
addButtonText: {
fontSize: 13,
fontWeight: '500',
},
});
@@ -0,0 +1,369 @@
import React, { useEffect, useState } from 'react';
import {
Modal,
View,
Text,
StyleSheet,
TextInput,
TouchableOpacity,
KeyboardAvoidingView,
Platform,
ActivityIndicator,
} from 'react-native';
import { useSettings } from '@/theme';
import { AuthUser, getAuthToken, getAuthUser, login, register, signOutAuth } from '@/services/auth';
import { runSync, getLastSyncTime, SyncResult } from '@/database/sync';
import Svg, { Path } from 'react-native-svg';
interface SyncModalProps {
visible: boolean;
onClose: () => void;
}
type Status = 'idle' | 'syncing' | 'success' | 'error';
export function SyncModal({ visible, onClose }: SyncModalProps) {
const { theme } = useSettings();
const [user, setUser] = useState<AuthUser | null>(null);
const [checking, setChecking] = useState(true);
const [mode, setMode] = useState<'login' | 'register'>('login');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState<Status>('idle');
const [lastSync, setLastSync] = useState<number | null>(null);
const [result, setResult] = useState<SyncResult | null>(null);
useEffect(() => {
if (!visible) return;
let mounted = true;
setChecking(true);
setError(null);
setStatus('idle');
setResult(null);
(async () => {
const token = await getAuthToken();
const storedUser = await getAuthUser();
const lastSyncedAt = await getLastSyncTime();
if (mounted) {
setUser(token && storedUser ? storedUser : null);
setLastSync(lastSyncedAt);
setChecking(false);
}
})();
return () => {
mounted = false;
};
}, [visible]);
const handleAuth = async () => {
if (busy) return;
setError(null);
if (!username.trim()) {
setError('Username is required');
return;
}
if (password.length < 8) {
setError('Password must be at least 8 characters');
return;
}
setBusy(true);
try {
const authedUser = mode === 'register'
? await register(username.trim(), password)
: await login(username.trim(), password);
setUser(authedUser);
} catch (err: any) {
setError(err?.message ?? 'Sign in failed');
} finally {
setBusy(false);
}
};
const handleSync = async () => {
if (busy) return;
setError(null);
setStatus('syncing');
try {
const syncResult = await runSync();
setResult(syncResult);
setStatus('success');
setLastSync(Date.now());
} catch (err: any) {
setStatus('error');
if (err?.message === 'NOT_SIGNED_IN') {
setUser(null);
setError('Not signed in. Sign in to sync.');
} else {
setError(err?.message ?? 'Sync failed. Is the server running?');
}
}
};
const handleSignOut = async () => {
await signOutAuth();
setUser(null);
setError(null);
setStatus('idle');
setResult(null);
};
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<KeyboardAvoidingView
style={styles.overlay}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<TouchableOpacity style={styles.backdrop} activeOpacity={1} onPress={onClose} />
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
<View style={[styles.handle, { backgroundColor: theme.borderStrong }]} />
<Text style={[styles.title, { color: theme.text }]}>Backup & Sync</Text>
{checking ? (
<View style={styles.centerBox}>
<ActivityIndicator color={theme.accent} />
</View>
) : user ? (
<View style={styles.body}>
<Text style={[styles.subtitle, { color: theme.textSecondary }]} numberOfLines={1}>
Signed in as {user.username}
</Text>
<Text style={[styles.lastSync, { color: theme.textFaint }]}>
{lastSync ? `Last synced ${formatRelativeTime(lastSync)}` : 'Never synced'}
</Text>
<TouchableOpacity
style={[styles.primaryButton, { backgroundColor: theme.accent }]}
onPress={handleSync}
disabled={status === 'syncing'}
activeOpacity={0.8}
>
{status === 'syncing' ? (
<ActivityIndicator color="#FFFFFF" />
) : (
<Text style={styles.primaryButtonText}>Sync Now</Text>
)}
</TouchableOpacity>
{status === 'success' && result && (
<Text style={[styles.successText, { color: '#43A047' }]}>
Synced! {result.pushed.tasks + result.pulled.tasks} tasks,{' '}
{result.pushed.categories + result.pulled.categories} categories,{' '}
{result.pushed.subtasks + result.pulled.subtasks} subtasks,{' '}
{result.pushed.repeatProfiles + result.pulled.repeatProfiles} repeat profiles.
{result.conflicts > 0 ? ` ${result.conflicts} conflict(s) resolved.` : ''}
</Text>
)}
{status === 'error' && error && (
<Text style={[styles.errorText, { color: '#E53935' }]}>{error}</Text>
)}
<TouchableOpacity
style={[styles.secondaryButton, { borderColor: theme.borderStrong }]}
onPress={handleSignOut}
activeOpacity={0.7}
>
<Text style={[styles.secondaryButtonText, { color: '#E53935' }]}>Sign Out</Text>
</TouchableOpacity>
</View>
) : (
<View style={styles.body}>
<Text style={[styles.subtitle, { color: theme.textSecondary }]}>
Sync your tasks with the server. Create an account or sign in.
</Text>
<View style={[styles.segmentRow, { backgroundColor: theme.inputBg }]}>
{(['login', 'register'] as const).map((m) => (
<TouchableOpacity
key={m}
style={[
styles.segment,
mode === m && { backgroundColor: theme.accent },
]}
onPress={() => {
setMode(m);
setError(null);
}}
activeOpacity={0.8}
>
<Text
style={[
styles.segmentText,
{ color: mode === m ? '#FFFFFF' : theme.textSecondary },
]}
>
{m === 'login' ? 'Sign In' : 'Create Account'}
</Text>
</TouchableOpacity>
))}
</View>
{mode === 'register' && (
<Text style={[styles.registerHint, { color: theme.textFaint }]}>
3-30 chars, letters, numbers, dots, dashes, underscores
</Text>
)}
<TextInput
style={[styles.input, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text }]}
placeholder="Username"
placeholderTextColor={theme.textMuted}
value={username}
onChangeText={setUsername}
autoCapitalize="none"
autoCorrect={false}
/>
<TextInput
style={[styles.input, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text }]}
placeholder="Password (min 8 chars)"
placeholderTextColor={theme.textMuted}
value={password}
onChangeText={setPassword}
secureTextEntry
/>
{error && <Text style={[styles.errorText, { color: '#E53935' }]}>{error}</Text>}
<TouchableOpacity
style={[styles.primaryButton, { backgroundColor: theme.accent }]}
onPress={handleAuth}
disabled={busy}
activeOpacity={0.8}
>
{busy ? (
<ActivityIndicator color="#FFFFFF" />
) : (
<Text style={styles.primaryButtonText}>
{mode === 'login' ? 'Sign In' : 'Create Account'}
</Text>
)}
</TouchableOpacity>
</View>
)}
<TouchableOpacity
style={[styles.secondaryButton, { borderColor: theme.borderStrong }]}
onPress={onClose}
activeOpacity={0.7}
>
<Text style={[styles.secondaryButtonText, { color: theme.textSecondary }]}>Close</Text>
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
</Modal>
);
}
function formatRelativeTime(timestamp: number): string {
const seconds = Math.floor((Date.now() - timestamp) / 1000);
if (seconds < 60) return 'just now';
if (seconds < 3600) return `${Math.floor(seconds / 60)} min ago`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)} h ago`;
return new Date(timestamp).toLocaleDateString();
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
justifyContent: 'flex-end',
},
backdrop: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.4)',
},
sheet: {
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
paddingTop: 8,
paddingHorizontal: 20,
paddingBottom: 32,
},
handle: {
width: 40,
height: 4,
borderRadius: 2,
alignSelf: 'center',
marginBottom: 12,
},
title: {
fontSize: 18,
fontWeight: '700',
textAlign: 'center',
marginBottom: 16,
},
centerBox: {
paddingVertical: 32,
alignItems: 'center',
},
body: {
gap: 12,
marginBottom: 16,
},
subtitle: {
fontSize: 14,
},
lastSync: {
fontSize: 13,
marginTop: -6,
},
segmentRow: {
flexDirection: 'row',
borderRadius: 12,
padding: 4,
gap: 4,
},
segment: {
flex: 1,
paddingVertical: 10,
borderRadius: 9,
alignItems: 'center',
},
segmentText: {
fontSize: 14,
fontWeight: '600',
},
input: {
borderRadius: 12,
borderWidth: 1,
paddingHorizontal: 14,
paddingVertical: 12,
fontSize: 15,
},
primaryButton: {
paddingVertical: 14,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
minHeight: 50,
},
primaryButtonText: {
color: '#FFFFFF',
fontSize: 15,
fontWeight: '700',
},
secondaryButton: {
paddingVertical: 12,
borderRadius: 12,
borderWidth: 1,
alignItems: 'center',
},
secondaryButtonText: {
fontSize: 15,
fontWeight: '600',
},
errorText: {
fontSize: 13,
fontWeight: '500',
},
registerHint: {
fontSize: 12,
marginTop: -4,
},
successText: {
fontSize: 13,
fontWeight: '500',
},
});
@@ -0,0 +1,194 @@
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import { useSettings } from '@/theme';
import Svg, { Path, Circle } from 'react-native-svg';
import { runSync, getLastSyncTime } from '@/database/sync';
interface SyncStatusProps {
compact?: boolean;
}
export default function SyncStatus({ compact = false }: SyncStatusProps) {
const { theme } = useSettings();
const [status, setStatus] = useState<'idle' | 'syncing' | 'success' | 'error'>('idle');
const [lastSync, setLastSync] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
loadLastSync();
}, []);
const loadLastSync = async () => {
const time = await getLastSyncTime();
setLastSync(time);
};
const handleSync = async () => {
setStatus('syncing');
setError(null);
try {
await runSync();
setStatus('success');
await loadLastSync();
setTimeout(() => setStatus('idle'), 3000);
} catch (err) {
setStatus('error');
setError(err instanceof Error ? err.message : 'Sync failed');
setTimeout(() => setStatus('idle'), 5000);
}
};
const formatTime = (timestamp: number | null): string => {
if (!timestamp) return 'Never';
const diff = Date.now() - timestamp;
if (diff < 60000) return 'Just now';
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
return new Date(timestamp).toLocaleDateString();
};
const getStatusIcon = () => {
switch (status) {
case 'syncing':
return (
<Svg width={16} height={16} viewBox="0 0 24 24">
<Path
d="M12 2a10 10 0 1 0 10 10"
stroke={theme.accent}
strokeWidth={2}
strokeLinecap="round"
fill="none"
/>
</Svg>
);
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" />
</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" />
</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" />
</Svg>
);
}
};
const getStatusText = () => {
switch (status) {
case 'syncing': return 'Syncing...';
case 'success': return 'Synced';
case 'error': return 'Sync failed';
default: return lastSync ? `Last sync: ${formatTime(lastSync)}` : 'Not synced';
}
};
if (compact) {
return (
<TouchableOpacity style={[styles.compact, { backgroundColor: theme.sheetBg }]} onPress={handleSync} disabled={status === 'syncing'} activeOpacity={0.7}>
<View style={styles.compactRow}>
{getStatusIcon()}
<Text style={[styles.compactText, { color: theme.textMuted }]}>
{status === 'syncing' ? 'Syncing...' : formatTime(lastSync)}
</Text>
</View>
</TouchableOpacity>
);
}
return (
<View style={[styles.container, { backgroundColor: theme.sheetBg }]}>
<View style={styles.header}>
<View style={styles.statusRow}>
{getStatusIcon()}
<Text style={[styles.statusText, { color: theme.text }]}>{getStatusText()}</Text>
</View>
<TouchableOpacity
style={[styles.syncButton, { backgroundColor: status === 'syncing' ? theme.accentSoft : theme.accent }]}
onPress={handleSync}
disabled={status === 'syncing'}
activeOpacity={0.8}
>
<Text style={[styles.syncButtonText, { color: status === 'syncing' ? theme.accent : '#FFFFFF' }]}>
{status === 'syncing' ? 'Syncing...' : 'Sync Now'}
</Text>
</TouchableOpacity>
</View>
{error && (
<View style={styles.error}>
<Text style={[styles.errorText, { color: '#E53935' }]}>{error}</Text>
</View>
)}
<Text style={[styles.lastSync, { color: theme.textFaint }]}>
Last sync: {formatTime(lastSync)}
</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
padding: 16,
borderRadius: 12,
marginVertical: 8,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 8,
},
statusRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
statusText: {
fontSize: 14,
fontWeight: '500',
},
syncButton: {
paddingVertical: 8,
paddingHorizontal: 16,
borderRadius: 8,
},
syncButtonText: {
fontSize: 13,
fontWeight: '600',
},
error: {
paddingTop: 8,
borderTopWidth: 1,
},
errorText: {
fontSize: 12,
},
lastSync: {
fontSize: 11,
marginTop: 8,
},
compact: {
paddingVertical: 6,
paddingHorizontal: 10,
borderRadius: 8,
borderWidth: 1,
},
compactRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
compactText: {
fontSize: 12,
},
});
@@ -0,0 +1,56 @@
import React from 'react';
import { ColorValue } from 'react-native';
import Svg, { Path, Circle } from 'react-native-svg';
interface TabBarIconProps {
name: 'checklist' | 'calendar' | 'gear';
focused: boolean;
color: ColorValue;
size?: number;
}
export function TabBarIcon({ name, focused, color, size = 24 }: TabBarIconProps) {
return (
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
{name === 'checklist' && (
<>
<Path
d="M3 5h18M3 12h18M3 19h18"
stroke={color}
strokeWidth={focused ? 2.5 : 2}
strokeLinecap="round"
strokeLinejoin="round"
/>
{focused && (
<Circle cx={6} cy={5} r={2} fill={color} />
)}
</>
)}
{name === 'calendar' && (
<>
<Path
d="M8 2v4M16 2v4M3 10h18M3 18h18"
stroke={color}
strokeWidth={focused ? 2.5 : 2}
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"
stroke={color}
strokeWidth={focused ? 2.5 : 2}
fill="none"
/>
</>
)}
{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"
/>
)}
</Svg>
);
}
@@ -0,0 +1,154 @@
import React, { useEffect, useState } from 'react';
import { Modal, View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import { useSettings } from '@/theme';
import { TaskDeleteScope, getSeriesOccurrenceCounts } from '@/utils/taskActions';
import Svg, { Path } from 'react-native-svg';
interface TaskDeleteModalProps {
visible: boolean;
taskId: string | null;
taskTitle?: string;
isRepeating?: boolean;
onClose: () => void;
onDelete: (scope: TaskDeleteScope) => void;
}
export function TaskDeleteModal({ visible, taskId, taskTitle, isRepeating, onClose, onDelete }: TaskDeleteModalProps) {
const { theme } = useSettings();
const [counts, setCounts] = useState({ future: 1, all: 1 });
useEffect(() => {
if (!visible || !taskId) return;
let mounted = true;
getSeriesOccurrenceCounts(taskId)
.then((c) => {
if (mounted) setCounts(c);
})
.catch(() => {});
return () => { mounted = false; };
}, [visible, taskId]);
const options: Array<{ scope: TaskDeleteScope; label: string; hint?: string }> = [
{ scope: 'this', label: 'This task only' },
];
if (isRepeating) {
options.push({ scope: 'future', label: 'This and future tasks', hint: counts.future > 1 ? `${counts.future} occurrences` : undefined });
options.push({ scope: 'all', label: 'All tasks in series', hint: `${counts.all} occurrences` });
}
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<View style={styles.overlay}>
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
<View style={styles.header}>
<Text style={[styles.title, { color: theme.text }]}>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" />
</Svg>
</TouchableOpacity>
</View>
<Text style={[styles.taskTitle, { color: theme.textSecondary }]} numberOfLines={1}>
{taskTitle}
</Text>
{isRepeating ? (
<Text style={[styles.hint, { color: theme.textMuted }]}>
This task repeats. What would you like to delete?
</Text>
) : (
<Text style={[styles.hint, { color: theme.textMuted }]}>
This action cannot be undone.
</Text>
)}
<View style={styles.actions}>
{options.map((option) => (
<TouchableOpacity
key={option.scope}
style={[styles.actionButton, { borderColor: theme.accentBorder }]}
onPress={() => onDelete(option.scope)}
activeOpacity={0.7}
>
<Text style={[styles.actionLabel, { color: theme.accent }]}>{option.label}</Text>
{option.hint && <Text style={[styles.actionHint, { color: theme.textMuted }]}>{option.hint}</Text>}
</TouchableOpacity>
))}
<TouchableOpacity
style={[styles.cancelButton, { borderColor: theme.borderStrong, backgroundColor: theme.card }]}
onPress={onClose}
activeOpacity={0.7}
>
<Text style={[styles.cancelLabel, { color: theme.textSecondary }]}>Cancel</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.4)',
alignItems: 'center',
justifyContent: 'center',
padding: 24,
},
sheet: {
width: '100%',
maxWidth: 380,
borderRadius: 16,
padding: 20,
gap: 8,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
title: {
fontSize: 18,
fontWeight: '700',
},
closeButton: {
padding: 4,
},
taskTitle: {
fontSize: 14,
fontWeight: '500',
},
hint: {
fontSize: 13,
},
actions: {
gap: 8,
marginTop: 8,
},
actionButton: {
paddingVertical: 12,
paddingHorizontal: 14,
borderRadius: 12,
borderWidth: 1,
alignItems: 'center',
gap: 2,
},
actionLabel: {
fontSize: 15,
fontWeight: '600',
},
actionHint: {
fontSize: 12,
},
cancelButton: {
paddingVertical: 12,
borderRadius: 12,
borderWidth: 1,
alignItems: 'center',
},
cancelLabel: {
fontSize: 15,
fontWeight: '600',
},
});
+511
View File
@@ -0,0 +1,511 @@
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Animated } from 'react-native';
import { Swipeable, Gesture, GestureDetector } from 'react-native-gesture-handler';
import { TaskData } from '@/types';
import { PRIORITY_COLORS } from '@/constants';
import { useSettings } from '@/theme';
import Svg, { Path, Circle } from 'react-native-svg';
interface TaskItemProps {
task: TaskData;
onToggle: () => void | Promise<void>;
onDelete?: () => void;
onPress: () => void;
onLongPress?: () => void;
onMenuOpen?: () => void;
selected?: boolean;
selectionMode?: boolean;
completedSection?: boolean;
draggable?: boolean;
hovered?: boolean;
onDragStart?: () => void;
onDragUpdate?: (absoluteY: number) => void;
onDragEnd?: (absoluteY: number) => void;
assigneeUsername?: string;
expanded?: boolean;
}
export function TaskItem({ task, onToggle, onDelete, onPress, onLongPress, onMenuOpen, selected, selectionMode, completedSection, draggable, hovered, onDragStart, onDragUpdate, onDragEnd, expanded }: TaskItemProps) {
const { theme } = useSettings();
const [opacityAnim] = React.useState(new Animated.Value(task.completed ? 0.5 : 1));
const [dragTranslateX] = React.useState(new Animated.Value(0));
const [dragTranslateY] = React.useState(new Animated.Value(0));
const [dragging, setDragging] = React.useState(false);
const swipeableRef = React.useRef<Swipeable>(null);
const [rotateAnim] = React.useState(new Animated.Value(0));
React.useEffect(() => {
Animated.timing(rotateAnim, {
toValue: expanded ? 1 : 0,
duration: 200,
useNativeDriver: true,
}).start();
}, [expanded, rotateAnim]);
const rotate = rotateAnim.interpolate({ inputRange: [0, 1], outputRange: ['0deg', '180deg'] });
const dragGesture = React.useMemo(
() =>
Gesture.Pan()
.activateAfterLongPress(400)
.minDistance(2)
.runOnJS(true)
. onStart(() => {
setDragging(true);
onDragStart?.();
})
.onUpdate((e) => {
dragTranslateX.setValue(e.translationX);
dragTranslateY.setValue(e.translationY);
onDragUpdate?.(e.absoluteY);
})
.onEnd((e) => {
onDragEnd?.(e.absoluteY);
})
.onFinalize(() => {
setDragging(false);
dragTranslateX.setValue(0);
dragTranslateY.setValue(0);
}),
[onDragStart, onDragUpdate, onDragEnd, dragTranslateX, dragTranslateY]
);
React.useEffect(() => {
Animated.timing(opacityAnim, {
toValue: task.completed ? 0.5 : 1,
duration: 200,
useNativeDriver: true,
}).start();
}, [task.completed, opacityAnim]);
const startOfToday = new Date();
startOfToday.setHours(0, 0, 0, 0);
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 < new Date().setHours(23, 59, 59, 999);
const canComplete = !hasDueDate || isOverdue || isDueToday;
const renderRightActions = (progress: Animated.AnimatedInterpolation<number>) => {
const translateX = progress.interpolate({ inputRange: [0, 1], outputRange: [80, 0] });
return (
<Animated.View style={[styles.swipeAction, styles.swipeDelete, { transform: [{ translateX }] }]}>
<Svg width={24} height={24} viewBox="0 0 24 24">
<Path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13" stroke="#FFFFFF" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" fill="none" />
</Svg>
<Text style={styles.swipeActionText}>Delete</Text>
</Animated.View>
);
};
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">
<Path d="M5 12l5 5 9-10" stroke="#FFFFFF" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
</Svg>
<Text style={styles.swipeActionText}>{task.completed ? 'Undo' : 'Done'}</Text>
</Animated.View>
);
};
return (
<Swipeable
ref={swipeableRef}
renderRightActions={renderRightActions}
renderLeftActions={renderLeftActions}
onSwipeableRightOpen={() => {
onDelete?.();
swipeableRef.current?.close();
}}
onSwipeableLeftOpen={() => {
if (canComplete || task.completed) {
onToggle();
}
swipeableRef.current?.close();
}}
overshootRight={false}
overshootLeft={false}
>
<GestureDetector gesture={draggable ? dragGesture : Gesture.Native()}>
<Animated.View
style={[
styles.container,
{ backgroundColor: theme.card, borderColor: theme.border },
task.completed && styles.taskCompleted,
isOverdue && styles.taskOverdue,
isDueToday && styles.taskDueToday,
selectionMode && styles.taskSelected,
selected && { borderColor: theme.accent, borderWidth: 2 },
hovered && { borderColor: theme.accent, borderWidth: 2, backgroundColor: theme.accentSoft },
completedSection && { backgroundColor: theme.cardAlt },
dragging && styles.dragLifted,
{ transform: [{ translateX: dragTranslateX }, { translateY: dragTranslateY }] },
]}
pointerEvents={dragging ? 'none' : 'auto'}
>
<TouchableOpacity
style={styles.contentWrapper}
onPress={onPress}
onLongPress={onLongPress}
delayLongPress={350}
activeOpacity={0.8}
>
<View style={styles.content}>
<View style={styles.titleRow}>
<TouchableOpacity
style={[styles.checkCircle, !canComplete && !task.completed && styles.checkCircleDisabled]}
onPress={canComplete || task.completed ? onToggle : undefined}
activeOpacity={0.7}
accessibilityLabel={task.completed ? 'Mark incomplete' : canComplete ? 'Mark complete' : 'Task not due yet'}
>
<Svg width={24} height={24} 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" />
)}
</Svg>
</TouchableOpacity>
<Animated.Text
style={[
styles.title,
{ color: theme.text },
task.completed && styles.titleCompleted,
isOverdue && styles.titleOverdue,
]}
>
{task.title}
</Animated.Text>
{task.priority !== 'none' && (
<View
style={[
styles.priorityBadge,
{ backgroundColor: PRIORITY_COLORS[task.priority] },
]}
>
<Text style={styles.priorityText}>
{task.priority.charAt(0).toUpperCase()}
</Text>
</View>
)}
{task.assigneeId && (
<View style={styles.assigneeBadge}>
<Text style={styles.assigneeText}>
{task.assigneeId.charAt(0).toUpperCase()}
</Text>
</View>
)}
{task.repeat && task.repeat !== 'none' && (
<View style={styles.repeatIcon}>
<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}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</Svg>
</View>
)}
{task.allDay && (
<View style={styles.allDayBadge}>
<Svg width={14} height={14} viewBox="0 0 24 24">
<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}
strokeLinecap="round"
fill="none"
/>
<Circle cx={12} cy={12} r={4} stroke={theme.accent} strokeWidth={1.5} fill="none" />
</Svg>
<Text style={styles.allDayText}>All Day</Text>
</View>
)}
<Animated.View style={[styles.chevron, { transform: [{ rotate }] }]}>
<Svg width={16} height={16} viewBox="0 0 24 24">
<Path
d="M6 9l6 6 6-6"
stroke={theme.textMuted}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</Svg>
</Animated.View>
</View>
{task.dueDate && (
<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" />
</Svg>
<Animated.Text
style={[
styles.dueText,
{ color: theme.textFaint },
isOverdue && styles.dueTextOverdue,
isDueToday && styles.dueTextDueToday,
]}
>
{formatDueDate(task.dueDate, task.dueTime, task.endTime || '')}
</Animated.Text>
{task.reminder && task.reminder !== 'none' && (
<View style={styles.reminderIcon}>
<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}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</Svg>
</View>
)}
</View>
)}
</View>
</TouchableOpacity>
<TouchableOpacity
style={styles.menuButton}
onPress={onMenuOpen}
activeOpacity={0.7}
>
<Svg width={24} height={24} viewBox="0 0 24 24">
<Circle cx={12} cy={12} r={1.5} fill={theme.textMuted} />
<Circle cx={12} cy={6} r={1.5} fill={theme.textMuted} />
<Circle cx={12} cy={18} r={1.5} fill={theme.textMuted} />
</Svg>
</TouchableOpacity>
</Animated.View>
</GestureDetector>
</Swipeable>
);
}
function formatDueDate(dueDate: number, dueTime: string, endTime?: string): string {
const date = new Date(dueDate);
const today = new Date();
today.setHours(0, 0, 0, 0);
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
let dateStr = '';
if (date.getTime() === today.getTime()) {
dateStr = 'Today';
} else if (date.getTime() === tomorrow.getTime()) {
dateStr = 'Tomorrow';
} else {
dateStr = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
if (dueTime && endTime) {
return `${dateStr} · ${formatTime12(dueTime)} – ${formatTime12(endTime)}`;
}
return dueTime ? `${dateStr} at ${formatTime12(dueTime)}` : dateStr;
}
function formatTime12(time: string): string {
const [hours, minutes] = time.split(':').map(Number);
if (Number.isNaN(hours) || Number.isNaN(minutes)) return time;
const period = hours >= 12 ? 'PM' : 'AM';
const h = hours % 12 === 0 ? 12 : hours % 12;
return `${h}:${String(minutes).padStart(2, '0')} ${period}`;
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 14,
borderRadius: 16,
borderWidth: 1,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.04,
shadowRadius: 4,
elevation: 1,
},
taskCompleted: {
opacity: 0.5,
},
taskOverdue: {
borderColor: '#4A2B2B',
},
taskDueToday: {
borderColor: '#1E88E5',
},
taskSelected: {
shadowColor: '#000',
shadowOpacity: 0.1,
shadowRadius: 6,
elevation: 2,
},
dragLifted: {
zIndex: 100,
elevation: 12,
shadowColor: '#000',
shadowOpacity: 0.25,
shadowRadius: 12,
shadowOffset: { width: 0, height: 8 },
},
contentWrapper: {
flex: 1,
},
content: {
gap: 4,
},
titleRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
title: {
fontSize: 16,
fontWeight: '500',
flex: 1,
marginRight: 8,
},
titleCompleted: {
textDecorationLine: 'line-through',
color: '#9E9E9E',
},
repeatIcon: {
marginLeft: 8,
width: 14,
height: 14,
alignItems: 'center',
justifyContent: 'center',
},
chevron: {
marginLeft: 8,
width: 16,
height: 16,
alignItems: 'center',
justifyContent: 'center',
},
reminderIcon: {
marginLeft: 6,
width: 13,
height: 13,
alignItems: 'center',
justifyContent: 'center',
},
titleOverdue: {
color: '#E53935',
},
priorityBadge: {
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 8,
minWidth: 20,
alignItems: 'center',
},
priorityText: {
fontSize: 9,
fontWeight: '700',
color: '#FFFFFF',
},
assigneeBadge: {
width: 20,
height: 20,
borderRadius: 10,
backgroundColor: '#4A2B2B',
alignItems: 'center',
justifyContent: 'center',
},
assigneeText: {
fontSize: 9,
fontWeight: '700',
color: '#EF5350',
},
dueRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
dueText: {
fontSize: 13,
},
dueTextOverdue: {
color: '#E53935',
fontWeight: '600',
},
dueTextDueToday: {
color: '#1E88E5',
fontWeight: '600',
},
menuButton: {
padding: 4,
marginLeft: 8,
},
allDayBadge: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
marginLeft: 8,
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 8,
backgroundColor: 'rgba(30, 136, 229, 0.15)',
},
allDayText: {
fontSize: 11,
fontWeight: '600',
color: '#1E88E5',
},
checkCircle: {
width: 24,
height: 24,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
},
checkCircleDisabled: {
opacity: 0.4,
},
swipeAction: {
width: 80,
alignItems: 'center',
justifyContent: 'center',
gap: 4,
},
swipeDelete: {
backgroundColor: '#E53935',
borderTopRightRadius: 16,
borderBottomRightRadius: 16,
},
swipeComplete: {
backgroundColor: '#43A047',
borderTopLeftRadius: 16,
borderBottomLeftRadius: 16,
},
swipeActionText: {
color: '#FFFFFF',
fontSize: 12,
fontWeight: '600',
},
});
+622
View File
@@ -0,0 +1,622 @@
import React, { useState, useCallback, useMemo, useRef, useEffect } from 'react';
import { View, Text, StyleSheet, FlatList, TouchableOpacity, Animated, RefreshControl, Alert } from 'react-native';
import { useRouter } from 'expo-router';
import { useTasks } from '@/hooks/useTasks';
import { TaskItem } from './TaskItem';
import { SubtaskItem } from './SubtaskItem';
import { TaskData, SubtaskData } from '@/types';
import { useCategories, useDatabase } from '@/hooks/useDatabase';
import { useSettings } from '@/theme';
import { OptionPickerModal } from './OptionPickerModal';
import { TaskOverflowMenu } from './TaskOverflowMenu';
import { TaskDeleteModal } from './TaskDeleteModal';
import {
toggleTaskComplete,
deleteTask,
duplicateTask,
setTaskCategory,
setTaskPriority,
setTaskCompleted,
deleteTaskOccurrences,
convertTaskToSubtask,
toggleSubtaskComplete,
TaskDeleteScope,
} from '@/utils/taskActions';
import { PRIORITY_LABELS } from '@/constants';
import { Q } from '@nozbe/watermelondb';
import Svg, { Path } from 'react-native-svg';
interface TaskListProps {
categoryId?: string;
onSelectionChange?: (active: boolean) => void;
}
const PRIORITY_RANK: Record<string, number> = { none: 0, low: 1, medium: 2, high: 3, critical: 4 };
export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProps) {
const router = useRouter();
const { theme, sortBy } = useSettings();
const { collections } = useDatabase();
const categories = useCategories();
const { tasks, loading } = useTasks(categoryId, false);
const { tasks: completedTasks } = useTasks(categoryId, true);
const [refreshing, setRefreshing] = useState(false);
const [selectionMode, setSelectionMode] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [menuTaskId, setMenuTaskId] = useState<string | null>(null);
const [deleteTaskId, setDeleteTaskId] = useState<string | null>(null);
const [picker, setPicker] = useState<null | { type: 'category' | 'priority'; taskId?: string }>(null);
const [hoverTaskId, setHoverTaskId] = useState<string | null>(null);
const [expandedTasks, setExpandedTasks] = useState<Set<string>>(new Set());
const [subtasksMap, setSubtasksMap] = useState<Map<string, SubtaskData[]>>(new Map());
const itemRefs = useRef<Map<string, View>>(new Map());
const dragStateRef = useRef<{ taskId: string; positions: Record<string, { top: number; bottom: number }> } | null>(null);
const allTasks = useMemo(() => [...tasks, ...completedTasks], [tasks, completedTasks]);
const sortedTasks = useMemo(() => {
const sorted = [...tasks];
switch (sortBy) {
case 'date':
sorted.sort((a, b) => (a.dueDate || Number.MAX_SAFE_INTEGER) - (b.dueDate || Number.MAX_SAFE_INTEGER));
break;
case 'priority':
sorted.sort((a, b) => PRIORITY_RANK[b.priority] - PRIORITY_RANK[a.priority]);
break;
case 'alpha':
sorted.sort((a, b) => a.title.localeCompare(b.title));
break;
case 'created':
sorted.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
break;
}
return sorted;
}, [tasks, sortBy]);
const menuTask = useMemo(
() => allTasks.find((t) => t.id === menuTaskId) ?? null,
[allTasks, menuTaskId]
);
const deleteTarget = useMemo(
() => allTasks.find((t) => t.id === deleteTaskId) ?? null,
[allTasks, deleteTaskId]
);
const onRefresh = useCallback(() => {
setRefreshing(true);
setTimeout(() => setRefreshing(false), 600);
}, []);
const exitSelection = useCallback(() => {
setSelectionMode(false);
setSelectedIds(new Set());
onSelectionChange?.(false);
}, [onSelectionChange]);
const enterSelection = useCallback((taskId: string) => {
setSelectionMode(true);
setSelectedIds(new Set([taskId]));
onSelectionChange?.(true);
}, [onSelectionChange]);
const toggleSelect = useCallback((taskId: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(taskId)) {
next.delete(taskId);
} else {
next.add(taskId);
}
if (next.size === 0) {
setSelectionMode(false);
onSelectionChange?.(false);
}
return next;
});
}, [onSelectionChange]);
const refreshAll = useCallback(() => {}, []);
const handleEdit = useCallback((taskId: string) => {
router.push({ pathname: '/task-detail', params: { id: taskId } });
}, [router]);
const handleToggle = useCallback(async (taskId: string) => {
await toggleTaskComplete(taskId);
refreshAll();
}, [refreshAll]);
const fetchSubtasks = useCallback(async (taskId: string) => {
const subs = await collections.subtasks.query(Q.where('task_id', taskId)).fetch();
const mapped: SubtaskData[] = subs.map((s: any) => ({
id: s.id,
taskId: s.taskId,
title: s.title,
description: s.description || '',
priority: (s.priority || 'none') as SubtaskData['priority'],
completed: s.completed,
dueDate: s.dueDate || 0,
dueTime: s.dueTime || '',
endTime: s.endTime || '',
allDay: s.allDay ?? false,
repeat: (s.repeat || 'none') as SubtaskData['repeat'],
repeatInterval: s.repeatInterval ?? 1,
repeatDays: s.repeatDays || '',
seriesId: s.seriesId || '',
reminder: (s.reminder || 'none') as SubtaskData['reminder'],
assigneeId: s.assigneeId ?? null,
order: s.order,
}));
setSubtasksMap((prev) => new Map(prev).set(taskId, mapped));
return mapped;
}, [collections.subtasks]);
const toggleExpand = useCallback(async (taskId: string) => {
setExpandedTasks((prev) => {
const next = new Set(prev);
if (next.has(taskId)) {
next.delete(taskId);
} else {
next.add(taskId);
}
return next;
});
const isCurrentlyExpanded = expandedTasks.has(taskId);
if (isCurrentlyExpanded) {
setSubtasksMap((prev) => {
const next = new Map(prev);
next.delete(taskId);
return next;
});
} else {
await fetchSubtasks(taskId);
}
}, [expandedTasks, fetchSubtasks]);
const handleSubtaskToggle = useCallback(async (subtaskId: string, taskId: string) => {
await toggleSubtaskComplete(subtaskId);
await fetchSubtasks(taskId);
}, [fetchSubtasks]);
const handleDeleteOne = useCallback((taskId: string) => {
setDeleteTaskId(taskId);
}, []);
const handleDeleteScope = useCallback(async (scope: TaskDeleteScope) => {
const taskId = deleteTaskId;
if (!taskId) return;
setDeleteTaskId(null);
await deleteTaskOccurrences(taskId, scope);
refreshAll();
}, [deleteTaskId, refreshAll]);
const handleDuplicate = useCallback(async (taskId: string) => {
await duplicateTask(taskId);
refreshAll();
}, [refreshAll]);
const handleBulkDelete = useCallback(() => {
Alert.alert(`Delete ${selectedIds.size} task${selectedIds.size > 1 ? 's' : ''}?`, 'This cannot be undone.', [
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
for (const taskId of selectedIds) {
await deleteTask(taskId);
}
exitSelection();
refreshAll();
},
},
]);
}, [selectedIds, exitSelection, refreshAll]);
const handleBulkComplete = useCallback(async () => {
for (const taskId of selectedIds) {
await setTaskCompleted(taskId, true);
}
exitSelection();
refreshAll();
}, [selectedIds, exitSelection, refreshAll]);
const handleSingleCategory = useCallback(async (value: string) => {
if (picker?.taskId) {
await setTaskCategory(picker.taskId, value);
refreshAll();
}
}, [picker, refreshAll]);
const handleSinglePriority = useCallback(async (value: string) => {
if (picker?.taskId) {
await setTaskPriority(picker.taskId, value as TaskData['priority']);
refreshAll();
}
}, [picker, refreshAll]);
const measureItems = useCallback(async () => {
const positions: Record<string, { top: number; bottom: number }> = {};
const entries = Array.from(itemRefs.current.entries());
await Promise.all(entries.map(([id, ref]) => {
return new Promise<void>((resolve) => {
ref?.measureInWindow((_x, y, _w, h) => {
positions[id] = { top: y, bottom: y + h };
resolve();
});
});
}));
return positions;
}, []);
const findHoverTarget = useCallback((absoluteY: number, draggedId: string, positions: Record<string, { top: number; bottom: number }>) => {
for (const [id, p] of Object.entries(positions)) {
if (id === draggedId) continue;
if (absoluteY >= p.top && absoluteY <= p.bottom) {
return id;
}
}
return null;
}, []);
const handleDragStart = useCallback(async (taskId: string) => {
enterSelection(taskId);
dragStateRef.current = { taskId, positions: await measureItems() };
}, [enterSelection, measureItems]);
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));
}, [findHoverTarget]);
const handleDragEnd = useCallback((absoluteY: number) => {
const state = dragStateRef.current;
dragStateRef.current = null;
setHoverTaskId(null);
if (!state) return;
const target = findHoverTarget(absoluteY, state.taskId, state.positions);
if (target) {
(async () => {
await convertTaskToSubtask(state.taskId, target);
exitSelection();
refreshAll();
})();
}
}, [findHoverTarget, exitSelection, refreshAll]);
if (loading && !refreshing) {
return (
<View style={styles.loadingContainer}>
<Text style={[styles.loadingText, { color: theme.textFaint }]}>Loading tasks...</Text>
</View>
);
}
const priorityOptions = Object.entries(PRIORITY_LABELS).map(([value, label]) => ({ value, label }));
return (
<View style={styles.container}>
<Animated.FlatList
data={sortedTasks}
keyExtractor={(item) => item.id}
renderItem={({ item }) => {
const isExpanded = expandedTasks.has(item.id);
const itemSubtasks = subtasksMap.get(item.id) ?? [];
return (
<View
ref={(ref) => {
if (ref) {
itemRefs.current.set(item.id, ref);
} else {
itemRefs.current.delete(item.id);
}
}}
>
<TaskItem
task={item as TaskData}
onToggle={() => handleToggle(item.id)}
onDelete={() => handleDeleteOne(item.id)}
onPress={() => selectionMode ? toggleSelect(item.id) : handleEdit(item.id)}
onLongPress={selectionMode ? undefined : () => enterSelection(item.id)}
onMenuOpen={() => setMenuTaskId(item.id)}
selected={selectedIds.has(item.id)}
selectionMode={selectionMode}
draggable
hovered={hoverTaskId === item.id}
onDragStart={() => handleDragStart(item.id)}
onDragUpdate={handleDragUpdate}
onDragEnd={handleDragEnd}
/>
{isExpanded && itemSubtasks.length > 0 && (
<View style={styles.subtaskList}>
{itemSubtasks
.slice()
.sort((a, b) => a.order - b.order)
.map((sub) => (
<SubtaskItem
key={sub.id}
subtask={sub}
onToggle={() => handleSubtaskToggle(sub.id, item.id)}
/>
))}
</View>
)}
</View>
);
}}
ItemSeparatorComponent={() => <View style={styles.separator} />}
ListHeaderComponent={
sortedTasks.length === 0 && completedTasks.length === 0 ? (
<View style={styles.emptyState}>
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>No tasks yet</Text>
<Text style={[styles.emptySubtext, { color: theme.textMuted }]}>Tap + to add your first task</Text>
</View>
) : sortedTasks.length === 0 ? (
<View style={styles.emptyState}>
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>All caught up!</Text>
<Text style={[styles.emptySubtext, { color: theme.textMuted }]}>No pending tasks</Text>
</View>
) : null
}
ListFooterComponent={
completedTasks.length > 0 ? (
<CompletedSection
tasks={completedTasks}
onToggle={(task) => handleToggle(task.id)}
onDelete={(task) => handleDeleteOne(task.id)}
onMenuOpen={(task) => setMenuTaskId(task.id)}
onLongPress={(task) => enterSelection(task.id)}
selectionMode={selectionMode}
selectedIds={selectedIds}
onSelect={(taskId) => toggleSelect(taskId)}
/>
) : null
}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={theme.accent}
colors={[theme.accent]}
progressBackgroundColor={theme.card}
/>
}
contentContainerStyle={styles.listContent}
/>
<TaskDeleteModal
visible={deleteTarget !== null}
taskId={deleteTarget?.id ?? null}
taskTitle={deleteTarget?.title}
isRepeating={deleteTarget ? deleteTarget.repeat !== 'none' : false}
onClose={() => setDeleteTaskId(null)}
onDelete={handleDeleteScope}
/>
<TaskOverflowMenu
visible={menuTask !== null}
task={menuTask}
onClose={() => setMenuTaskId(null)}
onEdit={() => menuTask && handleEdit(menuTask.id)}
onDelete={() => menuTask && handleDeleteOne(menuTask.id)}
onDuplicate={() => menuTask && handleDuplicate(menuTask.id)}
onToggleComplete={() => menuTask && handleToggle(menuTask.id)}
onChangeCategory={() => menuTask && setPicker({ type: 'category', taskId: menuTask.id })}
onChangePriority={() => menuTask && setPicker({ type: 'priority', taskId: menuTask.id })}
/>
<OptionPickerModal
visible={picker?.type === 'category'}
title="Change Category"
options={categories.map((c) => ({ value: c.id, label: c.name, color: c.color }))}
selectedValue={menuTask?.categoryId}
onSelect={handleSingleCategory}
onClose={() => setPicker(null)}
/>
<OptionPickerModal
visible={picker?.type === 'priority'}
title="Change Priority"
options={priorityOptions}
selectedValue={menuTask?.priority}
onSelect={handleSinglePriority}
onClose={() => setPicker(null)}
/>
{selectionMode && (
<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" />
</Svg>
<Text style={[styles.selectionCancelText, { color: theme.textFaint }]}>Cancel</Text>
</TouchableOpacity>
<Text style={[styles.selectionCount, { color: theme.text }]}>{selectedIds.size} selected</Text>
<View style={styles.selectionActions}>
<SelectionButton label="Complete" color="#43A047" onPress={handleBulkComplete} />
<SelectionButton label="Delete" color="#E53935" onPress={handleBulkDelete} />
</View>
</View>
)}
</View>
);
}
function SelectionButton({ label, color, onPress }: { label: string; color: string; onPress: () => void }) {
const { theme } = useSettings();
return (
<TouchableOpacity
style={[styles.selectionButton, { backgroundColor: color }]}
onPress={onPress}
activeOpacity={0.8}
>
<Text style={styles.selectionButtonText}>{label}</Text>
</TouchableOpacity>
);
}
interface CompletedSectionProps {
tasks: TaskData[];
onToggle: (task: TaskData) => void;
onDelete: (task: TaskData) => void;
onMenuOpen: (task: TaskData) => void;
onLongPress: (task: TaskData) => void;
selectionMode: boolean;
selectedIds: Set<string>;
onSelect: (taskId: string) => void;
}
function CompletedSection({ tasks, onToggle, onDelete, onMenuOpen, onLongPress, selectionMode, selectedIds, onSelect }: CompletedSectionProps) {
const { theme } = useSettings();
const [expanded, setExpanded] = useState(false);
return (
<View style={styles.completedSection}>
<TouchableOpacity
style={styles.completedHeader}
onPress={() => setExpanded(!expanded)}
>
<Text style={[styles.completedTitle, { color: theme.textFaint }]}>
Completed ({tasks.length})
</Text>
<Text style={[styles.completedToggle, { color: theme.accent }]}>
{expanded ? 'Hide' : 'Show'}
</Text>
</TouchableOpacity>
{expanded && (
<View style={styles.completedList}>
{tasks.map((task) => (
<TaskItem
key={task.id}
task={task}
onToggle={() => onToggle(task)}
onDelete={() => onDelete(task)}
onPress={() => {}}
onLongPress={() => onLongPress(task)}
onMenuOpen={() => onMenuOpen(task)}
selected={selectedIds.has(task.id)}
selectionMode={selectionMode}
completedSection
/>
))}
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
listContent: {
paddingHorizontal: 16,
paddingTop: 8,
paddingBottom: 100,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
loadingText: {
fontSize: 16,
},
separator: {
height: 8,
},
subtaskList: {
paddingLeft: 8,
paddingRight: 4,
},
emptyState: {
alignItems: 'center',
paddingVertical: 48,
},
emptyText: {
fontSize: 18,
fontWeight: '600',
marginBottom: 4,
},
emptySubtext: {
fontSize: 14,
},
completedSection: {
marginTop: 16,
paddingBottom: 24,
},
completedHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 4,
paddingVertical: 8,
},
completedTitle: {
fontSize: 14,
fontWeight: '600',
},
completedToggle: {
fontSize: 14,
fontWeight: '500',
},
completedList: {
paddingTop: 8,
},
selectionBar: {
position: 'absolute',
left: 16,
right: 16,
bottom: 84,
borderRadius: 20,
borderWidth: 1,
paddingHorizontal: 16,
paddingVertical: 12,
gap: 8,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
shadowRadius: 12,
elevation: 8,
},
selectionCancel: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
alignSelf: 'flex-start',
},
selectionCancelText: {
fontSize: 14,
fontWeight: '500',
},
selectionCount: {
fontSize: 15,
fontWeight: '700',
textAlign: 'center',
},
selectionActions: {
flexDirection: 'row',
gap: 8,
},
selectionSecondRow: {
flexDirection: 'row',
gap: 8,
},
selectionButton: {
flex: 1,
paddingVertical: 10,
borderRadius: 12,
alignItems: 'center',
},
selectionButtonText: {
color: '#FFFFFF',
fontSize: 14,
fontWeight: '600',
},
});
@@ -0,0 +1,69 @@
import React from 'react';
import { View, Text, StyleSheet, TextInput } from 'react-native';
import { TextInputProps } from 'react-native';
import { useSettings } from '@/theme';
interface TaskNameInputProps extends TextInputProps {
error?: string;
}
export function TaskNameInput({ error, ...props }: TaskNameInputProps) {
const { theme } = useSettings();
return (
<View style={styles.container}>
<View style={styles.labelRow}>
<Text style={[styles.label, { color: theme.text }]}>Task Name</Text>
<Text style={styles.required}>*</Text>
</View>
<TextInput
style={[
styles.input,
{ backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text },
error && styles.inputError,
]}
placeholder="Task name"
placeholderTextColor={theme.textMuted}
maxLength={100}
autoCapitalize="sentences"
{...props}
/>
{error && <Text style={styles.errorText}>{error}</Text>}
</View>
);
}
const styles = StyleSheet.create({
container: {
gap: 6,
},
labelRow: {
flexDirection: 'row',
alignItems: 'center',
},
label: {
fontSize: 14,
fontWeight: '600',
},
required: {
color: '#E53935',
fontSize: 14,
fontWeight: '600',
},
input: {
height: 52,
paddingHorizontal: 16,
borderRadius: 12,
borderWidth: 1,
fontSize: 16,
},
inputError: {
borderColor: '#E53935',
borderWidth: 1.5,
},
errorText: {
fontSize: 12,
color: '#E53935',
marginLeft: 4,
},
});
@@ -0,0 +1,170 @@
import React from 'react';
import { View, Text, StyleSheet, Modal, TouchableOpacity } from 'react-native';
import { useSettings } from '@/theme';
import { TaskData } from '@/types';
import Svg, { Path } from 'react-native-svg';
interface TaskOverflowMenuProps {
visible: boolean;
task: TaskData | null;
onClose: () => void;
onEdit: () => void;
onDelete: () => void;
onDuplicate: () => void;
onToggleComplete: () => void;
onChangeCategory: () => void;
onChangePriority: () => void;
}
interface MenuAction {
key: string;
label: string;
icon: React.ReactNode;
onPress: () => void;
destructive?: boolean;
}
export function TaskOverflowMenu({
visible,
task,
onClose,
onEdit,
onDelete,
onDuplicate,
onToggleComplete,
onChangeCategory,
onChangePriority,
}: TaskOverflowMenuProps) {
const { theme } = useSettings();
if (!task) return null;
const actions: MenuAction[] = [
{
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>,
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>,
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>,
onPress: onDuplicate,
},
{
key: 'category',
label: 'Change Category',
icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Circle2 /></Svg>,
onPress: onChangeCategory,
},
{
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>,
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>,
onPress: onDelete,
},
];
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<TouchableOpacity
style={[styles.overlay, { backgroundColor: theme.overlay }]}
activeOpacity={1}
onPress={onClose}
>
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
<View style={[styles.handle, { backgroundColor: theme.borderStrong }]} />
<Text style={[styles.title, { color: theme.text }]} numberOfLines={1}>
{task.title}
</Text>
{actions.map((action) => (
<TouchableOpacity
key={action.key}
style={[styles.action, { borderColor: theme.border }]}
onPress={() => {
onClose();
action.onPress();
}}
activeOpacity={0.7}
>
{action.icon}
<Text style={[styles.actionText, action.destructive ? styles.destructiveText : { color: theme.textSecondary }]}>
{action.label}
</Text>
</TouchableOpacity>
))}
</View>
</TouchableOpacity>
</Modal>
);
}
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} />
</Svg>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
justifyContent: 'flex-end',
},
sheet: {
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
paddingTop: 8,
paddingHorizontal: 16,
paddingBottom: 32,
},
handle: {
width: 40,
height: 4,
borderRadius: 2,
alignSelf: 'center',
marginBottom: 12,
},
title: {
fontSize: 14,
fontWeight: '600',
textAlign: 'center',
marginBottom: 16,
opacity: 0.8,
},
action: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
paddingVertical: 14,
paddingHorizontal: 16,
borderRadius: 12,
borderWidth: 1,
marginBottom: 8,
},
actionText: {
fontSize: 15,
fontWeight: '500',
},
destructiveText: {
color: '#E53935',
fontWeight: '600',
},
});
@@ -0,0 +1,338 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
View,
Text,
StyleSheet,
Modal,
TouchableOpacity,
FlatList,
NativeScrollEvent,
NativeSyntheticEvent,
ListRenderItemInfo,
LayoutChangeEvent,
} from 'react-native';
import { useSettings } from '@/theme';
const ITEM_HEIGHT = 44;
const VISIBLE_ITEMS = 5;
const MAX_COLUMN_WIDTH = 88;
const MIN_COLUMN_WIDTH = 64;
const HOURS = Array.from({ length: 24 }, (_, i) => i);
const MINUTES = Array.from({ length: 60 }, (_, i) => i);
interface WheelTimePickerProps {
visible: boolean;
title: string;
initialTime?: string;
onConfirm: (time: string) => void;
onDismiss: () => void;
}
function parseTime(value?: string): { hours: number; minutes: number } {
if (value) {
const [h, m] = value.split(':').map(Number);
if (!Number.isNaN(h) && !Number.isNaN(m)) {
return { hours: ((h % 24) + 24) % 24, minutes: ((m % 60) + 60) % 60 };
}
}
const now = new Date();
return { hours: now.getHours(), minutes: now.getMinutes() };
}
function formatTime12(hours: number, minutes: number): string {
const period = hours >= 12 ? 'PM' : 'AM';
const h = hours % 12 === 0 ? 12 : hours % 12;
return `${h}:${String(minutes).padStart(2, '0')} ${period}`;
}
interface WheelRowProps {
label: string;
selected: boolean;
accent: string;
textMuted: string;
onPress: () => void;
}
const WheelRow = React.memo(function WheelRow({ label, selected, accent, textMuted, onPress }: WheelRowProps) {
return (
<TouchableOpacity
style={[styles.row, selected && styles.rowSelected]}
onPress={onPress}
activeOpacity={0.6}
>
<Text
style={[
styles.rowText,
{ color: selected ? accent : textMuted },
selected && styles.rowTextSelected,
]}
>
{label}
</Text>
</TouchableOpacity>
);
});
interface WheelColumnProps {
data: number[];
initialIndex: number;
selectedIndex: number;
width: number;
accent: string;
textMuted: string;
onIndexChange: (index: number) => void;
}
function WheelColumn({
data,
initialIndex,
selectedIndex,
width,
accent,
textMuted,
onIndexChange,
}: WheelColumnProps) {
const listRef = useRef<FlatList<number>>(null);
const indexRef = useRef(initialIndex);
useEffect(() => {
listRef.current?.scrollToOffset({ offset: initialIndex * ITEM_HEIGHT - (ITEM_HEIGHT * VISIBLE_ITEMS - ITEM_HEIGHT) / 2, animated: false });
indexRef.current = initialIndex;
}, [initialIndex]);
const selectIndex = useCallback(
(index: number) => {
const clamped = Math.min(Math.max(index, 0), data.length - 1);
indexRef.current = clamped;
onIndexChange(clamped);
listRef.current?.scrollToOffset({ offset: clamped * ITEM_HEIGHT - (ITEM_HEIGHT * VISIBLE_ITEMS - ITEM_HEIGHT) / 2, animated: true });
},
[data.length, onIndexChange]
);
const snapToNearest = useCallback(() => {
const current = listRef.current;
if (!current) return;
current.scrollToOffset({ offset: indexRef.current * ITEM_HEIGHT - (ITEM_HEIGHT * VISIBLE_ITEMS - ITEM_HEIGHT) / 2, animated: true });
}, []);
const renderItem = useCallback(
({ item, index }: ListRenderItemInfo<number>) => (
<WheelRow
label={String(item).padStart(2, '0')}
selected={index === selectedIndex}
accent={accent}
textMuted={textMuted}
onPress={() => selectIndex(index)}
/>
),
[selectedIndex, accent, textMuted, selectIndex]
);
const handleScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
const offset = event.nativeEvent.contentOffset.y;
const index = Math.round((offset + (ITEM_HEIGHT * VISIBLE_ITEMS - ITEM_HEIGHT) / 2) / ITEM_HEIGHT);
const clamped = Math.min(Math.max(index, 0), data.length - 1);
if (clamped !== indexRef.current) {
indexRef.current = clamped;
onIndexChange(clamped);
}
},
[data.length, onIndexChange]
);
const handleScrollEnd = useCallback(() => {
// Rest the wheel exactly on the snapped row so the selection band
// and the highlighted text always line up (FlatList doesn't snap on web).
snapToNearest();
}, [snapToNearest]);
return (
<FlatList
ref={listRef}
data={data}
keyExtractor={(item) => String(item)}
renderItem={renderItem}
getItemLayout={(_data, index) => ({
length: ITEM_HEIGHT,
offset: ITEM_HEIGHT * index,
index,
})}
initialScrollIndex={initialIndex}
snapToOffsets={data.map((_, i) => i * ITEM_HEIGHT)}
decelerationRate="fast"
showsVerticalScrollIndicator={false}
onScroll={handleScroll}
onScrollEndDrag={handleScrollEnd}
onMomentumScrollEnd={handleScrollEnd}
scrollEventThrottle={16}
style={[styles.column, { width }]}
contentContainerStyle={{ paddingBottom: (VISIBLE_ITEMS - 1) * ITEM_HEIGHT, alignItems: 'stretch' }}
/>
);
}
export function WheelTimePicker({
visible,
title,
initialTime,
onConfirm,
onDismiss,
}: WheelTimePickerProps) {
const { theme } = useSettings();
const initial = parseTime(initialTime);
const [hourIndex, setHourIndex] = useState(initial.hours);
const [minuteIndex, setMinuteIndex] = useState(initial.minutes);
const [prevVisible, setPrevVisible] = useState(visible);
const [wheelWidth, setWheelWidth] = useState(0);
const [columnWidth, setColumnWidth] = useState(MAX_COLUMN_WIDTH);
if (visible !== prevVisible) {
setPrevVisible(visible);
if (visible) {
setHourIndex(initial.hours);
setMinuteIndex(initial.minutes);
}
}
const onWheelLayout = (event: LayoutChangeEvent) => {
const width = event.nativeEvent.layout.width;
if (Math.abs(width - wheelWidth) < 1) return;
setWheelWidth(width);
const available = Math.max(0, width - 24);
setColumnWidth(Math.max(MIN_COLUMN_WIDTH, Math.min(MAX_COLUMN_WIDTH, available / 2)));
};
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onDismiss}>
<View style={[styles.overlay, { backgroundColor: theme.overlay }]}>
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
<View style={styles.header}>
<TouchableOpacity onPress={onDismiss} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
<Text style={[styles.headerAction, { color: theme.textMuted }]}>Cancel</Text>
</TouchableOpacity>
<View style={styles.headerCenter}>
<Text style={[styles.title, { color: theme.text }]}>{title}</Text>
<Text style={[styles.preview, { color: theme.accent }]}>
{formatTime12(hourIndex, minuteIndex)}
</Text>
</View>
<TouchableOpacity
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>
</TouchableOpacity>
</View>
<View
style={[styles.wheelArea, { height: ITEM_HEIGHT * VISIBLE_ITEMS }]}
onLayout={onWheelLayout}
>
<WheelColumn
data={HOURS}
initialIndex={initial.hours}
selectedIndex={hourIndex}
width={columnWidth}
accent={theme.accent}
textMuted={theme.textMuted}
onIndexChange={setHourIndex}
/>
<View style={[styles.colonWrap, { width: 24, backgroundColor: theme.sheetBg }]}>
<Text style={[styles.colon, { color: theme.accent }]}>:</Text>
</View>
<WheelColumn
data={MINUTES}
initialIndex={initial.minutes}
selectedIndex={minuteIndex}
width={columnWidth}
accent={theme.accent}
textMuted={theme.textMuted}
onIndexChange={setMinuteIndex}
/>
</View>
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
justifyContent: 'flex-end',
},
sheet: {
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
paddingTop: 12,
paddingBottom: 32,
width: '100%',
maxWidth: 420,
alignSelf: 'center',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 20,
paddingBottom: 8,
},
headerCenter: {
alignItems: 'center',
},
headerAction: {
fontSize: 15,
fontWeight: '600',
minWidth: 56,
},
doneText: {
textAlign: 'right',
},
title: {
fontSize: 16,
fontWeight: '700',
},
preview: {
fontSize: 13,
fontWeight: '600',
marginTop: 2,
},
wheelArea: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
},
column: {
height: '100%',
},
colonWrap: {
width: 24,
alignItems: 'center',
justifyContent: 'center',
height: '100%',
},
row: {
height: ITEM_HEIGHT,
alignItems: 'center',
justifyContent: 'center',
width: '100%',
},
rowSelected: {
backgroundColor: 'rgba(255,255,255,0.08)',
borderRadius: 8,
},
rowText: {
fontSize: 20,
fontWeight: '500',
},
rowTextSelected: {
fontSize: 22,
fontWeight: '700',
},
colon: {
fontSize: 24,
fontWeight: '600',
},
});
+46
View File
@@ -0,0 +1,46 @@
import { CategoryData } from '../types';
export const DEFAULT_CATEGORIES: Omit<CategoryData, 'id'>[] = [
{ name: 'Work', color: '#E53935', order: 0 },
{ name: 'Personal', color: '#43A047', order: 1 },
{ name: 'School', color: '#1E88E5', order: 2 },
{ name: 'Shopping', color: '#FB8C00', order: 3 },
{ name: 'Health', color: '#8E24AA', order: 4 },
{ name: 'Finance', color: '#FDD835', order: 5 },
];
export const CATEGORY_COLORS: string[] = [
'#E53935',
'#D81B60',
'#8E24AA',
'#5E35B1',
'#3949AB',
'#1E88E5',
'#039BE5',
'#00ACC1',
'#00897B',
'#43A047',
'#7CB342',
'#C0CA33',
'#FDD835',
'#FB8C00',
'#F4511E',
'#6D4C41',
'#757575',
];
export const PRIORITY_COLORS: Record<string, string> = {
none: '#9E9E9E',
low: '#43A047',
medium: '#FB8C00',
high: '#E53935',
critical: '#B71C1C',
};
export const PRIORITY_LABELS: Record<string, string> = {
none: 'None',
low: 'Low',
medium: 'Medium',
high: 'High',
critical: 'Critical',
};
@@ -0,0 +1,12 @@
import SQLiteAdapter from '@nozbe/watermelondb/adapters/sqlite';
import { schema } from './schema';
import { migrations } from './migrations';
export const adapter = new SQLiteAdapter({
schema,
migrations,
dbName: 'carryyourlive',
onSetUpError: (error) => {
console.error('Database setup error:', error);
},
});
+2
View File
@@ -0,0 +1,2 @@
import { adapter } from './adapter.native';
export { adapter };
@@ -0,0 +1,14 @@
import LokiJSAdapter from '@nozbe/watermelondb/adapters/lokijs';
import { schema } from './schema';
import { migrations } from './migrations';
export const adapter = new LokiJSAdapter({
schema,
migrations,
dbName: 'carryyourlive',
useWebWorker: false,
useIncrementalIndexedDB: true,
onSetUpError: (error) => {
console.error('Database setup error:', error);
},
});
+20
View File
@@ -0,0 +1,20 @@
import { Database } from '@nozbe/watermelondb';
import { adapter } from './adapter';
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';
export const database = new Database({
adapter,
modelClasses: [Category, Task, Subtask, RepeatProfile, Friendship],
});
export const collections = {
categories: database.collections.get<Category>('categories'),
tasks: database.collections.get<Task>('tasks'),
subtasks: database.collections.get<Subtask>('subtasks'),
repeatProfiles: database.collections.get<RepeatProfile>('repeat_profiles'),
friendships: database.collections.get<Friendship>('friendships'),
};
+141
View File
@@ -0,0 +1,141 @@
import { schemaMigrations, addColumns, createTable } from '@nozbe/watermelondb/Schema/migrations';
export const migrations = schemaMigrations({
migrations: [
{
toVersion: 2,
steps: [
addColumns({
table: 'tasks',
columns: [{ name: 'repeat', type: 'string' }],
}),
],
},
{
toVersion: 3,
steps: [
addColumns({
table: 'tasks',
columns: [
{ name: 'repeat_interval', type: 'number', isOptional: true },
{ name: 'repeat_days', type: 'string', isOptional: true },
{ name: 'color', type: 'string', isOptional: true },
{ name: 'series_id', type: 'string', isOptional: true },
],
}),
],
},
{
toVersion: 4,
steps: [
addColumns({
table: 'tasks',
columns: [{ name: 'reminder', type: 'string', isOptional: true }],
}),
],
},
{
toVersion: 5,
steps: [
addColumns({
table: 'tasks',
columns: [{ name: 'end_time', type: 'string', isOptional: true }],
}),
],
},
{
toVersion: 6,
steps: [
createTable({
name: 'repeat_profiles',
columns: [
{ name: 'name', type: 'string', isOptional: true },
{ name: 'repeat', type: 'string', isOptional: true },
{ name: 'repeat_interval', type: 'number', isOptional: true },
{ name: 'repeat_days', type: 'string', isOptional: true },
{ name: 'created_at', type: 'number' },
{ name: 'updated_at', type: 'number' },
],
}),
],
},
{
toVersion: 7,
steps: [
addColumns({
table: 'tasks',
columns: [{ name: 'assignee_id', type: 'string', isOptional: true }],
}),
],
},
{
toVersion: 8,
steps: [
addColumns({
table: 'tasks',
columns: [{ name: 'all_day', type: 'boolean', isOptional: true }],
}),
],
},
{
toVersion: 9,
steps: [
addColumns({
table: 'subtasks',
columns: [
{ name: 'description', type: 'string', isOptional: true },
{ name: 'priority', type: 'string', isOptional: true },
{ name: 'due_date', type: 'number', isOptional: true },
{ name: 'due_time', type: 'string', isOptional: true },
{ name: 'end_time', type: 'string', isOptional: true },
{ name: 'reminder', type: 'string', isOptional: true },
{ name: 'assignee_id', type: 'string', isOptional: true },
],
}),
],
},
{
toVersion: 10,
steps: [
addColumns({
table: 'subtasks',
columns: [
{ name: 'all_day', type: 'boolean', isOptional: true },
{ name: 'repeat', type: 'string', isOptional: true },
{ name: 'repeat_interval', type: 'number', isOptional: true },
{ name: 'repeat_days', type: 'string', isOptional: true },
{ name: 'series_id', type: 'string', isOptional: true },
],
}),
],
},
{
toVersion: 11,
steps: [
addColumns({
table: 'tasks',
columns: [{ name: 'reminders', type: 'string', isOptional: true }],
}),
addColumns({
table: 'subtasks',
columns: [{ name: 'reminders', type: 'string', isOptional: true }],
}),
],
},
{
toVersion: 12,
steps: [
createTable({
name: 'friendships',
columns: [
{ name: 'user_id', type: 'string', isIndexed: true },
{ name: 'friend_id', type: 'string', isIndexed: true },
{ name: 'status', type: 'string' },
{ name: 'created_at', type: 'number' },
{ name: 'updated_at', type: 'number' },
],
}),
],
},
],
});
+86
View File
@@ -0,0 +1,86 @@
import { appSchema, tableSchema } from '@nozbe/watermelondb';
export const schema = appSchema({
version: 12,
tables: [
tableSchema({
name: 'categories',
columns: [
{ name: 'name', type: 'string' },
{ name: 'color', type: 'string' },
{ name: 'order', type: 'number' },
{ name: 'created_at', type: 'number' },
{ name: 'updated_at', type: 'number' },
],
}),
tableSchema({
name: 'tasks',
columns: [
{ name: 'title', type: 'string' },
{ name: 'description', type: 'string' },
{ name: 'category_id', type: 'string', isIndexed: true },
{ name: 'priority', type: 'string' },
{ name: 'completed', type: 'boolean' },
{ name: 'due_date', type: 'number', isIndexed: true },
{ name: 'due_time', type: 'string' },
{ name: 'end_time', type: 'string' },
{ name: 'all_day', type: 'boolean' },
{ name: 'repeat', type: 'string' },
{ name: 'repeat_interval', type: 'number' },
{ name: 'repeat_days', type: 'string' },
{ name: 'color', type: 'string' },
{ name: 'series_id', type: 'string', isIndexed: true },
{ name: 'reminder', type: 'string' },
{ name: 'reminders', type: 'string' },
{ name: 'assignee_id', type: 'string', isOptional: true },
{ name: 'created_at', type: 'number' },
{ name: 'updated_at', type: 'number' },
],
}),
tableSchema({
name: 'subtasks',
columns: [
{ name: 'task_id', type: 'string', isIndexed: true },
{ name: 'title', type: 'string' },
{ name: 'description', type: 'string', isOptional: true },
{ name: 'priority', type: 'string', isOptional: true },
{ name: 'completed', type: 'boolean' },
{ name: 'due_date', type: 'number', isOptional: true },
{ name: 'due_time', type: 'string', isOptional: true },
{ name: 'end_time', type: 'string', isOptional: true },
{ name: 'all_day', type: 'boolean' },
{ name: 'repeat', type: 'string' },
{ name: 'repeat_interval', type: 'number' },
{ name: 'repeat_days', type: 'string' },
{ name: 'series_id', type: 'string' },
{ name: 'reminder', type: 'string', isOptional: true },
{ name: 'reminders', type: 'string', isOptional: true },
{ name: 'assignee_id', type: 'string', isOptional: true },
{ name: 'order', type: 'number' },
{ name: 'created_at', type: 'number' },
{ name: 'updated_at', type: 'number' },
],
}),
tableSchema({
name: 'repeat_profiles',
columns: [
{ name: 'name', type: 'string' },
{ name: 'repeat', type: 'string' },
{ name: 'repeat_interval', type: 'number' },
{ name: 'repeat_days', type: 'string' },
{ name: 'created_at', type: 'number' },
{ name: 'updated_at', type: 'number' },
],
}),
tableSchema({
name: 'friendships',
columns: [
{ name: 'user_id', type: 'string', isIndexed: true },
{ name: 'friend_id', type: 'string', isIndexed: true },
{ name: 'status', type: 'string' },
{ name: 'created_at', type: 'number' },
{ name: 'updated_at', type: 'number' },
],
}),
],
});
+475
View File
@@ -0,0 +1,475 @@
import { database, collections } from './index';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { apiFetch, getAuthToken } from '@/services/auth';
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';
interface FriendshipRow {
id: string;
userId: string;
friendId: string;
status: string;
createdAt: number;
updatedAt: number;
}
const LAST_PULLED_AT_KEY = 'sync:lastPulledAt';
const LAST_RUN_AT_KEY = 'sync:lastRunAt';
export interface SyncResult {
pushed: { categories: number; tasks: number; subtasks: number; repeatProfiles: number; friendships: number };
pulled: { categories: number; tasks: number; subtasks: number; repeatProfiles: number; friendships: number };
conflicts: number;
timestamp: number;
}
interface ServerRow {
id: string;
createdAt: number;
updatedAt: number;
[key: string]: any;
}
interface PushConflict {
entity: 'categories' | 'tasks' | 'subtasks' | 'repeatProfiles' | 'friendships';
id: string;
serverVersion: ServerRow;
resolution: string;
}
async function getLastPulledAt(): Promise<number> {
try {
const raw = await AsyncStorage.getItem(LAST_PULLED_AT_KEY);
const value = raw ? Number(raw) : 0;
return Number.isFinite(value) ? value : 0;
} catch {
return 0;
}
}
async function setLastPulledAt(timestamp: number): Promise<void> {
try {
await AsyncStorage.setItem(LAST_PULLED_AT_KEY, String(timestamp));
await AsyncStorage.setItem(LAST_RUN_AT_KEY, String(Date.now()));
} catch {
// ignore
}
}
export async function getLastSyncTime(): Promise<number | null> {
try {
const raw = await AsyncStorage.getItem(LAST_RUN_AT_KEY);
const value = raw ? Number(raw) : null;
return value && Number.isFinite(value) ? value : null;
} catch {
return null;
}
}
async function findOrNull<T>(table: any, id: string): Promise<T | null> {
return table.find(id).catch(() => null);
}
export async function runSync(): Promise<SyncResult> {
const token = await getAuthToken();
if (!token) {
throw new Error('NOT_SIGNED_IN');
}
const result: SyncResult = {
pushed: { categories: 0, tasks: 0, subtasks: 0, repeatProfiles: 0, friendships: 0 },
pulled: { categories: 0, tasks: 0, subtasks: 0, repeatProfiles: 0, friendships: 0 },
conflicts: 0,
timestamp: 0,
};
const conflicts = await pushChanges();
result.conflicts = conflicts.length;
await applyConflicts(conflicts);
const lastPulledAt = await getLastPulledAt();
const response = await apiFetch(`/sync?since=${lastPulledAt}`);
const data = await response.json();
const pulledCounts = await applyServerChanges(data);
result.pulled = pulledCounts;
result.timestamp = data.timestamp;
await setLastPulledAt(data.timestamp);
return result;
}
async function pushChanges(): Promise<PushConflict[]> {
const lastPulledAt = await getLastPulledAt();
const [categories, tasks, subtasks, repeatProfiles, friendships] = await Promise.all([
collections.categories.query().fetch(),
collections.tasks.query().fetch(),
collections.subtasks.query().fetch(),
collections.repeatProfiles.query().fetch(),
collections.friendships.query().fetch(),
]);
const localTaskIds = new Set(tasks.map((t) => t.id));
const taskById = new Map(tasks.map((t) => [t.id, t]));
const taskPayload = (t: Task) => ({
id: t.id,
title: t.title,
description: t.description,
categoryId: t.categoryId,
priority: t.priority,
completed: t.completed,
dueDate: t.dueDate,
dueTime: t.dueTime,
endTime: t.endTime || '',
allDay: t.allDay ?? false,
repeat: t.repeat || 'none',
repeatInterval: t.repeatInterval || 1,
repeatDays: t.repeatDays || '',
seriesId: t.seriesId || '',
reminder: t.reminder || 'none',
reminders: t.reminders || '',
assigneeId: t.assigneeId || null,
createdAt: t.createdAt.getTime(),
updatedAt: t.updatedAt.getTime(),
});
const changedTasks = tasks
.filter((t) => t.updatedAt.getTime() > lastPulledAt)
.map(taskPayload);
const changedSubtasks = subtasks
.filter((s) => localTaskIds.has(s.taskId) && s.updatedAt.getTime() > lastPulledAt)
.map((s) => ({
id: s.id,
taskId: s.taskId,
title: s.title,
description: s.description ?? '',
priority: s.priority ?? 'none',
completed: s.completed,
dueDate: s.dueDate ?? 0,
dueTime: s.dueTime ?? '',
endTime: s.endTime ?? '',
allDay: s.allDay ?? false,
repeat: s.repeat ?? 'none',
repeatInterval: s.repeatInterval ?? 1,
repeatDays: s.repeatDays ?? '',
seriesId: s.seriesId ?? '',
reminder: s.reminder ?? 'none',
reminders: s.reminders ?? '',
assigneeId: s.assigneeId ?? null,
order: s.order,
createdAt: s.createdAt.getTime(),
updatedAt: s.updatedAt.getTime(),
}));
const referencedCategoryIds = new Set(
changedTasks.map((t) => t.categoryId).filter((id) => id && id.length > 0)
);
const includedTaskIds = new Set(changedTasks.map((t) => t.id));
for (const sub of changedSubtasks) {
if (includedTaskIds.has(sub.taskId) || !taskById.has(sub.taskId)) continue;
const task = taskById.get(sub.taskId)!;
includedTaskIds.add(sub.taskId);
changedTasks.push(taskPayload(task));
if (task.categoryId) referencedCategoryIds.add(task.categoryId);
}
const changedCategories = categories
.filter((c) => c.updatedAt.getTime() > lastPulledAt || referencedCategoryIds.has(c.id))
.map((c) => ({
id: c.id,
name: c.name,
color: c.color,
order: c.order,
createdAt: c.createdAt.getTime(),
updatedAt: c.updatedAt.getTime(),
}));
const changedRepeatProfiles = repeatProfiles
.filter((p) => p.updatedAt.getTime() > lastPulledAt)
.map((p) => ({
id: p.id,
name: p.name,
repeat: p.repeat,
repeatInterval: p.repeatInterval,
repeatDays: p.repeatDays,
createdAt: p.createdAt.getTime(),
updatedAt: p.updatedAt.getTime(),
}));
const changedFriendships = friendships
.filter((f) => f.updatedAt.getTime() > lastPulledAt)
.map((f) => ({
id: f.id,
userId: f.userId,
friendId: f.friendId,
status: f.status,
createdAt: f.createdAt.getTime(),
updatedAt: f.updatedAt.getTime(),
}));
if (
changedCategories.length === 0 &&
changedTasks.length === 0 &&
changedSubtasks.length === 0 &&
changedRepeatProfiles.length === 0 &&
changedFriendships.length === 0
) {
return [];
}
const response = await apiFetch('/sync/push', {
method: 'POST',
body: JSON.stringify({
changes: {
categories: changedCategories,
tasks: changedTasks,
subtasks: changedSubtasks,
repeatProfiles: changedRepeatProfiles,
friendships: changedFriendships,
},
lastPulledAt,
}),
});
const data = await response.json();
return (data.conflicts ?? []) as PushConflict[];
}
async function applyConflicts(conflicts: PushConflict[]): Promise<void> {
if (conflicts.length === 0) return;
await database.write(async () => {
for (const conflict of conflicts) {
const row = conflict.serverVersion;
switch (conflict.entity) {
case 'categories':
await upsertCategory(row, true);
break;
case 'tasks':
await upsertTask(row, true);
break;
case 'subtasks':
await upsertSubtask(row, true);
break;
case 'repeatProfiles':
await upsertRepeatProfile(row, true);
break;
case 'friendships':
await upsertFriendship(row, true);
break;
}
}
});
}
async function applyServerChanges(data: {
categories: ServerRow[];
tasks: ServerRow[];
subtasks: ServerRow[];
repeatProfiles: ServerRow[];
friendships: ServerRow[];
}): Promise<SyncResult['pulled']> {
const counts = { categories: 0, tasks: 0, subtasks: 0, repeatProfiles: 0, friendships: 0 };
await database.write(async () => {
for (const cat of data.categories) {
if (await upsertCategory(cat, false)) counts.categories++;
}
for (const task of data.tasks) {
if (await upsertTask(task, false)) counts.tasks++;
}
for (const sub of data.subtasks) {
if (await upsertSubtask(sub, false)) counts.subtasks++;
}
for (const profile of data.repeatProfiles) {
if (await upsertRepeatProfile(profile, false)) counts.repeatProfiles++;
}
for (const friendship of data.friendships) {
if (await upsertFriendship(friendship, false)) counts.friendships++;
}
});
return counts;
}
async function upsertCategory(row: ServerRow, force: boolean): Promise<boolean> {
const local = await findOrNull<Category>(collections.categories, row.id);
if (!local) {
await collections.categories.create((c) => {
c.name = String(row.name ?? '');
c.color = String(row.color ?? '#9E9E9E');
c.order = Number(row.order ?? 0);
c.createdAt = new Date(row.createdAt ?? Date.now());
c.updatedAt = new Date(row.updatedAt ?? Date.now());
});
return true;
}
if (!force && local.updatedAt.getTime() > Number(row.updatedAt ?? 0)) {
return false;
}
await local.update((c) => {
c.name = String(row.name ?? c.name);
c.color = String(row.color ?? c.color);
c.order = Number(row.order ?? c.order);
c.updatedAt = new Date(row.updatedAt ?? Date.now());
});
return true;
}
async function upsertTask(row: ServerRow, force: boolean): Promise<boolean> {
const local = await findOrNull<Task>(collections.tasks, row.id);
if (!local) {
await collections.tasks.create((t) => {
t.title = String(row.title ?? '');
t.description = String(row.description ?? '');
t.categoryId = String(row.categoryId ?? '');
t.priority = row.priority ?? 'none';
t.completed = Boolean(row.completed);
t.dueDate = Number(row.dueDate ?? 0);
t.dueTime = String(row.dueTime ?? '');
t.endTime = String(row.endTime ?? '');
t.repeat = row.repeat ?? 'none';
t.repeatInterval = Number(row.repeatInterval ?? 1);
t.repeatDays = String(row.repeatDays ?? '');
t.seriesId = String(row.seriesId ?? '');
t.reminder = row.reminder ?? 'none';
t.assigneeId = row.assigneeId ?? null;
t.createdAt = new Date(row.createdAt ?? Date.now());
t.updatedAt = new Date(row.updatedAt ?? Date.now());
});
return true;
}
if (!force && local.updatedAt.getTime() > Number(row.updatedAt ?? 0)) {
return false;
}
await local.update((t) => {
t.title = String(row.title ?? t.title);
t.description = String(row.description ?? t.description);
t.categoryId = String(row.categoryId ?? t.categoryId);
t.priority = row.priority ?? t.priority;
t.completed = Boolean(row.completed ?? t.completed);
t.dueDate = Number(row.dueDate ?? t.dueDate);
t.dueTime = String(row.dueTime ?? t.dueTime);
t.endTime = String(row.endTime ?? t.endTime);
t.repeat = row.repeat ?? t.repeat;
t.repeatInterval = Number(row.repeatInterval ?? t.repeatInterval);
t.repeatDays = String(row.repeatDays ?? t.repeatDays);
t.seriesId = String(row.seriesId ?? t.seriesId);
t.reminder = row.reminder ?? t.reminder;
t.assigneeId = row.assigneeId ?? t.assigneeId;
t.updatedAt = new Date(row.updatedAt ?? Date.now());
});
return true;
}
async function upsertRepeatProfile(row: ServerRow, force: boolean): Promise<boolean> {
const local = await findOrNull<RepeatProfile>(collections.repeatProfiles, row.id);
if (!local) {
await collections.repeatProfiles.create((p) => {
p.name = String(row.name ?? '');
p.repeat = row.repeat ?? 'none';
p.repeatInterval = Number(row.repeatInterval ?? 1);
p.repeatDays = String(row.repeatDays ?? '');
p.createdAt = new Date(row.createdAt ?? Date.now());
p.updatedAt = new Date(row.updatedAt ?? Date.now());
});
return true;
}
if (!force && local.updatedAt.getTime() > Number(row.updatedAt ?? 0)) {
return false;
}
await local.update((p) => {
p.name = String(row.name ?? p.name);
p.repeat = row.repeat ?? p.repeat;
p.repeatInterval = Number(row.repeatInterval ?? p.repeatInterval);
p.repeatDays = String(row.repeatDays ?? p.repeatDays);
p.updatedAt = new Date(row.updatedAt ?? Date.now());
});
return true;
}
async function upsertSubtask(row: ServerRow, force: boolean): Promise<boolean> {
const task = await findOrNull<Task>(collections.tasks, String(row.taskId ?? ''));
if (!task) return false;
const local = await findOrNull<Subtask>(collections.subtasks, row.id);
if (!local) {
await collections.subtasks.create((s) => {
s.taskId = String(row.taskId ?? '');
s.title = String(row.title ?? '');
s.description = String(row.description ?? '');
s.priority = row.priority ?? 'none';
s.completed = Boolean(row.completed);
s.dueDate = Number(row.dueDate ?? 0);
s.dueTime = String(row.dueTime ?? '');
s.endTime = String(row.endTime ?? '');
s.allDay = Boolean(row.allDay ?? false);
s.repeat = row.repeat ?? 'none';
s.repeatInterval = Number(row.repeatInterval ?? 1);
s.repeatDays = String(row.repeatDays ?? '');
s.seriesId = String(row.seriesId ?? '');
s.reminder = row.reminder ?? 'none';
s.assigneeId = row.assigneeId ?? null;
s.order = Number(row.order ?? 0);
s.createdAt = new Date(row.createdAt ?? Date.now());
s.updatedAt = new Date(row.updatedAt ?? Date.now());
});
return true;
}
if (!force && local.updatedAt.getTime() > Number(row.updatedAt ?? 0)) {
return false;
}
await local.update((s) => {
s.taskId = String(row.taskId ?? s.taskId);
s.title = String(row.title ?? s.title);
s.description = String(row.description ?? s.description);
s.priority = row.priority ?? s.priority;
s.completed = Boolean(row.completed ?? s.completed);
s.dueDate = Number(row.dueDate ?? s.dueDate);
s.dueTime = String(row.dueTime ?? s.dueTime);
s.endTime = String(row.endTime ?? s.endTime);
s.allDay = Boolean(row.allDay ?? s.allDay);
s.repeat = row.repeat ?? s.repeat;
s.repeatInterval = Number(row.repeatInterval ?? s.repeatInterval);
s.repeatDays = String(row.repeatDays ?? s.repeatDays);
s.seriesId = String(row.seriesId ?? s.seriesId);
s.reminder = row.reminder ?? s.reminder;
s.assigneeId = row.assigneeId ?? s.assigneeId;
s.order = Number(row.order ?? s.order);
s.updatedAt = new Date(row.updatedAt ?? Date.now());
});
return true;
}
async function upsertFriendship(row: ServerRow, force: boolean): Promise<boolean> {
const local = await findOrNull<Friendship>(collections.friendships, row.id);
if (!local) {
await collections.friendships.create((f) => {
f.userId = String(row.userId ?? '');
f.friendId = String(row.friendId ?? '');
f.status = (row.status as 'pending' | 'accepted') ?? 'pending';
f.createdAt = new Date(row.createdAt ?? Date.now());
f.updatedAt = new Date(row.updatedAt ?? Date.now());
});
return true;
}
if (!force && local.updatedAt.getTime() > Number(row.updatedAt ?? 0)) {
return false;
}
await local.update((f) => {
f.userId = String(row.userId ?? f.userId);
f.friendId = String(row.friendId ?? f.friendId);
f.status = (row.status as 'pending' | 'accepted') ?? f.status;
f.updatedAt = new Date(row.updatedAt ?? Date.now());
});
return true;
}
+126
View File
@@ -0,0 +1,126 @@
import React, { createContext, useContext, useEffect, useState, ReactNode } from 'react';
import { map } from 'rxjs/operators';
import { database, collections } from '../database';
import { runSync } from '../database/sync';
import { getAuthToken } from '../services/auth';
import { DEFAULT_CATEGORIES } from '../constants';
import Category from '../models/Category';
import RepeatProfile from '../models/RepeatProfile';
interface DatabaseContextType {
database: typeof database;
collections: typeof collections;
isReady: boolean;
initializeDatabase: () => Promise<void>;
}
const DatabaseContext = createContext<DatabaseContextType | null>(null);
export function DatabaseProvider({ children }: { children: ReactNode }) {
const [isReady, setIsReady] = useState(false);
const initializeDatabase = async () => {
try {
const existingCategories = await collections.categories.query().fetch();
if (existingCategories.length === 0) {
await database.write(async () => {
for (const cat of DEFAULT_CATEGORIES) {
await collections.categories.create((c) => {
c.name = cat.name;
c.color = cat.color;
c.order = cat.order;
c.createdAt = new Date();
c.updatedAt = new Date();
});
}
});
}
setIsReady(true);
const token = await getAuthToken();
if (token) {
runSync().catch(() => {});
}
} catch (error) {
console.error('Failed to initialize database:', error);
setIsReady(true);
}
};
useEffect(() => {
initializeDatabase();
}, []);
return (
<DatabaseContext.Provider value={{ database, collections, isReady, initializeDatabase }}>
{children}
</DatabaseContext.Provider>
);
}
export function useDatabase() {
const context = useContext(DatabaseContext);
if (!context) {
throw new Error('useDatabase must be used within a DatabaseProvider');
}
return context;
}
export function useCategories(): Category[] {
const { collections } = useDatabase();
const [categories, setCategories] = useState<Category[]>([]);
useEffect(() => {
let mounted = true;
const subscription = collections.categories
.query()
.observeWithColumns(['name', 'color', 'order'])
.pipe(map((cats) => [...cats].sort((a, b) => a.order - b.order)))
.subscribe({
next: (cats) => {
if (mounted) setCategories(cats);
},
error: () => {},
});
return () => {
mounted = false;
subscription.unsubscribe();
};
}, [collections]);
return categories;
}
export function useCategory(categoryId: string) {
const { collections } = useDatabase();
return collections.categories.find(categoryId);
}
export function useRepeatProfiles(): RepeatProfile[] {
const { collections } = useDatabase();
const [profiles, setProfiles] = useState<RepeatProfile[]>([]);
useEffect(() => {
let mounted = true;
const subscription = collections.repeatProfiles
.query()
.observeWithColumns(['name', 'repeat', 'repeat_interval', 'repeat_days'])
.pipe(map((rows) => [...rows].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())))
.subscribe({
next: (rows) => {
if (mounted) setProfiles(rows);
},
error: () => {},
});
return () => {
mounted = false;
subscription.unsubscribe();
};
}, [collections]);
return profiles;
}
+97
View File
@@ -0,0 +1,97 @@
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
import { getFriends, sendFriendRequest, acceptFriendRequest, deleteFriendRequest, removeFriend, searchUsers, Friend, FriendRequest } from '@/services/friends';
interface FriendsContextType {
friends: Friend[];
incoming: FriendRequest[];
outgoing: FriendRequest[];
loading: boolean;
error: string | null;
refresh: () => Promise<void>;
sendRequest: (username: string) => Promise<void>;
acceptRequest: (requestId: string) => Promise<void>;
declineRequest: (requestId: string) => Promise<void>;
removeFriend: (friendId: string) => Promise<void>;
searchUsers: (query: string) => Promise<Friend[]>;
}
const FriendsContext = createContext<FriendsContextType | null>(null);
export function FriendsProvider({ children }: { children: React.ReactNode }) {
const [friends, setFriends] = useState<Friend[]>([]);
const [incoming, setIncoming] = useState<FriendRequest[]>([]);
const [outgoing, setOutgoing] = useState<FriendRequest[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchFriends = useCallback(async () => {
try {
setLoading(true);
setError(null);
const data = await getFriends();
setFriends(data.friends);
setIncoming(data.incoming);
setOutgoing(data.outgoing);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load friends');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchFriends();
}, [fetchFriends]);
const sendRequest = async (username: string) => {
await sendFriendRequest(username);
await fetchFriends();
};
const acceptRequest = async (requestId: string) => {
await acceptFriendRequest(requestId);
await fetchFriends();
};
const declineRequest = async (requestId: string) => {
await deleteFriendRequest(requestId);
await fetchFriends();
};
const handleRemoveFriend = async (friendId: string) => {
await removeFriend(friendId);
await fetchFriends();
};
const handleSearchUsers = async (query: string) => {
return searchUsers(query);
};
return (
<FriendsContext.Provider
value={{
friends,
incoming,
outgoing,
loading,
error,
refresh: fetchFriends,
sendRequest,
acceptRequest,
declineRequest,
removeFriend: handleRemoveFriend,
searchUsers: handleSearchUsers,
}}
>
{children}
</FriendsContext.Provider>
);
}
export function useFriends(): FriendsContextType {
const context = useContext(FriendsContext);
if (!context) {
throw new Error('useFriends must be used within a FriendsProvider');
}
return context;
}
+169
View File
@@ -0,0 +1,169 @@
import { useDatabase } from './useDatabase';
import { Q } from '@nozbe/watermelondb';
import { useEffect, useMemo, useState } from 'react';
import Task from '../models/Task';
export function useTasks(categoryId?: string, showCompleted = false) {
const { collections } = useDatabase();
const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let mounted = true;
const conditions: any[] = [];
if (categoryId && categoryId !== 'all') {
conditions.push(Q.where('category_id', categoryId));
}
if (!showCompleted) {
conditions.push(Q.where('completed', false));
}
const query = conditions.length > 0
? collections.tasks.query(Q.and(...conditions))
: collections.tasks.query();
const subscription = query
.observe()
.subscribe({
next: (result) => {
if (mounted) {
setTasks(result);
setLoading(false);
}
},
error: () => {
if (mounted) setLoading(false);
},
});
return () => {
mounted = false;
subscription.unsubscribe();
};
}, [collections, categoryId, showCompleted]);
return { tasks, loading };
}
export function useTask(taskId: string) {
const { collections } = useDatabase();
return collections.tasks.find(taskId);
}
export function useTasksByDate(date: Date) {
const { collections } = useDatabase();
const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true);
const startOfDay = new Date(date);
startOfDay.setHours(0, 0, 0, 0);
const endOfDay = new Date(date);
endOfDay.setHours(23, 59, 59, 999);
useEffect(() => {
let mounted = true;
const subscription = collections.tasks
.query(
Q.where('due_date', Q.between(startOfDay.getTime(), endOfDay.getTime())),
Q.sortBy('due_date', 'asc')
)
.observe()
.subscribe({
next: (result) => {
if (mounted) {
setTasks(result);
setLoading(false);
}
},
error: () => {
if (mounted) setLoading(false);
},
});
return () => {
mounted = false;
subscription.unsubscribe();
};
}, [collections, startOfDay.getTime(), endOfDay.getTime()]);
return { tasks, loading };
}
export function useOverdueTasks() {
const { collections } = useDatabase();
const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true);
const now = useMemo(() => Date.now(), []);
useEffect(() => {
let mounted = true;
const subscription = collections.tasks
.query(
Q.where('completed', false),
Q.where('due_date', Q.lt(now)),
Q.sortBy('due_date', 'asc')
)
.observe()
.subscribe({
next: (result) => {
if (mounted) {
setTasks(result);
setLoading(false);
}
},
error: () => {
if (mounted) setLoading(false);
},
});
return () => {
mounted = false;
subscription.unsubscribe();
};
}, [collections, now]);
return { tasks, loading };
}
export function useTasksDueToday() {
const { collections } = useDatabase();
const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const today = new Date();
today.setHours(0, 0, 0, 0);
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
let mounted = true;
const subscription = collections.tasks
.query(
Q.where('completed', false),
Q.where('due_date', Q.between(today.getTime(), tomorrow.getTime() - 1)),
Q.sortBy('due_date', 'asc')
)
.observe()
.subscribe({
next: (result) => {
if (mounted) {
setTasks(result);
setLoading(false);
}
},
error: () => {
if (mounted) setLoading(false);
},
});
return () => {
mounted = false;
subscription.unsubscribe();
};
}, [collections]);
return { tasks, loading };
}
+12
View File
@@ -0,0 +1,12 @@
import { Model } from '@nozbe/watermelondb';
import { field, date, readonly } from '@nozbe/watermelondb/decorators';
export default class Category extends Model {
static table = 'categories';
@field('name') name!: string;
@field('color') color!: string;
@field('order') order!: number;
@date('created_at') createdAt!: Date;
@date('updated_at') updatedAt!: Date;
}
+12
View File
@@ -0,0 +1,12 @@
import { Model } from '@nozbe/watermelondb';
import { field, date, readonly } from '@nozbe/watermelondb/decorators';
export default class Friendship extends Model {
static table = 'friendships';
@field('user_id') userId!: string;
@field('friend_id') friendId!: string;
@field('status') status!: 'pending' | 'accepted';
@date('created_at') createdAt!: Date;
@date('updated_at') updatedAt!: Date;
}
@@ -0,0 +1,14 @@
import { Model } from '@nozbe/watermelondb';
import { field, date } from '@nozbe/watermelondb/decorators';
import { Repeat } from '@/types';
export default class RepeatProfile extends Model {
static table = 'repeat_profiles';
@field('name') name!: string;
@field('repeat') repeat!: Repeat;
@field('repeat_interval') repeatInterval!: number;
@field('repeat_days') repeatDays!: string;
@date('created_at') createdAt!: Date;
@date('updated_at') updatedAt!: Date;
}
+27
View File
@@ -0,0 +1,27 @@
import { Model } from '@nozbe/watermelondb';
import { field, date, readonly } from '@nozbe/watermelondb/decorators';
import { Priority, Repeat, Reminder } from '@/types';
export default class Subtask extends Model {
static table = 'subtasks';
@field('task_id') taskId!: string;
@field('title') title!: string;
@field('description') description!: string;
@field('priority') priority!: Priority;
@field('completed') completed!: boolean;
@field('due_date') dueDate!: number;
@field('due_time') dueTime!: string;
@field('end_time') endTime!: string;
@field('all_day') allDay!: boolean;
@field('repeat') repeat!: Repeat;
@field('repeat_interval') repeatInterval!: number;
@field('repeat_days') repeatDays!: string;
@field('series_id') seriesId!: string;
@field('reminder') reminder!: Reminder;
@field('reminders') reminders!: string;
@field('assignee_id') assigneeId!: string | null;
@field('order') order!: number;
@date('created_at') createdAt!: Date;
@date('updated_at') updatedAt!: Date;
}
+34
View File
@@ -0,0 +1,34 @@
import { Model } from '@nozbe/watermelondb';
import { field, date, readonly, children } from '@nozbe/watermelondb/decorators';
import { Associations } from '@nozbe/watermelondb/Model';
import { Priority, Repeat, Reminder } from '@/types';
export default class Task extends Model {
static table = 'tasks';
static associations: Associations = {
subtasks: { type: 'has_many', foreignKey: 'task_id' },
};
@field('title') title!: string;
@field('description') description!: string;
@field('category_id') categoryId!: string;
@field('priority') priority!: Priority;
@field('completed') completed!: boolean;
@field('due_date') dueDate!: number;
@field('due_time') dueTime!: string;
@field('end_time') endTime!: string;
@field('all_day') allDay!: boolean;
@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;
@field('assignee_id') assigneeId!: string | null;
@date('created_at') createdAt!: Date;
@date('updated_at') updatedAt!: Date;
@children('subtasks') subtasks!: any;
}
+91
View File
@@ -0,0 +1,91 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
export const API_BASE_URL = process.env.EXPO_PUBLIC_API_URL ?? 'http://localhost:3000/api';
const TOKEN_KEY = 'auth:token';
const USER_KEY = 'auth:user';
export interface AuthUser {
id: string;
username: string;
}
export async function getAuthToken(): Promise<string | null> {
try {
return await AsyncStorage.getItem(TOKEN_KEY);
} catch {
return null;
}
}
export async function getAuthUser(): Promise<AuthUser | null> {
try {
const raw = await AsyncStorage.getItem(USER_KEY);
return raw ? (JSON.parse(raw) as AuthUser) : null;
} catch {
return null;
}
}
export async function setAuth(token: string, user: AuthUser): Promise<void> {
try {
await AsyncStorage.setItem(TOKEN_KEY, token);
await AsyncStorage.setItem(USER_KEY, JSON.stringify(user));
} catch {
// ignore
}
}
export async function signOutAuth(): Promise<void> {
try {
await AsyncStorage.removeItem(TOKEN_KEY);
await AsyncStorage.removeItem(USER_KEY);
} catch {
// ignore
}
}
export async function apiFetch(path: string, options: RequestInit = {}): Promise<Response> {
const token = await getAuthToken();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string> | undefined),
};
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const response = await fetch(`${API_BASE_URL}${path}`, { ...options, headers });
if (!response.ok) {
let message = `Request failed (${response.status})`;
try {
const body = await response.json();
if (body && typeof body.message === 'string') {
message = body.message;
}
} catch {
// ignore
}
throw new Error(message);
}
return response;
}
export async function login(username: string, password: string): Promise<AuthUser> {
const response = await apiFetch('/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
});
const data = await response.json();
await setAuth(data.token, data.user);
return data.user as AuthUser;
}
export async function register(username: string, password: string): Promise<AuthUser> {
const response = await apiFetch('/auth/register', {
method: 'POST',
body: JSON.stringify({ username, password }),
});
const data = await response.json();
await setAuth(data.token, data.user);
return data.user as AuthUser;
}
+72
View File
@@ -0,0 +1,72 @@
import { apiFetch } from './auth';
export interface Friend {
id: string;
username: string;
status: 'accepted' | 'pending';
requestId?: string;
}
export interface FriendRequest {
id: string;
username: string;
requestId: string;
status: 'pending';
}
export interface FriendsResponse {
friends: Friend[];
incoming: FriendRequest[];
outgoing: FriendRequest[];
}
export async function getFriends(): Promise<FriendsResponse> {
const response = await apiFetch('/friends');
return response.json();
}
export async function searchUsers(query: string): Promise<Friend[]> {
const response = await apiFetch(`/friends/search?q=${encodeURIComponent(query)}`);
return response.json();
}
export async function sendFriendRequest(username: string): Promise<void> {
const response = await apiFetch('/friends/requests', {
method: 'POST',
body: JSON.stringify({ username }),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.message || 'Failed to send friend request');
}
}
export async function acceptFriendRequest(requestId: string): Promise<void> {
const response = await apiFetch(`/friends/requests/${requestId}/accept`, {
method: 'POST',
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.message || 'Failed to accept friend request');
}
}
export async function deleteFriendRequest(requestId: string): Promise<void> {
const response = await apiFetch(`/friends/requests/${requestId}`, {
method: 'DELETE',
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.message || 'Failed to decline friend request');
}
}
export async function removeFriend(friendId: string): Promise<void> {
const response = await apiFetch(`/friends/${friendId}`, {
method: 'DELETE',
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.message || 'Failed to remove friend');
}
}
@@ -0,0 +1,191 @@
import { Platform } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Reminder, reminderToMinutes, parseReminders } from '@/types';
const ID_MAP_KEY = 'notifications:taskIdToNotificationIds';
async function readIdMap(): Promise<Record<string, string[]>> {
try {
const raw = await AsyncStorage.getItem(ID_MAP_KEY);
return raw ? (JSON.parse(raw) as Record<string, string[]>) : {};
} catch {
return {};
}
}
async function writeIdMap(map: Record<string, string[]>): Promise<void> {
try {
await AsyncStorage.setItem(ID_MAP_KEY, JSON.stringify(map));
} catch {
// ignore
}
}
function getNotificationsModule(): any {
if (Platform.OS === 'web') return null;
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
return require('expo-notifications');
} catch {
return null;
}
}
export async function scheduleTaskReminder(task: any): Promise<void> {
const notifications = getNotificationsModule();
const dueDate = task.dueDate ? new Date(task.dueDate) : null;
if (!notifications) return;
const idMap = await readIdMap();
// Cancel any existing notifications for this task
const oldIds = idMap[task.id] || [];
for (const id of oldIds) {
try {
await notifications.cancelScheduledNotificationAsync(id);
} catch {
// ignore
}
}
delete idMap[task.id];
await writeIdMap(idMap);
// Determine the set of reminders to schedule
const reminders: Reminder[] = task.reminders
? parseReminders(task.reminders)
: task.reminder && task.reminder !== 'none'
? [task.reminder as Reminder]
: [];
if (reminders.length === 0 || !dueDate || !task.dueTime) return;
const granted = await requestNotificationPermission();
if (!granted) return;
const [hours, mins] = (task.dueTime as string).split(':').map(Number);
if (!Number.isNaN(hours)) {
dueDate.setHours(hours, Number.isNaN(mins) ? 0 : mins, 0, 0);
}
const newIds: string[] = [];
for (const reminder of reminders) {
const minutes = reminderToMinutes(reminder);
if (minutes === null) continue;
const triggerDate = new Date(dueDate.getTime() - minutes * 60000);
if (triggerDate.getTime() <= Date.now()) continue;
try {
const id = await notifications.scheduleNotificationAsync({
content: {
title: task.title,
body: `Due ${task.dueTime}${task.repeat && task.repeat !== 'none' ? ' · repeating' : ''}`,
sound: 'default',
},
trigger: { type: 'date', date: triggerDate },
});
newIds.push(id);
} catch (error) {
if (__DEV__) console.warn('Failed to schedule reminder:', error);
}
}
if (newIds.length > 0) {
idMap[task.id] = newIds;
await writeIdMap(idMap);
}
}
export async function cancelTaskReminder(taskId: string): Promise<void> {
const notifications = getNotificationsModule();
if (!notifications) return;
const idMap = await readIdMap();
const ids = idMap[taskId];
if (!ids) return;
for (const id of ids) {
try {
await notifications.cancelScheduledNotificationAsync(id);
} catch {
// ignore
}
}
delete idMap[taskId];
await writeIdMap(idMap);
}
export async function requestNotificationPermission(): Promise<boolean> {
const notifications = getNotificationsModule();
if (!notifications) return false;
try {
const settings = await notifications.getPermissionsAsync();
if (!settings.granted) {
const requested = await notifications.requestPermissionsAsync();
return requested.granted;
}
return settings.granted;
} catch (error) {
if (__DEV__) console.warn('Failed to request notification permission:', error);
return false;
}
}
export async function rescheduleAllReminders(tasks: any[]): Promise<void> {
const notifications = getNotificationsModule();
if (!notifications) return;
try {
await notifications.cancelAllScheduledNotificationsAsync();
} catch {
// ignore
}
const idMap: Record<string, string[]> = {};
for (const task of tasks) {
const dueDate = task.dueDate ? new Date(task.dueDate) : null;
const reminders: Reminder[] = task.reminders
? parseReminders(task.reminders)
: task.reminder && task.reminder !== 'none'
? [task.reminder as Reminder]
: [];
if (reminders.length === 0 || !dueDate || !task.dueTime) continue;
const granted = await requestNotificationPermission();
if (!granted) continue;
const [hours, mins] = (task.dueTime as string).split(':').map(Number);
if (!Number.isNaN(hours)) dueDate.setHours(hours, Number.isNaN(mins) ? 0 : mins, 0, 0);
const newIds: string[] = [];
for (const reminder of reminders) {
const minutes = reminderToMinutes(reminder);
if (minutes === null) continue;
const triggerDate = new Date(dueDate.getTime() - minutes * 60000);
if (triggerDate.getTime() <= Date.now()) continue;
try {
const id = await notifications.scheduleNotificationAsync({
content: {
title: task.title,
body: `Due ${task.dueTime}${task.repeat && task.repeat !== 'none' ? ' · repeating' : ''}`,
sound: 'default',
},
trigger: { type: 'date', date: triggerDate },
});
newIds.push(id);
} catch {
// skip individual failures
}
}
if (newIds.length > 0) idMap[task.id] = newIds;
}
await writeIdMap(idMap);
}
+143
View File
@@ -0,0 +1,143 @@
import React, { createContext, useContext, useEffect, useState, ReactNode } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
export type SortBy = 'date' | 'priority' | 'alpha' | 'created';
export const SORT_OPTIONS: { value: SortBy; label: string }[] = [
{ value: 'date', label: 'Date' },
{ value: 'priority', label: 'Priority' },
{ value: 'alpha', label: 'Alphabetically' },
{ value: 'created', label: 'Creation Date' },
];
export type ReminderPreference = 'none' | 'atTime' | '5m' | '15m' | '30m' | '1h' | '1d';
export const REMINDER_OPTIONS: { value: ReminderPreference; label: string }[] = [
{ value: 'none', label: 'No reminder' },
{ value: 'atTime', label: 'At due time' },
{ value: '5m', label: '5 minutes before' },
{ value: '15m', label: '15 minutes before' },
{ value: '30m', label: '30 minutes before' },
{ value: '1h', label: '1 hour before' },
{ value: '1d', label: '1 day before' },
];
export interface ThemeColors {
background: string;
card: string;
cardAlt: string;
border: string;
borderStrong: string;
text: string;
textSecondary: string;
textFaint: string;
textMuted: string;
accent: string;
accentSoft: string;
accentBorder: string;
inputBg: string;
overlay: string;
sheetBg: string;
tabBarBg: string;
}
export const colors: ThemeColors = {
background: '#121212',
card: '#1E1E1E',
cardAlt: '#262626',
border: '#2A2A2A',
borderStrong: '#3A3A3A',
text: '#F5F5F5',
textSecondary: '#E0E0E0',
textFaint: '#BDBDBD',
textMuted: '#8E8E8E',
accent: '#EF5350',
accentSoft: '#2A1D1D',
accentBorder: '#4A2B2B',
inputBg: '#1A1A1A',
overlay: 'rgba(0,0,0,0.6)',
sheetBg: '#242424',
tabBarBg: '#1A1A1A',
};
interface SettingsContextType {
notifications: boolean;
setNotifications: (value: boolean) => void;
defaultCategoryId: string;
setDefaultCategoryId: (value: string) => void;
sortBy: SortBy;
setSortBy: (value: SortBy) => void;
reminderPreference: ReminderPreference;
setReminderPreference: (value: ReminderPreference) => void;
theme: ThemeColors;
}
const STORAGE_KEYS = {
notifications: 'settings:notifications',
defaultCategoryId: 'settings:defaultCategoryId',
sortBy: 'settings:sortBy',
reminderPreference: 'settings:reminderPreference',
};
const SettingsContext = createContext<SettingsContextType | null>(null);
function useStoredSetting<T>(key: string, initialValue: T): [T, (value: T) => void, boolean] {
const [value, setValue] = useState<T>(initialValue);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
AsyncStorage.getItem(key)
.then((stored) => {
if (stored !== null) {
setValue(JSON.parse(stored) as T);
}
})
.catch(() => {})
.finally(() => setLoaded(true));
}, [key]);
const update = (next: T) => {
setValue(next);
AsyncStorage.setItem(key, JSON.stringify(next)).catch(() => {});
};
return [value, update, loaded];
}
export function SettingsProvider({ children }: { children: ReactNode }) {
const [notifications, setNotifications] = useStoredSetting<boolean>(STORAGE_KEYS.notifications, true);
const [defaultCategoryId, setDefaultCategoryId] = useStoredSetting<string>(STORAGE_KEYS.defaultCategoryId, '');
const [sortBy, setSortBy] = useStoredSetting<SortBy>(STORAGE_KEYS.sortBy, 'date');
const [reminderPreference, setReminderPreference] = useStoredSetting<ReminderPreference>(
STORAGE_KEYS.reminderPreference,
'15m',
);
const theme = colors;
return (
<SettingsContext.Provider
value={{
notifications,
setNotifications,
defaultCategoryId,
setDefaultCategoryId,
sortBy,
setSortBy,
reminderPreference,
setReminderPreference,
theme,
}}
>
{children}
</SettingsContext.Provider>
);
}
export function useSettings() {
const context = useContext(SettingsContext);
if (!context) {
throw new Error('useSettings must be used within a SettingsProvider');
}
return context;
}
+183
View File
@@ -0,0 +1,183 @@
export type Priority = 'none' | 'low' | 'medium' | 'high' | 'critical';
export type Repeat = 'none' | 'daily' | 'weekly' | 'monthly' | 'custom';
export const REPEAT_OPTIONS: { value: Repeat; label: string }[] = [
{ value: 'none', label: 'No Repeat' },
{ value: 'daily', label: 'Daily' },
{ value: 'weekly', label: 'Weekly' },
{ value: 'monthly', label: 'Monthly' },
{ value: 'custom', label: 'Custom' },
];
export const WEEKDAY_LABELS = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
export function repeatDaysToString(days: number[]): string {
return [...days].sort().join(',');
}
export function repeatDaysFromString(raw: string): number[] {
return (raw || '')
.split(',')
.map(Number)
.filter((d) => !Number.isNaN(d));
}
export interface RepeatProfileData {
id: string;
name: string;
repeat: Repeat;
repeatInterval: number;
repeatDays: string;
createdAt: Date;
updatedAt: Date;
}
export type Reminder = 'none' | 'at_time' | '15' | '30' | '60' | '120' | '1440';
export const REMINDER_OPTIONS: { value: Reminder; label: string }[] = [
{ value: 'none', label: 'No Reminder' },
{ value: 'at_time', label: 'At due time' },
{ value: '15', label: '15 minutes before' },
{ value: '30', label: '30 minutes before' },
{ value: '60', label: '1 hour before' },
{ value: '120', label: '2 hours before' },
{ value: '1440', label: '1 day before' },
];
export const MULTIPLE_REMINDER_OPTIONS = REMINDER_OPTIONS.filter((o) => o.value !== 'none');
export function reminderToMinutes(reminder: Reminder): number | null {
if (reminder === 'none') return null;
if (reminder === 'at_time') return 0;
return parseInt(reminder, 10);
}
export function reminderShortLabel(reminder: Reminder): string {
switch (reminder) {
case 'none':
return '';
case 'at_time':
return 'at due time';
case '15':
return '15 min';
case '30':
return '30 min';
case '60':
return '1 hr';
case '120':
return '2 hrs';
case '1440':
return '1 day';
default:
return '';
}
}
export function parseReminders(reminders: string): Reminder[] {
if (!reminders) return [];
return reminders
.split(',')
.map((r) => r.trim() as Reminder)
.filter((r): r is Exclude<Reminder, 'none'> => r !== 'none' && Boolean(r));
}
export function toRemindersString(reminders: Reminder[]): string {
return reminders.filter((r) => r !== 'none').join(',');
}
export type TaskStatus = 'pending' | 'completed' | 'overdue' | 'due_today';
export interface CategoryData {
id: string;
name: string;
color: string;
order: number;
}
export interface TaskData {
id: string;
title: string;
description: string;
categoryId: string;
priority: Priority;
completed: boolean;
dueDate: number;
dueTime: string;
endTime: string;
allDay: boolean;
repeat: Repeat;
repeatInterval: number;
repeatDays: string;
reminder: Reminder;
reminders: string;
assigneeId: string | null;
createdAt: Date;
updatedAt: Date;
subtasks: SubtaskData[];
}
export interface SubtaskData {
id: string;
taskId: string;
title: string;
description: string;
priority: Priority;
completed: boolean;
dueDate: number;
dueTime: string;
endTime: string;
allDay: boolean;
repeat: Repeat;
repeatInterval: number;
repeatDays: string;
seriesId: string;
reminder: Reminder;
assigneeId: string | null;
order: number;
}
export interface TaskWithCategory extends TaskData {
category: CategoryData;
}
export interface CreateTaskInput {
title: string;
description: string;
categoryId: string;
priority: Priority;
dueDate: Date | null;
dueTime: string;
endTime: string;
allDay: boolean;
repeat: Repeat;
repeatInterval: number;
repeatDays: string;
reminder: Reminder;
reminders: string;
assigneeId: string | null;
subtasks: { title: string }[];
}
export interface SubtaskFormValue {
title: string;
_key?: string;
}
export interface TaskFormData {
title: string;
description?: string;
categoryId: string;
priority: Priority;
dueDate: Date | null;
dueTime?: string;
endTime?: string;
allDay?: boolean;
repeat: Repeat;
repeatInterval?: number;
repeatDays?: number[];
reminder: Reminder;
reminders: string;
assigneeId: string | null;
subtasks?: SubtaskFormValue[];
}
@@ -0,0 +1,48 @@
import { database, collections } from '@/database';
import { Q } from '@nozbe/watermelondb';
export async function createCategory(name: string, color: string): Promise<void> {
await database.write(async () => {
const existing = await collections.categories.query().fetch();
const nextOrder = existing.reduce((max, c) => Math.max(max, c.order), -1) + 1;
const now = new Date();
await collections.categories.create((c) => {
c.name = name;
c.color = color;
c.order = nextOrder;
c.createdAt = now;
c.updatedAt = now;
});
});
}
export async function updateCategory(categoryId: string, name: string, color: string): Promise<void> {
await database.write(async () => {
const category = await collections.categories.find(categoryId);
await category.update((c) => {
c.name = name;
c.color = color;
c.updatedAt = new Date();
});
});
}
export async function deleteCategory(categoryId: string): Promise<void> {
await database.write(async () => {
const others = (await collections.categories.query().fetch()).filter((c) => c.id !== categoryId);
const fallback = others[0];
const tasks = await collections.tasks.query(Q.where('category_id', categoryId)).fetch();
for (const task of tasks) {
if (fallback) {
await task.update((t) => {
t.categoryId = fallback.id;
t.updatedAt = new Date();
});
}
}
const category = await collections.categories.find(categoryId);
await category.destroyPermanently();
});
}
+21
View File
@@ -0,0 +1,21 @@
import { Alert, Platform } from 'react-native';
type ConfirmOptions = {
title: string;
message?: string;
confirmLabel?: string;
destructive?: boolean;
onConfirm: () => void;
};
export function confirmAction({ title, message, confirmLabel = 'Delete', destructive, onConfirm }: ConfirmOptions): void {
if (Platform.OS === 'web') {
const confirmed = (globalThis as any).confirm(message ? `${title}\n\n${message}` : title);
if (confirmed) onConfirm();
return;
}
Alert.alert(title, message, [
{ text: 'Cancel', style: 'cancel' },
{ text: confirmLabel, style: destructive ? 'destructive' : 'default', onPress: onConfirm },
]);
}
@@ -0,0 +1,28 @@
import { database, collections } from '../database';
import { Repeat } from '@/types';
export async function createRepeatProfile(
name: string,
repeat: Repeat,
interval: number,
days: number[]
): Promise<void> {
const now = new Date();
await database.write(async () => {
await collections.repeatProfiles.create((p) => {
p.name = name.trim();
p.repeat = repeat;
p.repeatInterval = interval || 1;
p.repeatDays = [...days].sort().join(',');
p.createdAt = now;
p.updatedAt = now;
});
});
}
export async function deleteRepeatProfile(id: string): Promise<void> {
await database.write(async () => {
const profile = await collections.repeatProfiles.find(id);
await profile.destroyPermanently();
});
}
+412
View File
@@ -0,0 +1,412 @@
import { database, collections } from '@/database';
import { Q } from '@nozbe/watermelondb';
import { Priority, Repeat } from '@/types';
import { scheduleTaskReminder, cancelTaskReminder } from '@/services/notifications';
function addDays(date: Date, days: number): Date {
const next = new Date(date);
next.setDate(next.getDate() + days);
return next;
}
function addMonths(date: Date, months: number): Date {
const next = new Date(date);
const day = next.getDate();
next.setDate(1);
next.setMonth(next.getMonth() + months);
const lastDay = new Date(next.getFullYear(), next.getMonth() + 1, 0).getDate();
next.setDate(Math.min(day, lastDay));
return next;
}
function startOfDay(date: Date): Date {
const d = new Date(date);
d.setHours(0, 0, 0, 0);
return d;
}
function nextSelectedDay(base: Date, days: number[], intervalWeeks: number, todayStart: Date): Date {
const msDay = 86400000;
for (let offset = 1; offset <= intervalWeeks * 7 + 7; offset++) {
const candidate = new Date(base.getTime() + offset * msDay);
if (days.includes(candidate.getDay())) {
const weeksFromBase = Math.floor(offset / 7);
if (weeksFromBase % intervalWeeks === 0 && candidate.getTime() > todayStart.getTime()) {
return candidate;
}
}
}
for (let offset = 1; offset <= 7 * intervalWeeks; offset++) {
const candidate = new Date(todayStart.getTime() + offset * msDay);
if (days.includes(candidate.getDay())) return candidate;
}
return addDays(todayStart, 7 * intervalWeeks);
}
export function parseRepeatDays(repeatDays: string): number[] {
if (!repeatDays) return [];
return repeatDays
.split(',')
.map((d) => parseInt(d, 10))
.filter((d) => !Number.isNaN(d) && d >= 0 && d <= 6);
}
export function computeNextOccurrence(
currentDueDate: number,
repeat: Repeat,
interval = 1,
repeatDays = ''
): Date {
const base = new Date(currentDueDate || Date.now());
const todayStart = startOfDay(new Date());
const effectiveInterval = Math.max(1, interval || 1);
const days = parseRepeatDays(repeatDays);
switch (repeat) {
case 'daily': {
let next = addDays(base, effectiveInterval);
while (next.getTime() <= todayStart.getTime()) next = addDays(next, effectiveInterval);
return next;
}
case 'monthly': {
let next = addMonths(base, effectiveInterval);
while (next.getTime() <= todayStart.getTime()) next = addMonths(next, effectiveInterval);
return next;
}
case 'weekly':
if (days.length === 0) {
let next = addDays(base, 7 * effectiveInterval);
while (next.getTime() <= todayStart.getTime()) next = addDays(next, 7 * effectiveInterval);
return next;
}
return nextSelectedDay(base, days, effectiveInterval, todayStart);
case 'custom':
if (days.length === 0) {
let next = addDays(base, 7 * effectiveInterval);
while (next.getTime() <= todayStart.getTime()) next = addDays(next, 7 * effectiveInterval);
return next;
}
return nextSelectedDay(base, days, effectiveInterval, todayStart);
default:
return new Date(currentDueDate || Date.now());
}
}
function newSeriesId(): string {
return `series_${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}`;
}
export async function deleteTaskWithSubtasks(taskId: string): Promise<void> {
const subtasks = await collections.subtasks.query(Q.where('task_id', taskId)).fetch();
for (const subtask of subtasks) {
await subtask.destroyPermanently();
}
const task = await collections.tasks.find(taskId);
await task.destroyPermanently();
}
export type TaskDeleteScope = 'this' | 'future' | 'all';
export async function deleteTaskOccurrences(taskId: string, scope: TaskDeleteScope): Promise<void> {
await database.write(async () => {
const task = await collections.tasks.find(taskId);
if (scope === 'this' || !task.seriesId || task.repeat === 'none') {
await deleteTaskWithSubtasks(taskId);
return;
}
const seriesTasks = await collections.tasks.query(Q.where('series_id', task.seriesId)).fetch();
const anchor = task.dueDate || task.createdAt.getTime();
const toDelete = seriesTasks.filter((t) => {
if (scope === 'all') return true;
return (t.dueDate || t.createdAt.getTime()) >= anchor;
});
for (const t of toDelete) {
await deleteTaskWithSubtasks(t.id);
}
for (const t of toDelete) {
await cancelTaskReminder(t.id);
}
});
}
export async function getSeriesOccurrenceCounts(taskId: string): Promise<{ future: number; all: number }> {
const task = await collections.tasks.find(taskId);
if (!task.seriesId || task.repeat === 'none') {
return { future: 1, all: 1 };
}
const seriesTasks = await collections.tasks.query(Q.where('series_id', task.seriesId)).fetch();
const anchor = task.dueDate || task.createdAt.getTime();
const future = seriesTasks.filter((t) => (t.dueDate || t.createdAt.getTime()) >= anchor).length;
return { future, all: seriesTasks.length };
}
export async function toggleTaskComplete(taskId: string): Promise<string | null> {
let nextOccurrence: any = null;
await database.write(async () => {
const task = await collections.tasks.find(taskId);
const completing = !task.completed;
await task.update((t) => {
t.completed = completing;
t.updatedAt = new Date();
});
if (completing && task.repeat !== 'none' && task.dueDate) {
nextOccurrence = await createNextOccurrence(task);
}
});
if (nextOccurrence) await scheduleTaskReminder(nextOccurrence);
const task = await collections.tasks.find(taskId);
if (task.completed) await cancelTaskReminder(taskId);
else await scheduleTaskReminder(task);
return nextOccurrence ? nextOccurrence.id : null;
}
export async function toggleSubtaskComplete(subtaskId: string): Promise<void> {
const subtask = await collections.subtasks.find(subtaskId);
const completing = !subtask.completed;
await database.write(async () => {
await subtask.update((s) => {
s.completed = completing;
s.updatedAt = new Date();
});
const task = await collections.tasks.find(subtask.taskId);
await task.update((t) => {
t.updatedAt = new Date();
});
});
}
export async function undoTaskComplete(taskId: string, nextOccurrenceId: string | null): Promise<void> {
await database.write(async () => {
if (nextOccurrenceId) {
try {
const occurrence = await collections.tasks.find(nextOccurrenceId);
await deleteTaskWithSubtasks(occurrence.id);
await cancelTaskReminder(occurrence.id);
} catch {
// occurrence already gone
}
}
try {
const task = await collections.tasks.find(taskId);
if (task.completed) {
await task.update((t) => {
t.completed = false;
t.updatedAt = new Date();
});
}
} catch {
// task already gone
}
});
try {
const task = await collections.tasks.find(taskId);
await scheduleTaskReminder(task);
} catch {
// task already gone
}
}
export async function deleteTask(taskId: string): Promise<void> {
await database.write(async () => {
await deleteTaskWithSubtasks(taskId);
});
}
export async function convertTaskToSubtask(taskId: string, parentTaskId: string): 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 now = new Date();
let order = existing.length;
await collections.subtasks.create((s) => {
s.taskId = parentTaskId;
s.title = task.title;
s.description = task.description;
s.priority = task.priority;
s.completed = task.completed;
s.dueDate = task.dueDate;
s.dueTime = task.dueTime;
s.endTime = task.endTime || '';
s.allDay = task.allDay ?? false;
s.repeat = task.repeat || 'none';
s.repeatInterval = task.repeatInterval || 1;
s.repeatDays = task.repeatDays || '';
s.seriesId = task.seriesId || '';
s.reminder = task.reminder || 'none';
s.assigneeId = task.assigneeId ?? null;
s.order = order++;
s.createdAt = now;
s.updatedAt = now;
});
for (const subtask of ownSubtasks) {
await collections.subtasks.create((s) => {
s.taskId = parentTaskId;
s.title = subtask.title;
s.description = subtask.description;
s.priority = subtask.priority;
s.completed = subtask.completed;
s.dueDate = subtask.dueDate;
s.dueTime = subtask.dueTime;
s.endTime = subtask.endTime || '';
s.allDay = subtask.allDay ?? false;
s.repeat = subtask.repeat || 'none';
s.repeatInterval = subtask.repeatInterval || 1;
s.repeatDays = subtask.repeatDays || '';
s.seriesId = subtask.seriesId || '';
s.reminder = subtask.reminder || 'none';
s.assigneeId = subtask.assigneeId ?? null;
s.order = order++;
s.createdAt = now;
s.updatedAt = now;
});
}
await task.destroyPermanently();
});
}
export async function setTaskCompleted(taskId: string, completed: boolean): Promise<void> {
let nextOccurrence: any = null;
await database.write(async () => {
const task = await collections.tasks.find(taskId);
await task.update((t) => {
t.completed = completed;
t.updatedAt = new Date();
});
if (completed && task.repeat !== 'none' && task.dueDate) {
nextOccurrence = await createNextOccurrence(task);
}
});
if (nextOccurrence) await scheduleTaskReminder(nextOccurrence);
const task = await collections.tasks.find(taskId);
if (task.completed) await cancelTaskReminder(taskId);
else await scheduleTaskReminder(task);
}
async function createNextOccurrence(task: any): 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();
const next = await collections.tasks.create((t) => {
t.title = task.title;
t.description = task.description;
t.categoryId = task.categoryId;
t.priority = task.priority;
t.completed = false;
t.dueDate = nextDate.getTime();
t.dueTime = task.dueTime;
t.endTime = task.endTime || '';
t.allDay = task.allDay ?? false;
t.repeat = task.repeat;
t.repeatInterval = task.repeatInterval || 1;
t.repeatDays = task.repeatDays || '';
t.seriesId = task.seriesId || newSeriesId();
t.reminder = task.reminder || 'none';
t.assigneeId = task.assigneeId ?? null;
t.createdAt = now;
t.updatedAt = now;
});
for (const subtask of subtasks) {
await collections.subtasks.create((s) => {
s.taskId = next.id;
s.title = subtask.title;
s.description = subtask.description;
s.priority = subtask.priority;
s.completed = false;
s.dueDate = subtask.dueDate;
s.dueTime = subtask.dueTime;
s.endTime = subtask.endTime || '';
s.allDay = subtask.allDay ?? false;
s.reminder = subtask.reminder || 'none';
s.assigneeId = subtask.assigneeId ?? null;
s.order = subtask.order;
s.createdAt = now;
s.updatedAt = now;
});
}
return next;
}
export async function setTaskCategory(taskId: string, categoryId: string): Promise<void> {
await database.write(async () => {
const task = await collections.tasks.find(taskId);
await task.update((t) => {
t.categoryId = categoryId;
t.updatedAt = new Date();
});
});
}
export async function setTaskPriority(taskId: string, priority: Priority): Promise<void> {
await database.write(async () => {
const task = await collections.tasks.find(taskId);
await task.update((t) => {
t.priority = priority;
t.updatedAt = new Date();
});
});
}
export async function duplicateTask(taskId: string): Promise<void> {
let clone: any = null;
await database.write(async () => {
const task = await collections.tasks.find(taskId);
const subtasks = await collections.subtasks.query(Q.where('task_id', taskId)).fetch();
const now = new Date();
clone = await collections.tasks.create((t) => {
t.title = task.title;
t.description = task.description;
t.categoryId = task.categoryId;
t.priority = task.priority;
t.completed = false;
t.dueDate = task.dueDate;
t.dueTime = task.dueTime;
t.endTime = task.endTime || '';
t.allDay = task.allDay ?? false;
t.repeat = task.repeat;
t.repeatInterval = task.repeatInterval || 1;
t.repeatDays = task.repeatDays || '';
t.seriesId = '';
t.reminder = task.reminder || 'none';
t.assigneeId = task.assigneeId ?? null;
t.createdAt = now;
t.updatedAt = now;
});
for (const subtask of subtasks) {
await collections.subtasks.create((s) => {
s.taskId = clone.id;
s.title = subtask.title;
s.description = subtask.description;
s.priority = subtask.priority;
s.completed = false;
s.dueDate = subtask.dueDate;
s.dueTime = subtask.dueTime;
s.endTime = subtask.endTime || '';
s.allDay = subtask.allDay ?? false;
s.reminder = subtask.reminder || 'none';
s.assigneeId = subtask.assigneeId ?? null;
s.order = subtask.order;
s.createdAt = now;
s.updatedAt = now;
});
}
});
await scheduleTaskReminder(clone);
}
+29
View File
@@ -0,0 +1,29 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@/components/*": ["src/components/*"],
"@/hooks/*": ["src/hooks/*"],
"@/database/*": ["src/database/*"],
"@/models/*": ["src/models/*"],
"@/constants/*": ["src/constants/*"],
"@/types/*": ["src/types/*"],
"@/utils/*": ["src/utils/*"]
},
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"jsx": "react-native",
"moduleResolution": "bundler",
"moduleSuffixes": [".ios", ".android", ".web", ".native", ""],
"noEmit": true,
"ignoreDeprecations": "6.0"
},
"exclude": ["node_modules"]
}