This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"enabledPlugins": {
|
||||
"expo@claude-plugins-official": true
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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 |
@@ -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 }],
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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/*"],
|
||||
}
|
||||
]);
|
||||
@@ -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);
|
||||
Generated
+12226
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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'),
|
||||
};
|
||||
@@ -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' },
|
||||
],
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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' },
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user