2 Commits
Author SHA1 Message Date
tech08mag ecb7fbefb1 fixxed big ui cavia
Build APK / build (push) Canceled after 0s
2026-08-07 01:06:03 +02:00
tech08mag 3d594670b0 working backend connectivity 2026-08-06 11:43:38 +02:00
74 changed files with 4282 additions and 1238 deletions
+49 -1
View File
@@ -76,7 +76,8 @@ A minimalist, offline-first task management app built with Expo, React Native, a
| UI Components | React Native Paper + Custom SVG icons |
| Animations | React Native Reanimated |
| Date/Time | @react-native-community/datetimepicker + date-fns |
| Build | Expo Dev Client / EAS Build |
| Build | EAS Build / Gradle (local APK) |
| CI/CD | Gitea Actions (`.gitea/workflows/build-apk.yml`) |
## Project Structure
@@ -210,6 +211,8 @@ npx expo start --web
```
### Building
#### EAS Build (cloud)
```bash
# Install EAS CLI
npm install -g eas-cli
@@ -222,6 +225,51 @@ eas build --platform ios
eas build --platform android
eas build --platform web
```
Profiles are defined in `eas.json` (`development`, `preview`, `production`; production auto-increments version code).
#### Gradle (local Android APK)
The native Android project lives in `carry-your-live/android/`. Requires JDK 17+ and the Android SDK (platform 35, build-tools 35.0.0, NDK 27.1).
```bash
cd carry-your-live/android
# Debug APK (unsigned)
./gradlew assembleDebug
# Release APK (currently signed with the debug keystore)
./gradlew assembleRelease
```
Output:
```
android/app/build/outputs/apk/debug/app-debug.apk
android/app/build/outputs/apk/release/app-release.apk
```
Key notes:
- The debug keystore (`android/app/debug.keystore`) is generated by the CI pipeline and must exist for release builds — create it locally with the same command used in the pipeline (see below) if it's missing.
- Release builds use `signingConfig signingConfigs.debug` until a production keystore is configured.
- Versions are set in `android/app/build.gradle` (`versionCode`, `versionName`).
## CI/CD Pipeline (Gitea Actions)
`.gitea/workflows/build-apk.yml` builds a release APK on every push to `main` (also manually triggerable via workflow_dispatch):
1. **Checkout** and setup Node 22, JDK 17 (Temurin)
2. **Android SDK**: installs cmdline-tools, licenses, platform-tools, `platforms;android-35`, `build-tools;35.0.0`, `ndk;27.1.12297006`
3. **JS deps**: `npm ci` in `carry-your-live/`
4. **Debug keystore**: generates `carry-your-live/android/app/debug.keystore` with `keytool` (alias `androiddebugkey`, passwords `android`)
5. **Build**: `./gradlew assembleRelease` in `carry-your-live/android/`
6. **Upload**: the APK is saved as the `carry-your-live-release` artifact (downloadable from the run's artifacts page)
To run the build locally exactly as CI does:
```bash
keytool -genkeypair -v \
-keystore carry-your-live/android/app/debug.keystore \
-alias androiddebugkey -storepass android -keypass android \
-keyalg RSA -keysize 2048 -validity 10000 \
-dname "CN=Android Debug,O=Android,C=US"
cd carry-your-live/android && ./gradlew assembleRelease
```
## Sync API Specification
+1
View File
@@ -75,6 +75,7 @@ export const subtasks = pgTable('subtasks', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
taskId: text('task_id').notNull().references(() => tasks.id, { onDelete: 'cascade' }),
parentSubtaskId: text('parent_subtask_id').references(() => subtasks.id, { onDelete: 'cascade' }),
title: text('title').notNull(),
description: text('description').notNull().default(''),
priority: text('priority', { enum: ['none', 'low', 'medium', 'high', 'critical'] }).notNull().default('none'),
+22 -2
View File
@@ -11,6 +11,17 @@ const router = Router();
router.use(authMiddleware);
// Helper to build nested subtask tree
const buildSubtaskTree = (allSubtasks: any[], parentId: string | null = null): any[] => {
return allSubtasks
.filter((s) => s.parentSubtaskId === parentId)
.sort((a, b) => a.order - b.order)
.map((s) => ({
...s,
subtasks: buildSubtaskTree(allSubtasks, s.id),
}));
};
router.get('/task/:taskId', asyncHandler(async (req: Request, res: Response) => {
const userId = req.user!.userId;
@@ -31,7 +42,9 @@ router.get('/task/:taskId', asyncHandler(async (req: Request, res: Response) =>
.where(and(eq(subtasks.taskId, req.params.taskId), eq(subtasks.userId, userId)))
.orderBy(asc(subtasks.order));
res.json({ subtasks: taskSubtasks });
const nestedSubtasks = buildSubtaskTree(taskSubtasks);
res.json({ subtasks: nestedSubtasks });
}));
router.post('/task/:taskId', asyncHandler(async (req: Request, res: Response) => {
@@ -49,10 +62,16 @@ router.post('/task/:taskId', asyncHandler(async (req: Request, res: Response) =>
}
const now = Date.now();
const parentSubtaskId = data.parentSubtaskId || null;
const maxOrder = await db
.select({ order: subtasks.order })
.from(subtasks)
.where(and(eq(subtasks.taskId, req.params.taskId), eq(subtasks.userId, userId)))
.where(and(
eq(subtasks.taskId, req.params.taskId),
eq(subtasks.userId, userId),
parentSubtaskId ? eq(subtasks.parentSubtaskId, parentSubtaskId) : eq(subtasks.parentSubtaskId, null)
))
.orderBy(desc(subtasks.order))
.limit(1);
@@ -62,6 +81,7 @@ router.post('/task/:taskId', asyncHandler(async (req: Request, res: Response) =>
id: subtaskId,
userId,
taskId: req.params.taskId,
parentSubtaskId,
title: data.title,
description: data.description ?? '',
priority: data.priority ?? 'none',
+2
View File
@@ -69,6 +69,7 @@ export const subtaskCreateSchema = z.object({
reminders: z.string().max(100).optional(),
assigneeId: z.string().nullable().optional(),
order: z.number().int().min(0).optional(),
parentSubtaskId: z.string().nullable().optional(),
});
export const subtaskUpdateSchema = z.object({
@@ -88,6 +89,7 @@ export const subtaskUpdateSchema = z.object({
reminders: z.string().max(100).optional(),
assigneeId: z.string().nullable().optional(),
order: z.number().int().min(0).optional(),
parentSubtaskId: z.string().nullable().optional(),
});
export const userSettingsSchema = z.object({
+12
View File
@@ -0,0 +1,12 @@
./gradlew assembleRelease
/home/tech08mag/Android/Sdk/platform-tools/adb push /home/tech08mag/Code/carry-your-live/carry-your-live/android/app/build/outputs/apk/release/app-release.apk /sdcard/Download/
./gradlew installDebug
./gradlew assembleRelease Build release APK (what you ran)
./gradlew bundleRelease Build release AAB (for Play Store)
./gradlew assembleDebug Build debug APK
./gradlew installRelease Build + install release APK to connected device
./gradlew installDebug Build + install debug APK
./gradlew clean Clean build outputs
./gradlew tasks List all available tasks
+9 -1
View File
@@ -10,6 +10,7 @@
"supportsTablet": true
},
"android": {
"softwareKeyboardLayoutMode": "resize",
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/android-icon-foreground.png",
@@ -30,12 +31,19 @@
"icon": "./assets/android-icon-foreground.png",
"color": "#1E88E5"
}
]
],
"./plugins/withQuickAddWidget"
],
"extra": {
"eas": {
"projectId": "93a56631-01e5-45ab-9de1-7e6fb863c0a9"
}
},
"runtimeVersion": {
"policy": "appVersion"
},
"updates": {
"url": "https://u.expo.dev/93a56631-01e5-45ab-9de1-7e6fb863c0a9"
}
}
}
+13 -2
View File
@@ -1,10 +1,12 @@
import { Tabs } from 'expo-router';
import React from 'react';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { TabBarIcon } from '@/components/TabBarIcon';
import { useSettings } from '@/theme';
export default function TabLayout() {
const { theme } = useSettings();
const insets = useSafeAreaInsets();
return (
<Tabs
@@ -15,8 +17,8 @@ export default function TabLayout() {
backgroundColor: theme.tabBarBg,
borderTopWidth: 1,
borderTopColor: theme.border,
height: 64,
paddingBottom: 0,
height: 64 + insets.bottom,
paddingBottom: insets.bottom,
},
tabBarLabelStyle: {
fontSize: 11,
@@ -43,6 +45,15 @@ export default function TabLayout() {
),
}}
/>
<Tabs.Screen
name="stats"
options={{
title: 'Stats',
tabBarIcon: ({ focused, color }) => (
<TabBarIcon name="stats" focused={focused} color={color} />
),
}}
/>
<Tabs.Screen
name="settings"
options={{
+79 -49
View File
@@ -1,15 +1,17 @@
import React, { useMemo, useRef, useState } from 'react';
import React, { useMemo, useRef, useState, useCallback } 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 { useTaskModals } from '@/hooks/useTaskModals';
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 { format, startOfMonth, endOfMonth, eachDayOfInterval, isSameDay, isSameMonth, addMonths, isToday, startOfDay } from 'date-fns';
import Svg, { Path } from 'react-native-svg';
import type { ThemeColors } from '@/theme';
const DAY_WIDTH = 44;
const DAY_GAP = 6;
@@ -17,6 +19,7 @@ const DAY_GAP = 6;
export default function CalendarScreen() {
const router = useRouter();
const { theme } = useSettings();
const { modals, openTaskMenu, openTaskDelete } = useTaskModals();
const [visibleMonth, setVisibleMonth] = useState(() => new Date());
const [selectedDate, setSelectedDate] = useState(() => new Date());
const stripRef = useRef<ScrollView>(null);
@@ -28,46 +31,57 @@ export default function CalendarScreen() {
const { tasks, loading } = useTasksByDate(selectedDate);
const handleDayPress = (day: Date) => {
const handleDayPress = useCallback((day: Date) => {
setSelectedDate(day);
if (!isSameMonth(day, visibleMonth)) {
setVisibleMonth(day);
}
};
}, [visibleMonth]);
const handlePrevMonth = () => {
const handlePrevMonth = useCallback(() => {
const prev = addMonths(visibleMonth, -1);
setVisibleMonth(prev);
if (!isSameMonth(selectedDate, prev)) {
setSelectedDate(startOfMonth(prev));
}
};
}, [visibleMonth, selectedDate]);
const handleNextMonth = () => {
const handleNextMonth = useCallback(() => {
const next = addMonths(visibleMonth, 1);
setVisibleMonth(next);
if (!isSameMonth(selectedDate, next)) {
setSelectedDate(startOfMonth(next));
}
};
}, [visibleMonth, selectedDate]);
const handleToggleComplete = async (taskId: string) => {
const handleToggleComplete = useCallback(async (taskId: string) => {
await toggleTaskComplete(taskId);
};
}, []);
const today = new Date();
const scrollToDay = (day: Date) => {
const scrollToDay = useCallback((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 });
}
};
}, [days]);
React.useEffect(() => {
scrollToDay(isSameMonth(selectedDate, visibleMonth) ? selectedDate : today);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visibleMonth]);
const target = isSameMonth(selectedDate, visibleMonth) ? selectedDate : startOfDay(new Date());
scrollToDay(target);
}, [visibleMonth, scrollToDay, selectedDate]);
const renderTask = useCallback(
({ item }: { item: TaskData }) => (
<TaskItem
task={item}
onToggle={() => handleToggleComplete(item.id)}
onDelete={() => openTaskDelete(item)}
onPress={() => router.push({ pathname: '/task-detail', params: { id: item.id } })}
onMenuOpen={() => openTaskMenu(item)}
/>
),
[handleToggleComplete, openTaskDelete, openTaskMenu, router]
);
return (
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
@@ -79,30 +93,16 @@ export default function CalendarScreen() {
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>
);
})}
{days.map((day) => (
<DayButton
key={day.toISOString()}
day={day}
selected={isSameDay(day, selectedDate)}
current={isToday(day) && !isSameDay(day, selectedDate)}
onPress={handleDayPress}
theme={theme}
/>
))}
</ScrollView>
<View style={styles.monthRow}>
@@ -122,14 +122,8 @@ export default function CalendarScreen() {
<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} />}
renderItem={renderTask}
ItemSeparatorComponent={MemoSeparator}
ListEmptyComponent={
loading ? (
<View style={styles.emptyState}>
@@ -149,10 +143,46 @@ export default function CalendarScreen() {
dueDate={selectedDate.getTime()}
placeholder={`Add task for ${format(selectedDate, 'MMM d')}`}
/>
{modals(() => {})}
</SafeAreaView>
);
}
interface DayButtonProps {
day: Date;
selected: boolean;
current: boolean;
onPress: (day: Date) => void;
theme: ThemeColors;
}
const DayButton = React.memo(function DayButton({ day, selected, current, onPress, theme }: DayButtonProps) {
return (
<TouchableOpacity
style={[
styles.dayButton,
{ backgroundColor: theme.card, borderColor: theme.borderStrong },
current && { borderColor: theme.accent, borderWidth: 1.5 },
selected && { backgroundColor: theme.accent, borderColor: theme.accent },
]}
onPress={() => onPress(day)}
activeOpacity={0.7}
>
<Text style={[styles.dayWeekday, { color: theme.textMuted }, selected && styles.dayTextSelected]}>
{format(day, 'EEE').charAt(0)}
</Text>
<Text style={[styles.dayNumber, { color: theme.text }, selected && styles.dayTextSelected]}>
{format(day, 'd')}
</Text>
</TouchableOpacity>
);
});
const MemoSeparator = React.memo(function Separator() {
return <View style={styles.separator} />;
});
const styles = StyleSheet.create({
container: {
flex: 1,
+7 -3
View File
@@ -11,7 +11,6 @@ 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 (
@@ -24,8 +23,10 @@ export default function TasksScreen() {
return (
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
<Header title="TODO" showLogo={true} />
<CategoryFilter selected={selectedCategory} onSelect={setSelectedCategory} />
<TaskList categoryId={selectedCategory} onSelectionChange={setSelectionActive} />
<View style={styles.categoryFilterWrapper}>
<CategoryFilter selected={selectedCategory} onSelect={setSelectedCategory} />
</View>
<TaskList categoryId={selectedCategory} />
<QuickAddBar />
</SafeAreaView>
);
@@ -35,6 +36,9 @@ const styles = StyleSheet.create({
container: {
flex: 1,
},
categoryFilterWrapper: {
height: 36,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
+49 -8
View File
@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet, SafeAreaView, ScrollView, Switch, TouchableOpacity } from 'react-native';
import { View, Text, StyleSheet, SafeAreaView, ScrollView, Switch, TouchableOpacity, Alert, Linking } from 'react-native';
import { Header } from '@/components/Header';
import { ListItem } from '@/components/ListItem';
import { OptionPickerModal } from '@/components/OptionPickerModal';
@@ -7,23 +7,48 @@ import { CategoryEditorModal } from '@/components/CategoryEditorModal';
import { SyncModal } from '@/components/SyncModal';
import { FriendsModal } from '@/components/FriendsModal';
import { LegalModal } from '@/components/LegalModal';
import { ServerUrlModal } from '@/components/ServerUrlModal';
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 { checkForUpdates, getCurrentAppVersion } from '@/services/updates';
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 { theme, notifications, setNotifications, defaultCategoryId, setDefaultCategoryId, sortBy, setSortBy, reminderPreference, setReminderPreference, apiUrl } = 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 [serverUrlVisible, setServerUrlVisible] = useState(false);
const [syncSubtitle, setSyncSubtitle] = useState('Checking...');
const [updateSubtitle, setUpdateSubtitle] = useState('Tap to check');
const handleCheckUpdates = async () => {
setUpdateSubtitle('Checking...');
const update = await checkForUpdates();
if (!update) {
setUpdateSubtitle('Up to date');
Alert.alert('Up to date', `You're running the latest version (${getCurrentAppVersion()}).`);
return;
}
setUpdateSubtitle(`${update.version} available`);
const url = update.apkUrl ?? update.releaseUrl;
Alert.alert('Update available', `Version ${update.version} is available for download.`, [
{ text: 'Later', style: 'cancel' },
{
text: 'Download',
onPress: () => {
if (url) Linking.openURL(url).catch(() => {});
},
},
]);
};
const refreshSyncStatus = async () => {
const token = await getAuthToken();
@@ -43,6 +68,7 @@ export default function SettingsScreen() {
};
React.useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
refreshSyncStatus();
}, []);
@@ -50,12 +76,12 @@ export default function SettingsScreen() {
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 handleDefaultCategory = (value: string | string[]) => {
setDefaultCategoryId(Array.isArray(value) ? value[0] : value);
};
const handleReminderPreference = (value: string) => {
setReminderPreference(value as typeof reminderPreference);
const handleReminderPreference = (value: string | string[]) => {
setReminderPreference((Array.isArray(value) ? value[0] : value) as typeof reminderPreference);
};
const editorVisible = editingCategory !== null;
@@ -136,10 +162,23 @@ export default function SettingsScreen() {
onPress={() => setFriendsVisible(true)}
showChevron
/>
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>Server</Text>
<ListItem
title="Backend URL"
subtitle={apiUrl}
onPress={() => setServerUrlVisible(true)}
showChevron
/>
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>About</Text>
<ListItem
title="Check for Updates"
subtitle={updateSubtitle}
onPress={handleCheckUpdates}
showChevron
/>
<ListItem
title="Version"
subtitle="1.0.0"
subtitle={getCurrentAppVersion()}
/>
<ListItem
title="Privacy Policy"
@@ -191,6 +230,8 @@ export default function SettingsScreen() {
<FriendsModal visible={friendsVisible} onClose={() => setFriendsVisible(false)} />
<ServerUrlModal visible={serverUrlVisible} onClose={() => setServerUrlVisible(false)} />
<LegalModal
visible={legalVisible !== null}
type={legalVisible}
@@ -213,7 +254,7 @@ const styles = StyleSheet.create({
flex: 1,
},
content: {
flex: 1,
flexGrow: 1,
paddingHorizontal: 16,
paddingTop: 8,
},
+201
View File
@@ -0,0 +1,201 @@
import React from 'react';
import { View, Text, StyleSheet, SafeAreaView, ScrollView } from 'react-native';
import { Header } from '@/components/Header';
import { useStats } from '@/hooks/useStats';
import { useSettings } from '@/theme';
export default function StatsScreen() {
const { theme } = useSettings();
const stats = useStats();
const maxDaily = Math.max(1, ...stats.daily.map((d) => d.count));
return (
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
<Header title="Stats" showLogo={true} />
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
<View style={[styles.card, { backgroundColor: theme.card }]}>
<Text style={[styles.cardTitle, { color: theme.textMuted }]}>Overview</Text>
<View style={styles.summaryGrid}>
<View style={styles.summaryItem}>
<Text style={[styles.summaryValue, { color: theme.accent }]}>{stats.totalCompleted}</Text>
<Text style={[styles.summaryLabel, { color: theme.textMuted }]}>Completed</Text>
</View>
<View style={styles.summaryItem}>
<Text style={[styles.summaryValue, { color: theme.text }]}>{stats.completedLast7}</Text>
<Text style={[styles.summaryLabel, { color: theme.textMuted }]}>Last 7 days</Text>
</View>
<View style={styles.summaryItem}>
<Text style={[styles.summaryValue, { color: theme.text }]}>{stats.completedLast30}</Text>
<Text style={[styles.summaryLabel, { color: theme.textMuted }]}>Last 30 days</Text>
</View>
<View style={styles.summaryItem}>
<Text style={[styles.summaryValue, { color: theme.text }]}>{stats.completionRate}%</Text>
<Text style={[styles.summaryLabel, { color: theme.textMuted }]}>Success rate</Text>
</View>
<View style={styles.summaryItem}>
<Text style={[styles.summaryValue, { color: theme.text }]}>{stats.currentStreak}</Text>
<Text style={[styles.summaryLabel, { color: theme.textMuted }]}>Day streak</Text>
</View>
<View style={styles.summaryItem}>
<Text style={[styles.summaryValue, { color: stats.overdueCount > 0 ? theme.accent : theme.text }]}>
{stats.overdueCount}
</Text>
<Text style={[styles.summaryLabel, { color: theme.textMuted }]}>Overdue</Text>
</View>
</View>
</View>
<View style={[styles.card, { backgroundColor: theme.card }]}>
<Text style={[styles.cardTitle, { color: theme.textMuted }]}>Last 7 days</Text>
<View style={styles.chartRow}>
{stats.daily.map((d, i) => {
const height = (d.count / maxDaily) * BASE_HEIGHT;
return (
<View key={i} style={styles.chartCol}>
<View style={styles.chartBarTrack}>
<View
style={[
styles.chartBar,
{ height: Math.max(4, height), backgroundColor: d.count > 0 ? theme.accent : theme.cardAlt },
]}
/>
</View>
<Text style={[styles.chartLabel, { color: theme.textFaint }]}>{d.label}</Text>
</View>
);
})}
</View>
</View>
<View style={[styles.card, { backgroundColor: theme.card }]}>
<Text style={[styles.cardTitle, { color: theme.textMuted }]}>By category</Text>
{stats.byCategory.length === 0 ? (
<Text style={[styles.emptyText, { color: theme.textFaint }]}>No completed tasks yet</Text>
) : (
stats.byCategory.map((c) => (
<View key={c.id} style={styles.row}>
<View style={[styles.dot, { backgroundColor: c.color }]} />
<Text style={[styles.rowLabel, { color: theme.text }]}>{c.name}</Text>
<Text style={[styles.rowValue, { color: theme.textMuted }]}>{c.count}</Text>
</View>
))
)}
</View>
<View style={[styles.card, { backgroundColor: theme.card }]}>
<Text style={[styles.cardTitle, { color: theme.textMuted }]}>By priority</Text>
{stats.byPriority.length === 0 ? (
<Text style={[styles.emptyText, { color: theme.textFaint }]}>No completed tasks yet</Text>
) : (
stats.byPriority.map((p) => (
<View key={p.priority} style={styles.row}>
<View style={[styles.dot, { backgroundColor: priorityColor(p.priority) }]} />
<Text style={[styles.rowLabel, { color: theme.text }]}>{p.label}</Text>
<Text style={[styles.rowValue, { color: theme.textMuted }]}>{p.count}</Text>
</View>
))
)}
</View>
</ScrollView>
</SafeAreaView>
);
}
const BASE_HEIGHT = 90;
function priorityColor(priority: string): string {
switch (priority) {
case 'high':
return '#EF5350';
case 'medium':
return '#FFA726';
case 'low':
return '#66BB6A';
default:
return '#9E9E9E';
}
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
content: {
padding: 16,
paddingBottom: 32,
gap: 12,
},
card: {
borderRadius: 16,
padding: 16,
},
cardTitle: {
fontSize: 13,
fontWeight: '600',
textTransform: 'uppercase',
letterSpacing: 0.5,
marginBottom: 14,
},
summaryGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
},
summaryItem: {
width: '33.33%',
marginBottom: 16,
},
summaryValue: {
fontSize: 26,
fontWeight: '700',
},
summaryLabel: {
fontSize: 12,
marginTop: 2,
},
chartRow: {
flexDirection: 'row',
alignItems: 'flex-end',
gap: 8,
},
chartCol: {
flex: 1,
alignItems: 'center',
},
chartBarTrack: {
height: BASE_HEIGHT,
justifyContent: 'flex-end',
width: '100%',
},
chartBar: {
width: '100%',
borderRadius: 6,
minHeight: 4,
},
chartLabel: {
fontSize: 11,
marginTop: 6,
},
row: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 8,
},
dot: {
width: 10,
height: 10,
borderRadius: 5,
marginRight: 10,
},
rowLabel: {
flex: 1,
fontSize: 15,
},
rowValue: {
fontSize: 15,
fontWeight: '600',
},
emptyText: {
fontSize: 14,
},
});
+22 -1
View File
@@ -1,11 +1,31 @@
import { Stack } from 'expo-router';
import { Stack, useRouter } 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';
import { UpdateNotifier } from '@/components/UpdateNotifier';
import { Linking } from 'react-native';
import { useEffect } from 'react';
import { requestQuickAddFocus } from '@/utils/quickAddFocus';
function RootNavigator() {
const router = useRouter();
useEffect(() => {
const handleUrl = (url: string | null) => {
if (!url || !url.includes('://quick-add')) return;
router.replace('/(tabs)');
requestQuickAddFocus();
};
Linking.getInitialURL().then(handleUrl).catch(() => {});
const subscription = Linking.addEventListener('url', ({ url }) => handleUrl(url));
return () => {
subscription.remove();
};
}, [router]);
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<StatusBar style="light" />
@@ -23,6 +43,7 @@ export default function RootLayout() {
<SettingsProvider>
<DatabaseProvider>
<FriendsProvider>
<UpdateNotifier />
<RootNavigator />
</FriendsProvider>
</DatabaseProvider>
+10 -6
View File
@@ -35,6 +35,7 @@ const taskSchema = z.object({
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']),
reminders: z.string().optional(),
assigneeId: z.string().nullable().optional(),
subtasks: z.array(z.object({ title: z.string(), _key: z.string().optional() })).optional(),
});
@@ -69,6 +70,7 @@ export default function AddTaskScreen() {
repeatInterval: 1,
repeatDays: [],
reminder: 'none',
reminders: '',
assigneeId: null,
subtasks: [],
},
@@ -88,6 +90,7 @@ export default function AddTaskScreen() {
const repeatInterval = watch('repeatInterval') ?? 1;
const repeatDays = watch('repeatDays') ?? [];
const reminder = watch('reminder');
const reminders = watch('reminders');
const dueDate = watch('dueDate');
const assigneeId = watch('assigneeId');
@@ -124,6 +127,7 @@ export default function AddTaskScreen() {
t.repeatDays = (data.repeatDays || []).join(',');
t.seriesId = seriesId;
t.reminder = data.reminder || 'none';
t.reminders = data.reminders || '';
t.assigneeId = data.assigneeId ?? null;
t.createdAt = now;
t.updatedAt = now;
@@ -172,7 +176,7 @@ export default function AddTaskScreen() {
<FormProvider {...methods}>
<KeyboardAvoidingView
style={styles.keyboardAvoiding}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<ScrollView
contentContainerStyle={styles.scrollContent}
@@ -213,9 +217,9 @@ export default function AddTaskScreen() {
}}
/>
<ReminderSelector
value={reminder}
value={reminders}
hasDueDate={!!dueDate}
onChange={(value) => setValue('reminder', value)}
onChange={(value) => setValue('reminders', value)}
/>
<AssigneeSelector
value={assigneeId}
@@ -255,8 +259,8 @@ const styles = StyleSheet.create({
},
scrollContent: {
paddingHorizontal: 16,
paddingTop: 8,
paddingBottom: 100,
gap: 24,
paddingTop: 12,
paddingBottom: 120,
gap: 20,
},
});
+260
View File
@@ -0,0 +1,260 @@
import React from 'react';
import { View, Text, StyleSheet, SafeAreaView, ScrollView, KeyboardAvoidingView, Platform, TouchableOpacity, Alert } from 'react-native';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { Header } from '@/components/Header';
import { TaskNameInput } from '@/components/TaskNameInput';
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 { 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 { collections } from '@/database';
import { SubtaskFormData, Reminder, parseReminders, toRemindersString } from '@/types';
import { useSettings } from '@/theme';
import { updateSubtask, deleteSubtask } from '@/utils/taskActions';
import { useFriends } from '@/hooks/useFriends';
import Svg, { Path } from 'react-native-svg';
const subtaskSchema = z.object({
title: z.string().trim().min(1, 'Subtask name is required').max(100),
description: z.string().max(1000).optional(),
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']),
reminders: z.string().optional(),
assigneeId: z.string().nullable().optional(),
});
export default function SubtaskDetailScreen() {
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<SubtaskFormData>({
resolver: zodResolver(subtaskSchema),
defaultValues: {
title: '',
description: '',
priority: 'none',
dueDate: null,
dueTime: '',
endTime: '',
allDay: false,
repeat: 'none',
repeatInterval: 1,
repeatDays: [],
reminder: 'none',
reminders: '',
assigneeId: null,
},
});
const { handleSubmit, watch, setValue, control, reset, formState: { errors } } = methods;
const priority = watch('priority');
const repeat = watch('repeat');
const repeatInterval = watch('repeatInterval') ?? 1;
const repeatDays = watch('repeatDays') ?? [];
const reminder = watch('reminder');
const reminders = watch('reminders');
const dueDate = watch('dueDate');
const assigneeId = watch('assigneeId');
React.useEffect(() => {
if (!id || !isReady) return;
let mounted = true;
(async () => {
try {
const subtask = await collections.subtasks.find(id);
if (!mounted) return;
reset({
title: subtask.title,
description: subtask.description,
priority: subtask.priority,
dueDate: subtask.dueDate ? new Date(subtask.dueDate) : null,
dueTime: subtask.dueTime,
endTime: subtask.endTime || '',
allDay: subtask.allDay ?? false,
repeat: subtask.repeat,
repeatInterval: subtask.repeatInterval || 1,
repeatDays: (subtask.repeatDays || '').split(',').map(Number).filter((d) => !Number.isNaN(d)),
reminder: (subtask.reminder || 'none') as Reminder,
reminders: subtask.reminders || '',
assigneeId: subtask.assigneeId ?? null,
});
setLoaded(true);
} catch {
if (mounted) setNotFound(true);
}
})();
return () => { mounted = false; };
}, [id, isReady, reset]);
const onSubmit = async (data: SubtaskFormData) => {
if (!id || !isReady) return;
await updateSubtask(id, {
title: data.title,
description: data.description || '',
priority: data.priority,
dueDate: data.dueDate ? data.dueDate.getTime() : 0,
dueTime: data.dueTime || '',
endTime: data.endTime || '',
allDay: data.allDay ?? false,
repeat: data.repeat,
repeatInterval: data.repeatInterval || 1,
repeatDays: (data.repeatDays || []).join(','),
reminder: data.reminder || 'none',
assigneeId: data.assigneeId ?? null,
});
router.back();
};
const confirmDelete = () => {
Alert.alert('Delete Subtask', 'This action cannot be undone.', [
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
if (!id) return;
await deleteSubtask(id);
router.back();
},
},
]);
};
if (!isReady || !loaded) {
return (
<View style={[styles.loadingContainer, { backgroundColor: theme.background }]}>
<Text style={{ color: theme.textFaint }}>{notFound ? 'Subtask not found' : 'Loading...'}</Text>
</View>
);
}
return (
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
<Header
title="Edit Subtask"
showLogo={true}
rightAction={
<TouchableOpacity onPress={confirmDelete} activeOpacity={0.7} style={[styles.deleteButton, { borderColor: theme.accentBorder, backgroundColor: theme.accentSoft }]}>
<Svg width={22} height={22} viewBox="0 0 24 24">
<Path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13" stroke={theme.accent} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" fill="none" />
</Svg>
</TouchableOpacity>
}
/>
<FormProvider {...methods}>
<KeyboardAvoidingView
style={styles.keyboardAvoiding}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<ScrollView
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
>
<Controller
control={control}
name="title"
render={({ field }) => (
<TaskNameInput
value={field.value}
onChangeText={field.onChange}
onBlur={field.onBlur}
error={errors.title?.message}
/>
)}
/>
<PrioritySelector
value={priority}
onChange={(value) => setValue('priority', value)}
/>
<DateTimePickerComponent control={control as any} />
<RepeatSelector
value={repeat}
interval={repeatInterval}
days={repeatDays}
onChange={(nextRepeat, nextInterval, nextDays) => {
setValue('repeat', nextRepeat);
setValue('repeatInterval', nextInterval);
setValue('repeatDays', nextDays);
}}
/>
<ReminderSelector
value={reminders}
hasDueDate={!!dueDate}
onChange={(value) => setValue('reminders', 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,
},
deleteButton: {
width: 36,
height: 36,
borderRadius: 12,
borderWidth: 1,
alignItems: 'center',
justifyContent: 'center',
},
});
+145 -40
View File
@@ -22,9 +22,9 @@ 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 { scheduleTaskReminder } from '@/services/notifications';
import { useFriends } from '@/hooks/useFriends';
import Svg, { Path } from 'react-native-svg';
import Svg, { Path, Circle } from 'react-native-svg';
const taskSchema = z.object({
title: z.string().trim().min(1, 'Task name is required').max(100),
@@ -39,10 +39,38 @@ const taskSchema = z.object({
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']),
reminders: z.string().optional(),
assigneeId: z.string().nullable().optional(),
subtasks: z.array(z.object({ title: z.string(), _key: z.string().optional() })).optional(),
});
function CollapsibleSection({ title, children, defaultExpanded = false, icon }: { title: string; children: React.ReactNode; defaultExpanded?: boolean; icon: React.ReactNode }) {
const { theme } = useSettings();
const [expanded, setExpanded] = React.useState(defaultExpanded);
return (
<View style={styles.section}>
<TouchableOpacity style={[styles.sectionHeader, { backgroundColor: theme.card, borderColor: theme.border }]} onPress={() => setExpanded(!expanded)} activeOpacity={0.8}>
<View style={styles.sectionHeaderLeft}>
{icon}
<Text style={[styles.sectionTitle, { color: theme.text }]}>{title}</Text>
</View>
<Svg width={20} height={20} viewBox="0 0 24 24">
<Path
d="M6 9l6 6 6-6"
stroke={theme.textMuted}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</Svg>
</TouchableOpacity>
{expanded && <View style={styles.sectionContent}>{children}</View>}
</View>
);
}
export default function TaskDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const { isReady } = useDatabase();
@@ -80,6 +108,7 @@ export default function TaskDetailScreen() {
const repeatInterval = watch('repeatInterval') ?? 1;
const repeatDays = watch('repeatDays') ?? [];
const reminder = watch('reminder');
const reminders = watch('reminders');
const dueDate = watch('dueDate');
const assigneeId = watch('assigneeId');
const [deleteModalVisible, setDeleteModalVisible] = React.useState(false);
@@ -224,7 +253,7 @@ export default function TaskDetailScreen() {
<FormProvider {...methods}>
<KeyboardAvoidingView
style={styles.keyboardAvoiding}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<ScrollView
contentContainerStyle={styles.scrollContent}
@@ -253,38 +282,89 @@ export default function TaskDetailScreen() {
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}
<CollapsibleSection title="Date & Time" icon={
<Svg width={20} height={20} viewBox="0 0 24 24">
<Circle cx={12} cy={12} r={10} stroke={theme.accent} strokeWidth={1.5} fill="none" />
<Path d="M12 6v6l4 2" stroke={theme.accent} strokeWidth={1.5} strokeLinecap="round" />
</Svg>
} defaultExpanded={!!dueDate}>
<DateTimePickerComponent control={control} />
</CollapsibleSection>
<CollapsibleSection title="Repeat" icon={
<Svg width={20} height={20} 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.accent}
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
)}
/>
</Svg>
} defaultExpanded={repeat !== 'none'}>
<RepeatSelector
value={repeat}
interval={repeatInterval}
days={repeatDays}
onChange={(nextRepeat, nextInterval, nextDays) => {
setValue('repeat', nextRepeat);
setValue('repeatInterval', nextInterval);
setValue('repeatDays', nextDays);
}}
/>
</CollapsibleSection>
<CollapsibleSection title="Reminder" icon={
<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={theme.accent}
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</Svg>
} defaultExpanded={!!reminders && reminders !== ''}>
<ReminderSelector
value={reminders}
hasDueDate={!!dueDate}
onChange={(value) => setValue('reminders', value)}
/>
</CollapsibleSection>
<CollapsibleSection title="Assignee" icon={
<Svg width={20} height={20} viewBox="0 0 24 24">
<Circle cx={12} cy={12} r={10} stroke={theme.accent} strokeWidth={1.5} fill="none" />
<Path d="M12 10v6M12 19v1" stroke={theme.accent} strokeWidth={1.5} strokeLinecap="round" />
</Svg>
} defaultExpanded={!!assigneeId}>
<AssigneeSelector
value={assigneeId}
onChange={(value: string | null) => setValue('assigneeId', value)}
friends={friends.map((f) => f.username)}
/>
</CollapsibleSection>
<CollapsibleSection title="Description" icon={
<Svg width={20} height={20} viewBox="0 0 24 24">
<Path d="M4 6h16M4 12h16M4 18h10" stroke={theme.accent} strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
</Svg>
} defaultExpanded={!!methods.getValues('description')}>
<Controller
control={control}
name="description"
render={({ field }) => (
<DescriptionInput
value={field.value ?? ''}
onChangeText={field.onChange}
onBlur={field.onBlur}
/>
)}
/>
</CollapsibleSection>
</ScrollView>
</KeyboardAvoidingView>
<FormButtons onSubmit={handleSubmit(onSubmit)} submitLabel="Save" />
@@ -315,15 +395,40 @@ const styles = StyleSheet.create({
flex: 1,
},
scrollContent: {
paddingHorizontal: 16,
paddingHorizontal: 12,
paddingTop: 8,
paddingBottom: 100,
gap: 24,
paddingBottom: 80,
gap: 12,
},
section: {
gap: 6,
},
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 10,
paddingHorizontal: 12,
borderRadius: 10,
borderWidth: 1,
},
sectionHeaderLeft: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
sectionTitle: {
fontSize: 14,
fontWeight: '600',
},
sectionContent: {
paddingHorizontal: 2,
gap: 6,
},
deleteButton: {
width: 36,
height: 36,
borderRadius: 12,
width: 32,
height: 32,
borderRadius: 10,
borderWidth: 1,
alignItems: 'center',
justifyContent: 'center',
+16
View File
@@ -2,9 +2,25 @@
const { defineConfig } = require('eslint/config');
const expoConfig = require("eslint-config-expo/flat");
const platformExtensions = [];
for (const platform of ['.android', '.ios', '.web', '.native', '']) {
for (const base of ['.ts', '.tsx', '.d.ts']) {
platformExtensions.push(`${platform}${base}`);
}
}
module.exports = defineConfig([
expoConfig,
{
ignores: ["dist/*"],
},
{
settings: {
'import/resolver': {
typescript: {
extensions: platformExtensions,
},
},
},
}
]);
+219
View File
@@ -7,6 +7,7 @@
"": {
"name": "carry-your-live",
"version": "1.0.0",
"hasInstallScript": true,
"dependencies": {
"@hookform/resolvers": "^3.3.4",
"@nozbe/watermelondb": "^0.28.1-0",
@@ -21,6 +22,7 @@
"expo-router": "~57.0.10",
"expo-sqlite": "~57.0.1",
"expo-status-bar": "~57.0.1",
"expo-updates": "~57.0.12",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-hook-form": "^7.51.5",
@@ -40,6 +42,7 @@
"@types/react": "~19.2.2",
"eslint": "^9.0.0",
"eslint-config-expo": "~57.0.1",
"patch-package": "^8.0.1",
"prettier": "^3.9.6",
"typescript": "~6.0.3"
}
@@ -3594,6 +3597,13 @@
"node": ">=10.0.0"
}
},
"node_modules/@yarnpkg/lockfile": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz",
"integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==",
"dev": true,
"license": "BSD-2-Clause"
},
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
@@ -5965,6 +5975,12 @@
"expo": "*"
}
},
"node_modules/expo-eas-client": {
"version": "57.0.1",
"resolved": "https://registry.npmjs.org/expo-eas-client/-/expo-eas-client-57.0.1.tgz",
"integrity": "sha512-4w51+zsl/ziUHQMJgLgUdgsNhRPAwHBfySpPB1hpWU21X74QS9T4SqDftaRnrDagn/DfcrXUMJvHbDQUxLPJNA==",
"license": "MIT"
},
"node_modules/expo-font": {
"version": "57.0.1",
"resolved": "https://registry.npmjs.org/expo-font/-/expo-font-57.0.1.tgz",
@@ -6297,6 +6313,12 @@
"react-native": "*"
}
},
"node_modules/expo-structured-headers": {
"version": "57.0.0",
"resolved": "https://registry.npmjs.org/expo-structured-headers/-/expo-structured-headers-57.0.0.tgz",
"integrity": "sha512-//t9UNPbJSEysc2x4VKJG/u7Osvv5DYJWsET5bqt/B+qcD1by/JXvSQzX3Q/YAgA96xFPontrz6OAPLbO4JKEA==",
"license": "MIT"
},
"node_modules/expo-symbols": {
"version": "57.0.1",
"resolved": "https://registry.npmjs.org/expo-symbols/-/expo-symbols-57.0.1.tgz",
@@ -6313,6 +6335,43 @@
"react-native": "*"
}
},
"node_modules/expo-updates": {
"version": "57.0.12",
"resolved": "https://registry.npmjs.org/expo-updates/-/expo-updates-57.0.12.tgz",
"integrity": "sha512-ZFsW8Mi9qFrrYPSXF++1FXejjflRdgbVPzaSJJATuHelVmIIb3D98gCO9sIsaLPHO1RCQVj1ltkj51VnwVL+4g==",
"license": "MIT",
"dependencies": {
"@expo/code-signing-certificates": "^0.0.6",
"@expo/plist": "^0.8.1",
"@expo/spawn-async": "^1.8.0",
"arg": "^4.1.0",
"chalk": "^4.1.2",
"debug": "^4.3.4",
"expo-eas-client": "~57.0.1",
"expo-manifests": "~57.0.1",
"expo-structured-headers": "~57.0.0",
"expo-updates-interface": "~57.0.1",
"getenv": "^2.0.0",
"glob": "^13.0.0",
"ignore": "^5.3.1",
"nullthrows": "^1.1.1",
"resolve-from": "^5.0.0"
},
"bin": {
"expo-updates": "bin/cli.js"
},
"peerDependencies": {
"expo": "*",
"expo-dev-client": "*",
"react": "*",
"react-native": "*"
},
"peerDependenciesMeta": {
"expo-dev-client": {
"optional": true
}
}
},
"node_modules/expo-updates-interface": {
"version": "57.0.1",
"resolved": "https://registry.npmjs.org/expo-updates-interface/-/expo-updates-interface-57.0.1.tgz",
@@ -6322,6 +6381,12 @@
"expo": "*"
}
},
"node_modules/expo-updates/node_modules/arg": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
"license": "MIT"
},
"node_modules/expo/node_modules/@expo/cli": {
"version": "57.0.12",
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-57.0.12.tgz",
@@ -6861,6 +6926,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/find-yarn-workspace-root": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz",
"integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"micromatch": "^4.0.2"
}
},
"node_modules/flat-cache": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
@@ -6919,6 +6994,21 @@
"node": ">= 0.6"
}
},
"node_modules/fs-extra": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -8112,6 +8202,26 @@
"dev": true,
"license": "MIT"
},
"node_modules/json-stable-stringify": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz",
"integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.8",
"call-bound": "^1.0.4",
"isarray": "^2.0.5",
"jsonify": "^0.0.1",
"object-keys": "^1.1.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/json-stable-stringify-without-jsonify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
@@ -8131,6 +8241,29 @@
"node": ">=6"
}
},
"node_modules/jsonfile": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
"integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"universalify": "^2.0.0"
},
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/jsonify": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz",
"integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==",
"dev": true,
"license": "Public Domain",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/jsx-ast-utils": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
@@ -8157,6 +8290,16 @@
"json-buffer": "3.0.1"
}
},
"node_modules/klaw-sync": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz",
"integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.1.11"
}
},
"node_modules/kleur": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
@@ -9628,6 +9771,52 @@
"node": ">= 0.8"
}
},
"node_modules/patch-package": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz",
"integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@yarnpkg/lockfile": "^1.1.0",
"chalk": "^4.1.2",
"ci-info": "^3.7.0",
"cross-spawn": "^7.0.3",
"find-yarn-workspace-root": "^2.0.0",
"fs-extra": "^10.0.0",
"json-stable-stringify": "^1.0.2",
"klaw-sync": "^6.0.0",
"minimist": "^1.2.6",
"open": "^7.4.2",
"semver": "^7.5.3",
"slash": "^2.0.0",
"tmp": "^0.2.4",
"yaml": "^2.2.2"
},
"bin": {
"patch-package": "index.js"
},
"engines": {
"node": ">=14",
"npm": ">5"
}
},
"node_modules/patch-package/node_modules/ci-info": {
"version": "3.9.0",
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz",
"integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/sibiraj-s"
}
],
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -10965,6 +11154,16 @@
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
"license": "MIT"
},
"node_modules/slash": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz",
"integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/slugify": {
"version": "1.6.9",
"resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.9.tgz",
@@ -11398,6 +11597,16 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/tmp": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
"integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.14"
}
},
"node_modules/tmpl": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
@@ -11687,6 +11896,16 @@
"node": ">=4"
}
},
"node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+4 -4
View File
@@ -16,18 +16,16 @@
"expo-router": "~57.0.10",
"expo-sqlite": "~57.0.1",
"expo-status-bar": "~57.0.1",
"expo-updates": "~57.0.12",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-hook-form": "^7.51.5",
"react-native": "0.86.2",
"react-native-gesture-handler": "2.32.0",
"react-native-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": {
@@ -35,6 +33,7 @@
"@types/react": "~19.2.2",
"eslint": "^9.0.0",
"eslint-config-expo": "~57.0.1",
"patch-package": "^8.0.1",
"prettier": "^3.9.6",
"typescript": "~6.0.3"
},
@@ -50,7 +49,8 @@
"build:android": "eas build --platform android",
"build:web": "eas build --platform web",
"submit:ios": "eas submit --platform ios",
"submit:android": "eas submit --platform android"
"submit:android": "eas submit --platform android",
"postinstall": "patch-package"
},
"private": true
}
@@ -0,0 +1,78 @@
diff --git a/node_modules/react-native/src/private/webapis/dom/events/Event.js b/node_modules/react-native/src/private/webapis/dom/events/Event.js
index f918f97..5deab46 100644
--- a/node_modules/react-native/src/private/webapis/dom/events/Event.js
+++ b/node_modules/react-native/src/private/webapis/dom/events/Event.js
@@ -70,7 +70,7 @@ export default class Event {
[CURRENT_TARGET_KEY]: EventTarget | null = null;
// $FlowExpectedError[unsupported-syntax]
- [EVENT_PHASE_KEY]: boolean = Event.NONE;
+ [EVENT_PHASE_KEY]: boolean = 0;
// $FlowExpectedError[unsupported-syntax]
[IN_PASSIVE_LISTENER_FLAG_KEY]: boolean = false;
@@ -193,48 +193,64 @@ export default class Event {
// $FlowExpectedError[cannot-write]
Object.defineProperty(Event, 'NONE', {
+ writable: true,
+ configurable: true,
enumerable: true,
value: 0,
});
// $FlowExpectedError[cannot-write]
Object.defineProperty(Event.prototype, 'NONE', {
+ writable: true,
+ configurable: true,
enumerable: true,
value: 0,
});
// $FlowExpectedError[cannot-write]
Object.defineProperty(Event, 'CAPTURING_PHASE', {
+ writable: true,
+ configurable: true,
enumerable: true,
value: 1,
});
// $FlowExpectedError[cannot-write]
Object.defineProperty(Event.prototype, 'CAPTURING_PHASE', {
+ writable: true,
+ configurable: true,
enumerable: true,
value: 1,
});
// $FlowExpectedError[cannot-write]
Object.defineProperty(Event, 'AT_TARGET', {
+ writable: true,
+ configurable: true,
enumerable: true,
value: 2,
});
// $FlowExpectedError[cannot-write]
Object.defineProperty(Event.prototype, 'AT_TARGET', {
+ writable: true,
+ configurable: true,
enumerable: true,
value: 2,
});
// $FlowExpectedError[cannot-write]
Object.defineProperty(Event, 'BUBBLING_PHASE', {
+ writable: true,
+ configurable: true,
enumerable: true,
value: 3,
});
// $FlowExpectedError[cannot-write]
Object.defineProperty(Event.prototype, 'BUBBLING_PHASE', {
+ writable: true,
+ configurable: true,
enumerable: true,
value: 3,
});
@@ -0,0 +1,213 @@
const {
withAndroidManifest,
withDangerousMod,
withStringsXml,
} = require('expo/config-plugins');
const fs = require('fs');
const path = require('path');
const RECEIVER_NAME = '.QuickAddWidgetProvider';
const WIDGET_PROVIDER_XML = `<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="40dp"
android:targetCellWidth="4"
android:targetCellHeight="1"
android:updatePeriodMillis="0"
android:initialLayout="@layout/widget_quick_add"
android:resizeMode="horizontal"
android:widgetCategory="home_screen"
android:description="@string/widget_quick_add_description" />
`;
const WIDGET_LAYOUT_XML = `<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="@drawable/widget_quick_add_bg"
android:padding="14dp">
<TextView
android:id="@+id/widget_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="carry your live"
android:textColor="#8E8E8E"
android:textSize="12sp"
android:textStyle="bold" />
<TextView
android:id="@+id/quick_add_button"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_marginTop="8dp"
android:gravity="center"
android:background="@drawable/widget_quick_add_button_bg"
android:text="+ Add task"
android:textColor="#FFFFFF"
android:textSize="16sp"
android:textStyle="bold" />
</LinearLayout>
`;
const WIDGET_BG_XML = `<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#1E1E1E" />
<corners android:radius="16dp" />
<stroke android:width="1dp" android:color="#2A2A2A" />
</shape>
`;
const WIDGET_BUTTON_BG_XML = `<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#EF5350" />
<corners android:radius="12dp" />
</shape>
`;
function kotlinProvider(packageName) {
return `package ${packageName}
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.widget.RemoteViews
class QuickAddWidgetProvider : AppWidgetProvider() {
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray
) {
for (appWidgetId in appWidgetIds) {
updateWidget(context, appWidgetManager, appWidgetId)
}
}
private fun updateWidget(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetId: Int
) {
val views = RemoteViews(context.packageName, R.layout.widget_quick_add)
val openAppIntent = Intent(context, MainActivity::class.java).apply {
action = Intent.ACTION_VIEW
data = Uri.parse("exp+carry-your-live://quick-add")
}
val pendingIntent = PendingIntent.getActivity(
context,
0,
openAppIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
views.setOnClickPendingIntent(R.id.quick_add_button, pendingIntent)
appWidgetManager.updateAppWidget(appWidgetId, views)
}
}
`;
}
function withQuickAddWidget(config) {
const packageName = config.android?.package ?? 'com.anonymous.carryyourlive';
config = withStringsXml(config, (config) => {
const strings = config.modResults;
if (!strings.resources.string) {
strings.resources.string = [];
}
const existing = (strings.resources.string || []).find(
(s) => s && s['$'] && s['$'].name === 'widget_quick_add_description'
);
if (!existing) {
strings.resources.string.push({
$: { name: 'widget_quick_add_description' },
_: 'Quickly add a task',
});
}
return config;
});
config = withAndroidManifest(config, (config) => {
const manifest = config.modResults;
const application = manifest.manifest.application?.[0];
if (!application) return config;
const receivers = application.receiver || [];
const exists = receivers.some((r) => r && r['$'] && r['$']['android:name'] === RECEIVER_NAME);
if (!exists) {
application.receiver = [
...receivers,
{
$: {
'android:name': RECEIVER_NAME,
'android:exported': 'false',
'android:label': 'Quick Add Task',
},
'intent-filter': [
{
action: [{ $: { 'android:name': 'android.appwidget.action.APPWIDGET_UPDATE' } }],
},
],
'meta-data': [
{
$: {
'android:name': 'android.appwidget.provider',
'android:resource': '@xml/quick_add_widget',
},
},
],
},
];
}
return config;
});
config = withDangerousMod(config, [
'android',
async (config) => {
const projectRoot = config.modRequest.projectRoot;
const resDir = path.join(projectRoot, 'android', 'app', 'src', 'main', 'res');
const javaDir = path.join(
projectRoot,
'android',
'app',
'src',
'main',
'java',
...packageName.split('.')
);
fs.mkdirSync(path.join(resDir, 'xml'), { recursive: true });
fs.mkdirSync(path.join(resDir, 'layout'), { recursive: true });
fs.mkdirSync(path.join(resDir, 'drawable'), { recursive: true });
fs.mkdirSync(javaDir, { recursive: true });
fs.writeFileSync(path.join(resDir, 'xml', 'quick_add_widget.xml'), WIDGET_PROVIDER_XML);
fs.writeFileSync(path.join(resDir, 'layout', 'widget_quick_add.xml'), WIDGET_LAYOUT_XML);
fs.writeFileSync(path.join(resDir, 'drawable', 'widget_quick_add_bg.xml'), WIDGET_BG_XML);
fs.writeFileSync(
path.join(resDir, 'drawable', 'widget_quick_add_button_bg.xml'),
WIDGET_BUTTON_BG_XML
);
fs.writeFileSync(
path.join(javaDir, 'QuickAddWidgetProvider.kt'),
kotlinProvider(packageName)
);
return config;
},
]);
return config;
}
module.exports = withQuickAddWidget;
@@ -1,5 +1,5 @@
import React from 'react';
import { View, Text, TouchableOpacity, StyleSheet, Modal, FlatList, ActivityIndicator, TextInput } from 'react-native';
import { View, Text, TouchableOpacity, StyleSheet, Modal, FlatList, ActivityIndicator, TextInput, KeyboardAvoidingView } from 'react-native';
import { useSettings } from '@/theme';
import { useFriends } from '@/hooks/useFriends';
@@ -15,11 +15,19 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
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 [searchResults, setSearchResults] = React.useState<{ id: string; username: string }[]>([]);
const [searching, setSearching] = React.useState(false);
const selectedFriend = value ? friends.find(f => f.id === value) : null;
const searchKey = `${showModal}:${searchQuery}`;
const [prevSearchKey, setPrevSearchKey] = React.useState(searchKey);
if (prevSearchKey !== searchKey && (!showModal || searchQuery.length < 2)) {
setPrevSearchKey(searchKey);
setSearchResults([]);
}
React.useEffect(() => {
if (showModal && searchQuery.length >= 2) {
const timeout = setTimeout(async () => {
@@ -34,8 +42,6 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
}
}, 300);
return () => clearTimeout(timeout);
} else {
setSearchResults([]);
}
}, [searchQuery, showModal, friends, searchUsers]);
@@ -97,7 +103,10 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
</TouchableOpacity>
<Modal visible={showModal} transparent animationType="fade" onRequestClose={() => setShowModal(false)}>
<View style={[styles.modalOverlay, { backgroundColor: 'rgba(0,0,0,0.5)' }]}>
<KeyboardAvoidingView
style={[styles.modalOverlay, { backgroundColor: 'rgba(0,0,0,0.5)' }]}
behavior="padding"
>
<View style={[styles.modalSheet, { backgroundColor: theme.sheetBg }]}>
<View style={styles.modalHeader}>
<Text style={[styles.modalTitle, { color: theme.text }]}>Assign Task</Text>
@@ -197,7 +206,7 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
) : null}
</View>
</View>
</View>
</KeyboardAvoidingView>
</Modal>
</View>
);
@@ -210,48 +219,48 @@ const styles = StyleSheet.create({
selectorButton: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
paddingVertical: 14,
paddingHorizontal: 12,
borderRadius: 12,
gap: 8,
paddingVertical: 12,
paddingHorizontal: 10,
borderRadius: 10,
borderWidth: 1,
minHeight: 52,
minHeight: 48,
},
selectorIcon: {
fontSize: 20,
fontSize: 18,
},
selectorContent: {
flex: 1,
justifyContent: 'center',
},
selectorLabel: {
fontSize: 11,
fontSize: 10,
fontWeight: '600',
textTransform: 'uppercase',
letterSpacing: 0.5,
marginBottom: 2,
marginBottom: 1,
},
selectorValue: {
fontSize: 15,
fontSize: 14,
fontWeight: '500',
},
clearButton: {
padding: 4,
padding: 3,
},
clearText: {
fontSize: 18,
fontSize: 16,
fontWeight: '300',
},
modalOverlay: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 24,
padding: 20,
},
modalSheet: {
width: '100%',
maxWidth: 400,
borderRadius: 20,
maxWidth: 360,
borderRadius: 16,
overflow: 'hidden',
maxHeight: '85%',
},
@@ -259,81 +268,81 @@ const styles = StyleSheet.create({
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: 20,
padding: 16,
borderBottomWidth: 1,
},
modalTitle: {
fontSize: 18,
fontSize: 16,
fontWeight: '700',
},
closeText: {
fontSize: 22,
fontSize: 20,
fontWeight: '300',
},
modalSection: {
padding: 12,
paddingBottom: 20,
padding: 10,
paddingBottom: 16,
borderBottomWidth: 1,
},
sectionTitle: {
fontSize: 12,
fontSize: 11,
fontWeight: '600',
textTransform: 'uppercase',
letterSpacing: 0.5,
marginBottom: 8,
paddingHorizontal: 8,
marginBottom: 6,
paddingHorizontal: 6,
},
optionRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
paddingVertical: 12,
paddingHorizontal: 16,
borderRadius: 10,
gap: 10,
paddingVertical: 10,
paddingHorizontal: 14,
borderRadius: 9,
borderWidth: 1,
},
optionIcon: {
width: 36,
height: 36,
borderRadius: 18,
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0,0,0,0.05)',
},
optionIconText: {
fontSize: 16,
fontSize: 15,
},
optionText: {
fontSize: 16,
fontSize: 15,
fontWeight: '500',
flex: 1,
},
optionSubtext: {
fontSize: 12,
marginTop: 2,
fontSize: 11,
marginTop: 1,
},
checkmark: {
fontSize: 18,
fontSize: 16,
fontWeight: '700',
},
loading: {
padding: 20,
padding: 16,
alignItems: 'center',
},
emptyText: {
fontSize: 14,
fontSize: 13,
textAlign: 'center',
paddingHorizontal: 20,
paddingHorizontal: 16,
},
searchContainer: {
padding: 12,
paddingBottom: 8,
padding: 10,
paddingBottom: 6,
},
searchInput: {
fontSize: 16,
paddingVertical: 12,
paddingHorizontal: 16,
borderRadius: 12,
fontSize: 15,
paddingVertical: 10,
paddingHorizontal: 14,
borderRadius: 10,
borderWidth: 1,
},
});
@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
import { Modal, View, Text, StyleSheet, TextInput, TouchableOpacity, ScrollView } from 'react-native';
import React, { useState } from 'react';
import { Modal, View, Text, StyleSheet, TextInput, TouchableOpacity, ScrollView, KeyboardAvoidingView } from 'react-native';
import Category from '@/models/Category';
import { useSettings } from '@/theme';
import { CATEGORY_COLORS } from '@/constants';
@@ -19,13 +19,15 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose
const { theme } = useSettings();
const [name, setName] = useState('');
const [color, setColor] = useState(CATEGORY_COLORS[0]);
const [prevVisible, setPrevVisible] = useState(visible);
useEffect(() => {
if (prevVisible !== visible) {
setPrevVisible(visible);
if (visible) {
setName(category?.name ?? '');
setColor(category?.color ?? CATEGORY_COLORS[0]);
}
}, [visible, category]);
}
const canDelete = category !== null && categoryCount > 1;
@@ -56,8 +58,12 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<View style={styles.overlay}>
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
<KeyboardAvoidingView
style={styles.overlay}
behavior="padding"
>
<ScrollView contentContainerStyle={styles.overlay} keyboardShouldPersistTaps="handled">
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
<View style={styles.header}>
<Text style={[styles.title, { color: theme.text }]}>
{category ? 'Edit Category' : 'New Category'}
@@ -129,8 +135,9 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose
<Text style={styles.saveButtonText}>Save</Text>
</TouchableOpacity>
</View>
</View>
</View>
</View>
</ScrollView>
</KeyboardAvoidingView>
</Modal>
);
}
@@ -1,6 +1,6 @@
import React from 'react';
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, Animated, Easing } from 'react-native';
import { useCategories } from '@/hooks/useDatabase';
import { useUniqueCategories } from '@/hooks/useDatabase';
import { useSettings, ThemeColors } from '@/theme';
import Category from '@/models/Category';
@@ -10,7 +10,7 @@ interface CategoryFilterProps {
}
export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) {
const categories = useCategories();
const categories = useUniqueCategories();
const { theme } = useSettings();
if (categories.length === 0) {
@@ -91,9 +91,9 @@ interface AnimatedCategoryButtonProps {
}
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;
const [scaleAnim] = React.useState(() => new Animated.Value(selected ? 1.05 : 1));
const [borderWidthAnim] = React.useState(() => new Animated.Value(selected ? 2 : 1));
const [shadowOpacityAnim] = React.useState(() => new Animated.Value(selected ? 0.15 : 0));
React.useEffect(() => {
Animated.timing(scaleAnim, {
@@ -150,28 +150,31 @@ function AnimatedCategoryButton({ category, selected, onPress, theme }: Animated
const styles = StyleSheet.create({
scrollView: {
paddingVertical: 8,
paddingVertical: 0,
marginBottom: 0,
},
container: {
paddingHorizontal: 16,
gap: 8,
alignItems: 'center',
paddingHorizontal: 12,
paddingTop: 0,
paddingBottom: 0,
gap: 6,
alignItems: 'flex-start',
},
button: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 20,
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 16,
borderWidth: 1,
minWidth: 72,
minWidth: 64,
justifyContent: 'center',
},
animatedButton: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowRadius: 8,
elevation: 3,
shadowOffset: { width: 0, height: 1 },
shadowRadius: 4,
elevation: 2,
},
buttonInner: {
flexDirection: 'row',
@@ -179,17 +182,17 @@ const styles = StyleSheet.create({
gap: 6,
},
colorDot: {
width: 8,
height: 8,
borderRadius: 4,
},
colorDotSelected: {
width: 10,
height: 10,
borderRadius: 5,
},
colorDotSelected: {
width: 12,
height: 12,
borderRadius: 6,
},
buttonText: {
fontSize: 13,
fontSize: 12,
fontWeight: '500',
},
});
@@ -1,5 +1,5 @@
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity, ScrollView } from 'react-native';
import React, { useState } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, ScrollView, Modal, Pressable, KeyboardAvoidingView } from 'react-native';
import { useCategories } from '@/hooks/useDatabase';
import { useSettings } from '@/theme';
@@ -12,82 +12,109 @@ interface CategorySelectorProps {
export function CategorySelector({ value, onChange, error }: CategorySelectorProps) {
const categories = useCategories();
const { theme } = useSettings();
const [showModal, setShowModal] = useState(false);
const selectedCategory = categories.find(c => c.id === value);
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}
<TouchableOpacity
style={[
styles.selectorButton,
{ backgroundColor: theme.inputBg, borderColor: theme.borderStrong },
]}
onPress={() => setShowModal(true)}
activeOpacity={0.8}
>
{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 style={styles.selectorContent}>
<View style={styles.selectorRow}>
<View style={[styles.colorCircle, { backgroundColor: selectedCategory?.color || '#9E9E9E' }]} />
<Text style={[styles.selectorValue, { color: theme.text }]}>{selectedCategory?.name || 'Select category'}</Text>
</View>
</View>
<Svg width={20} height={20} viewBox="0 0 24 24">
<Path d="M9 18l6-6-6-6" stroke={theme.textMuted} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" fill="none" />
</Svg>
</TouchableOpacity>
{error && <Text style={[styles.errorText, { color: '#E53935' }]}>{error}</Text>}
<Modal visible={showModal} transparent animationType="fade" onRequestClose={() => setShowModal(false)}>
<KeyboardAvoidingView
style={[styles.modalOverlay, { backgroundColor: 'rgba(0,0,0,0.5)' }]}
behavior="padding"
>
<Pressable style={styles.modalOverlay} onPress={() => setShowModal(false)}>
<Pressable style={[styles.modalSheet, { backgroundColor: theme.sheetBg }]}>
<View style={styles.modalHeader}>
<Text style={[styles.modalTitle, { color: theme.text }]}>Select Category</Text>
<TouchableOpacity onPress={() => setShowModal(false)} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
<Text style={[styles.closeText, { color: theme.textMuted }]}></Text>
</TouchableOpacity>
</View>
<ScrollView contentContainerStyle={styles.modalContent}>
{categories.map((category) => (
<TouchableOpacity
key={category.id}
style={[
styles.modalOption,
{ backgroundColor: theme.card, borderColor: theme.borderStrong },
value === category.id && { borderColor: theme.accent, backgroundColor: theme.accentSoft, borderWidth: 2 },
]}
onPress={() => { onChange(category.id); setShowModal(false); }}
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>
{value === category.id && (
<Svg width={20} height={20} 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>
))}
</ScrollView>
</Pressable>
</Pressable>
</KeyboardAvoidingView>
</Modal>
</View>
);
}
import Svg, { Path } from 'react-native-svg';
const styles = StyleSheet.create({
container: {
gap: 8,
gap: 6,
},
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,
selectorButton: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
minWidth: 90,
justifyContent: 'center',
justifyContent: 'space-between',
paddingVertical: 14,
paddingHorizontal: 12,
borderRadius: 12,
borderWidth: 1,
minHeight: 52,
},
selectorContent: {
flex: 1,
},
selectorRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
},
colorCircle: {
width: 12,
@@ -99,13 +126,59 @@ const styles = StyleSheet.create({
height: 14,
borderRadius: 7,
},
categoryName: {
fontSize: 13,
selectorValue: {
fontSize: 15,
fontWeight: '500',
},
errorText: {
fontSize: 12,
color: '#E53935',
marginLeft: 16,
marginLeft: 4,
},
modalOverlay: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 24,
},
modalSheet: {
width: '100%',
maxWidth: 400,
borderRadius: 20,
overflow: 'hidden',
maxHeight: '80%',
},
modalHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: 20,
borderBottomWidth: 1,
},
modalTitle: {
fontSize: 18,
fontWeight: '700',
},
closeText: {
fontSize: 22,
fontWeight: '300',
},
modalContent: {
padding: 12,
gap: 8,
},
modalOption: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
paddingVertical: 14,
paddingHorizontal: 16,
borderRadius: 12,
borderWidth: 1,
},
categoryName: {
fontSize: 15,
fontWeight: '500',
flex: 1,
},
});
@@ -7,30 +7,39 @@ interface ColorPickerInputProps {
onChange: (color: string) => void;
}
function normalizeHex(input: string): string {
const cleaned = input.replace(/[^0-9a-fA-F]/g, '').slice(0, 6);
return cleaned ? `#${cleaned}` : '';
const HEX_PATTERN = /^[0-9a-fA-F]{6}$/;
function isValidHex(color: string): boolean {
return HEX_PATTERN.test(color);
}
export default function NativeColorPickerInput({ value, onChange }: ColorPickerInputProps) {
const { theme } = useSettings();
const hex = value.replace(/^#/, '').toUpperCase();
const valid = isValidHex(hex);
const handleChange = (raw: string) => {
const cleaned = raw.replace(/[^0-9a-fA-F]/g, '').slice(0, 6).toUpperCase();
onChange(`#${cleaned}`);
};
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}
/>
<View style={[styles.preview, { backgroundColor: valid ? value : theme.borderStrong, borderColor: theme.borderStrong }]} />
<View style={[styles.inputWrap, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong }]}>
<Text style={[styles.hash, { color: theme.textMuted }]}>#</Text>
<TextInput
style={[styles.input, { color: theme.text }]}
value={hex}
onChangeText={handleChange}
placeholder="E53935"
placeholderTextColor={theme.textMuted}
autoCapitalize="characters"
autoCorrect={false}
maxLength={6}
/>
</View>
<Text style={[styles.hint, { color: theme.textMuted }]}>Hex code</Text>
</View>
);
@@ -47,16 +56,26 @@ const styles = StyleSheet.create({
height: 36,
borderRadius: 18,
borderWidth: 1,
borderColor: '#E0E0E0',
},
input: {
inputWrap: {
flex: 1,
height: 44,
paddingHorizontal: 14,
borderRadius: 10,
borderWidth: 1,
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
hash: {
fontSize: 15,
fontWeight: '600',
},
input: {
flex: 1,
fontSize: 15,
fontWeight: '500',
paddingVertical: 0,
},
hint: {
fontSize: 12,
@@ -7,15 +7,23 @@ interface ColorPickerInputProps {
onChange: (color: string) => void;
}
const HEX_PATTERN = /^#[0-9a-fA-F]{6}$/;
export default function WebColorPickerInput({ value, onChange }: ColorPickerInputProps) {
const { theme } = useSettings();
const valid = HEX_PATTERN.test(value);
const normalized = valid ? value.toUpperCase() : '#000000';
return (
<View style={styles.row}>
{React.createElement('input', {
type: 'color',
value: value.toUpperCase(),
onChange: (e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.value),
value: normalized,
onChange: (e: React.ChangeEvent<HTMLInputElement>) => {
if (HEX_PATTERN.test(e.target.value)) {
onChange(e.target.value.toUpperCase());
}
},
style: {
width: 44,
height: 44,
@@ -26,7 +34,7 @@ export default function WebColorPickerInput({ value, onChange }: ColorPickerInpu
cursor: 'pointer',
},
})}
<Text style={[styles.hexText, { color: theme.textSecondary }]}>{value.toUpperCase()}</Text>
<Text style={[styles.hexText, { color: theme.textSecondary }]}>{normalized}</Text>
</View>
);
}
@@ -1,5 +1,5 @@
import React from 'react';
import { View, StyleSheet } from 'react-native';
import { StyleSheet } from 'react-native';
import DateTimePicker, { DateTimePickerEvent } from '@react-native-community/datetimepicker';
interface NativeDateTimeInputProps {
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useState } from 'react';
import { View, Text, TouchableOpacity, StyleSheet, Modal } from 'react-native';
import { DateTimePickerEvent } from '@react-native-community/datetimepicker';
@@ -33,10 +33,12 @@ export default function WebDateTimeInput({
is24Hour,
}: WebDateTimeInputProps) {
const [inputValue, setInputValue] = useState(() => dateInputValue(value));
const [prevValue, setPrevValue] = useState(value);
useEffect(() => {
if (value !== prevValue) {
setPrevValue(value);
setInputValue(dateInputValue(value));
}, [value]);
}
const emit = (raw: string) => {
if (!raw) return;
@@ -15,14 +15,12 @@ interface DateTimePickerComponentProps {
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 dueDate = useWatch({ control, name: 'dueDate' }) ?? null;
const startTime = useWatch({ control, name: 'dueTime' }) ?? '';
const endTime = useWatch({ control, name: 'endTime' }) ?? '';
@@ -37,7 +35,6 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
const renderTimeButton = (
fieldName: 'dueTime' | 'endTime',
ref: React.MutableRefObject<((value: string) => void) | null>,
valueRef: React.MutableRefObject<string>,
placeholder: string,
onPress: () => void
) => (
@@ -46,7 +43,6 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
name={fieldName}
render={({ field }) => {
ref.current = field.onChange;
valueRef.current = field.value ?? '';
return (
<TouchableOpacity
style={[
@@ -105,7 +101,6 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
name="dueDate"
render={({ field }) => {
dateRef.current = field.onChange;
dateValueRef.current = field.value;
return (
<TouchableOpacity
style={[
@@ -181,8 +176,8 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
{!allDay && (
<>
<View style={styles.timeRow}>
{renderTimeButton('dueTime', timeRef, timeValueRef, 'Start Time', () => setPicker('time'))}
{renderTimeButton('endTime', endTimeRef, endTimeValueRef, 'End Time', () => setPicker('endTime'))}
{renderTimeButton('dueTime', timeRef, 'Start Time', () => setPicker('time'))}
{renderTimeButton('endTime', endTimeRef, 'End Time', () => setPicker('endTime'))}
</View>
{endTimeInvalid && (
@@ -203,7 +198,7 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
<DateTimeInput
testID="date-picker"
value={dateValueRef.current ?? new Date()}
value={dueDate ?? new Date()}
mode="date"
is24Hour={false}
isVisible={picker === 'date'}
@@ -220,8 +215,8 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
title={picker === 'endTime' ? 'End Time' : 'Start Time'}
initialTime={
picker === 'endTime'
? endTimeValueRef.current || timeValueRef.current
: timeValueRef.current
? endTime || startTime
: startTime
}
onConfirm={(time) => {
if (picker === 'endTime') {
@@ -261,7 +256,7 @@ const styles = StyleSheet.create({
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 12,
paddingVertical: 14,
},
allDayLabel: {
fontSize: 15,
@@ -293,7 +288,7 @@ const styles = StyleSheet.create({
fontWeight: '500',
},
clearButton: {
padding: 2,
padding: 4,
},
warningRow: {
flexDirection: 'row',
@@ -1,6 +1,5 @@
import React from 'react';
import { View, Text, StyleSheet, TextInput } from 'react-native';
import { TextInputProps } from 'react-native';
import { View, Text, StyleSheet, TextInput , TextInputProps } from 'react-native';
import { useSettings } from '@/theme';
interface DescriptionInputProps extends TextInputProps {
@@ -38,30 +37,30 @@ export function DescriptionInput({ error, ...props }: DescriptionInputProps) {
const styles = StyleSheet.create({
container: {
gap: 6,
gap: 5,
},
label: {
fontSize: 14,
fontSize: 13,
fontWeight: '600',
},
input: {
minHeight: 100,
padding: 16,
borderRadius: 12,
minHeight: 88,
padding: 14,
borderRadius: 10,
borderWidth: 1,
fontSize: 15,
fontSize: 14,
},
inputError: {
borderColor: '#E53935',
borderWidth: 1.5,
},
charCount: {
fontSize: 11,
fontSize: 10,
textAlign: 'right',
marginTop: -4,
marginTop: -3,
},
errorText: {
fontSize: 12,
fontSize: 11,
color: '#E53935',
marginLeft: 4,
},
@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { View, StyleSheet, TouchableOpacity, Animated, Easing } from 'react-native';
import { StyleSheet, TouchableOpacity, Animated, Easing } from 'react-native';
import { useRouter } from 'expo-router';
import Svg, { Path } from 'react-native-svg';
+15 -13
View File
@@ -3,6 +3,7 @@ import { View, StyleSheet, TouchableOpacity, Text } from 'react-native';
import { useFormContext } from 'react-hook-form';
import { useRouter } from 'expo-router';
import { useSettings } from '@/theme';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
interface FormButtonsProps {
onSubmit: (data: any) => void;
@@ -13,6 +14,7 @@ export function FormButtons({ onSubmit, submitLabel = 'Submit' }: FormButtonsPro
const router = useRouter();
const { theme } = useSettings();
const { handleSubmit, formState: { isSubmitting } } = useFormContext();
const insets = useSafeAreaInsets();
const cancel = () => {
if (router.canGoBack()) {
@@ -23,7 +25,7 @@ export function FormButtons({ onSubmit, submitLabel = 'Submit' }: FormButtonsPro
};
return (
<View style={[styles.container, { backgroundColor: theme.background, borderTopColor: theme.border }]}>
<View style={[styles.container, { backgroundColor: theme.background, borderTopColor: theme.border, paddingBottom: insets.bottom + 12 }]}>
<TouchableOpacity
style={[styles.cancelButton, { borderColor: theme.borderStrong, backgroundColor: theme.card }]}
onPress={cancel}
@@ -52,45 +54,45 @@ const styles = StyleSheet.create({
bottom: 0,
left: 0,
right: 0,
paddingHorizontal: 16,
paddingVertical: 16,
paddingHorizontal: 12,
paddingVertical: 12,
borderTopWidth: 1,
flexDirection: 'row',
justifyContent: 'space-between',
gap: 12,
gap: 10,
shadowColor: '#000',
shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.05,
shadowRadius: 8,
elevation: 4,
shadowRadius: 6,
elevation: 3,
},
cancelButton: {
flex: 1,
paddingVertical: 14,
borderRadius: 12,
paddingVertical: 12,
borderRadius: 10,
borderWidth: 1,
alignItems: 'center',
justifyContent: 'center',
},
cancelButtonText: {
fontSize: 16,
fontSize: 15,
fontWeight: '600',
},
submitButton: {
flex: 1,
paddingVertical: 14,
borderRadius: 12,
paddingVertical: 12,
borderRadius: 10,
backgroundColor: '#E53935',
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#E53935',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.3,
shadowRadius: 8,
shadowRadius: 6,
elevation: 3,
},
submitButtonText: {
fontSize: 16,
fontSize: 15,
fontWeight: '600',
color: '#FFFFFF',
},
+21 -25
View File
@@ -9,6 +9,7 @@ import {
FlatList,
ActivityIndicator,
Alert,
KeyboardAvoidingView,
} from 'react-native';
import { useSettings } from '@/theme';
import { useFriends } from '@/hooks/useFriends';
@@ -22,10 +23,18 @@ 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 [searchResults, setSearchResults] = useState<{ id: string; username: string }[]>([]);
const [searching, setSearching] = useState(false);
const [selectedTab, setSelectedTab] = useState<'friends' | 'incoming' | 'outgoing' | 'add'>('friends');
const searchKey = `${selectedTab}:${searchQuery}`;
const [prevSearchKey, setPrevSearchKey] = useState(searchKey);
if (prevSearchKey !== searchKey && (selectedTab !== 'add' || searchQuery.length < 2)) {
setPrevSearchKey(searchKey);
setSearchResults([]);
}
useEffect(() => {
if (selectedTab === 'add' && searchQuery.length >= 2) {
const timeout = setTimeout(async () => {
@@ -40,23 +49,9 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
}
}, 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);
@@ -90,7 +85,10 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<View style={[styles.overlay, { backgroundColor: 'rgba(0,0,0,0.5)' }]}>
<KeyboardAvoidingView
style={[styles.overlay, { backgroundColor: 'rgba(0,0,0,0.5)' }]}
behavior="padding"
>
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
<View style={styles.header}>
<Text style={[styles.title, { color: theme.text }]}>Friends</Text>
@@ -137,7 +135,7 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
) : 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>
<Text style={[styles.emptyHint, { color: theme.textFaint }]}>{'Tap "Add" to find friends by username'}</Text>
</View>
) : (
<FlatList
@@ -258,16 +256,14 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
<Text style={[styles.friendName, { color: theme.text }]}>{item.username}</Text>
<TouchableOpacity
style={[styles.addBtn, { backgroundColor: theme.accent }]}
onPress={async () => {
try {
await sendRequest(item.username);
onPress={() => sendRequest(item.username)
.then(() => {
setSearchQuery('');
setSearchResults([]);
setSelectedTab('friends');
} catch (err) {
Alert.alert('Error', err instanceof Error ? err.message : 'Failed to send request');
}
}}
})
.catch(err => Alert.alert('Error', err instanceof Error ? err.message : 'Failed to send request'))
}
>
<Text style={styles.addBtnText}>Add</Text>
</TouchableOpacity>
@@ -282,7 +278,7 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
</View>
)}
</View>
</View>
</KeyboardAvoidingView>
</Modal>
);
}
+15 -24
View File
@@ -1,6 +1,5 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { View, Text, StyleSheet, StatusBar, Platform } from 'react-native';
import { useSettings } from '@/theme';
interface HeaderProps {
@@ -11,9 +10,10 @@ interface HeaderProps {
export function Header({ title, showLogo, rightAction }: HeaderProps) {
const { theme } = useSettings();
const topInset = Platform.OS === 'android' ? (StatusBar.currentHeight ?? 24) : 0;
return (
<SafeAreaView style={[styles.header, { backgroundColor: theme.background }]}>
<View style={[styles.header, { backgroundColor: theme.background, paddingTop: topInset }]}>
<View style={styles.headerContent}>
{showLogo && (
<View style={styles.logoContainer}>
@@ -23,33 +23,30 @@ export function Header({ title, showLogo, rightAction }: HeaderProps) {
<Text style={[styles.title, { color: theme.text }]}>{title}</Text>
<View style={styles.spacer}>{rightAction}</View>
</View>
<View style={[styles.bottomRounded, { backgroundColor: theme.background }]} />
</SafeAreaView>
</View>
);
}
const styles = StyleSheet.create({
header: {
borderBottomLeftRadius: 24,
borderBottomRightRadius: 24,
borderBottomLeftRadius: 16,
borderBottomRightRadius: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.05,
shadowRadius: 8,
elevation: 2,
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.04,
shadowRadius: 4,
elevation: 1,
},
headerContent: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 20,
paddingTop: 8,
paddingBottom: 16,
height: 80,
height: 48,
},
logoContainer: {
width: 36,
height: 36,
width: 32,
height: 32,
borderRadius: 10,
backgroundColor: '#E53935',
alignItems: 'center',
@@ -57,7 +54,7 @@ const styles = StyleSheet.create({
},
logoText: {
color: '#FFFFFF',
fontSize: 20,
fontSize: 18,
fontWeight: '700',
},
title: {
@@ -68,13 +65,7 @@ const styles = StyleSheet.create({
marginLeft: -30,
},
spacer: {
width: 36,
width: 32,
alignItems: 'flex-end',
},
bottomRounded: {
height: 24,
borderBottomLeftRadius: 24,
borderBottomRightRadius: 24,
marginTop: -24,
},
});
@@ -123,12 +123,12 @@ 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';
if (!visible || !type) return null;
return (
<SafeAreaView style={[styles.overlay, { backgroundColor: theme.overlay }]}>
<View style={[styles.modal, { backgroundColor: theme.card }]}>
@@ -192,6 +192,5 @@ const styles = StyleSheet.create({
body: {
fontSize: 14,
lineHeight: 22,
whiteSpace: 'pre-wrap' as const,
},
});
@@ -1,7 +1,7 @@
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';
import Svg, { Path, Circle } from 'react-native-svg';
export interface PickerOption {
value: string;
@@ -13,14 +13,17 @@ interface OptionPickerModalProps {
visible: boolean;
title: string;
options: PickerOption[];
selectedValue?: string;
onSelect: (value: string) => void;
selectedValue?: string | string[];
onSelect: (value: string | string[]) => void;
onClose: () => void;
multiSelect?: boolean;
}
export function OptionPickerModal({ visible, title, options, selectedValue, onSelect, onClose }: OptionPickerModalProps) {
export function OptionPickerModal({ visible, title, options, selectedValue, onSelect, onClose, multiSelect = false }: OptionPickerModalProps) {
const { theme } = useSettings();
const selectedValues = Array.isArray(selectedValue) ? selectedValue : selectedValue ? [selectedValue] : [];
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<View style={[styles.overlay, { backgroundColor: theme.overlay }]}>
@@ -30,7 +33,7 @@ export function OptionPickerModal({ visible, title, options, selectedValue, onSe
data={options}
keyExtractor={(item) => item.value}
renderItem={({ item }) => {
const selected = item.value === selectedValue;
const selected = selectedValues.includes(item.value);
return (
<TouchableOpacity
style={[
@@ -39,8 +42,15 @@ export function OptionPickerModal({ visible, title, options, selectedValue, onSe
selected && { backgroundColor: theme.accentSoft },
]}
onPress={() => {
onSelect(item.value);
onClose();
if (multiSelect) {
const newValues = selected
? selectedValues.filter((v) => v !== item.value)
: [...selectedValues, item.value];
onSelect(newValues);
} else {
onSelect(item.value);
onClose();
}
}}
activeOpacity={0.7}
>
@@ -50,7 +60,14 @@ export function OptionPickerModal({ visible, title, options, selectedValue, onSe
<Text style={[styles.optionText, { color: selected ? theme.accent : theme.textSecondary }]}>
{item.label}
</Text>
{selected && (
{multiSelect ? (
<Svg width={22} height={22} viewBox="0 0 24 24">
<Circle cx={12} cy={12} r={10} stroke={selected ? theme.accent : theme.borderStrong} strokeWidth={2} fill={selected ? theme.accent : 'transparent'} />
{selected && (
<Path d="M8 12l3 3 6-6" stroke="#FFFFFF" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
)}
</Svg>
) : 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>
@@ -3,14 +3,13 @@ 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 }> = [
const priorities: { value: Priority; label: string }[] = [
{ value: 'none', label: 'None' },
{ value: 'low', label: 'Low' },
{ value: 'medium', label: 'Medium' },
@@ -57,15 +56,15 @@ export function PrioritySelector({ value, onChange }: PrioritySelectorProps) {
const styles = StyleSheet.create({
container: {
gap: 10,
gap: 6,
},
label: {
fontSize: 14,
fontSize: 13,
fontWeight: '600',
},
options: {
flexDirection: 'row',
gap: 8,
gap: 6,
},
option: {
flex: 1,
@@ -73,16 +72,17 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center',
gap: 6,
paddingVertical: 12,
paddingHorizontal: 16,
borderRadius: 12,
paddingVertical: 10,
paddingHorizontal: 8,
borderRadius: 10,
borderWidth: 1,
minHeight: 40,
},
colorIndicator: {
width: 10,
height: 10,
borderRadius: 5,
opacity: 0.5,
opacity: 0.6,
},
colorIndicatorSelected: {
opacity: 1,
+73 -35
View File
@@ -1,10 +1,12 @@
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, TextInput, TouchableOpacity, KeyboardAvoidingView, Platform } from 'react-native';
import React, { useState, useEffect, useRef } from 'react';
import { View, StyleSheet, TextInput, TouchableOpacity, Platform, Keyboard, Animated, Easing } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { 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';
import { subscribeToQuickAdd } from '@/utils/quickAddFocus';
interface QuickAddBarProps {
dueDate?: number;
@@ -13,19 +15,49 @@ interface QuickAddBarProps {
export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
const { theme, defaultCategoryId } = useSettings();
const insets = useSafeAreaInsets();
const categories = useCategories();
const [title, setTitle] = useState('');
const [categoryId, setCategoryId] = useState(defaultCategoryId || categories[0]?.id || '');
const [categoryId, setCategoryId] = useState(() => defaultCategoryId || categories[0]?.id || '');
const [categoryPickerVisible, setCategoryPickerVisible] = useState(false);
const inputRef = useRef<TextInput>(null);
const keyboardHeight = useRef(new Animated.Value(0)).current;
const categoryColor = categories.find((c) => c.id === categoryId)?.color ?? '#9E9E9E';
useEffect(() => {
if (!categoryId) {
setCategoryId(defaultCategoryId || categories[0]?.id || '');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [categories, defaultCategoryId]);
return subscribeToQuickAdd(() => {
setTimeout(() => {
inputRef.current?.focus();
}, 100);
});
}, []);
useEffect(() => {
const showListener = Keyboard.addListener('keyboardDidShow', (e) => {
const height = e.endCoordinates.height;
Animated.timing(keyboardHeight, {
toValue: height,
duration: 250,
easing: Easing.out(Easing.cubic),
useNativeDriver: false,
}).start();
});
const hideListener = Keyboard.addListener('keyboardDidHide', () => {
Animated.timing(keyboardHeight, {
toValue: 0,
duration: 250,
easing: Easing.out(Easing.cubic),
useNativeDriver: false,
}).start();
});
return () => {
showListener.remove();
hideListener.remove();
};
}, []);
const handleAdd = async () => {
const trimmed = title.trim();
@@ -54,11 +86,14 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
setTitle('');
};
const animatedBottom = keyboardHeight.interpolate({
inputRange: [0, 500],
outputRange: [insets.bottom + 0, insets.bottom + 0 + 500],
extrapolate: 'clamp',
});
return (
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
style={styles.wrapper}
>
<Animated.View style={[styles.wrapper, { bottom: animatedBottom }]}>
<View style={[styles.bar, { backgroundColor: theme.card, borderColor: theme.border }]}>
<TouchableOpacity
style={[styles.categoryButton, { borderColor: theme.borderStrong, backgroundColor: theme.cardAlt }]}
@@ -72,6 +107,7 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
</Svg>
</TouchableOpacity>
<TextInput
ref={inputRef}
style={[styles.input, { color: theme.text }]}
placeholder={placeholder ?? 'Add a task'}
placeholderTextColor={theme.textMuted}
@@ -81,8 +117,8 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
returnKeyType="done"
/>
<TouchableOpacity
style={[styles.submit, { backgroundColor: theme.accent }, !title.trim() && styles.submitDisabled]}
onPress={handleAdd}
style={[styles.submit, { backgroundColor: theme.accent }, title.trim() ? {} : styles.submitDisabled]}
onPress={title.trim() ? handleAdd : undefined}
disabled={!title.trim()}
activeOpacity={0.8}
accessibilityLabel="Add task"
@@ -104,57 +140,59 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
title="Select Category"
options={categories.map((c) => ({ value: c.id, label: c.name, color: c.color }))}
selectedValue={categoryId}
onSelect={(value) => setCategoryId(value)}
onSelect={(value) => setCategoryId(Array.isArray(value) ? value[0] : value)}
onClose={() => setCategoryPickerVisible(false)}
/>
</KeyboardAvoidingView>
</Animated.View>
);
}
const styles = StyleSheet.create({
wrapper: {
position: 'absolute',
left: 16,
right: 16,
bottom: 24,
left: 8,
right: 8,
bottom: 12,
},
bar: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
paddingHorizontal: 10,
paddingVertical: 10,
borderRadius: 16,
gap: 6,
paddingHorizontal: 8,
paddingVertical: 8,
borderRadius: 14,
borderWidth: 1,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.12,
shadowRadius: 12,
elevation: 8,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.08,
shadowRadius: 6,
elevation: 4,
},
categoryButton: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingVertical: 10,
paddingHorizontal: 12,
borderRadius: 12,
gap: 3,
paddingVertical: 6,
paddingHorizontal: 6,
borderRadius: 8,
borderWidth: 1,
height: 40,
},
categoryButtonDot: {
width: 12,
height: 12,
borderRadius: 6,
width: 8,
height: 8,
borderRadius: 4,
},
input: {
flex: 1,
fontSize: 15,
paddingVertical: 10,
minHeight: 40,
},
submit: {
width: 40,
height: 40,
borderRadius: 12,
borderRadius: 8,
alignItems: 'center',
justifyContent: 'center',
},
@@ -1,13 +1,13 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import { Reminder, REMINDER_OPTIONS } from '@/types';
import { Reminder, REMINDER_OPTIONS, parseReminders, toRemindersString } from '@/types';
import { useSettings } from '@/theme';
import { OptionPickerModal } from '@/components/OptionPickerModal';
import Svg, { Path, Circle } from 'react-native-svg';
import Svg, { Path, Circle, Rect } from 'react-native-svg';
interface ReminderSelectorProps {
value: Reminder;
onChange: (value: Reminder) => void;
value: string;
onChange: (value: string) => void;
hasDueDate: boolean;
}
@@ -15,17 +15,21 @@ export function ReminderSelector({ value, onChange, hasDueDate }: ReminderSelect
const { theme } = useSettings();
const [showPicker, setShowPicker] = useState(false);
const selected = REMINDER_OPTIONS.find((o) => o.value === value) ?? REMINDER_OPTIONS[0];
const selectedReminders = parseReminders(value);
const disabled = !hasDueDate;
const reminderLabels = selectedReminders.length > 0
? selectedReminders.map(r => REMINDER_OPTIONS.find(o => o.value === r)?.label).filter(Boolean).join(', ')
: 'No reminder';
return (
<View style={styles.container}>
<Text style={[styles.label, { color: theme.text }]}>Reminder</Text>
<Text style={[styles.label, { color: theme.text }]}>Reminders</Text>
<TouchableOpacity
style={[
styles.row,
{ backgroundColor: theme.card, borderColor: theme.borderStrong },
value !== 'none' && { borderColor: theme.accent, backgroundColor: theme.accentSoft },
selectedReminders.length > 0 && { borderColor: theme.accent, backgroundColor: theme.accentSoft },
disabled && { opacity: 0.5 },
]}
onPress={() => setShowPicker(true)}
@@ -35,22 +39,22 @@ export function ReminderSelector({ value, onChange, hasDueDate }: ReminderSelect
<Svg width={20} height={20} viewBox="0 0 24 24">
<Path
d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9M10.3 21a1.94 1.94 0 0 0 3.4 0"
stroke={value !== 'none' ? theme.accent : theme.textMuted}
stroke={selectedReminders.length > 0 ? 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} />
<Circle cx={18.5} cy={5.5} r={3.5} fill={selectedReminders.length > 0 ? theme.accent : 'transparent'} stroke={selectedReminders.length > 0 ? theme.accent : theme.textMuted} strokeWidth={1.5} />
</Svg>
<Text
style={[
styles.valueText,
{ color: value !== 'none' ? theme.text : theme.textMuted },
value !== 'none' && styles.valueTextFilled,
{ color: selectedReminders.length > 0 ? theme.text : theme.textMuted },
selectedReminders.length > 0 && styles.valueTextFilled,
]}
>
{value !== 'none' ? selected.label : 'No reminder'}
{reminderLabels}
</Text>
<View style={styles.chevron}>
<Svg width={16} height={16} viewBox="0 0 24 24">
@@ -59,16 +63,17 @@ export function ReminderSelector({ value, onChange, hasDueDate }: ReminderSelect
</View>
</TouchableOpacity>
{!hasDueDate && (
<Text style={[styles.hint, { color: theme.textMuted }]}>Set a due date to add a reminder.</Text>
<Text style={[styles.hint, { color: theme.textMuted }]}>Set a due date to add reminders.</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)}
title="Reminders"
options={REMINDER_OPTIONS.filter((o) => o.value !== 'none').map((o) => ({ value: o.value, label: o.label }))}
selectedValue={selectedReminders}
onSelect={(v) => onChange(toRemindersString(v as Reminder[]))}
onClose={() => setShowPicker(false)}
multiSelect
/>
</View>
);
@@ -76,10 +81,10 @@ export function ReminderSelector({ value, onChange, hasDueDate }: ReminderSelect
const styles = StyleSheet.create({
container: {
gap: 8,
gap: 6,
},
label: {
fontSize: 14,
fontSize: 13,
fontWeight: '600',
},
row: {
@@ -93,7 +98,7 @@ const styles = StyleSheet.create({
},
valueText: {
flex: 1,
fontSize: 15,
fontSize: 14,
},
valueTextFilled: {
fontWeight: '500',
@@ -102,7 +107,7 @@ const styles = StyleSheet.create({
transform: [{ rotate: '-90deg' }],
},
hint: {
fontSize: 12,
fontSize: 11,
marginLeft: 4,
},
});
@@ -1,5 +1,5 @@
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Modal, TextInput, Pressable } from 'react-native';
import { View, Text, StyleSheet, TouchableOpacity, Modal, TextInput, Pressable, KeyboardAvoidingView } from 'react-native';
import { Repeat, REPEAT_OPTIONS, WEEKDAY_LABELS, repeatDaysFromString } from '@/types';
import { useSettings } from '@/theme';
import { useRepeatProfiles } from '@/hooks/useDatabase';
@@ -246,43 +246,48 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
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>
<KeyboardAvoidingView
style={styles.modalBackdrop}
behavior="padding"
>
<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>
</Pressable>
</KeyboardAvoidingView>
</Modal>
</View>
);
@@ -290,115 +295,115 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
const styles = StyleSheet.create({
container: {
gap: 10,
gap: 8,
},
label: {
fontSize: 14,
fontSize: 13,
fontWeight: '600',
},
chipRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
gap: 6,
},
chip: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingVertical: 10,
paddingHorizontal: 12,
borderRadius: 20,
gap: 5,
paddingVertical: 8,
paddingHorizontal: 10,
borderRadius: 18,
borderWidth: 1,
},
chipText: {
fontSize: 13,
fontSize: 12,
fontWeight: '500',
},
settings: {
gap: 10,
gap: 8,
},
intervalRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
paddingHorizontal: 14,
paddingVertical: 10,
borderRadius: 12,
gap: 8,
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 10,
borderWidth: 1,
},
intervalLabel: {
fontSize: 14,
fontSize: 13,
fontWeight: '500',
},
intervalValue: {
fontSize: 16,
fontSize: 15,
fontWeight: '700',
minWidth: 24,
minWidth: 22,
textAlign: 'center',
},
stepButton: {
width: 32,
height: 32,
borderRadius: 8,
width: 28,
height: 28,
borderRadius: 7,
borderWidth: 1,
alignItems: 'center',
justifyContent: 'center',
},
stepButtonText: {
fontSize: 18,
fontSize: 16,
fontWeight: '600',
lineHeight: 20,
lineHeight: 18,
},
dayRow: {
flexDirection: 'row',
justifyContent: 'space-between',
gap: 6,
gap: 5,
},
dayChip: {
flex: 1,
height: 40,
borderRadius: 10,
height: 36,
borderRadius: 9,
borderWidth: 1,
alignItems: 'center',
justifyContent: 'center',
},
dayChipLast: {},
dayChipText: {
fontSize: 13,
fontSize: 12,
fontWeight: '600',
},
profileRow: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: 8,
gap: 6,
},
profileLabel: {
fontSize: 12,
fontSize: 11,
fontWeight: '600',
paddingTop: 8,
paddingTop: 6,
},
profileChips: {
flex: 1,
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
gap: 6,
},
profileChip: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingVertical: 6,
paddingHorizontal: 10,
borderRadius: 12,
gap: 5,
paddingVertical: 5,
paddingHorizontal: 8,
borderRadius: 10,
borderWidth: 1,
},
profileChipText: {
fontSize: 13,
fontSize: 12,
fontWeight: '500',
},
profileChipX: {
fontSize: 14,
lineHeight: 16,
fontSize: 13,
lineHeight: 15,
fontWeight: '700',
paddingHorizontal: 2,
},
@@ -406,14 +411,14 @@ const styles = StyleSheet.create({
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
paddingVertical: 10,
borderRadius: 12,
gap: 5,
paddingVertical: 8,
borderRadius: 10,
borderWidth: 1,
borderStyle: 'dashed',
},
saveProfileText: {
fontSize: 13,
fontSize: 12,
fontWeight: '600',
},
modalBackdrop: {
@@ -421,45 +426,45 @@ const styles = StyleSheet.create({
backgroundColor: 'rgba(0,0,0,0.5)',
alignItems: 'center',
justifyContent: 'center',
padding: 24,
padding: 20,
},
modalCard: {
width: '100%',
maxWidth: 400,
borderRadius: 16,
maxWidth: 360,
borderRadius: 14,
borderWidth: 1,
padding: 20,
gap: 12,
padding: 16,
gap: 10,
},
modalTitle: {
fontSize: 16,
fontSize: 15,
fontWeight: '700',
},
modalHint: {
fontSize: 13,
fontSize: 12,
},
modalInput: {
borderRadius: 10,
borderRadius: 9,
borderWidth: 1,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 14,
paddingHorizontal: 10,
paddingVertical: 8,
fontSize: 13,
},
modalButtons: {
flexDirection: 'row',
gap: 10,
gap: 8,
},
modalButton: {
flex: 1,
alignItems: 'center',
paddingVertical: 11,
borderRadius: 10,
paddingVertical: 10,
borderRadius: 9,
borderWidth: 1,
},
modalButtonPrimary: {
borderWidth: 0,
},
modalButtonText: {
fontSize: 14,
fontSize: 13,
},
});
@@ -0,0 +1,165 @@
import React, { useState } from 'react';
import { Modal, View, Text, StyleSheet, TextInput, TouchableOpacity, KeyboardAvoidingView } from 'react-native';
import { useSettings } from '@/theme';
import { DEFAULT_API_BASE_URL } from '@/services/auth';
import Svg, { Path } from 'react-native-svg';
interface ServerUrlModalProps {
visible: boolean;
onClose: () => void;
}
export function ServerUrlModal({ visible, onClose }: ServerUrlModalProps) {
const { theme, apiUrl, setApiUrl } = useSettings();
const [value, setValue] = useState(apiUrl);
const [prevVisible, setPrevVisible] = useState(visible);
if (prevVisible !== visible) {
setPrevVisible(visible);
if (visible) {
setValue(apiUrl);
}
}
const handleSave = () => {
const trimmed = value.trim().replace(/\/+$/, '');
if (trimmed && /^https?:\/\/.+/.test(trimmed)) {
setApiUrl(trimmed);
}
onClose();
};
const isValid = /^https?:\/\/.+/.test(value.trim());
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<KeyboardAvoidingView
style={styles.overlay}
behavior="padding"
>
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
<View style={styles.header}>
<Text style={[styles.title, { color: theme.text }]}>Backend URL</Text>
<TouchableOpacity onPress={onClose} style={styles.closeButton} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
<Svg width={18} height={18} viewBox="0 0 24 24">
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textMuted} strokeWidth={2} strokeLinecap="round" />
</Svg>
</TouchableOpacity>
</View>
<Text style={[styles.label, { color: theme.textSecondary }]}>API base URL</Text>
<TextInput
style={[
styles.input,
{ backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text },
]}
placeholder={DEFAULT_API_BASE_URL}
placeholderTextColor={theme.textMuted}
value={value}
onChangeText={setValue}
autoFocus
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
/>
<Text style={[styles.hint, { color: theme.textMuted }]}>
Include the /api suffix, e.g. https://example.com/api
</Text>
<View style={styles.actions}>
<TouchableOpacity
style={[styles.cancelButton, { borderColor: theme.borderStrong }]}
onPress={onClose}
activeOpacity={0.7}
>
<Text style={[styles.cancelButtonText, { color: theme.textSecondary }]}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.saveButton, { backgroundColor: theme.accent }, !isValid && styles.saveButtonDisabled]}
onPress={handleSave}
disabled={!isValid}
activeOpacity={0.8}
>
<Text style={styles.saveButtonText}>Save</Text>
</TouchableOpacity>
</View>
</View>
</KeyboardAvoidingView>
</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,
},
hint: {
fontSize: 12,
},
actions: {
flexDirection: 'row',
gap: 12,
marginTop: 16,
},
cancelButton: {
flex: 1,
paddingVertical: 12,
borderRadius: 12,
borderWidth: 1,
alignItems: 'center',
},
cancelButtonText: {
fontSize: 15,
fontWeight: '600',
},
saveButton: {
flex: 1,
paddingVertical: 12,
borderRadius: 12,
alignItems: 'center',
},
saveButtonDisabled: {
opacity: 0.5,
},
saveButtonText: {
fontSize: 15,
fontWeight: '600',
color: '#FFFFFF',
},
});
+77 -126
View File
@@ -1,145 +1,96 @@
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import React, { useState } from 'react';
import { View, StyleSheet } from 'react-native';
import { SubtaskData } from '@/types';
import { PRIORITY_COLORS } from '@/constants';
import { TaskItem } from './TaskItem';
import { useSettings } from '@/theme';
import Svg, { Path } from 'react-native-svg';
interface SubtaskItemProps {
subtask: SubtaskData;
onToggle: () => void;
onToggle: () => void | Promise<void>;
onDelete?: () => void;
onPress?: () => void;
onLongPress?: () => void;
onMenuOpen?: () => void;
selected?: boolean;
selectionMode?: boolean;
draggable?: boolean;
onDragStart?: () => void;
onDragUpdate?: (absoluteY: number) => void;
onDragEnd?: (absoluteY: number) => void;
depth?: number;
}
export function SubtaskItem({ subtask, onToggle }: SubtaskItemProps) {
export const SubtaskItem = React.memo(function SubtaskItem({
subtask,
onToggle,
onDelete,
onPress,
onLongPress,
onMenuOpen,
selected,
selectionMode,
draggable,
onDragStart,
onDragUpdate,
onDragEnd,
depth = 1
}: SubtaskItemProps) {
const { theme } = useSettings();
const [expanded, setExpanded] = useState(true);
const hasChildren = subtask.subtasks && subtask.subtasks.length > 0;
const handleExpand = () => setExpanded(!expanded);
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"
<View>
<TaskItem
task={subtask}
indented
depth={depth}
onToggle={onToggle}
onDelete={onDelete}
onPress={onPress ?? (() => {})}
onLongPress={onLongPress}
onMenuOpen={onMenuOpen}
selected={selected}
selectionMode={selectionMode}
completedSection={subtask.completed}
draggable={draggable}
onDragStart={onDragStart}
onDragUpdate={onDragUpdate}
onDragEnd={onDragEnd}
/>
{hasChildren && expanded && (
<View style={[styles.nestedSubtasks, { marginLeft: depth * 12 }]}>
{subtask.subtasks
.slice()
.sort((a, b) => a.order - b.order)
.map((child) => (
<SubtaskItem
key={child.id}
subtask={child}
onToggle={() => {}}
onDelete={onDelete}
onPress={onPress}
onLongPress={onLongPress}
onMenuOpen={onMenuOpen}
selected={selected}
selectionMode={selectionMode}
draggable={draggable}
onDragStart={onDragStart}
onDragUpdate={onDragUpdate}
onDragEnd={onDragEnd}
depth={depth + 1}
/>
</>
) : (
<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%',
nestedSubtasks: {
marginTop: 4,
},
});
@@ -159,35 +159,35 @@ function SubtaskItem({ index, value, onChange, onRemove }: SubtaskItemProps) {
const styles = StyleSheet.create({
container: {
gap: 8,
gap: 6,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 4,
paddingVertical: 2,
},
headerLeft: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
gap: 6,
},
label: {
fontSize: 14,
fontSize: 13,
fontWeight: '600',
},
badge: {
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 10,
paddingHorizontal: 6,
paddingVertical: 1,
borderRadius: 8,
},
badgeText: {
fontSize: 12,
fontSize: 11,
fontWeight: '700',
},
chevron: {
width: 16,
height: 16,
width: 14,
height: 14,
alignItems: 'center',
justifyContent: 'center',
},
@@ -195,43 +195,43 @@ const styles = StyleSheet.create({
overflow: 'hidden',
},
list: {
gap: 8,
paddingBottom: 8,
gap: 6,
paddingBottom: 6,
},
item: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
paddingHorizontal: 12,
paddingVertical: 10,
borderRadius: 12,
gap: 8,
paddingHorizontal: 10,
paddingVertical: 8,
borderRadius: 10,
borderWidth: 1,
},
checkbox: {
width: 22,
height: 22,
width: 20,
height: 20,
alignItems: 'center',
justifyContent: 'center',
},
input: {
flex: 1,
fontSize: 15,
paddingVertical: 4,
fontSize: 14,
paddingVertical: 2,
},
removeButton: {
padding: 4,
padding: 3,
},
addButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
paddingVertical: 10,
borderRadius: 12,
gap: 5,
paddingVertical: 8,
borderRadius: 10,
borderWidth: 1,
},
addButtonText: {
fontSize: 13,
fontSize: 12,
fontWeight: '500',
},
});
+20 -11
View File
@@ -7,13 +7,11 @@ import {
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';
import { runSyncGuarded, startAutoSync, stopAutoSync, getLastSyncTime, SyncResult } from '@/database/sync';
interface SyncModalProps {
visible: boolean;
@@ -35,14 +33,21 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
const [status, setStatus] = useState<Status>('idle');
const [lastSync, setLastSync] = useState<number | null>(null);
const [result, setResult] = useState<SyncResult | null>(null);
const [prevVisible, setPrevVisible] = useState(visible);
if (prevVisible !== visible) {
setPrevVisible(visible);
if (visible) {
setChecking(true);
setError(null);
setStatus('idle');
setResult(null);
}
}
useEffect(() => {
if (!visible) return;
let mounted = true;
setChecking(true);
setError(null);
setStatus('idle');
setResult(null);
(async () => {
const token = await getAuthToken();
@@ -77,6 +82,7 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
? await register(username.trim(), password)
: await login(username.trim(), password);
setUser(authedUser);
startAutoSync();
} catch (err: any) {
setError(err?.message ?? 'Sign in failed');
} finally {
@@ -89,13 +95,15 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
setError(null);
setStatus('syncing');
try {
const syncResult = await runSync();
const syncResult = await runSyncGuarded();
setResult(syncResult);
setStatus('success');
setLastSync(Date.now());
} catch (err: any) {
setStatus('error');
if (err?.message === 'NOT_SIGNED_IN') {
setStatus('idle');
if (err?.message === 'SYNC_IN_FLIGHT') {
setError('Sync already in progress');
} else if (err?.message === 'NOT_SIGNED_IN') {
setUser(null);
setError('Not signed in. Sign in to sync.');
} else {
@@ -106,6 +114,7 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
const handleSignOut = async () => {
await signOutAuth();
stopAutoSync();
setUser(null);
setError(null);
setStatus('idle');
@@ -116,7 +125,7 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<KeyboardAvoidingView
style={styles.overlay}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
behavior="padding"
>
<TouchableOpacity style={styles.backdrop} activeOpacity={1} onPress={onClose} />
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
@@ -2,7 +2,7 @@ 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';
import { runSyncGuarded, getLastSyncTime } from '@/database/sync';
interface SyncStatusProps {
compact?: boolean;
@@ -13,21 +13,23 @@ export default function SyncStatus({ compact = false }: SyncStatusProps) {
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 [now] = useState(() => Date.now());
const loadLastSync = async () => {
const time = await getLastSyncTime();
setLastSync(time);
};
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
loadLastSync();
}, []);
const handleSync = async () => {
setStatus('syncing');
setError(null);
try {
await runSync();
await runSyncGuarded();
setStatus('success');
await loadLastSync();
setTimeout(() => setStatus('idle'), 3000);
@@ -40,7 +42,7 @@ export default function SyncStatus({ compact = false }: SyncStatusProps) {
const formatTime = (timestamp: number | null): string => {
if (!timestamp) return 'Never';
const diff = Date.now() - timestamp;
const diff = 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`;
+15 -1
View File
@@ -3,7 +3,7 @@ import { ColorValue } from 'react-native';
import Svg, { Path, Circle } from 'react-native-svg';
interface TabBarIconProps {
name: 'checklist' | 'calendar' | 'gear';
name: 'checklist' | 'calendar' | 'gear' | 'stats';
focused: boolean;
color: ColorValue;
size?: number;
@@ -51,6 +51,20 @@ export function TabBarIcon({ name, focused, color, size = 24 }: TabBarIconProps)
fill="none"
/>
)}
{name === 'stats' && (
<>
<Path
d="M4 20V10M10 20V4M16 20v-7M21 20H3"
stroke={color}
strokeWidth={focused ? 2.5 : 2}
strokeLinecap="round"
strokeLinejoin="round"
/>
{focused && (
<Circle cx={4} cy={10} r={2} fill={color} />
)}
</>
)}
</Svg>
);
}
@@ -9,16 +9,17 @@ interface TaskDeleteModalProps {
taskId: string | null;
taskTitle?: string;
isRepeating?: boolean;
subtask?: boolean;
onClose: () => void;
onDelete: (scope: TaskDeleteScope) => void;
}
export function TaskDeleteModal({ visible, taskId, taskTitle, isRepeating, onClose, onDelete }: TaskDeleteModalProps) {
export function TaskDeleteModal({ visible, taskId, taskTitle, isRepeating, subtask, onClose, onDelete }: TaskDeleteModalProps) {
const { theme } = useSettings();
const [counts, setCounts] = useState({ future: 1, all: 1 });
useEffect(() => {
if (!visible || !taskId) return;
if (!visible || !taskId || subtask) return;
let mounted = true;
getSeriesOccurrenceCounts(taskId)
.then((c) => {
@@ -26,11 +27,11 @@ export function TaskDeleteModal({ visible, taskId, taskTitle, isRepeating, onClo
})
.catch(() => {});
return () => { mounted = false; };
}, [visible, taskId]);
}, [visible, taskId, subtask]);
const options: Array<{ scope: TaskDeleteScope; label: string; hint?: string }> = [
{ scope: 'this', label: 'This task only' },
];
const options: { scope: TaskDeleteScope; label: string; hint?: string }[] = subtask
? [{ scope: 'this', label: 'This subtask only' }]
: [{ 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 });
@@ -42,7 +43,7 @@ export function TaskDeleteModal({ visible, taskId, taskTitle, isRepeating, onClo
<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>
<Text style={[styles.title, { color: theme.text }]}>{subtask ? 'Delete Subtask' : 'Delete Task'}</Text>
<TouchableOpacity onPress={onClose} style={styles.closeButton} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
<Svg width={18} height={18} viewBox="0 0 24 24">
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textMuted} strokeWidth={2} strokeLinecap="round" />
+122 -28
View File
@@ -1,13 +1,27 @@
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 { View, Text, StyleSheet, TouchableOpacity, Animated, Platform, Dimensions } from 'react-native';
import { Swipeable, Gesture, GestureDetector, PanGestureHandler } from 'react-native-gesture-handler';
import { TaskData, SubtaskData, Priority, Repeat, Reminder } from '@/types';
import { PRIORITY_COLORS } from '@/constants';
import { useSettings } from '@/theme';
import Svg, { Path, Circle } from 'react-native-svg';
export type TaskLike = {
id: string;
title: string;
priority: Priority;
assigneeId: string | null;
repeat: Repeat;
allDay: boolean;
completed: boolean;
dueDate: number;
dueTime: string;
endTime: string;
reminder: Reminder;
};
interface TaskItemProps {
task: TaskData;
task: TaskData | SubtaskData | TaskLike;
onToggle: () => void | Promise<void>;
onDelete?: () => void;
onPress: () => void;
@@ -20,17 +34,22 @@ interface TaskItemProps {
hovered?: boolean;
onDragStart?: () => void;
onDragUpdate?: (absoluteY: number) => void;
onDragEnd?: (absoluteY: number) => void;
assigneeUsername?: string;
expanded?: boolean;
onDragEnd?: (absoluteY: number) => void;
onReorderStart?: () => void;
onReorderUpdate?: (translationY: number) => void;
onReorderEnd?: (translationY: number) => void;
expanded?: boolean;
indented?: boolean;
depth?: number;
}
export function TaskItem({ task, onToggle, onDelete, onPress, onLongPress, onMenuOpen, selected, selectionMode, completedSection, draggable, hovered, onDragStart, onDragUpdate, onDragEnd, expanded }: TaskItemProps) {
export const TaskItem = React.memo(function TaskItem({ task, onToggle, onDelete, onPress, onLongPress, onMenuOpen, selected, selectionMode, completedSection, draggable, hovered, onDragStart, onDragUpdate, onDragEnd, onReorderStart, onReorderUpdate, onReorderEnd, expanded, indented, depth = 0 }: 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 [dragging, setDragging] = React.useState(false);
const [dragStartY, setDragStartY] = React.useState(0);
const swipeableRef = React.useRef<Swipeable>(null);
const [rotateAnim] = React.useState(new Animated.Value(0));
@@ -44,13 +63,40 @@ export function TaskItem({ task, onToggle, onDelete, onPress, onLongPress, onMen
const rotate = rotateAnim.interpolate({ inputRange: [0, 1], outputRange: ['0deg', '180deg'] });
const reorderGesture = React.useMemo(
() =>
Gesture.Pan()
.activateAfterLongPress(0)
.minDistance(5)
.runOnJS(true)
.onStart((e) => {
setDragging(true);
setDragStartY(e.absoluteY);
onReorderStart?.();
})
.onUpdate((e) => {
dragTranslateX.setValue(e.translationX);
dragTranslateY.setValue(e.translationY);
onReorderUpdate?.(e.translationY);
})
.onEnd((e) => {
onReorderEnd?.(e.translationY);
})
.onFinalize(() => {
setDragging(false);
dragTranslateX.setValue(0);
dragTranslateY.setValue(0);
}),
[onReorderStart, onReorderUpdate, onReorderEnd, dragTranslateX, dragTranslateY]
);
const dragGesture = React.useMemo(
() =>
Gesture.Pan()
.activateAfterLongPress(400)
.minDistance(2)
.runOnJS(true)
. onStart(() => {
.onStart(() => {
setDragging(true);
onDragStart?.();
})
@@ -78,12 +124,24 @@ export function TaskItem({ task, onToggle, onDelete, onPress, onLongPress, onMen
}).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 dueInfo = React.useMemo(() => {
const startOfToday = new Date();
startOfToday.setHours(0, 0, 0, 0);
const endOfToday = new Date();
endOfToday.setHours(23, 59, 59, 999);
const hasDueDate = task.dueDate > 0;
const isOverdue = hasDueDate && !task.completed && task.dueDate < startOfToday.getTime();
const isDueToday = hasDueDate && !task.completed && task.dueDate >= startOfToday.getTime() && task.dueDate <= endOfToday.getTime();
const canComplete = !hasDueDate || isOverdue || isDueToday;
return { hasDueDate, isOverdue, isDueToday, canComplete };
}, [task.dueDate, task.completed]);
const { hasDueDate, isOverdue, isDueToday, canComplete } = dueInfo;
const formattedDueDate = React.useMemo(
() => formatDueDate(task.dueDate, task.dueTime, task.endTime || ''),
[task.dueDate, task.dueTime, task.endTime]
);
const renderRightActions = (progress: Animated.AnimatedInterpolation<number>) => {
const translateX = progress.interpolate({ inputRange: [0, 1], outputRange: [80, 0] });
@@ -138,19 +196,20 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
overshootRight={false}
overshootLeft={false}
>
<GestureDetector gesture={draggable ? dragGesture : Gesture.Native()}>
<GestureDetector gesture={draggable ? (onReorderStart ? reorderGesture : dragGesture) : Gesture.Native()}>
<Animated.View
style={[
styles.container,
{ backgroundColor: theme.card, borderColor: theme.border },
(indented || depth > 0) && { marginLeft: depth > 0 ? depth * 12 : 32, marginBottom: 4 },
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,
hovered && styles.taskHovered,
{ transform: [{ translateX: dragTranslateX }, { translateY: dragTranslateY }] },
]}
pointerEvents={dragging ? 'none' : 'auto'}
@@ -164,6 +223,24 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
>
<View style={styles.content}>
<View style={styles.titleRow}>
<TouchableOpacity
style={[styles.dragHandle, { opacity: draggable ? 1 : 0 }]}
accessible={false}
onPressIn={() => {}}
onPressOut={() => {}}
>
<Svg width={20} height={20} viewBox="0 0 24 24">
<Circle cx="6" cy="6" r="2" fill={theme.textFaint} />
<Circle cx="6" cy="12" r="2" fill={theme.textFaint} />
<Circle cx="6" cy="18" r="2" fill={theme.textFaint} />
<Circle cx="12" cy="6" r="2" fill={theme.textFaint} />
<Circle cx="12" cy="12" r="2" fill={theme.textFaint} />
<Circle cx="12" cy="18" r="2" fill={theme.textFaint} />
<Circle cx="18" cy="6" r="2" fill={theme.textFaint} />
<Circle cx="18" cy="12" r="2" fill={theme.textFaint} />
<Circle cx="18" cy="18" r="2" fill={theme.textFaint} />
</Svg>
</TouchableOpacity>
<TouchableOpacity
style={[styles.checkCircle, !canComplete && !task.completed && styles.checkCircleDisabled]}
onPress={canComplete || task.completed ? onToggle : undefined}
@@ -252,7 +329,7 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
</Svg>
</Animated.View>
</View>
{task.dueDate && (
{hasDueDate && (
<View style={styles.dueRow}>
<Svg width={14} height={14} viewBox="0 0 24 24">
<Circle cx={12} cy={12} r={10} stroke={theme.textMuted} strokeWidth={1.5} fill="none" />
@@ -266,7 +343,7 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
isDueToday && styles.dueTextDueToday,
]}
>
{formatDueDate(task.dueDate, task.dueTime, task.endTime || '')}
{formattedDueDate}
</Animated.Text>
{task.reminder && task.reminder !== 'none' && (
<View style={styles.reminderIcon}>
@@ -302,7 +379,7 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
</GestureDetector>
</Swipeable>
);
}
});
function formatDueDate(dueDate: number, dueTime: string, endTime?: string): string {
const date = new Date(dueDate);
@@ -338,19 +415,24 @@ const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingHorizontal: 12,
paddingVertical: 14,
borderRadius: 16,
borderWidth: 1,
marginVertical: 4,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.04,
shadowRadius: 4,
shadowOpacity: 0.05,
shadowRadius: 6,
elevation: 1,
},
taskCompleted: {
opacity: 0.5,
},
indented: {
marginLeft: 32,
marginBottom: 4,
},
taskOverdue: {
borderColor: '#4A2B2B',
},
@@ -363,6 +445,10 @@ const styles = StyleSheet.create({
shadowRadius: 6,
elevation: 2,
},
taskHovered: {
borderColor: '#1E88E5',
backgroundColor: 'rgba(30, 136, 229, 0.05)',
},
dragLifted: {
zIndex: 100,
elevation: 12,
@@ -375,18 +461,26 @@ const styles = StyleSheet.create({
flex: 1,
},
content: {
gap: 4,
gap: 12,
},
titleRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
dragHandle: {
width: 28,
height: 28,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
marginRight: 8,
},
title: {
fontSize: 16,
fontSize: 17,
fontWeight: '500',
flex: 1,
marginRight: 8,
marginRight: 16,
},
titleCompleted: {
textDecorationLine: 'line-through',
@@ -444,7 +538,7 @@ const styles = StyleSheet.create({
dueRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
gap: 8,
},
dueText: {
fontSize: 13,
+473 -204
View File
@@ -1,30 +1,25 @@
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 React, { useState, useCallback, useMemo, useRef } from 'react';
import { View, Text, StyleSheet, Animated, RefreshControl, Alert, TouchableOpacity } from 'react-native';
import { useTasks } from '@/hooks/useTasks';
import { useTaskModals } from '@/hooks/useTaskModals';
import { TaskItem } from './TaskItem';
import { SubtaskItem } from './SubtaskItem';
import { TaskData, SubtaskData } from '@/types';
import { useCategories, useDatabase } from '@/hooks/useDatabase';
import Task from '@/models/Task';
import { 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,
convertSubtaskToTask,
moveSubtaskToTask,
toggleSubtaskComplete,
TaskDeleteScope,
reorderTasks,
} from '@/utils/taskActions';
import { PRIORITY_LABELS } from '@/constants';
import { Q } from '@nozbe/watermelondb';
import Svg, { Path } from 'react-native-svg';
import Svg, { Path, Rect } from 'react-native-svg';
interface TaskListProps {
categoryId?: string;
@@ -33,11 +28,20 @@ interface TaskListProps {
const PRIORITY_RANK: Record<string, number> = { none: 0, low: 1, medium: 2, high: 3, critical: 4 };
const SEPARATOR = () => <View style={styles.separator} />;
const MemoSeparator = React.memo(SEPARATOR);
const DropIndicator = ({ theme }: { theme: any }) => (
<View style={styles.dropIndicatorContainer}>
<View style={[styles.dropIndicator, { backgroundColor: theme.accent }]} />
<View style={[styles.dropIndicatorDot, { backgroundColor: theme.accent }]} />
<View style={[styles.dropIndicatorDot, { backgroundColor: theme.accent }]} />
</View>
);
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);
@@ -45,16 +49,30 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
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 [reorderState, setReorderState] = useState<{ draggedId: string; draggedIndex: number; targetIndex: number | null; positions: Record<string, { top: number; bottom: number }> } | null>(null);
const [dropIndicator, setDropIndicator] = useState<{ targetId: string | null; position: 'above' | 'below' } | null>(null);
const itemRefs = useRef<Map<string, View>>(new Map());
const dragStateRef = useRef<{ taskId: string; positions: Record<string, { top: number; bottom: number }> } | null>(null);
const subtaskDragRef = useRef<{ subtaskId: string; parentTaskId: string; positions: Record<string, { top: number; bottom: number }> } | null>(null);
const {
modals,
openTaskMenu,
openTaskDelete,
openSubtaskMenu,
openSubtaskDelete,
openSubtaskEdit,
} = useTaskModals();
const allTasks = useMemo(() => [...tasks, ...completedTasks], [tasks, completedTasks]);
const registerRef = useCallback((taskId: string, ref: View | null) => {
if (ref) {
itemRefs.current.set(taskId, ref);
} else {
itemRefs.current.delete(taskId);
}
}, []);
const sortedTasks = useMemo(() => {
const sorted = [...tasks];
@@ -75,16 +93,6 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
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);
@@ -118,22 +126,12 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
});
}, [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 subs = await collections.subtasks.query(Q.where('task_id', taskId), Q.where('parent_subtask_id', null)).fetch();
const mapped: SubtaskData[] = subs.map((s: any) => ({
id: s.id,
taskId: s.taskId,
parentSubtaskId: s.parentSubtaskId || null,
title: s.title,
description: s.description || '',
priority: (s.priority || 'none') as SubtaskData['priority'],
@@ -149,11 +147,62 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
reminder: (s.reminder || 'none') as SubtaskData['reminder'],
assigneeId: s.assigneeId ?? null,
order: s.order,
subtasks: [],
}));
setSubtasksMap((prev) => new Map(prev).set(taskId, mapped));
return mapped;
// Fetch nested subtasks for each subtask
const fetchNested = async (subtaskId: string): Promise<SubtaskData[]> => {
const nested = await collections.subtasks.query(Q.where('parent_subtask_id', subtaskId)).fetch();
return nested.map((s: any) => ({
id: s.id,
taskId: s.taskId,
parentSubtaskId: s.parentSubtaskId || null,
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,
subtasks: [],
}));
};
// Recursively fetch all nested subtasks
const buildNested = async (subtasks: SubtaskData[]): Promise<SubtaskData[]> => {
for (const sub of subtasks) {
const children = await fetchNested(sub.id);
if (children.length > 0) {
sub.subtasks = await buildNested(children);
}
}
return subtasks;
};
const withNested = await buildNested(mapped);
setSubtasksMap((prev) => new Map(prev).set(taskId, withNested));
return withNested;
}, [collections.subtasks]);
const refreshAll = useCallback(() => {
for (const taskId of expandedTasks) {
fetchSubtasks(taskId);
}
}, [expandedTasks, fetchSubtasks]);
const handleToggle = useCallback(async (taskId: string) => {
await toggleTaskComplete(taskId);
refreshAll();
}, [refreshAll]);
const toggleExpand = useCallback(async (taskId: string) => {
setExpandedTasks((prev) => {
const next = new Set(prev);
@@ -182,23 +231,6 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
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' },
@@ -206,9 +238,7 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
text: 'Delete',
style: 'destructive',
onPress: async () => {
for (const taskId of selectedIds) {
await deleteTask(taskId);
}
await Promise.all(Array.from(selectedIds).map(taskId => deleteTask(taskId)));
exitSelection();
refreshAll();
},
@@ -217,27 +247,11 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
}, [selectedIds, exitSelection, refreshAll]);
const handleBulkComplete = useCallback(async () => {
for (const taskId of selectedIds) {
await setTaskCompleted(taskId, true);
}
await Promise.all(Array.from(selectedIds).map(taskId => 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());
@@ -262,33 +276,268 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
return null;
}, []);
const calculateDropPosition = useCallback((absoluteY: number, targetId: string, positions: Record<string, { top: number; bottom: number }>) => {
const target = positions[targetId];
if (!target) return 'below' as const;
const middle = (target.top + target.bottom) / 2;
return absoluteY < middle ? 'above' : 'below';
}, []);
const handleDragStart = useCallback(async (taskId: string) => {
enterSelection(taskId);
dragStateRef.current = { taskId, positions: await measureItems() };
}, [enterSelection, measureItems]);
}, [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]);
if (target) {
const position = calculateDropPosition(absoluteY, target, state.positions);
setDropIndicator({ targetId: target, position });
} else {
// Check if below last item
const positions = state.positions;
const lastItem = Object.values(positions).reduce((max, p) => p.bottom > max.bottom ? p : max, { bottom: 0 });
if (absoluteY > lastItem.bottom) {
setDropIndicator({ targetId: null, position: 'below' });
} else {
setDropIndicator(null);
}
}
}, [findHoverTarget, calculateDropPosition]);
const handleDragEnd = useCallback((absoluteY: number) => {
const state = dragStateRef.current;
dragStateRef.current = null;
setHoverTaskId(null);
setDropIndicator(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]);
}, [findHoverTarget, refreshAll]);
const measureReorderItems = 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 findReorderTarget = 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 calculateReorderDropPosition = useCallback((absoluteY: number, targetId: string, positions: Record<string, { top: number; bottom: number }>) => {
const target = positions[targetId];
if (!target) return 'below' as const;
const middle = (target.top + target.bottom) / 2;
return absoluteY < middle ? 'above' : 'below';
}, []);
const handleReorderStart = useCallback(async (taskId: string) => {
const positions = await measureReorderItems();
const draggedIndex = sortedTasks.findIndex(t => t.id === taskId);
if (draggedIndex === -1) return;
setReorderState({ draggedId: taskId, draggedIndex, targetIndex: null, positions });
}, [measureReorderItems, sortedTasks]);
const handleReorderUpdate = useCallback((absoluteY: number) => {
const state = reorderState;
if (!state) return;
const targetId = findReorderTarget(absoluteY, state.draggedId, state.positions);
let targetIndex = null;
if (targetId) {
targetIndex = sortedTasks.findIndex(t => t.id === targetId);
const position = calculateReorderDropPosition(absoluteY, targetId, state.positions);
setDropIndicator({ targetId, position });
} else {
// Check if below last item
const positions = state.positions;
const lastItem = Object.values(positions).reduce((max, p) => p.bottom > max.bottom ? p : max, { bottom: 0 });
if (absoluteY > lastItem.bottom) {
setDropIndicator({ targetId: null, position: 'below' });
targetIndex = sortedTasks.length; // Insert at end
} else {
setDropIndicator(null);
}
}
setReorderState(prev => prev ? { ...prev, targetIndex } : null);
setHoverTaskId(targetId);
}, [findReorderTarget, calculateReorderDropPosition, reorderState, sortedTasks]);
const handleReorderEnd = useCallback(async (translationY: number) => {
const state = reorderState;
setReorderState(null);
setHoverTaskId(null);
setDropIndicator(null);
if (!state) return;
if (state.targetIndex !== null && state.targetIndex !== state.draggedIndex) {
const newOrder = [...sortedTasks];
const [removed] = newOrder.splice(state.draggedIndex, 1);
newOrder.splice(state.targetIndex, 0, removed);
const newTaskIds = newOrder.map(t => t.id);
await reorderTasks(newTaskIds);
refreshAll();
}
}, [reorderState, sortedTasks, reorderTasks, refreshAll]);
const handleSubtaskDragStart = useCallback(async (subtaskId: string, parentTaskId: string) => {
subtaskDragRef.current = { subtaskId, parentTaskId, positions: await measureItems() };
}, [measureItems]);
const handleSubtaskDragEnd = useCallback(async (absoluteY: number) => {
const state = subtaskDragRef.current;
subtaskDragRef.current = null;
setHoverTaskId(null);
setDropIndicator(null);
if (!state) return;
const target = findHoverTarget(absoluteY, state.subtaskId, state.positions);
if (target === state.parentTaskId) return;
if (target) {
await moveSubtaskToTask(state.subtaskId, target);
await fetchSubtasks(target);
} else {
await convertSubtaskToTask(state.subtaskId);
}
await fetchSubtasks(state.parentTaskId);
refreshAll();
}, [findHoverTarget, fetchSubtasks, refreshAll]);
const renderItem = useCallback(
({ item, index }: { item: Task; index: number }) => {
const isExpanded = expandedTasks.has(item.id);
const itemSubtasks = subtasksMap.get(item.id) ?? [];
const showDropAbove = dropIndicator?.targetId === item.id && dropIndicator?.position === 'above';
const showDropBelow = dropIndicator?.targetId === item.id && dropIndicator?.position === 'below';
return (
<View>
{showDropAbove && <DropIndicator theme={theme} />}
<TaskRow
task={item}
expanded={isExpanded}
subtasks={itemSubtasks}
selected={selectedIds.has(item.id)}
selectionMode={selectionMode}
hovered={hoverTaskId === item.id}
registerRef={registerRef}
onToggle={handleToggle}
onDelete={openTaskDelete}
onExpand={toggleExpand}
onSelect={toggleSelect}
onEnterSelection={enterSelection}
onMenuOpen={openTaskMenu}
onSubtaskToggle={handleSubtaskToggle}
onSubtaskDelete={openSubtaskDelete}
onSubtaskEdit={openSubtaskEdit}
onSubtaskMenuOpen={openSubtaskMenu}
onDragStart={handleDragStart}
onDragUpdate={handleDragUpdate}
onDragEnd={handleDragEnd}
onSubtaskDragStart={handleSubtaskDragStart}
onSubtaskDragEnd={handleSubtaskDragEnd}
onReorderStart={handleDragStart}
onReorderUpdate={handleDragUpdate}
onReorderEnd={handleDragEnd}
selectedIds={selectedIds}
/>
{showDropBelow && <DropIndicator theme={theme} />}
</View>
);
},
[
expandedTasks,
subtasksMap,
selectedIds,
selectionMode,
hoverTaskId,
dropIndicator,
theme,
registerRef,
handleToggle,
openTaskDelete,
toggleExpand,
toggleSelect,
enterSelection,
openTaskMenu,
handleSubtaskToggle,
openSubtaskDelete,
openSubtaskEdit,
openSubtaskMenu,
handleDragStart,
handleDragUpdate,
handleDragEnd,
handleSubtaskDragStart,
handleSubtaskDragEnd,
]
);
const listHeader = useMemo(() => {
if (sortedTasks.length > 0 || completedTasks.length > 0) return null;
return (
<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, completedTasks.length, theme.textSecondary, theme.textMuted]);
const listFooter = useMemo(() => {
const footerContent = completedTasks.length === 0 ? null : (
<CompletedSection
tasks={completedTasks}
onToggle={(task) => handleToggle(task.id)}
onDelete={openTaskDelete}
onMenuOpen={openTaskMenu}
onLongPress={(task) => enterSelection(task.id)}
selectionMode={selectionMode}
selectedIds={selectedIds}
onSelect={toggleSelect}
/>
);
const showDropAtEnd = dropIndicator && dropIndicator.targetId === null;
return (
<View>
{footerContent}
{showDropAtEnd && <DropIndicator theme={theme} />}
</View>
);
}, [
completedTasks,
handleToggle,
openTaskDelete,
openTaskMenu,
enterSelection,
selectionMode,
selectedIds,
toggleSelect,
dropIndicator,
theme,
]);
if (loading && !refreshing) {
return (
@@ -298,86 +547,16 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
);
}
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
}
renderItem={renderItem}
extraData={{ expandedTasks, subtasksMap, selectedIds, selectionMode, hoverTaskId, dropIndicator }}
ItemSeparatorComponent={MemoSeparator}
ListHeaderComponent={listHeader}
ListFooterComponent={listFooter}
refreshControl={
<RefreshControl
refreshing={refreshing}
@@ -390,44 +569,7 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
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)}
/>
{modals(refreshAll)}
{selectionMode && (
<View style={[styles.selectionBar, { backgroundColor: theme.sheetBg, borderTopColor: theme.border }]}>
@@ -448,8 +590,7 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
);
}
function SelectionButton({ label, color, onPress }: { label: string; color: string; onPress: () => void }) {
const { theme } = useSettings();
const SelectionButton = React.memo(function SelectionButton({ label, color, onPress }: { label: string; color: string; onPress: () => void }) {
return (
<TouchableOpacity
style={[styles.selectionButton, { backgroundColor: color }]}
@@ -459,8 +600,117 @@ function SelectionButton({ label, color, onPress }: { label: string; color: stri
<Text style={styles.selectionButtonText}>{label}</Text>
</TouchableOpacity>
);
});
interface TaskRowProps {
task: Task;
expanded: boolean;
subtasks: SubtaskData[];
selected: boolean;
selectionMode: boolean;
hovered: boolean;
registerRef: (taskId: string, ref: View | null) => void;
onToggle: (taskId: string) => void | Promise<void>;
onDelete: (task: Task) => void;
onExpand: (taskId: string) => void | Promise<void>;
onSelect: (taskId: string) => void;
onEnterSelection: (taskId: string) => void;
onMenuOpen: (task: Task) => void;
onSubtaskToggle: (subtaskId: string, taskId: string) => void;
onSubtaskDelete: (subtask: SubtaskData) => void;
onSubtaskEdit: (subtaskId: string) => void;
onSubtaskMenuOpen: (subtask: SubtaskData) => void;
onDragStart: (taskId: string) => void;
onDragUpdate: (absoluteY: number) => void;
onDragEnd: (absoluteY: number) => void;
onSubtaskDragStart: (subtaskId: string, parentTaskId: string) => void;
onSubtaskDragEnd: (absoluteY: number) => void;
onReorderStart: (taskId: string) => void;
onReorderUpdate: (absoluteY: number) => void;
onReorderEnd: (translationY: number) => void;
selectedIds: Set<string>;
}
const TaskRow = React.memo(function TaskRow({
task,
expanded,
subtasks,
selected,
selectionMode,
hovered,
registerRef,
onToggle,
onDelete,
onExpand,
onSelect,
onEnterSelection,
onMenuOpen,
onSubtaskToggle,
onSubtaskDelete,
onSubtaskEdit,
onSubtaskMenuOpen,
onDragStart,
onDragUpdate,
onDragEnd,
onSubtaskDragStart,
onSubtaskDragEnd,
onReorderStart,
onReorderUpdate,
onReorderEnd,
selectedIds,
}: TaskRowProps) {
const sortedSubtasks = useMemo(
() => subtasks.slice().sort((a, b) => a.order - b.order),
[subtasks]
);
return (
<View
ref={(ref) => registerRef(task.id, ref)}
style={styles.dragContainer}
>
<TaskItem
task={task as unknown as TaskData}
onToggle={() => onToggle(task.id)}
onDelete={() => onDelete(task)}
onPress={() => (selectionMode ? onSelect(task.id) : onExpand(task.id))}
onLongPress={selectionMode ? undefined : () => onEnterSelection(task.id)}
onMenuOpen={() => onMenuOpen(task)}
selected={selected}
selectionMode={selectionMode}
expanded={expanded}
draggable
hovered={hovered}
onDragStart={() => onDragStart(task.id)}
onDragUpdate={onDragUpdate}
onDragEnd={onDragEnd}
onReorderStart={() => onReorderStart(task.id)}
onReorderUpdate={onReorderUpdate}
onReorderEnd={onReorderEnd}
/>
{expanded && subtasks.length > 0 && (
<View style={styles.subtaskList}>
{sortedSubtasks.map((sub) => (
<SubtaskItem
key={sub.id}
subtask={sub}
onToggle={() => onSubtaskToggle(sub.id, task.id)}
onDelete={() => onSubtaskDelete(sub)}
onPress={() => onSubtaskEdit(sub.id)}
onMenuOpen={() => onSubtaskMenuOpen(sub)}
selected={selectedIds.has(sub.id)}
selectionMode={selectionMode}
draggable
onDragStart={() => onSubtaskDragStart(sub.id, task.id)}
onDragUpdate={onDragUpdate}
onDragEnd={onSubtaskDragEnd}
/>
))}
</View>
)}
</View>
);
});
interface CompletedSectionProps {
tasks: TaskData[];
onToggle: (task: TaskData) => void;
@@ -472,7 +722,7 @@ interface CompletedSectionProps {
onSelect: (taskId: string) => void;
}
function CompletedSection({ tasks, onToggle, onDelete, onMenuOpen, onLongPress, selectionMode, selectedIds, onSelect }: CompletedSectionProps) {
const CompletedSection = React.memo(function CompletedSection({ tasks, onToggle, onDelete, onMenuOpen, onLongPress, selectionMode, selectedIds, onSelect }: CompletedSectionProps) {
const { theme } = useSettings();
const [expanded, setExpanded] = useState(false);
@@ -509,7 +759,7 @@ function CompletedSection({ tasks, onToggle, onDelete, onMenuOpen, onLongPress,
)}
</View>
);
}
});
const styles = StyleSheet.create({
container: {
@@ -518,7 +768,7 @@ const styles = StyleSheet.create({
listContent: {
paddingHorizontal: 16,
paddingTop: 8,
paddingBottom: 100,
paddingBottom: 120,
},
loadingContainer: {
flex: 1,
@@ -534,6 +784,25 @@ const styles = StyleSheet.create({
subtaskList: {
paddingLeft: 8,
paddingRight: 4,
paddingTop: 8,
},
dragContainer: {
},
dropIndicatorContainer: {
height: 8,
justifyContent: 'center',
alignItems: 'center',
},
dropIndicator: {
width: '80%',
height: 2,
borderRadius: 1,
},
dropIndicatorDot: {
width: 8,
height: 8,
borderRadius: 4,
position: 'absolute',
},
emptyState: {
alignItems: 'center',
@@ -1,6 +1,5 @@
import React from 'react';
import { View, Text, StyleSheet, TextInput } from 'react-native';
import { TextInputProps } from 'react-native';
import { View, Text, StyleSheet, TextInput , TextInputProps } from 'react-native';
import { useSettings } from '@/theme';
interface TaskNameInputProps extends TextInputProps {
@@ -35,34 +34,34 @@ export function TaskNameInput({ error, ...props }: TaskNameInputProps) {
const styles = StyleSheet.create({
container: {
gap: 6,
gap: 5,
},
labelRow: {
flexDirection: 'row',
alignItems: 'center',
},
label: {
fontSize: 14,
fontSize: 13,
fontWeight: '600',
},
required: {
color: '#E53935',
fontSize: 14,
fontSize: 13,
fontWeight: '600',
},
input: {
height: 52,
paddingHorizontal: 16,
borderRadius: 12,
height: 48,
paddingHorizontal: 14,
borderRadius: 10,
borderWidth: 1,
fontSize: 16,
fontSize: 15,
},
inputError: {
borderColor: '#E53935',
borderWidth: 1.5,
},
errorText: {
fontSize: 12,
fontSize: 11,
color: '#E53935',
marginLeft: 4,
},
@@ -1,19 +1,21 @@
import React from 'react';
import { View, Text, StyleSheet, Modal, TouchableOpacity } from 'react-native';
import { useSettings } from '@/theme';
import { TaskData } from '@/types';
import { TaskData, SubtaskData } from '@/types';
import Svg, { Path } from 'react-native-svg';
interface TaskOverflowMenuProps {
visible: boolean;
task: TaskData | null;
task: TaskData | SubtaskData | null;
subtask?: boolean;
onClose: () => void;
onEdit: () => void;
onDelete: () => void;
onDuplicate: () => void;
onToggleComplete: () => void;
onChangeCategory: () => void;
onChangeCategory?: () => void;
onChangePriority: () => void;
onAddSubtask?: () => void;
}
interface MenuAction {
@@ -27,6 +29,7 @@ interface MenuAction {
export function TaskOverflowMenu({
visible,
task,
subtask,
onClose,
onEdit,
onDelete,
@@ -34,6 +37,7 @@ export function TaskOverflowMenu({
onToggleComplete,
onChangeCategory,
onChangePriority,
onAddSubtask,
}: TaskOverflowMenuProps) {
const { theme } = useSettings();
@@ -58,12 +62,18 @@ export function TaskOverflowMenu({
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,
},
{
];
if (!subtask) {
actions.push({
key: 'category',
label: 'Change Category',
icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Circle2 /></Svg>,
onPress: onChangeCategory,
},
onPress: onChangeCategory ?? (() => {}),
});
}
actions.push(
{
key: 'priority',
label: 'Change Priority',
@@ -77,7 +87,16 @@ export function TaskOverflowMenu({
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,
},
];
);
if (subtask && onAddSubtask) {
actions.push({
key: 'addSubtask',
label: 'Add Subtask',
icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Path d="M12 5v14M5 12h14" stroke={theme.textSecondary} strokeWidth={2} strokeLinecap="round" /></Svg>,
onPress: onAddSubtask,
});
}
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
@@ -0,0 +1,30 @@
import { useEffect } from 'react';
import { Alert, Linking } from 'react-native';
import { checkForUpdates, wasPromptedFor, markPrompted } from '@/services/updates';
export function UpdateNotifier() {
useEffect(() => {
const run = async () => {
const update = await checkForUpdates();
if (!update || (await wasPromptedFor(update.tagName))) return;
await markPrompted(update.tagName);
const url = update.apkUrl ?? update.releaseUrl;
Alert.alert(
'Update available',
`A new version (${update.version}) is available for download.`,
[
{ text: 'Later', style: 'cancel' },
{
text: 'Download',
onPress: () => {
if (url) Linking.openURL(url).catch(() => {});
},
},
],
);
};
run();
}, []);
return null;
}
@@ -110,12 +110,6 @@ function WheelColumn({
[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
@@ -143,10 +137,12 @@ function WheelColumn({
);
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]);
// Let FlatList's native snap handle the alignment
// Just update the indexRef from the scroll position
const current = listRef.current;
if (!current) return;
// The native snap will handle positioning, we just sync the index
}, []);
return (
<FlatList
@@ -161,7 +157,7 @@ function WheelColumn({
})}
initialScrollIndex={initialIndex}
snapToOffsets={data.map((_, i) => i * ITEM_HEIGHT)}
decelerationRate="fast"
decelerationRate="normal"
showsVerticalScrollIndicator={false}
onScroll={handleScroll}
onScrollEndDrag={handleScrollEnd}
@@ -169,6 +165,8 @@ function WheelColumn({
scrollEventThrottle={16}
style={[styles.column, { width }]}
contentContainerStyle={{ paddingBottom: (VISIBLE_ITEMS - 1) * ITEM_HEIGHT, alignItems: 'stretch' }}
snapToAlignment="center"
snapToInterval={ITEM_HEIGHT}
/>
);
}
+44 -1
View File
@@ -1,4 +1,4 @@
import { schemaMigrations, addColumns, createTable } from '@nozbe/watermelondb/Schema/migrations';
import { schemaMigrations, addColumns, createTable, unsafeExecuteSql } from '@nozbe/watermelondb/Schema/migrations';
export const migrations = schemaMigrations({
migrations: [
@@ -137,5 +137,48 @@ export const migrations = schemaMigrations({
}),
],
},
{
toVersion: 13,
steps: [
addColumns({
table: 'tasks',
columns: [{ name: 'completed_at', type: 'number', isOptional: true }],
}),
],
},
{
toVersion: 14,
steps: [
unsafeExecuteSql('CREATE INDEX IF NOT EXISTS idx_tasks_updated_at ON tasks(updated_at);'),
unsafeExecuteSql('CREATE INDEX IF NOT EXISTS idx_tasks_created_at ON tasks(created_at);'),
unsafeExecuteSql('CREATE INDEX IF NOT EXISTS idx_tasks_completed ON tasks(completed);'),
unsafeExecuteSql('CREATE INDEX IF NOT EXISTS idx_subtasks_updated_at ON subtasks(updated_at);'),
unsafeExecuteSql('CREATE INDEX IF NOT EXISTS idx_subtasks_created_at ON subtasks(created_at);'),
unsafeExecuteSql('CREATE INDEX IF NOT EXISTS idx_categories_updated_at ON categories(updated_at);'),
unsafeExecuteSql('CREATE INDEX IF NOT EXISTS idx_repeat_profiles_updated_at ON repeat_profiles(updated_at);'),
unsafeExecuteSql('CREATE INDEX IF NOT EXISTS idx_friendships_updated_at ON friendships(updated_at);'),
unsafeExecuteSql('CREATE INDEX IF NOT EXISTS idx_tasks_series_id ON tasks(series_id);'),
],
},
{
toVersion: 15,
steps: [
addColumns({
table: 'subtasks',
columns: [
{ name: 'parent_subtask_id', type: 'string', isIndexed: true, isOptional: true },
],
}),
],
},
{
toVersion: 16,
steps: [
addColumns({
table: 'tasks',
columns: [{ name: 'order', type: 'number', isOptional: true }],
}),
],
},
],
});
+11 -9
View File
@@ -1,7 +1,7 @@
import { appSchema, tableSchema } from '@nozbe/watermelondb';
export const schema = appSchema({
version: 12,
version: 15,
tables: [
tableSchema({
name: 'categories',
@@ -10,7 +10,7 @@ export const schema = appSchema({
{ name: 'color', type: 'string' },
{ name: 'order', type: 'number' },
{ name: 'created_at', type: 'number' },
{ name: 'updated_at', type: 'number' },
{ name: 'updated_at', type: 'number', isIndexed: true },
],
}),
tableSchema({
@@ -20,7 +20,8 @@ export const schema = appSchema({
{ name: 'description', type: 'string' },
{ name: 'category_id', type: 'string', isIndexed: true },
{ name: 'priority', type: 'string' },
{ name: 'completed', type: 'boolean' },
{ name: 'completed', type: 'boolean', isIndexed: true },
{ name: 'completed_at', type: 'number', isOptional: true },
{ name: 'due_date', type: 'number', isIndexed: true },
{ name: 'due_time', type: 'string' },
{ name: 'end_time', type: 'string' },
@@ -33,14 +34,15 @@ export const schema = appSchema({
{ 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' },
{ name: 'created_at', type: 'number', isIndexed: true },
{ name: 'updated_at', type: 'number', isIndexed: true },
],
}),
tableSchema({
name: 'subtasks',
columns: [
{ name: 'task_id', type: 'string', isIndexed: true },
{ name: 'parent_subtask_id', type: 'string', isIndexed: true, isOptional: true },
{ name: 'title', type: 'string' },
{ name: 'description', type: 'string', isOptional: true },
{ name: 'priority', type: 'string', isOptional: true },
@@ -57,8 +59,8 @@ export const schema = appSchema({
{ 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' },
{ name: 'created_at', type: 'number', isIndexed: true },
{ name: 'updated_at', type: 'number', isIndexed: true },
],
}),
tableSchema({
@@ -69,7 +71,7 @@ export const schema = appSchema({
{ name: 'repeat_interval', type: 'number' },
{ name: 'repeat_days', type: 'string' },
{ name: 'created_at', type: 'number' },
{ name: 'updated_at', type: 'number' },
{ name: 'updated_at', type: 'number', isIndexed: true },
],
}),
tableSchema({
@@ -79,7 +81,7 @@ export const schema = appSchema({
{ name: 'friend_id', type: 'string', isIndexed: true },
{ name: 'status', type: 'string' },
{ name: 'created_at', type: 'number' },
{ name: 'updated_at', type: 'number' },
{ name: 'updated_at', type: 'number', isIndexed: true },
],
}),
],
+116 -88
View File
@@ -1,6 +1,7 @@
import { AppState, AppStateStatus } from 'react-native';
import { database, collections } from './index';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Q } from '@nozbe/watermelondb';
import { apiFetch, getAuthToken } from '@/services/auth';
import Category from '@/models/Category';
import Task from '@/models/Task';
@@ -8,15 +9,6 @@ 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';
@@ -108,17 +100,13 @@ export async function runSync(): Promise<SyncResult> {
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 [tasks, subtasks, repeatProfiles, friendships] = await Promise.all([
collections.tasks.query(Q.where('updated_at', Q.gt(lastPulledAt))).fetch(),
collections.subtasks.query(Q.where('updated_at', Q.gt(lastPulledAt))).fetch(),
collections.repeatProfiles.query(Q.where('updated_at', Q.gt(lastPulledAt))).fetch(),
collections.friendships.query(Q.where('updated_at', Q.gt(lastPulledAt))).fetch(),
]);
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,
@@ -141,81 +129,97 @@ async function pushChanges(): Promise<PushConflict[]> {
updatedAt: t.updatedAt.getTime(),
});
const changedTasks = tasks
.filter((t) => t.updatedAt.getTime() > lastPulledAt)
.map(taskPayload);
const changedTasks: ReturnType<typeof taskPayload>[] = [];
const includedTaskIds = new Set<string>();
const missingTaskIds = new Set<string>();
const referencedCategoryIds = new Set<string>();
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);
for (const t of tasks) {
changedTasks.push(taskPayload(t));
includedTaskIds.add(t.id);
if (t.categoryId) referencedCategoryIds.add(t.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 changedSubtasks = subtasks.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 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(),
}));
for (const sub of changedSubtasks) {
if (!includedTaskIds.has(sub.taskId)) {
missingTaskIds.add(sub.taskId);
}
}
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 (missingTaskIds.size > 0) {
const parentTasks = await collections.tasks
.query(Q.where('id', Q.oneOf(Array.from(missingTaskIds))))
.fetch();
for (const t of parentTasks) {
includedTaskIds.add(t.id);
changedTasks.push(taskPayload(t));
if (t.categoryId) referencedCategoryIds.add(t.categoryId);
}
}
let categoryQuery = collections.categories.query();
if (referencedCategoryIds.size > 0) {
categoryQuery = collections.categories.query(
Q.or(
Q.where('updated_at', Q.gt(lastPulledAt)),
Q.where('id', Q.oneOf(Array.from(referencedCategoryIds)))
)
);
} else {
categoryQuery = collections.categories.query(Q.where('updated_at', Q.gt(lastPulledAt)));
}
const categories = await categoryQuery.fetch();
const changedCategories = categories.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.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.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 &&
@@ -542,6 +546,30 @@ export function stopWatchingForUpdates(): void {
watcherStop?.();
}
export function startAutoSync(intervalMs: number = DEFAULT_WATCH_INTERVAL_MS): UpdateWatcher {
return watchForUpdates(intervalMs);
}
export function stopAutoSync(): void {
stopWatchingForUpdates();
}
export async function runSyncGuarded(): Promise<SyncResult> {
if (syncInFlight) {
throw new Error('SYNC_IN_FLIGHT');
}
syncInFlight = true;
try {
return await runSync();
} finally {
syncInFlight = false;
}
}
export function isSyncInFlight(): boolean {
return syncInFlight;
}
export function isWatchingForUpdates(): boolean {
return watcherActive;
}
+56 -13
View File
@@ -1,11 +1,12 @@
import React, { createContext, useContext, useEffect, useState, ReactNode } from 'react';
import React, { createContext, useContext, useEffect, useState, ReactNode, useMemo } from 'react';
import { map } from 'rxjs/operators';
import { database, collections } from '../database';
import { runSync, watchForUpdates, stopWatchingForUpdates } from '../database/sync';
import { runSyncGuarded, startAutoSync, stopAutoSync } from '../database/sync';
import { getAuthToken } from '../services/auth';
import { DEFAULT_CATEGORIES } from '../constants';
import Category from '../models/Category';
import RepeatProfile from '../models/RepeatProfile';
import { Q } from '@nozbe/watermelondb';
interface DatabaseContextType {
database: typeof database;
@@ -23,7 +24,43 @@ export function DatabaseProvider({ children }: { children: ReactNode }) {
try {
const existingCategories = await collections.categories.query().fetch();
if (existingCategories.length === 0) {
// Merge categories with the same spelling (case-insensitive)
const categoriesByName = new Map<string, typeof existingCategories[0]>();
const duplicates: typeof existingCategories = [];
for (const cat of existingCategories) {
const key = cat.name.toLowerCase();
if (categoriesByName.has(key)) {
duplicates.push(cat);
} else {
categoriesByName.set(key, cat);
}
}
if (duplicates.length > 0) {
await database.write(async () => {
for (const dup of duplicates) {
const keep = categoriesByName.get(dup.name.toLowerCase())!;
// Reassign tasks from duplicate to kept category
const tasksToUpdate = await collections.tasks.query(Q.where('category_id', dup.id)).fetch();
for (const task of tasksToUpdate) {
await task.update((t) => { t.categoryId = keep.id; });
}
// Reassign subtasks too (they're linked via task_id, so we update their parent tasks)
const subtasksToUpdate = await collections.subtasks.query(Q.where('task_id', dup.id)).fetch();
for (const sub of subtasksToUpdate) {
const parentTask = await collections.tasks.find(sub.taskId);
await parentTask.update((t) => { t.categoryId = keep.id; });
}
// Delete duplicate category
await dup.destroyPermanently();
}
});
}
const freshCategories = await collections.categories.query().fetch();
if (freshCategories.length === 0) {
await database.write(async () => {
for (const cat of DEFAULT_CATEGORIES) {
await collections.categories.create((c) => {
@@ -41,13 +78,10 @@ export function DatabaseProvider({ children }: { children: ReactNode }) {
const token = await getAuthToken();
if (token) {
runSync()
.catch(() => {})
.finally(() => {
watchForUpdates();
});
runSyncGuarded().catch(() => {});
startAutoSync();
} else {
stopWatchingForUpdates();
stopAutoSync();
}
} catch (error) {
console.error('Failed to initialize database:', error);
@@ -56,9 +90,10 @@ export function DatabaseProvider({ children }: { children: ReactNode }) {
};
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
initializeDatabase();
return () => {
stopWatchingForUpdates();
stopAutoSync();
};
}, []);
@@ -103,9 +138,17 @@ export function useCategories(): Category[] {
return categories;
}
export function useCategory(categoryId: string) {
const { collections } = useDatabase();
return collections.categories.find(categoryId);
export function useUniqueCategories(): Category[] {
const categories = useCategories();
return useMemo(() => {
const seen = new Map<string, Category>();
for (const cat of categories) {
if (!seen.has(cat.name)) {
seen.set(cat.name, cat);
}
}
return Array.from(seen.values()).sort((a, b) => a.order - b.order);
}, [categories]);
}
export function useRepeatProfiles(): RepeatProfile[] {
+1
View File
@@ -40,6 +40,7 @@ export function FriendsProvider({ children }: { children: React.ReactNode }) {
}, []);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchFriends();
}, [fetchFriends]);
+199
View File
@@ -0,0 +1,199 @@
import { useEffect, useState } from 'react';
import { useDatabase } from './useDatabase';
const DAY_MS = 24 * 60 * 60 * 1000;
const WEEK_MS = 7 * DAY_MS;
const MONTH_MS = 30 * DAY_MS;
export interface DailyCompletion {
label: string;
count: number;
}
export interface CategoryStat {
id: string;
name: string;
color: string;
count: number;
}
export interface PriorityStat {
priority: string;
label: string;
count: number;
}
export interface TaskStats {
loading: boolean;
totalCompleted: number;
completedLast7: number;
completedLast30: number;
activeCount: number;
overdueCount: number;
completionRate: number;
daily: DailyCompletion[];
byCategory: CategoryStat[];
byPriority: PriorityStat[];
currentStreak: number;
}
export const PRIORITY_LABELS: Record<string, string> = {
high: 'High',
medium: 'Medium',
low: 'Low',
none: 'No priority',
};
function startOfDay(ts: number): number {
const d = new Date(ts);
d.setHours(0, 0, 0, 0);
return d.getTime();
}
function dayLabel(dayStart: number): string {
const d = new Date(dayStart);
const today = new Date();
today.setHours(0, 0, 0, 0);
const diff = Math.round((today.getTime() - d.getTime()) / DAY_MS);
if (diff === 0) return 'Today';
if (diff === 1) return 'Yest';
return d.toLocaleDateString(undefined, { weekday: 'short' });
}
const EMPTY_STATS: TaskStats = {
loading: true,
totalCompleted: 0,
completedLast7: 0,
completedLast30: 0,
activeCount: 0,
overdueCount: 0,
completionRate: 0,
daily: [],
byCategory: [],
byPriority: [],
currentStreak: 0,
};
export function useStats(): TaskStats {
const { collections, isReady } = useDatabase();
const [stats, setStats] = useState<TaskStats>(EMPTY_STATS);
useEffect(() => {
if (!isReady) return;
let mounted = true;
const compute = () => {
Promise.all([
collections.tasks.query().fetch(),
collections.categories.query().fetch(),
])
.then(([allTasks, categories]) => {
const now = Date.now();
const todayStart = startOfDay(now);
const weekStart = todayStart - WEEK_MS;
const monthStart = todayStart - MONTH_MS;
const categoryCounts = new Map<string, number>();
const priorityCounts = new Map<string, number>();
const dayBucket = new Map<number, number>();
let totalCompleted = 0;
let completedLast7 = 0;
let completedLast30 = 0;
let activeCount = 0;
let overdueCount = 0;
for (const t of allTasks) {
if (t.completed) {
if (t.completedAt == null) continue;
const ts = t.completedAt as number;
totalCompleted++;
categoryCounts.set(t.categoryId, (categoryCounts.get(t.categoryId) ?? 0) + 1);
priorityCounts.set(t.priority, (priorityCounts.get(t.priority) ?? 0) + 1);
if (ts >= weekStart) completedLast7++;
if (ts >= monthStart) completedLast30++;
const day = startOfDay(ts);
dayBucket.set(day, (dayBucket.get(day) ?? 0) + 1);
} else {
activeCount++;
if (t.dueDate > 0 && t.dueDate < now) overdueCount++;
}
}
const daily: DailyCompletion[] = [];
for (let i = 6; i >= 0; i--) {
const dayStart = todayStart - i * DAY_MS;
daily.push({
label: dayLabel(dayStart),
count: dayBucket.get(dayStart) ?? 0,
});
}
const categoryMap = new Map(categories.map((c) => [c.id, c]));
const byCategory: CategoryStat[] = Array.from(categoryMap.values())
.map((c) => ({
id: c.id,
name: c.name,
color: c.color,
count: categoryCounts.get(c.id) ?? 0,
}))
.filter((c) => c.count > 0)
.sort((a, b) => b.count - a.count);
const priorityOrder = ['high', 'medium', 'low', 'none'];
const byPriority: PriorityStat[] = priorityOrder
.map((p) => ({
priority: p,
label: PRIORITY_LABELS[p],
count: priorityCounts.get(p) ?? 0,
}))
.filter((p) => p.count > 0);
let streak = 0;
const todayCount = dayBucket.get(todayStart) ?? 0;
let cursor = todayCount > 0 ? todayStart : todayStart - DAY_MS;
while ((dayBucket.get(cursor) ?? 0) > 0 && streak < 365) {
streak++;
cursor -= DAY_MS;
}
if (mounted) {
setStats({
loading: false,
totalCompleted,
completedLast7,
completedLast30,
activeCount,
overdueCount,
completionRate: totalCompleted > 0
? Math.round((totalCompleted / (totalCompleted + activeCount)) * 100)
: 0,
daily,
byCategory,
byPriority,
currentStreak: streak,
});
}
})
.catch(() => {
if (mounted) setStats((s) => ({ ...s, loading: false }));
});
};
compute();
const subscription = collections.tasks.query().observe().subscribe({
next: () => {
compute();
},
error: () => {},
});
return () => {
mounted = false;
subscription.unsubscribe();
};
}, [collections, isReady]);
return stats;
}
+344
View File
@@ -0,0 +1,344 @@
import React, { useCallback, useRef, useState } from 'react';
import { View, Text, StyleSheet, Modal, TextInput, TouchableOpacity } from 'react-native';
import { useRouter } from 'expo-router';
import { TaskData, SubtaskData } from '@/types';
import { PRIORITY_LABELS } from '@/constants';
import { useCategories } from '@/hooks/useDatabase';
import { useSettings } from '@/theme';
import { TaskOverflowMenu } from '@/components/TaskOverflowMenu';
import { TaskDeleteModal } from '@/components/TaskDeleteModal';
import { OptionPickerModal } from '@/components/OptionPickerModal';
import {
toggleTaskComplete,
deleteTaskOccurrences,
duplicateTask,
setTaskCategory,
setTaskPriority,
toggleSubtaskComplete,
deleteSubtask,
duplicateSubtask,
setSubtaskPriority,
createSubtask,
TaskDeleteScope,
} from '@/utils/taskActions';
const PRIORITY_OPTIONS = Object.entries(PRIORITY_LABELS).map(([value, label]) => ({ value, label }));
interface AddSubtaskModalProps {
visible: boolean;
parentSubtask: SubtaskData | null;
onClose: () => void;
onAdd: (title: string) => void;
}
function AddSubtaskModal({ visible, parentSubtask, onClose, onAdd }: AddSubtaskModalProps) {
const { theme } = useSettings();
const [title, setTitle] = React.useState('');
React.useEffect(() => {
if (visible) {
setTitle('');
}
}, [visible]);
const handleSubmit = () => {
if (title.trim()) {
onAdd(title.trim());
}
};
if (!visible || !parentSubtask) return null;
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: 16,
fontWeight: '600',
textAlign: 'center',
marginBottom: 16,
color: theme.text,
},
inputContainer: {
marginBottom: 16,
},
input: {
fontSize: 16,
paddingHorizontal: 16,
paddingVertical: 12,
borderRadius: 12,
borderWidth: 1,
color: theme.text,
backgroundColor: theme.card,
borderColor: theme.border,
},
buttonRow: {
flexDirection: 'row',
gap: 8,
},
button: {
flex: 1,
paddingVertical: 14,
borderRadius: 12,
alignItems: 'center',
},
buttonText: {
fontSize: 16,
fontWeight: '600',
color: '#FFFFFF',
},
buttonCancel: {
backgroundColor: theme.card,
borderWidth: 1,
borderColor: theme.border,
},
buttonAdd: {
backgroundColor: theme.accent,
},
});
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}>
Add Subtask to &ldquo;{parentSubtask.title}&rdquo;
</Text>
<View style={styles.inputContainer}>
<TextInput
style={styles.input}
placeholder="Subtask name"
value={title}
onChangeText={setTitle}
autoCapitalize="sentences"
maxLength={100}
autoFocus
onSubmitEditing={handleSubmit}
/>
</View>
<View style={styles.buttonRow}>
<TouchableOpacity style={styles.buttonCancel} onPress={onClose} activeOpacity={0.7}>
<Text style={styles.buttonText}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.buttonAdd} onPress={handleSubmit} activeOpacity={0.7}>
<Text style={styles.buttonText}>Add</Text>
</TouchableOpacity>
</View>
</View>
</TouchableOpacity>
</Modal>
);
}
export function useTaskModals() {
const router = useRouter();
const categories = useCategories();
const [menuTask, setMenuTask] = useState<TaskData | null>(null);
const [menuSubtask, setMenuSubtask] = useState<SubtaskData | null>(null);
const [deleteTarget, setDeleteTarget] = useState<TaskData | null>(null);
const [deleteSubtaskTarget, setDeleteSubtaskTarget] = useState<SubtaskData | null>(null);
const [picker, setPicker] = useState<null | { type: 'category' | 'priority'; taskId?: string }>(null);
const [subtaskPicker, setSubtaskPicker] = useState<null | { type: 'priority'; subtaskId?: string }>(null);
const [addSubtaskTarget, setAddSubtaskTarget] = useState<SubtaskData | null>(null);
const refreshRef = useRef<() => void>(() => {});
const openTaskMenu = useCallback((task: TaskData) => setMenuTask(task), []);
const openSubtaskMenu = useCallback((subtask: SubtaskData) => setMenuSubtask(subtask), []);
const openTaskDelete = useCallback((task: TaskData) => setDeleteTarget(task), []);
const openSubtaskDelete = useCallback((subtask: SubtaskData) => setDeleteSubtaskTarget(subtask), []);
const openTaskEdit = useCallback((taskId: string) => {
router.push({ pathname: '/task-detail', params: { id: taskId } });
}, [router]);
const openSubtaskEdit = useCallback((subtaskId: string) => {
router.push({ pathname: '/subtask-detail', params: { id: subtaskId } });
}, [router]);
const openSubtaskAdd = useCallback((subtask: SubtaskData) => {
setAddSubtaskTarget(subtask);
}, []);
const handleToggleComplete = useCallback(async (taskId: string) => {
await toggleTaskComplete(taskId);
refreshRef.current();
}, []);
const handleSubtaskToggle = useCallback(async (subtask: SubtaskData) => {
await toggleSubtaskComplete(subtask.id);
refreshRef.current();
}, []);
const handleDuplicate = useCallback(async (taskId: string) => {
await duplicateTask(taskId);
refreshRef.current();
}, []);
const handleSubtaskDuplicate = useCallback(async (subtaskId: string) => {
await duplicateSubtask(subtaskId);
refreshRef.current();
}, []);
const handleDeleteScope = useCallback(async (scope: TaskDeleteScope) => {
const target = deleteTarget;
if (!target) return;
setDeleteTarget(null);
await deleteTaskOccurrences(target.id, scope);
refreshRef.current();
}, [deleteTarget]);
const handleDeleteSubtask = useCallback(async () => {
const target = deleteSubtaskTarget;
if (!target) return;
setDeleteSubtaskTarget(null);
await deleteSubtask(target.id);
refreshRef.current();
}, [deleteSubtaskTarget]);
const handleSingleCategory = useCallback(async (value: string | string[]) => {
if (picker?.taskId) {
await setTaskCategory(picker.taskId, Array.isArray(value) ? value[0] : value);
refreshRef.current();
}
}, [picker]);
const handleSinglePriority = useCallback(async (value: string | string[]) => {
if (picker?.taskId) {
await setTaskPriority(picker.taskId, (Array.isArray(value) ? value[0] : value) as TaskData['priority']);
refreshRef.current();
}
}, [picker]);
const handleSubtaskPriority = useCallback(async (value: string | string[]) => {
if (subtaskPicker?.subtaskId) {
await setSubtaskPriority(subtaskPicker.subtaskId, (Array.isArray(value) ? value[0] : value) as TaskData['priority']);
refreshRef.current();
}
}, [subtaskPicker]);
const handleAddSubtask = useCallback(async (title: string) => {
const target = addSubtaskTarget;
if (!target || !title.trim()) return;
await createSubtask({
taskId: target.taskId,
parentSubtaskId: target.id,
title: title.trim(),
});
setAddSubtaskTarget(null);
refreshRef.current();
}, [addSubtaskTarget]);
const modals = (refresh: () => void) => {
refreshRef.current = refresh;
return (
<>
<TaskDeleteModal
visible={deleteTarget !== null}
taskId={deleteTarget?.id ?? null}
taskTitle={deleteTarget?.title}
isRepeating={deleteTarget ? deleteTarget.repeat !== 'none' : false}
onClose={() => setDeleteTarget(null)}
onDelete={handleDeleteScope}
/>
<TaskDeleteModal
visible={deleteSubtaskTarget !== null}
taskId={deleteSubtaskTarget?.id ?? null}
taskTitle={deleteSubtaskTarget?.title}
subtask
onClose={() => setDeleteSubtaskTarget(null)}
onDelete={handleDeleteSubtask}
/>
<TaskOverflowMenu
visible={menuTask !== null}
task={menuTask}
onClose={() => setMenuTask(null)}
onEdit={() => menuTask && openTaskEdit(menuTask.id)}
onDelete={() => menuTask && openTaskDelete(menuTask)}
onDuplicate={() => menuTask && handleDuplicate(menuTask.id)}
onToggleComplete={() => menuTask && handleToggleComplete(menuTask.id)}
onChangeCategory={() => menuTask && setPicker({ type: 'category', taskId: menuTask.id })}
onChangePriority={() => menuTask && setPicker({ type: 'priority', taskId: menuTask.id })}
/>
<TaskOverflowMenu
visible={menuSubtask !== null}
task={menuSubtask}
subtask
onClose={() => setMenuSubtask(null)}
onEdit={() => menuSubtask && openSubtaskEdit(menuSubtask.id)}
onDelete={() => menuSubtask && openSubtaskDelete(menuSubtask)}
onDuplicate={() => menuSubtask && handleSubtaskDuplicate(menuSubtask.id)}
onToggleComplete={() => menuSubtask && handleSubtaskToggle(menuSubtask)}
onChangePriority={() => menuSubtask && setSubtaskPicker({ type: 'priority', subtaskId: menuSubtask.id })}
onAddSubtask={() => menuSubtask && openSubtaskAdd(menuSubtask)}
/>
<AddSubtaskModal
visible={addSubtaskTarget !== null}
parentSubtask={addSubtaskTarget}
onClose={() => setAddSubtaskTarget(null)}
onAdd={handleAddSubtask}
/>
<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={PRIORITY_OPTIONS}
selectedValue={menuTask?.priority}
onSelect={handleSinglePriority}
onClose={() => setPicker(null)}
/>
<OptionPickerModal
visible={subtaskPicker?.type === 'priority'}
title="Change Priority"
options={PRIORITY_OPTIONS}
selectedValue={menuSubtask?.priority}
onSelect={handleSubtaskPriority}
onClose={() => setSubtaskPicker(null)}
/>
</>
);
};
return {
modals,
openTaskMenu,
openSubtaskMenu,
openTaskDelete,
openSubtaskDelete,
openTaskEdit,
openSubtaskEdit,
openSubtaskAdd,
};
}
+15 -89
View File
@@ -1,6 +1,6 @@
import { useDatabase } from './useDatabase';
import { Q } from '@nozbe/watermelondb';
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useState, useMemo } from 'react';
import Task from '../models/Task';
export function useTasks(categoryId?: string, showCompleted = false) {
@@ -16,7 +16,9 @@ export function useTasks(categoryId?: string, showCompleted = false) {
conditions.push(Q.where('category_id', categoryId));
}
if (!showCompleted) {
if (showCompleted) {
conditions.push(Q.where('completed', true));
} else {
conditions.push(Q.where('completed', false));
}
@@ -47,21 +49,21 @@ export function useTasks(categoryId?: string, showCompleted = false) {
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);
const startOfDay = useMemo(() => {
const d = new Date(date);
d.setHours(0, 0, 0, 0);
return d;
}, [date]);
const endOfDay = useMemo(() => {
const d = new Date(date);
d.setHours(23, 59, 59, 999);
return d;
}, [date]);
useEffect(() => {
let mounted = true;
@@ -87,83 +89,7 @@ export function useTasksByDate(date: Date) {
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]);
}, [collections, startOfDay, endOfDay]);
return { tasks, loading };
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { Model } from '@nozbe/watermelondb';
import { field, date, readonly } from '@nozbe/watermelondb/decorators';
import { field, date } from '@nozbe/watermelondb/decorators';
export default class Category extends Model {
static table = 'categories';
+1 -1
View File
@@ -1,5 +1,5 @@
import { Model } from '@nozbe/watermelondb';
import { field, date, readonly } from '@nozbe/watermelondb/decorators';
import { field, date } from '@nozbe/watermelondb/decorators';
export default class Friendship extends Model {
static table = 'friendships';
+9 -1
View File
@@ -1,11 +1,17 @@
import { Model } from '@nozbe/watermelondb';
import { field, date, readonly } from '@nozbe/watermelondb/decorators';
import { field, date, children } from '@nozbe/watermelondb/decorators';
import { Associations } from '@nozbe/watermelondb/Model';
import { Priority, Repeat, Reminder } from '@/types';
export default class Subtask extends Model {
static table = 'subtasks';
static associations: Associations = {
subtasks: { type: 'has_many', foreignKey: 'parent_subtask_id' },
};
@field('task_id') taskId!: string;
@field('parent_subtask_id') parentSubtaskId!: string | null;
@field('title') title!: string;
@field('description') description!: string;
@field('priority') priority!: Priority;
@@ -24,4 +30,6 @@ export default class Subtask extends Model {
@field('order') order!: number;
@date('created_at') createdAt!: Date;
@date('updated_at') updatedAt!: Date;
@children('subtasks') subtasks!: any;
}
+3 -1
View File
@@ -1,5 +1,5 @@
import { Model } from '@nozbe/watermelondb';
import { field, date, readonly, children } from '@nozbe/watermelondb/decorators';
import { field, date, children } from '@nozbe/watermelondb/decorators';
import { Associations } from '@nozbe/watermelondb/Model';
import { Priority, Repeat, Reminder } from '@/types';
@@ -15,6 +15,7 @@ export default class Task extends Model {
@field('category_id') categoryId!: string;
@field('priority') priority!: Priority;
@field('completed') completed!: boolean;
@field('completed_at') completedAt!: number | null;
@field('due_date') dueDate!: number;
@field('due_time') dueTime!: string;
@field('end_time') endTime!: string;
@@ -27,6 +28,7 @@ export default class Task extends Model {
@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;
+42 -2
View File
@@ -1,9 +1,48 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
export const API_BASE_URL = process.env.EXPO_PUBLIC_API_URL ?? 'http://localhost:3000/api';
export const DEFAULT_API_BASE_URL = process.env.EXPO_PUBLIC_API_URL ?? 'http://localhost:3000/api';
const TOKEN_KEY = 'auth:token';
const USER_KEY = 'auth:user';
export const API_URL_KEY = 'settings:apiUrl';
function normalizeApiUrl(url: string): string {
return url.trim().replace(/\/+$/, '');
}
export async function getApiBaseUrl(): Promise<string> {
try {
const stored = await AsyncStorage.getItem(API_URL_KEY);
if (!stored) return DEFAULT_API_BASE_URL;
try {
const parsed = JSON.parse(stored) as unknown;
if (typeof parsed === 'string' && parsed.trim()) {
return normalizeApiUrl(parsed);
}
} catch {
// fall through to raw value
}
if (stored.trim()) {
return normalizeApiUrl(stored);
}
} catch {
// ignore
}
return DEFAULT_API_BASE_URL;
}
export async function setApiBaseUrl(url: string): Promise<void> {
const normalized = normalizeApiUrl(url);
try {
if (normalized) {
await AsyncStorage.setItem(API_URL_KEY, normalized);
} else {
await AsyncStorage.removeItem(API_URL_KEY);
}
} catch {
// ignore
}
}
export interface AuthUser {
id: string;
@@ -47,6 +86,7 @@ export async function signOutAuth(): Promise<void> {
export async function apiFetch(path: string, options: RequestInit = {}): Promise<Response> {
const token = await getAuthToken();
const apiBaseUrl = await getApiBaseUrl();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string> | undefined),
@@ -54,7 +94,7 @@ export async function apiFetch(path: string, options: RequestInit = {}): Promise
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const response = await fetch(`${API_BASE_URL}${path}`, { ...options, headers });
const response = await fetch(`${apiBaseUrl}${path}`, { ...options, headers });
if (!response.ok) {
let message = `Request failed (${response.status})`;
try {
@@ -24,15 +24,14 @@ async function writeIdMap(map: Record<string, string[]>): Promise<void> {
function getNotificationsModule(): any {
if (Platform.OS === 'web') return null;
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
return require('expo-notifications');
return import('expo-notifications');
} catch {
return null;
}
}
export async function scheduleTaskReminder(task: any): Promise<void> {
const notifications = getNotificationsModule();
const notifications = await getNotificationsModule();
const dueDate = task.dueDate ? new Date(task.dueDate) : null;
if (!notifications) return;
@@ -99,7 +98,7 @@ export async function scheduleTaskReminder(task: any): Promise<void> {
}
export async function cancelTaskReminder(taskId: string): Promise<void> {
const notifications = getNotificationsModule();
const notifications = await getNotificationsModule();
if (!notifications) return;
const idMap = await readIdMap();
@@ -118,7 +117,7 @@ export async function cancelTaskReminder(taskId: string): Promise<void> {
}
export async function requestNotificationPermission(): Promise<boolean> {
const notifications = getNotificationsModule();
const notifications = await getNotificationsModule();
if (!notifications) return false;
try {
const settings = await notifications.getPermissionsAsync();
@@ -134,7 +133,7 @@ export async function requestNotificationPermission(): Promise<boolean> {
}
export async function rescheduleAllReminders(tasks: any[]): Promise<void> {
const notifications = getNotificationsModule();
const notifications = await getNotificationsModule();
if (!notifications) return;
try {
await notifications.cancelAllScheduledNotificationsAsync();
+94
View File
@@ -0,0 +1,94 @@
import Constants from 'expo-constants';
import AsyncStorage from '@react-native-async-storage/async-storage';
const GITEA_BASE_URL = 'https://gitea.techmos.org';
const GITEA_OWNER = 'tech08mag';
const GITEA_REPO = 'carry-your-live';
const LAST_PROMPTED_KEY = 'updates:lastPromptedRelease';
export interface AppUpdate {
version: string;
tagName: string;
notes: string;
apkUrl: string | null;
releaseUrl: string;
}
export function getCurrentAppVersion(): string {
return Constants.expoConfig?.version ?? '1.0.0';
}
async function fetchWithTimeout(path: string, timeoutMs: number): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(`${GITEA_BASE_URL}${path}`, { signal: controller.signal });
} finally {
clearTimeout(timer);
}
}
export async function fetchLatestRelease(): Promise<AppUpdate | null> {
try {
const response = await fetchWithTimeout(
`/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/releases/latest`,
8000,
);
if (!response.ok) return null;
const release = await response.json();
const apkAsset = (release.assets ?? []).find(
(asset: { name?: string }) => asset.name && /\.apk$/i.test(asset.name),
);
const tagName: string = release.tag_name ?? release.name ?? '';
return {
version: tagName.replace(/^v/i, ''),
tagName,
notes: release.body ?? '',
apkUrl: apkAsset?.browser_download_url ?? null,
releaseUrl:
release.html_url ??
`${GITEA_BASE_URL}/${GITEA_OWNER}/${GITEA_REPO}/releases/tag/${tagName}`,
};
} catch {
return null;
}
}
export function compareVersions(a: string, b: string): number {
const parse = (value: string) =>
value.split('.').map((part) => parseInt(part.replace(/\D/g, ''), 10) || 0);
const left = parse(a);
const right = parse(b);
const length = Math.max(left.length, right.length);
for (let i = 0; i < length; i++) {
const x = left[i] ?? 0;
const y = right[i] ?? 0;
if (x > y) return 1;
if (x < y) return -1;
}
return 0;
}
export async function checkForUpdates(): Promise<AppUpdate | null> {
const release = await fetchLatestRelease();
if (!release) return null;
if (compareVersions(release.version, getCurrentAppVersion()) <= 0) return null;
return release;
}
export async function wasPromptedFor(tagName: string): Promise<boolean> {
try {
const stored = await AsyncStorage.getItem(LAST_PROMPTED_KEY);
return stored === tagName;
} catch {
return false;
}
}
export async function markPrompted(tagName: string): Promise<void> {
try {
await AsyncStorage.setItem(LAST_PROMPTED_KEY, tagName);
} catch {
// ignore
}
}
+42 -18
View File
@@ -1,5 +1,6 @@
import React, { createContext, useContext, useEffect, useState, ReactNode } from 'react';
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState, ReactNode } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { DEFAULT_API_BASE_URL, API_URL_KEY } from '@/services/auth';
export type SortBy = 'date' | 'priority' | 'alpha' | 'created';
@@ -69,6 +70,8 @@ interface SettingsContextType {
setSortBy: (value: SortBy) => void;
reminderPreference: ReminderPreference;
setReminderPreference: (value: ReminderPreference) => void;
apiUrl: string;
setApiUrl: (value: string) => void;
theme: ThemeColors;
}
@@ -96,10 +99,13 @@ function useStoredSetting<T>(key: string, initialValue: T): [T, (value: T) => vo
.finally(() => setLoaded(true));
}, [key]);
const update = (next: T) => {
setValue(next);
AsyncStorage.setItem(key, JSON.stringify(next)).catch(() => {});
};
const update = useCallback(
(next: T) => {
setValue(next);
AsyncStorage.setItem(key, JSON.stringify(next)).catch(() => {});
},
[key]
);
return [value, update, loaded];
}
@@ -112,23 +118,41 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
STORAGE_KEYS.reminderPreference,
'15m',
);
const [apiUrl, setApiUrl] = useStoredSetting<string>(API_URL_KEY, DEFAULT_API_BASE_URL);
const theme = colors;
const value = useMemo<SettingsContextType>(
() => ({
notifications,
setNotifications,
defaultCategoryId,
setDefaultCategoryId,
sortBy,
setSortBy,
reminderPreference,
setReminderPreference,
apiUrl,
setApiUrl,
theme,
}),
[
notifications,
setNotifications,
defaultCategoryId,
setDefaultCategoryId,
sortBy,
setSortBy,
reminderPreference,
setReminderPreference,
apiUrl,
setApiUrl,
theme,
]
);
return (
<SettingsContext.Provider
value={{
notifications,
setNotifications,
defaultCategoryId,
setDefaultCategoryId,
sortBy,
setSortBy,
reminderPreference,
setReminderPreference,
theme,
}}
>
<SettingsContext.Provider value={value}>
{children}
</SettingsContext.Provider>
);
+21 -24
View File
@@ -86,8 +86,6 @@ 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;
@@ -120,6 +118,7 @@ export interface TaskData {
export interface SubtaskData {
id: string;
taskId: string;
parentSubtaskId: string | null;
title: string;
description: string;
priority: Priority;
@@ -135,33 +134,14 @@ export interface SubtaskData {
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 }[];
subtasks: SubtaskData[];
}
export interface SubtaskFormValue {
title: string;
_key?: string;
parentSubtaskId?: string | null;
subtasks?: SubtaskFormValue[];
}
export interface TaskFormData {
@@ -180,4 +160,21 @@ export interface TaskFormData {
reminders: string;
assigneeId: string | null;
subtasks?: SubtaskFormValue[];
}
export interface SubtaskFormData {
title: string;
description?: 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;
parentSubtaskId?: string | null;
}
@@ -0,0 +1,20 @@
type QuickAddListener = () => void;
const listeners = new Set<QuickAddListener>();
let pending = false;
export function requestQuickAddFocus(): void {
pending = true;
listeners.forEach((listener) => listener());
}
export function subscribeToQuickAdd(listener: QuickAddListener): () => void {
if (pending) {
listener();
pending = false;
}
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
+257 -4
View File
@@ -1,6 +1,6 @@
import { database, collections } from '@/database';
import { Q } from '@nozbe/watermelondb';
import { Priority, Repeat } from '@/types';
import { Priority, Repeat, Reminder } from '@/types';
import { scheduleTaskReminder, cancelTaskReminder } from '@/services/notifications';
function addDays(date: Date, days: number): Date {
@@ -155,6 +155,7 @@ export async function toggleTaskComplete(taskId: string): Promise<string | null>
const completing = !task.completed;
await task.update((t) => {
t.completed = completing;
t.completedAt = completing ? Date.now() : null;
t.updatedAt = new Date();
});
if (completing && task.repeat !== 'none' && task.dueDate) {
@@ -169,11 +170,128 @@ export async function toggleTaskComplete(taskId: string): Promise<string | null>
}
export async function toggleSubtaskComplete(subtaskId: string): Promise<void> {
const subtask = await collections.subtasks.find(subtaskId);
const completing = !subtask.completed;
await database.write(async () => {
const subtask = await collections.subtasks.find(subtaskId);
await subtask.update((s) => {
s.completed = completing;
s.completed = !s.completed;
s.updatedAt = new Date();
});
const task = await collections.tasks.find(subtask.taskId);
await task.update((t) => {
t.updatedAt = new Date();
});
});
const subtask = await collections.subtasks.find(subtaskId);
if (subtask.completed) await cancelTaskReminder(subtaskId);
else await scheduleTaskReminder(subtask);
}
export interface SubtaskUpdateData {
title: string;
description?: string;
priority: Priority;
dueDate: number;
dueTime?: string;
endTime?: string;
allDay?: boolean;
repeat: Repeat;
repeatInterval?: number;
repeatDays?: string;
reminder?: Reminder;
assigneeId?: string | null;
}
export async function updateSubtask(subtaskId: string, data: SubtaskUpdateData): Promise<void> {
await database.write(async () => {
const subtask = await collections.subtasks.find(subtaskId);
await subtask.update((s) => {
s.title = data.title.trim();
s.description = data.description || '';
s.priority = data.priority;
s.dueDate = data.dueDate;
s.dueTime = data.dueTime || '';
s.endTime = data.endTime || '';
s.allDay = data.allDay ?? false;
s.repeat = data.repeat;
s.repeatInterval = data.repeatInterval || 1;
s.repeatDays = data.repeatDays || '';
s.reminder = data.reminder || 'none';
s.assigneeId = data.assigneeId ?? null;
s.updatedAt = new Date();
});
const task = await collections.tasks.find(subtask.taskId);
await task.update((t) => {
t.updatedAt = new Date();
});
});
const subtask = await collections.subtasks.find(subtaskId);
if (subtask.completed) await cancelTaskReminder(subtaskId);
else await scheduleTaskReminder(subtask);
}
export async function deleteSubtask(subtaskId: string): Promise<void> {
let parentTaskId: string | null = null;
await database.write(async () => {
const subtask = await collections.subtasks.find(subtaskId);
parentTaskId = subtask.taskId;
await subtask.destroyPermanently();
});
await cancelTaskReminder(subtaskId);
if (parentTaskId) {
try {
await database.write(async () => {
const task = await collections.tasks.find(parentTaskId!);
await task.update((t) => {
t.updatedAt = new Date();
});
});
} catch {
// parent already gone
}
}
}
export async function duplicateSubtask(subtaskId: string): Promise<void> {
let clone: any = null;
await database.write(async () => {
const subtask = await collections.subtasks.find(subtaskId);
const siblings = await collections.subtasks.query(Q.where('task_id', subtask.taskId)).fetch();
const now = new Date();
clone = await collections.subtasks.create((s) => {
s.taskId = subtask.taskId;
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.repeat = subtask.repeat || 'none';
s.repeatInterval = subtask.repeatInterval || 1;
s.repeatDays = subtask.repeatDays || '';
s.seriesId = '';
s.reminder = subtask.reminder || 'none';
s.assigneeId = subtask.assigneeId ?? null;
s.order = siblings.length;
s.createdAt = now;
s.updatedAt = now;
});
const task = await collections.tasks.find(subtask.taskId);
await task.update((t) => {
t.updatedAt = new Date();
});
});
await scheduleTaskReminder(clone);
}
export async function setSubtaskPriority(subtaskId: string, priority: Priority): Promise<void> {
await database.write(async () => {
const subtask = await collections.subtasks.find(subtaskId);
await subtask.update((s) => {
s.priority = priority;
s.updatedAt = new Date();
});
const task = await collections.tasks.find(subtask.taskId);
@@ -277,12 +395,82 @@ export async function convertTaskToSubtask(taskId: string, parentTaskId: string)
});
}
export async function convertSubtaskToTask(subtaskId: string): Promise<void> {
let newTaskId: string | null = null;
await database.write(async () => {
const subtask = await collections.subtasks.find(subtaskId);
const parent = await collections.tasks.find(subtask.taskId);
const now = new Date();
const task = await collections.tasks.create((t) => {
t.title = subtask.title;
t.description = subtask.description || '';
t.categoryId = parent.categoryId;
t.priority = subtask.priority;
t.completed = subtask.completed;
t.dueDate = subtask.dueDate;
t.dueTime = subtask.dueTime || '';
t.endTime = subtask.endTime || '';
t.allDay = subtask.allDay ?? false;
t.repeat = subtask.repeat || 'none';
t.repeatInterval = subtask.repeatInterval || 1;
t.repeatDays = subtask.repeatDays || '';
t.seriesId = subtask.seriesId || '';
t.reminder = subtask.reminder || 'none';
t.assigneeId = subtask.assigneeId ?? null;
t.createdAt = now;
t.updatedAt = now;
});
newTaskId = task.id;
await subtask.destroyPermanently();
await parent.update((p) => {
p.updatedAt = now;
});
});
await cancelTaskReminder(subtaskId);
if (newTaskId) {
const task = await collections.tasks.find(newTaskId);
await scheduleTaskReminder(task);
}
}
export async function moveSubtaskToTask(subtaskId: string, toTaskId: string): Promise<void> {
if (!toTaskId) return;
await database.write(async () => {
const subtask = await collections.subtasks.find(subtaskId);
if (subtask.taskId === toTaskId) return;
const oldParentId = subtask.taskId;
const siblings = await collections.subtasks.query(Q.where('task_id', toTaskId)).fetch();
const now = new Date();
await subtask.update((s) => {
s.taskId = toTaskId;
s.order = siblings.length;
s.updatedAt = now;
});
try {
const oldParent = await collections.tasks.find(oldParentId);
await oldParent.update((p) => {
p.updatedAt = now;
});
} catch {
// parent already gone
}
const newParent = await collections.tasks.find(toTaskId);
await newParent.update((p) => {
p.updatedAt = now;
});
});
}
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.completedAt = completed ? Date.now() : null;
t.updatedAt = new Date();
});
if (completed && task.repeat !== 'none' && task.dueDate) {
@@ -410,3 +598,68 @@ export async function duplicateTask(taskId: string): Promise<void> {
});
await scheduleTaskReminder(clone);
}
export async function reorderTasks(taskIds: string[]): Promise<void> {
await database.write(async () => {
const now = new Date();
for (let i = 0; i < taskIds.length; i++) {
const task = await collections.tasks.find(taskIds[i]);
await task.update((t) => {
t.order = i;
t.updatedAt = now;
});
}
});
}
export interface CreateSubtaskData {
taskId: string;
parentSubtaskId?: string | null;
title: string;
description?: string;
priority?: Priority;
dueDate?: number;
dueTime?: string;
endTime?: string;
allDay?: boolean;
repeat?: Repeat;
repeatInterval?: number;
repeatDays?: string;
reminder?: Reminder;
assigneeId?: string | null;
}
export async function createSubtask(data: CreateSubtaskData): Promise<string> {
let newSubtaskId: string | null = null;
await database.write(async () => {
const now = new Date();
const siblings = await collections.subtasks.query(
Q.where('task_id', data.taskId),
data.parentSubtaskId ? Q.where('parent_subtask_id', data.parentSubtaskId) : Q.where('parent_subtask_id', null)
).fetch();
const subtask = await collections.subtasks.create((s) => {
s.taskId = data.taskId;
s.parentSubtaskId = data.parentSubtaskId || null;
s.title = data.title.trim();
s.description = data.description || '';
s.priority = data.priority || 'none';
s.completed = false;
s.dueDate = data.dueDate || 0;
s.dueTime = data.dueTime || '';
s.endTime = data.endTime || '';
s.allDay = data.allDay ?? false;
s.repeat = data.repeat || 'none';
s.repeatInterval = data.repeatInterval || 1;
s.repeatDays = data.repeatDays || '';
s.seriesId = '';
s.reminder = data.reminder || 'none';
s.assigneeId = data.assigneeId ?? null;
s.order = siblings.length;
s.createdAt = now;
s.updatedAt = now;
});
newSubtaskId = subtask.id;
});
return newSubtaskId!;
}
-1
View File
@@ -1 +0,0 @@
opencode -s ses_031130d6fffeMxWjOd3Pv18pcB
+10 -1
View File
@@ -22,4 +22,13 @@ implemennt that just task for today and the past can be checked ✓
implement stats
implemnt an android widget for quickadd tasks
implemnt an android widget for quickadd tasks
fix spacing for todo cards more space for todos
fix edit todo form layout so it fitts nicely on phone screen
fix buggy scroll for time selection