Compare commits
2
Commits
ec165beeff
...
ecb7fbefb1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ecb7fbefb1 | ||
|
|
3d594670b0 |
@@ -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
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
]);
|
||||
|
||||
Generated
+219
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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`;
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }],
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -40,6 +40,7 @@ export function FriendsProvider({ children }: { children: React.ReactNode }) {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
fetchFriends();
|
||||
}, [fetchFriends]);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 “{parentSubtask.title}”
|
||||
</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,
|
||||
};
|
||||
}
|
||||
@@ -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,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,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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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 +0,0 @@
|
||||
opencode -s ses_031130d6fffeMxWjOd3Pv18pcB
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user