@@ -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 |
|
| UI Components | React Native Paper + Custom SVG icons |
|
||||||
| Animations | React Native Reanimated |
|
| Animations | React Native Reanimated |
|
||||||
| Date/Time | @react-native-community/datetimepicker + date-fns |
|
| 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
|
## Project Structure
|
||||||
|
|
||||||
@@ -210,6 +211,8 @@ npx expo start --web
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Building
|
### Building
|
||||||
|
|
||||||
|
#### EAS Build (cloud)
|
||||||
```bash
|
```bash
|
||||||
# Install EAS CLI
|
# Install EAS CLI
|
||||||
npm install -g eas-cli
|
npm install -g eas-cli
|
||||||
@@ -222,6 +225,51 @@ eas build --platform ios
|
|||||||
eas build --platform android
|
eas build --platform android
|
||||||
eas build --platform web
|
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
|
## Sync API Specification
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ export const subtasks = pgTable('subtasks', {
|
|||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
taskId: text('task_id').notNull().references(() => tasks.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(),
|
title: text('title').notNull(),
|
||||||
description: text('description').notNull().default(''),
|
description: text('description').notNull().default(''),
|
||||||
priority: text('priority', { enum: ['none', 'low', 'medium', 'high', 'critical'] }).notNull().default('none'),
|
priority: text('priority', { enum: ['none', 'low', 'medium', 'high', 'critical'] }).notNull().default('none'),
|
||||||
|
|||||||
@@ -11,6 +11,17 @@ const router = Router();
|
|||||||
|
|
||||||
router.use(authMiddleware);
|
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) => {
|
router.get('/task/:taskId', asyncHandler(async (req: Request, res: Response) => {
|
||||||
const userId = req.user!.userId;
|
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)))
|
.where(and(eq(subtasks.taskId, req.params.taskId), eq(subtasks.userId, userId)))
|
||||||
.orderBy(asc(subtasks.order));
|
.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) => {
|
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 now = Date.now();
|
||||||
|
const parentSubtaskId = data.parentSubtaskId || null;
|
||||||
|
|
||||||
const maxOrder = await db
|
const maxOrder = await db
|
||||||
.select({ order: subtasks.order })
|
.select({ order: subtasks.order })
|
||||||
.from(subtasks)
|
.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))
|
.orderBy(desc(subtasks.order))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
@@ -62,6 +81,7 @@ router.post('/task/:taskId', asyncHandler(async (req: Request, res: Response) =>
|
|||||||
id: subtaskId,
|
id: subtaskId,
|
||||||
userId,
|
userId,
|
||||||
taskId: req.params.taskId,
|
taskId: req.params.taskId,
|
||||||
|
parentSubtaskId,
|
||||||
title: data.title,
|
title: data.title,
|
||||||
description: data.description ?? '',
|
description: data.description ?? '',
|
||||||
priority: data.priority ?? 'none',
|
priority: data.priority ?? 'none',
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ export const subtaskCreateSchema = z.object({
|
|||||||
reminders: z.string().max(100).optional(),
|
reminders: z.string().max(100).optional(),
|
||||||
assigneeId: z.string().nullable().optional(),
|
assigneeId: z.string().nullable().optional(),
|
||||||
order: z.number().int().min(0).optional(),
|
order: z.number().int().min(0).optional(),
|
||||||
|
parentSubtaskId: z.string().nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const subtaskUpdateSchema = z.object({
|
export const subtaskUpdateSchema = z.object({
|
||||||
@@ -88,6 +89,7 @@ export const subtaskUpdateSchema = z.object({
|
|||||||
reminders: z.string().max(100).optional(),
|
reminders: z.string().max(100).optional(),
|
||||||
assigneeId: z.string().nullable().optional(),
|
assigneeId: z.string().nullable().optional(),
|
||||||
order: z.number().int().min(0).optional(),
|
order: z.number().int().min(0).optional(),
|
||||||
|
parentSubtaskId: z.string().nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const userSettingsSchema = z.object({
|
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
|
||||||
@@ -31,12 +31,19 @@
|
|||||||
"icon": "./assets/android-icon-foreground.png",
|
"icon": "./assets/android-icon-foreground.png",
|
||||||
"color": "#1E88E5"
|
"color": "#1E88E5"
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"./plugins/withQuickAddWidget"
|
||||||
],
|
],
|
||||||
"extra": {
|
"extra": {
|
||||||
"eas": {
|
"eas": {
|
||||||
"projectId": "93a56631-01e5-45ab-9de1-7e6fb863c0a9"
|
"projectId": "93a56631-01e5-45ab-9de1-7e6fb863c0a9"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"runtimeVersion": {
|
||||||
|
"policy": "appVersion"
|
||||||
|
},
|
||||||
|
"updates": {
|
||||||
|
"url": "https://u.expo.dev/93a56631-01e5-45ab-9de1-7e6fb863c0a9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,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
|
<Tabs.Screen
|
||||||
name="settings"
|
name="settings"
|
||||||
options={{
|
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 { View, Text, StyleSheet, SafeAreaView, ScrollView, TouchableOpacity, FlatList } from 'react-native';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import { Header } from '@/components/Header';
|
import { Header } from '@/components/Header';
|
||||||
import { useTasksByDate } from '@/hooks/useTasks';
|
import { useTasksByDate } from '@/hooks/useTasks';
|
||||||
|
import { useTaskModals } from '@/hooks/useTaskModals';
|
||||||
import { toggleTaskComplete } from '@/utils/taskActions';
|
import { toggleTaskComplete } from '@/utils/taskActions';
|
||||||
import { useSettings } from '@/theme';
|
import { useSettings } from '@/theme';
|
||||||
import { TaskItem } from '@/components/TaskItem';
|
import { TaskItem } from '@/components/TaskItem';
|
||||||
import { QuickAddBar } from '@/components/QuickAddBar';
|
import { QuickAddBar } from '@/components/QuickAddBar';
|
||||||
import { TaskData } from '@/types';
|
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 Svg, { Path } from 'react-native-svg';
|
||||||
|
import type { ThemeColors } from '@/theme';
|
||||||
|
|
||||||
const DAY_WIDTH = 44;
|
const DAY_WIDTH = 44;
|
||||||
const DAY_GAP = 6;
|
const DAY_GAP = 6;
|
||||||
@@ -17,6 +19,7 @@ const DAY_GAP = 6;
|
|||||||
export default function CalendarScreen() {
|
export default function CalendarScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { theme } = useSettings();
|
const { theme } = useSettings();
|
||||||
|
const { modals, openTaskMenu, openTaskDelete } = useTaskModals();
|
||||||
const [visibleMonth, setVisibleMonth] = useState(() => new Date());
|
const [visibleMonth, setVisibleMonth] = useState(() => new Date());
|
||||||
const [selectedDate, setSelectedDate] = useState(() => new Date());
|
const [selectedDate, setSelectedDate] = useState(() => new Date());
|
||||||
const stripRef = useRef<ScrollView>(null);
|
const stripRef = useRef<ScrollView>(null);
|
||||||
@@ -28,46 +31,57 @@ export default function CalendarScreen() {
|
|||||||
|
|
||||||
const { tasks, loading } = useTasksByDate(selectedDate);
|
const { tasks, loading } = useTasksByDate(selectedDate);
|
||||||
|
|
||||||
const handleDayPress = (day: Date) => {
|
const handleDayPress = useCallback((day: Date) => {
|
||||||
setSelectedDate(day);
|
setSelectedDate(day);
|
||||||
if (!isSameMonth(day, visibleMonth)) {
|
if (!isSameMonth(day, visibleMonth)) {
|
||||||
setVisibleMonth(day);
|
setVisibleMonth(day);
|
||||||
}
|
}
|
||||||
};
|
}, [visibleMonth]);
|
||||||
|
|
||||||
const handlePrevMonth = () => {
|
const handlePrevMonth = useCallback(() => {
|
||||||
const prev = addMonths(visibleMonth, -1);
|
const prev = addMonths(visibleMonth, -1);
|
||||||
setVisibleMonth(prev);
|
setVisibleMonth(prev);
|
||||||
if (!isSameMonth(selectedDate, prev)) {
|
if (!isSameMonth(selectedDate, prev)) {
|
||||||
setSelectedDate(startOfMonth(prev));
|
setSelectedDate(startOfMonth(prev));
|
||||||
}
|
}
|
||||||
};
|
}, [visibleMonth, selectedDate]);
|
||||||
|
|
||||||
const handleNextMonth = () => {
|
const handleNextMonth = useCallback(() => {
|
||||||
const next = addMonths(visibleMonth, 1);
|
const next = addMonths(visibleMonth, 1);
|
||||||
setVisibleMonth(next);
|
setVisibleMonth(next);
|
||||||
if (!isSameMonth(selectedDate, next)) {
|
if (!isSameMonth(selectedDate, next)) {
|
||||||
setSelectedDate(startOfMonth(next));
|
setSelectedDate(startOfMonth(next));
|
||||||
}
|
}
|
||||||
};
|
}, [visibleMonth, selectedDate]);
|
||||||
|
|
||||||
const handleToggleComplete = async (taskId: string) => {
|
const handleToggleComplete = useCallback(async (taskId: string) => {
|
||||||
await toggleTaskComplete(taskId);
|
await toggleTaskComplete(taskId);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const today = new Date();
|
const scrollToDay = useCallback((day: Date) => {
|
||||||
|
|
||||||
const scrollToDay = (day: Date) => {
|
|
||||||
const index = days.findIndex((d) => isSameDay(d, day));
|
const index = days.findIndex((d) => isSameDay(d, day));
|
||||||
if (index >= 0) {
|
if (index >= 0) {
|
||||||
stripRef.current?.scrollTo({ x: Math.max(0, index * (DAY_WIDTH + DAY_GAP) - 24), animated: true });
|
stripRef.current?.scrollTo({ x: Math.max(0, index * (DAY_WIDTH + DAY_GAP) - 24), animated: true });
|
||||||
}
|
}
|
||||||
};
|
}, [days]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
scrollToDay(isSameMonth(selectedDate, visibleMonth) ? selectedDate : today);
|
const target = isSameMonth(selectedDate, visibleMonth) ? selectedDate : startOfDay(new Date());
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
scrollToDay(target);
|
||||||
}, [visibleMonth]);
|
}, [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 (
|
return (
|
||||||
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
|
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
|
||||||
@@ -79,30 +93,16 @@ export default function CalendarScreen() {
|
|||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
contentContainerStyle={styles.dateStrip}
|
contentContainerStyle={styles.dateStrip}
|
||||||
>
|
>
|
||||||
{days.map((day) => {
|
{days.map((day) => (
|
||||||
const isSelected = isSameDay(day, selectedDate);
|
<DayButton
|
||||||
const isCurrent = isToday(day);
|
key={day.toISOString()}
|
||||||
return (
|
day={day}
|
||||||
<TouchableOpacity
|
selected={isSameDay(day, selectedDate)}
|
||||||
key={day.toISOString()}
|
current={isToday(day) && !isSameDay(day, selectedDate)}
|
||||||
style={[
|
onPress={handleDayPress}
|
||||||
styles.dayButton,
|
theme={theme}
|
||||||
{ backgroundColor: theme.card, borderColor: theme.borderStrong },
|
/>
|
||||||
isCurrent && !isSelected && { borderColor: theme.accent, borderWidth: 1.5 },
|
))}
|
||||||
isSelected && { backgroundColor: theme.accent, borderColor: theme.accent },
|
|
||||||
]}
|
|
||||||
onPress={() => handleDayPress(day)}
|
|
||||||
activeOpacity={0.7}
|
|
||||||
>
|
|
||||||
<Text style={[styles.dayWeekday, { color: theme.textMuted }, isSelected && styles.dayTextSelected]}>
|
|
||||||
{format(day, 'EEE').charAt(0)}
|
|
||||||
</Text>
|
|
||||||
<Text style={[styles.dayNumber, { color: theme.text }, isSelected && styles.dayTextSelected]}>
|
|
||||||
{format(day, 'd')}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
<View style={styles.monthRow}>
|
<View style={styles.monthRow}>
|
||||||
@@ -122,14 +122,8 @@ export default function CalendarScreen() {
|
|||||||
<FlatList
|
<FlatList
|
||||||
data={tasks}
|
data={tasks}
|
||||||
keyExtractor={(item) => item.id}
|
keyExtractor={(item) => item.id}
|
||||||
renderItem={({ item }) => (
|
renderItem={renderTask}
|
||||||
<TaskItem
|
ItemSeparatorComponent={MemoSeparator}
|
||||||
task={item as TaskData}
|
|
||||||
onToggle={() => handleToggleComplete(item.id)}
|
|
||||||
onPress={() => router.push({ pathname: '/task-detail', params: { id: item.id } })}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
ItemSeparatorComponent={() => <View style={styles.separator} />}
|
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
loading ? (
|
loading ? (
|
||||||
<View style={styles.emptyState}>
|
<View style={styles.emptyState}>
|
||||||
@@ -149,10 +143,46 @@ export default function CalendarScreen() {
|
|||||||
dueDate={selectedDate.getTime()}
|
dueDate={selectedDate.getTime()}
|
||||||
placeholder={`Add task for ${format(selectedDate, 'MMM d')}`}
|
placeholder={`Add task for ${format(selectedDate, 'MMM d')}`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{modals(() => {})}
|
||||||
</SafeAreaView>
|
</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({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ export default function TasksScreen() {
|
|||||||
const { isReady } = useDatabase();
|
const { isReady } = useDatabase();
|
||||||
const { theme } = useSettings();
|
const { theme } = useSettings();
|
||||||
const [selectedCategory, setSelectedCategory] = React.useState<string>('all');
|
const [selectedCategory, setSelectedCategory] = React.useState<string>('all');
|
||||||
const [selectionActive, setSelectionActive] = React.useState(false);
|
|
||||||
|
|
||||||
if (!isReady) {
|
if (!isReady) {
|
||||||
return (
|
return (
|
||||||
@@ -24,8 +23,10 @@ export default function TasksScreen() {
|
|||||||
return (
|
return (
|
||||||
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
|
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
|
||||||
<Header title="TODO" showLogo={true} />
|
<Header title="TODO" showLogo={true} />
|
||||||
<CategoryFilter selected={selectedCategory} onSelect={setSelectedCategory} />
|
<View style={styles.categoryFilterWrapper}>
|
||||||
<TaskList categoryId={selectedCategory} onSelectionChange={setSelectionActive} />
|
<CategoryFilter selected={selectedCategory} onSelect={setSelectedCategory} />
|
||||||
|
</View>
|
||||||
|
<TaskList categoryId={selectedCategory} />
|
||||||
<QuickAddBar />
|
<QuickAddBar />
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
@@ -35,6 +36,9 @@ const styles = StyleSheet.create({
|
|||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
|
categoryFilterWrapper: {
|
||||||
|
height: 36,
|
||||||
|
},
|
||||||
loadingContainer: {
|
loadingContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
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 { Header } from '@/components/Header';
|
||||||
import { ListItem } from '@/components/ListItem';
|
import { ListItem } from '@/components/ListItem';
|
||||||
import { OptionPickerModal } from '@/components/OptionPickerModal';
|
import { OptionPickerModal } from '@/components/OptionPickerModal';
|
||||||
@@ -12,6 +12,7 @@ import SyncStatus from '@/components/SyncStatus';
|
|||||||
import { useSettings, SORT_OPTIONS, REMINDER_OPTIONS } from '@/theme';
|
import { useSettings, SORT_OPTIONS, REMINDER_OPTIONS } from '@/theme';
|
||||||
import { useCategories } from '@/hooks/useDatabase';
|
import { useCategories } from '@/hooks/useDatabase';
|
||||||
import { getAuthUser, getAuthToken } from '@/services/auth';
|
import { getAuthUser, getAuthToken } from '@/services/auth';
|
||||||
|
import { checkForUpdates, getCurrentAppVersion } from '@/services/updates';
|
||||||
import { getLastSyncTime } from '@/database/sync';
|
import { getLastSyncTime } from '@/database/sync';
|
||||||
import Category from '@/models/Category';
|
import Category from '@/models/Category';
|
||||||
import Svg, { Path } from 'react-native-svg';
|
import Svg, { Path } from 'react-native-svg';
|
||||||
@@ -26,6 +27,28 @@ export default function SettingsScreen() {
|
|||||||
const [legalVisible, setLegalVisible] = useState<null | 'privacy' | 'terms'>(null);
|
const [legalVisible, setLegalVisible] = useState<null | 'privacy' | 'terms'>(null);
|
||||||
const [serverUrlVisible, setServerUrlVisible] = useState(false);
|
const [serverUrlVisible, setServerUrlVisible] = useState(false);
|
||||||
const [syncSubtitle, setSyncSubtitle] = useState('Checking...');
|
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 refreshSyncStatus = async () => {
|
||||||
const token = await getAuthToken();
|
const token = await getAuthToken();
|
||||||
@@ -45,6 +68,7 @@ export default function SettingsScreen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
refreshSyncStatus();
|
refreshSyncStatus();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -52,12 +76,12 @@ export default function SettingsScreen() {
|
|||||||
const defaultCategoryLabel = defaultCategory?.name ?? (categories.length > 0 ? categories[0].name : 'None');
|
const defaultCategoryLabel = defaultCategory?.name ?? (categories.length > 0 ? categories[0].name : 'None');
|
||||||
const reminderLabel = REMINDER_OPTIONS.find((o) => o.value === reminderPreference)?.label ?? 'No reminder';
|
const reminderLabel = REMINDER_OPTIONS.find((o) => o.value === reminderPreference)?.label ?? 'No reminder';
|
||||||
|
|
||||||
const handleDefaultCategory = (value: string) => {
|
const handleDefaultCategory = (value: string | string[]) => {
|
||||||
setDefaultCategoryId(value);
|
setDefaultCategoryId(Array.isArray(value) ? value[0] : value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReminderPreference = (value: string) => {
|
const handleReminderPreference = (value: string | string[]) => {
|
||||||
setReminderPreference(value as typeof reminderPreference);
|
setReminderPreference((Array.isArray(value) ? value[0] : value) as typeof reminderPreference);
|
||||||
};
|
};
|
||||||
|
|
||||||
const editorVisible = editingCategory !== null;
|
const editorVisible = editingCategory !== null;
|
||||||
@@ -146,9 +170,15 @@ export default function SettingsScreen() {
|
|||||||
showChevron
|
showChevron
|
||||||
/>
|
/>
|
||||||
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>About</Text>
|
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>About</Text>
|
||||||
|
<ListItem
|
||||||
|
title="Check for Updates"
|
||||||
|
subtitle={updateSubtitle}
|
||||||
|
onPress={handleCheckUpdates}
|
||||||
|
showChevron
|
||||||
|
/>
|
||||||
<ListItem
|
<ListItem
|
||||||
title="Version"
|
title="Version"
|
||||||
subtitle="1.0.0"
|
subtitle={getCurrentAppVersion()}
|
||||||
/>
|
/>
|
||||||
<ListItem
|
<ListItem
|
||||||
title="Privacy Policy"
|
title="Privacy Policy"
|
||||||
|
|||||||
@@ -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 { DatabaseProvider } from '@/hooks/useDatabase';
|
||||||
import { SettingsProvider } from '@/theme';
|
import { SettingsProvider } from '@/theme';
|
||||||
import { FriendsProvider } from '@/hooks/useFriends';
|
import { FriendsProvider } from '@/hooks/useFriends';
|
||||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||||
import { StatusBar } from 'expo-status-bar';
|
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() {
|
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 (
|
return (
|
||||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
<StatusBar style="light" />
|
<StatusBar style="light" />
|
||||||
@@ -23,6 +43,7 @@ export default function RootLayout() {
|
|||||||
<SettingsProvider>
|
<SettingsProvider>
|
||||||
<DatabaseProvider>
|
<DatabaseProvider>
|
||||||
<FriendsProvider>
|
<FriendsProvider>
|
||||||
|
<UpdateNotifier />
|
||||||
<RootNavigator />
|
<RootNavigator />
|
||||||
</FriendsProvider>
|
</FriendsProvider>
|
||||||
</DatabaseProvider>
|
</DatabaseProvider>
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ const taskSchema = z.object({
|
|||||||
repeatInterval: z.number().int().min(1).max(30).optional(),
|
repeatInterval: z.number().int().min(1).max(30).optional(),
|
||||||
repeatDays: z.array(z.number().int().min(0).max(6)).optional(),
|
repeatDays: z.array(z.number().int().min(0).max(6)).optional(),
|
||||||
reminder: z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']),
|
reminder: z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']),
|
||||||
|
reminders: z.string().optional(),
|
||||||
assigneeId: z.string().nullable().optional(),
|
assigneeId: z.string().nullable().optional(),
|
||||||
subtasks: z.array(z.object({ title: z.string(), _key: z.string().optional() })).optional(),
|
subtasks: z.array(z.object({ title: z.string(), _key: z.string().optional() })).optional(),
|
||||||
});
|
});
|
||||||
@@ -69,6 +70,7 @@ export default function AddTaskScreen() {
|
|||||||
repeatInterval: 1,
|
repeatInterval: 1,
|
||||||
repeatDays: [],
|
repeatDays: [],
|
||||||
reminder: 'none',
|
reminder: 'none',
|
||||||
|
reminders: '',
|
||||||
assigneeId: null,
|
assigneeId: null,
|
||||||
subtasks: [],
|
subtasks: [],
|
||||||
},
|
},
|
||||||
@@ -88,6 +90,7 @@ export default function AddTaskScreen() {
|
|||||||
const repeatInterval = watch('repeatInterval') ?? 1;
|
const repeatInterval = watch('repeatInterval') ?? 1;
|
||||||
const repeatDays = watch('repeatDays') ?? [];
|
const repeatDays = watch('repeatDays') ?? [];
|
||||||
const reminder = watch('reminder');
|
const reminder = watch('reminder');
|
||||||
|
const reminders = watch('reminders');
|
||||||
const dueDate = watch('dueDate');
|
const dueDate = watch('dueDate');
|
||||||
const assigneeId = watch('assigneeId');
|
const assigneeId = watch('assigneeId');
|
||||||
|
|
||||||
@@ -124,6 +127,7 @@ export default function AddTaskScreen() {
|
|||||||
t.repeatDays = (data.repeatDays || []).join(',');
|
t.repeatDays = (data.repeatDays || []).join(',');
|
||||||
t.seriesId = seriesId;
|
t.seriesId = seriesId;
|
||||||
t.reminder = data.reminder || 'none';
|
t.reminder = data.reminder || 'none';
|
||||||
|
t.reminders = data.reminders || '';
|
||||||
t.assigneeId = data.assigneeId ?? null;
|
t.assigneeId = data.assigneeId ?? null;
|
||||||
t.createdAt = now;
|
t.createdAt = now;
|
||||||
t.updatedAt = now;
|
t.updatedAt = now;
|
||||||
@@ -213,9 +217,9 @@ export default function AddTaskScreen() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<ReminderSelector
|
<ReminderSelector
|
||||||
value={reminder}
|
value={reminders}
|
||||||
hasDueDate={!!dueDate}
|
hasDueDate={!!dueDate}
|
||||||
onChange={(value) => setValue('reminder', value)}
|
onChange={(value) => setValue('reminders', value)}
|
||||||
/>
|
/>
|
||||||
<AssigneeSelector
|
<AssigneeSelector
|
||||||
value={assigneeId}
|
value={assigneeId}
|
||||||
@@ -255,8 +259,8 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
scrollContent: {
|
scrollContent: {
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
paddingTop: 8,
|
paddingTop: 12,
|
||||||
paddingBottom: 100,
|
paddingBottom: 120,
|
||||||
gap: 24,
|
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 { TaskFormData } from '@/types';
|
||||||
import { useSettings } from '@/theme';
|
import { useSettings } from '@/theme';
|
||||||
import { deleteTaskOccurrences } from '@/utils/taskActions';
|
import { deleteTaskOccurrences } from '@/utils/taskActions';
|
||||||
import { scheduleTaskReminder, cancelTaskReminder } from '@/services/notifications';
|
import { scheduleTaskReminder } from '@/services/notifications';
|
||||||
import { useFriends } from '@/hooks/useFriends';
|
import { useFriends } from '@/hooks/useFriends';
|
||||||
import Svg, { Path } from 'react-native-svg';
|
import Svg, { Path, Circle } from 'react-native-svg';
|
||||||
|
|
||||||
const taskSchema = z.object({
|
const taskSchema = z.object({
|
||||||
title: z.string().trim().min(1, 'Task name is required').max(100),
|
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(),
|
repeatInterval: z.number().int().min(1).max(30).optional(),
|
||||||
repeatDays: z.array(z.number().int().min(0).max(6)).optional(),
|
repeatDays: z.array(z.number().int().min(0).max(6)).optional(),
|
||||||
reminder: z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']),
|
reminder: z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']),
|
||||||
|
reminders: z.string().optional(),
|
||||||
assigneeId: z.string().nullable().optional(),
|
assigneeId: z.string().nullable().optional(),
|
||||||
subtasks: z.array(z.object({ title: z.string(), _key: z.string().optional() })).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() {
|
export default function TaskDetailScreen() {
|
||||||
const { id } = useLocalSearchParams<{ id: string }>();
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
const { isReady } = useDatabase();
|
const { isReady } = useDatabase();
|
||||||
@@ -80,6 +108,7 @@ export default function TaskDetailScreen() {
|
|||||||
const repeatInterval = watch('repeatInterval') ?? 1;
|
const repeatInterval = watch('repeatInterval') ?? 1;
|
||||||
const repeatDays = watch('repeatDays') ?? [];
|
const repeatDays = watch('repeatDays') ?? [];
|
||||||
const reminder = watch('reminder');
|
const reminder = watch('reminder');
|
||||||
|
const reminders = watch('reminders');
|
||||||
const dueDate = watch('dueDate');
|
const dueDate = watch('dueDate');
|
||||||
const assigneeId = watch('assigneeId');
|
const assigneeId = watch('assigneeId');
|
||||||
const [deleteModalVisible, setDeleteModalVisible] = React.useState(false);
|
const [deleteModalVisible, setDeleteModalVisible] = React.useState(false);
|
||||||
@@ -253,38 +282,89 @@ export default function TaskDetailScreen() {
|
|||||||
value={priority}
|
value={priority}
|
||||||
onChange={(value) => setValue('priority', value)}
|
onChange={(value) => setValue('priority', value)}
|
||||||
/>
|
/>
|
||||||
<DateTimePickerComponent control={control} />
|
|
||||||
<RepeatSelector
|
<CollapsibleSection title="Date & Time" icon={
|
||||||
value={repeat}
|
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||||
interval={repeatInterval}
|
<Circle cx={12} cy={12} r={10} stroke={theme.accent} strokeWidth={1.5} fill="none" />
|
||||||
days={repeatDays}
|
<Path d="M12 6v6l4 2" stroke={theme.accent} strokeWidth={1.5} strokeLinecap="round" />
|
||||||
onChange={(nextRepeat, nextInterval, nextDays) => {
|
</Svg>
|
||||||
setValue('repeat', nextRepeat);
|
} defaultExpanded={!!dueDate}>
|
||||||
setValue('repeatInterval', nextInterval);
|
<DateTimePickerComponent control={control} />
|
||||||
setValue('repeatDays', nextDays);
|
</CollapsibleSection>
|
||||||
}}
|
|
||||||
/>
|
<CollapsibleSection title="Repeat" icon={
|
||||||
<ReminderSelector
|
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||||
value={reminder}
|
<Path
|
||||||
hasDueDate={!!dueDate}
|
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"
|
||||||
onChange={(value) => setValue('reminder', value)}
|
stroke={theme.accent}
|
||||||
/>
|
strokeWidth={1.5}
|
||||||
<AssigneeSelector
|
strokeLinecap="round"
|
||||||
value={assigneeId}
|
strokeLinejoin="round"
|
||||||
onChange={(value: string | null) => setValue('assigneeId', value)}
|
fill="none"
|
||||||
friends={friends.map((f) => f.username)}
|
|
||||||
/>
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="description"
|
|
||||||
render={({ field }) => (
|
|
||||||
<DescriptionInput
|
|
||||||
value={field.value ?? ''}
|
|
||||||
onChangeText={field.onChange}
|
|
||||||
onBlur={field.onBlur}
|
|
||||||
/>
|
/>
|
||||||
)}
|
</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>
|
</ScrollView>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
<FormButtons onSubmit={handleSubmit(onSubmit)} submitLabel="Save" />
|
<FormButtons onSubmit={handleSubmit(onSubmit)} submitLabel="Save" />
|
||||||
@@ -315,15 +395,40 @@ const styles = StyleSheet.create({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
scrollContent: {
|
scrollContent: {
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 12,
|
||||||
paddingTop: 8,
|
paddingTop: 8,
|
||||||
paddingBottom: 100,
|
paddingBottom: 80,
|
||||||
gap: 24,
|
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: {
|
deleteButton: {
|
||||||
width: 36,
|
width: 32,
|
||||||
height: 36,
|
height: 32,
|
||||||
borderRadius: 12,
|
borderRadius: 10,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
|||||||
@@ -2,9 +2,25 @@
|
|||||||
const { defineConfig } = require('eslint/config');
|
const { defineConfig } = require('eslint/config');
|
||||||
const expoConfig = require("eslint-config-expo/flat");
|
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([
|
module.exports = defineConfig([
|
||||||
expoConfig,
|
expoConfig,
|
||||||
{
|
{
|
||||||
ignores: ["dist/*"],
|
ignores: ["dist/*"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
settings: {
|
||||||
|
'import/resolver': {
|
||||||
|
typescript: {
|
||||||
|
extensions: platformExtensions,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
|||||||
Generated
+219
@@ -7,6 +7,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "carry-your-live",
|
"name": "carry-your-live",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^3.3.4",
|
"@hookform/resolvers": "^3.3.4",
|
||||||
"@nozbe/watermelondb": "^0.28.1-0",
|
"@nozbe/watermelondb": "^0.28.1-0",
|
||||||
@@ -21,6 +22,7 @@
|
|||||||
"expo-router": "~57.0.10",
|
"expo-router": "~57.0.10",
|
||||||
"expo-sqlite": "~57.0.1",
|
"expo-sqlite": "~57.0.1",
|
||||||
"expo-status-bar": "~57.0.1",
|
"expo-status-bar": "~57.0.1",
|
||||||
|
"expo-updates": "~57.0.12",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"react-hook-form": "^7.51.5",
|
"react-hook-form": "^7.51.5",
|
||||||
@@ -40,6 +42,7 @@
|
|||||||
"@types/react": "~19.2.2",
|
"@types/react": "~19.2.2",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^9.0.0",
|
||||||
"eslint-config-expo": "~57.0.1",
|
"eslint-config-expo": "~57.0.1",
|
||||||
|
"patch-package": "^8.0.1",
|
||||||
"prettier": "^3.9.6",
|
"prettier": "^3.9.6",
|
||||||
"typescript": "~6.0.3"
|
"typescript": "~6.0.3"
|
||||||
}
|
}
|
||||||
@@ -3594,6 +3597,13 @@
|
|||||||
"node": ">=10.0.0"
|
"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": {
|
"node_modules/abort-controller": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
|
||||||
@@ -5965,6 +5975,12 @@
|
|||||||
"expo": "*"
|
"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": {
|
"node_modules/expo-font": {
|
||||||
"version": "57.0.1",
|
"version": "57.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/expo-font/-/expo-font-57.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/expo-font/-/expo-font-57.0.1.tgz",
|
||||||
@@ -6297,6 +6313,12 @@
|
|||||||
"react-native": "*"
|
"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": {
|
"node_modules/expo-symbols": {
|
||||||
"version": "57.0.1",
|
"version": "57.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/expo-symbols/-/expo-symbols-57.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/expo-symbols/-/expo-symbols-57.0.1.tgz",
|
||||||
@@ -6313,6 +6335,43 @@
|
|||||||
"react-native": "*"
|
"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": {
|
"node_modules/expo-updates-interface": {
|
||||||
"version": "57.0.1",
|
"version": "57.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/expo-updates-interface/-/expo-updates-interface-57.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/expo-updates-interface/-/expo-updates-interface-57.0.1.tgz",
|
||||||
@@ -6322,6 +6381,12 @@
|
|||||||
"expo": "*"
|
"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": {
|
"node_modules/expo/node_modules/@expo/cli": {
|
||||||
"version": "57.0.12",
|
"version": "57.0.12",
|
||||||
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-57.0.12.tgz",
|
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-57.0.12.tgz",
|
||||||
@@ -6861,6 +6926,16 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/flat-cache": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
|
||||||
@@ -6919,6 +6994,21 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/function-bind": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
@@ -8112,6 +8202,26 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/json-stable-stringify-without-jsonify": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
|
"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": ">=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": {
|
"node_modules/jsx-ast-utils": {
|
||||||
"version": "3.3.5",
|
"version": "3.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
|
||||||
@@ -8157,6 +8290,16 @@
|
|||||||
"json-buffer": "3.0.1"
|
"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": {
|
"node_modules/kleur": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
|
||||||
@@ -9628,6 +9771,52 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/path-exists": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
@@ -10965,6 +11154,16 @@
|
|||||||
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
|
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/slugify": {
|
||||||
"version": "1.6.9",
|
"version": "1.6.9",
|
||||||
"resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.9.tgz",
|
"resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.9.tgz",
|
||||||
@@ -11398,6 +11597,16 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"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": {
|
"node_modules/tmpl": {
|
||||||
"version": "1.0.5",
|
"version": "1.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
|
||||||
@@ -11687,6 +11896,16 @@
|
|||||||
"node": ">=4"
|
"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": {
|
"node_modules/unpipe": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||||
|
|||||||
@@ -16,18 +16,16 @@
|
|||||||
"expo-router": "~57.0.10",
|
"expo-router": "~57.0.10",
|
||||||
"expo-sqlite": "~57.0.1",
|
"expo-sqlite": "~57.0.1",
|
||||||
"expo-status-bar": "~57.0.1",
|
"expo-status-bar": "~57.0.1",
|
||||||
|
"expo-updates": "~57.0.12",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"react-hook-form": "^7.51.5",
|
"react-hook-form": "^7.51.5",
|
||||||
"react-native": "0.86.2",
|
"react-native": "0.86.2",
|
||||||
"react-native-gesture-handler": "2.32.0",
|
"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-safe-area-context": "5.7.0",
|
||||||
"react-native-screens": "4.26.0",
|
"react-native-screens": "4.26.0",
|
||||||
"react-native-svg": "^15.15.4",
|
"react-native-svg": "^15.15.4",
|
||||||
"react-native-web": "^0.21.2",
|
"react-native-web": "^0.21.2",
|
||||||
"react-native-worklets": "0.10.1",
|
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -35,6 +33,7 @@
|
|||||||
"@types/react": "~19.2.2",
|
"@types/react": "~19.2.2",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^9.0.0",
|
||||||
"eslint-config-expo": "~57.0.1",
|
"eslint-config-expo": "~57.0.1",
|
||||||
|
"patch-package": "^8.0.1",
|
||||||
"prettier": "^3.9.6",
|
"prettier": "^3.9.6",
|
||||||
"typescript": "~6.0.3"
|
"typescript": "~6.0.3"
|
||||||
},
|
},
|
||||||
@@ -50,7 +49,8 @@
|
|||||||
"build:android": "eas build --platform android",
|
"build:android": "eas build --platform android",
|
||||||
"build:web": "eas build --platform web",
|
"build:web": "eas build --platform web",
|
||||||
"submit:ios": "eas submit --platform ios",
|
"submit:ios": "eas submit --platform ios",
|
||||||
"submit:android": "eas submit --platform android"
|
"submit:android": "eas submit --platform android",
|
||||||
|
"postinstall": "patch-package"
|
||||||
},
|
},
|
||||||
"private": true
|
"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 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 { useSettings } from '@/theme';
|
||||||
import { useFriends } from '@/hooks/useFriends';
|
import { useFriends } from '@/hooks/useFriends';
|
||||||
|
|
||||||
@@ -15,11 +15,19 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
|
|||||||
const { friends, searchUsers, loading: friendsLoading } = useFriends();
|
const { friends, searchUsers, loading: friendsLoading } = useFriends();
|
||||||
const [showModal, setShowModal] = React.useState(false);
|
const [showModal, setShowModal] = React.useState(false);
|
||||||
const [searchQuery, setSearchQuery] = React.useState('');
|
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 [searching, setSearching] = React.useState(false);
|
||||||
|
|
||||||
const selectedFriend = value ? friends.find(f => f.id === value) : null;
|
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(() => {
|
React.useEffect(() => {
|
||||||
if (showModal && searchQuery.length >= 2) {
|
if (showModal && searchQuery.length >= 2) {
|
||||||
const timeout = setTimeout(async () => {
|
const timeout = setTimeout(async () => {
|
||||||
@@ -34,8 +42,6 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
|
|||||||
}
|
}
|
||||||
}, 300);
|
}, 300);
|
||||||
return () => clearTimeout(timeout);
|
return () => clearTimeout(timeout);
|
||||||
} else {
|
|
||||||
setSearchResults([]);
|
|
||||||
}
|
}
|
||||||
}, [searchQuery, showModal, friends, searchUsers]);
|
}, [searchQuery, showModal, friends, searchUsers]);
|
||||||
|
|
||||||
@@ -97,7 +103,10 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
<Modal visible={showModal} transparent animationType="fade" onRequestClose={() => setShowModal(false)}>
|
<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.modalSheet, { backgroundColor: theme.sheetBg }]}>
|
||||||
<View style={styles.modalHeader}>
|
<View style={styles.modalHeader}>
|
||||||
<Text style={[styles.modalTitle, { color: theme.text }]}>Assign Task</Text>
|
<Text style={[styles.modalTitle, { color: theme.text }]}>Assign Task</Text>
|
||||||
@@ -197,7 +206,7 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
|
|||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</KeyboardAvoidingView>
|
||||||
</Modal>
|
</Modal>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -210,48 +219,48 @@ const styles = StyleSheet.create({
|
|||||||
selectorButton: {
|
selectorButton: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 10,
|
gap: 8,
|
||||||
paddingVertical: 14,
|
paddingVertical: 12,
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 10,
|
||||||
borderRadius: 12,
|
borderRadius: 10,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
minHeight: 52,
|
minHeight: 48,
|
||||||
},
|
},
|
||||||
selectorIcon: {
|
selectorIcon: {
|
||||||
fontSize: 20,
|
fontSize: 18,
|
||||||
},
|
},
|
||||||
selectorContent: {
|
selectorContent: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
selectorLabel: {
|
selectorLabel: {
|
||||||
fontSize: 11,
|
fontSize: 10,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
letterSpacing: 0.5,
|
letterSpacing: 0.5,
|
||||||
marginBottom: 2,
|
marginBottom: 1,
|
||||||
},
|
},
|
||||||
selectorValue: {
|
selectorValue: {
|
||||||
fontSize: 15,
|
fontSize: 14,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
},
|
},
|
||||||
clearButton: {
|
clearButton: {
|
||||||
padding: 4,
|
padding: 3,
|
||||||
},
|
},
|
||||||
clearText: {
|
clearText: {
|
||||||
fontSize: 18,
|
fontSize: 16,
|
||||||
fontWeight: '300',
|
fontWeight: '300',
|
||||||
},
|
},
|
||||||
modalOverlay: {
|
modalOverlay: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
padding: 24,
|
padding: 20,
|
||||||
},
|
},
|
||||||
modalSheet: {
|
modalSheet: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
maxWidth: 400,
|
maxWidth: 360,
|
||||||
borderRadius: 20,
|
borderRadius: 16,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
maxHeight: '85%',
|
maxHeight: '85%',
|
||||||
},
|
},
|
||||||
@@ -259,81 +268,81 @@ const styles = StyleSheet.create({
|
|||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
padding: 20,
|
padding: 16,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
},
|
},
|
||||||
modalTitle: {
|
modalTitle: {
|
||||||
fontSize: 18,
|
fontSize: 16,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
},
|
},
|
||||||
closeText: {
|
closeText: {
|
||||||
fontSize: 22,
|
fontSize: 20,
|
||||||
fontWeight: '300',
|
fontWeight: '300',
|
||||||
},
|
},
|
||||||
modalSection: {
|
modalSection: {
|
||||||
padding: 12,
|
padding: 10,
|
||||||
paddingBottom: 20,
|
paddingBottom: 16,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
},
|
},
|
||||||
sectionTitle: {
|
sectionTitle: {
|
||||||
fontSize: 12,
|
fontSize: 11,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
letterSpacing: 0.5,
|
letterSpacing: 0.5,
|
||||||
marginBottom: 8,
|
marginBottom: 6,
|
||||||
paddingHorizontal: 8,
|
paddingHorizontal: 6,
|
||||||
},
|
},
|
||||||
optionRow: {
|
optionRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 12,
|
gap: 10,
|
||||||
paddingVertical: 12,
|
paddingVertical: 10,
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 14,
|
||||||
borderRadius: 10,
|
borderRadius: 9,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
},
|
},
|
||||||
optionIcon: {
|
optionIcon: {
|
||||||
width: 36,
|
width: 32,
|
||||||
height: 36,
|
height: 32,
|
||||||
borderRadius: 18,
|
borderRadius: 16,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
backgroundColor: 'rgba(0,0,0,0.05)',
|
backgroundColor: 'rgba(0,0,0,0.05)',
|
||||||
},
|
},
|
||||||
optionIconText: {
|
optionIconText: {
|
||||||
fontSize: 16,
|
fontSize: 15,
|
||||||
},
|
},
|
||||||
optionText: {
|
optionText: {
|
||||||
fontSize: 16,
|
fontSize: 15,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
optionSubtext: {
|
optionSubtext: {
|
||||||
fontSize: 12,
|
fontSize: 11,
|
||||||
marginTop: 2,
|
marginTop: 1,
|
||||||
},
|
},
|
||||||
checkmark: {
|
checkmark: {
|
||||||
fontSize: 18,
|
fontSize: 16,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
},
|
},
|
||||||
loading: {
|
loading: {
|
||||||
padding: 20,
|
padding: 16,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
emptyText: {
|
emptyText: {
|
||||||
fontSize: 14,
|
fontSize: 13,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
paddingHorizontal: 20,
|
paddingHorizontal: 16,
|
||||||
},
|
},
|
||||||
searchContainer: {
|
searchContainer: {
|
||||||
padding: 12,
|
padding: 10,
|
||||||
paddingBottom: 8,
|
paddingBottom: 6,
|
||||||
},
|
},
|
||||||
searchInput: {
|
searchInput: {
|
||||||
fontSize: 16,
|
fontSize: 15,
|
||||||
paddingVertical: 12,
|
paddingVertical: 10,
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 14,
|
||||||
borderRadius: 12,
|
borderRadius: 10,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Modal, View, Text, StyleSheet, TextInput, TouchableOpacity, ScrollView, KeyboardAvoidingView, Platform } from 'react-native';
|
import { Modal, View, Text, StyleSheet, TextInput, TouchableOpacity, ScrollView, KeyboardAvoidingView } from 'react-native';
|
||||||
import Category from '@/models/Category';
|
import Category from '@/models/Category';
|
||||||
import { useSettings } from '@/theme';
|
import { useSettings } from '@/theme';
|
||||||
import { CATEGORY_COLORS } from '@/constants';
|
import { CATEGORY_COLORS } from '@/constants';
|
||||||
@@ -19,13 +19,15 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose
|
|||||||
const { theme } = useSettings();
|
const { theme } = useSettings();
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [color, setColor] = useState(CATEGORY_COLORS[0]);
|
const [color, setColor] = useState(CATEGORY_COLORS[0]);
|
||||||
|
const [prevVisible, setPrevVisible] = useState(visible);
|
||||||
|
|
||||||
useEffect(() => {
|
if (prevVisible !== visible) {
|
||||||
|
setPrevVisible(visible);
|
||||||
if (visible) {
|
if (visible) {
|
||||||
setName(category?.name ?? '');
|
setName(category?.name ?? '');
|
||||||
setColor(category?.color ?? CATEGORY_COLORS[0]);
|
setColor(category?.color ?? CATEGORY_COLORS[0]);
|
||||||
}
|
}
|
||||||
}, [visible, category]);
|
}
|
||||||
|
|
||||||
const canDelete = category !== null && categoryCount > 1;
|
const canDelete = category !== null && categoryCount > 1;
|
||||||
|
|
||||||
@@ -58,7 +60,7 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose
|
|||||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
style={styles.overlay}
|
style={styles.overlay}
|
||||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
behavior="padding"
|
||||||
>
|
>
|
||||||
<ScrollView contentContainerStyle={styles.overlay} keyboardShouldPersistTaps="handled">
|
<ScrollView contentContainerStyle={styles.overlay} keyboardShouldPersistTaps="handled">
|
||||||
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
|
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
|
||||||
@@ -133,6 +135,7 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose
|
|||||||
<Text style={styles.saveButtonText}>Save</Text>
|
<Text style={styles.saveButtonText}>Save</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, Animated, Easing } from 'react-native';
|
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 { useSettings, ThemeColors } from '@/theme';
|
||||||
import Category from '@/models/Category';
|
import Category from '@/models/Category';
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ interface CategoryFilterProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) {
|
export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) {
|
||||||
const categories = useCategories();
|
const categories = useUniqueCategories();
|
||||||
const { theme } = useSettings();
|
const { theme } = useSettings();
|
||||||
|
|
||||||
if (categories.length === 0) {
|
if (categories.length === 0) {
|
||||||
@@ -91,9 +91,9 @@ interface AnimatedCategoryButtonProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function AnimatedCategoryButton({ category, selected, onPress, theme }: AnimatedCategoryButtonProps) {
|
function AnimatedCategoryButton({ category, selected, onPress, theme }: AnimatedCategoryButtonProps) {
|
||||||
const scaleAnim = React.useRef(new Animated.Value(selected ? 1.05 : 1)).current;
|
const [scaleAnim] = React.useState(() => new Animated.Value(selected ? 1.05 : 1));
|
||||||
const borderWidthAnim = React.useRef(new Animated.Value(selected ? 2 : 1)).current;
|
const [borderWidthAnim] = React.useState(() => new Animated.Value(selected ? 2 : 1));
|
||||||
const shadowOpacityAnim = React.useRef(new Animated.Value(selected ? 0.15 : 0)).current;
|
const [shadowOpacityAnim] = React.useState(() => new Animated.Value(selected ? 0.15 : 0));
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
Animated.timing(scaleAnim, {
|
Animated.timing(scaleAnim, {
|
||||||
@@ -151,28 +151,30 @@ function AnimatedCategoryButton({ category, selected, onPress, theme }: Animated
|
|||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
scrollView: {
|
scrollView: {
|
||||||
paddingVertical: 0,
|
paddingVertical: 0,
|
||||||
|
marginBottom: 0,
|
||||||
},
|
},
|
||||||
container: {
|
container: {
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 12,
|
||||||
paddingBottom: 4,
|
paddingTop: 0,
|
||||||
gap: 8,
|
paddingBottom: 0,
|
||||||
alignItems: 'center',
|
gap: 6,
|
||||||
|
alignItems: 'flex-start',
|
||||||
},
|
},
|
||||||
button: {
|
button: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingHorizontal: 14,
|
paddingHorizontal: 12,
|
||||||
paddingVertical: 8,
|
paddingVertical: 6,
|
||||||
borderRadius: 20,
|
borderRadius: 16,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
minWidth: 72,
|
minWidth: 64,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
animatedButton: {
|
animatedButton: {
|
||||||
shadowColor: '#000',
|
shadowColor: '#000',
|
||||||
shadowOffset: { width: 0, height: 2 },
|
shadowOffset: { width: 0, height: 1 },
|
||||||
shadowRadius: 8,
|
shadowRadius: 4,
|
||||||
elevation: 3,
|
elevation: 2,
|
||||||
},
|
},
|
||||||
buttonInner: {
|
buttonInner: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -180,17 +182,17 @@ const styles = StyleSheet.create({
|
|||||||
gap: 6,
|
gap: 6,
|
||||||
},
|
},
|
||||||
colorDot: {
|
colorDot: {
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
colorDotSelected: {
|
||||||
width: 10,
|
width: 10,
|
||||||
height: 10,
|
height: 10,
|
||||||
borderRadius: 5,
|
borderRadius: 5,
|
||||||
},
|
},
|
||||||
colorDotSelected: {
|
|
||||||
width: 12,
|
|
||||||
height: 12,
|
|
||||||
borderRadius: 6,
|
|
||||||
},
|
|
||||||
buttonText: {
|
buttonText: {
|
||||||
fontSize: 13,
|
fontSize: 12,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { View, Text, StyleSheet, TouchableOpacity, ScrollView } from 'react-native';
|
import { View, Text, StyleSheet, TouchableOpacity, ScrollView, Modal, Pressable, KeyboardAvoidingView } from 'react-native';
|
||||||
import { useCategories } from '@/hooks/useDatabase';
|
import { useCategories } from '@/hooks/useDatabase';
|
||||||
import { useSettings } from '@/theme';
|
import { useSettings } from '@/theme';
|
||||||
|
|
||||||
@@ -12,82 +12,109 @@ interface CategorySelectorProps {
|
|||||||
export function CategorySelector({ value, onChange, error }: CategorySelectorProps) {
|
export function CategorySelector({ value, onChange, error }: CategorySelectorProps) {
|
||||||
const categories = useCategories();
|
const categories = useCategories();
|
||||||
const { theme } = useSettings();
|
const { theme } = useSettings();
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
|
||||||
|
const selectedCategory = categories.find(c => c.id === value);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<Text style={[styles.label, { color: theme.text }]}>Category</Text>
|
<Text style={[styles.label, { color: theme.text }]}>Category</Text>
|
||||||
<View style={styles.requiredIndicator} />
|
<TouchableOpacity
|
||||||
<ScrollView
|
style={[
|
||||||
horizontal
|
styles.selectorButton,
|
||||||
showsHorizontalScrollIndicator={false}
|
{ backgroundColor: theme.inputBg, borderColor: theme.borderStrong },
|
||||||
contentContainerStyle={styles.scrollContent}
|
]}
|
||||||
style={styles.scrollView}
|
onPress={() => setShowModal(true)}
|
||||||
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
{categories.map((category) => (
|
<View style={styles.selectorContent}>
|
||||||
<TouchableOpacity
|
<View style={styles.selectorRow}>
|
||||||
key={category.id}
|
<View style={[styles.colorCircle, { backgroundColor: selectedCategory?.color || '#9E9E9E' }]} />
|
||||||
style={[
|
<Text style={[styles.selectorValue, { color: theme.text }]}>{selectedCategory?.name || 'Select category'}</Text>
|
||||||
styles.categoryButton,
|
</View>
|
||||||
{ backgroundColor: theme.card, borderColor: theme.borderStrong },
|
</View>
|
||||||
value === category.id && { borderColor: theme.accent, backgroundColor: theme.accentSoft, borderWidth: 2 },
|
<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" />
|
||||||
onPress={() => onChange(category.id)}
|
</Svg>
|
||||||
activeOpacity={0.8}
|
</TouchableOpacity>
|
||||||
>
|
{error && <Text style={[styles.errorText, { color: '#E53935' }]}>{error}</Text>}
|
||||||
<View
|
|
||||||
style={[
|
<Modal visible={showModal} transparent animationType="fade" onRequestClose={() => setShowModal(false)}>
|
||||||
styles.colorCircle,
|
<KeyboardAvoidingView
|
||||||
{ backgroundColor: category.color },
|
style={[styles.modalOverlay, { backgroundColor: 'rgba(0,0,0,0.5)' }]}
|
||||||
value === category.id && styles.colorCircleSelected,
|
behavior="padding"
|
||||||
]}
|
>
|
||||||
/>
|
<Pressable style={styles.modalOverlay} onPress={() => setShowModal(false)}>
|
||||||
<Text style={[
|
<Pressable style={[styles.modalSheet, { backgroundColor: theme.sheetBg }]}>
|
||||||
styles.categoryName,
|
<View style={styles.modalHeader}>
|
||||||
{ color: theme.textSecondary },
|
<Text style={[styles.modalTitle, { color: theme.text }]}>Select Category</Text>
|
||||||
value === category.id && { color: theme.accent, fontWeight: '600' },
|
<TouchableOpacity onPress={() => setShowModal(false)} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
|
||||||
]}>
|
<Text style={[styles.closeText, { color: theme.textMuted }]}>✕</Text>
|
||||||
{category.name}
|
</TouchableOpacity>
|
||||||
</Text>
|
</View>
|
||||||
</TouchableOpacity>
|
<ScrollView contentContainerStyle={styles.modalContent}>
|
||||||
))}
|
{categories.map((category) => (
|
||||||
</ScrollView>
|
<TouchableOpacity
|
||||||
{error && <Text style={styles.errorText}>{error}</Text>}
|
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>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import Svg, { Path } from 'react-native-svg';
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
gap: 8,
|
gap: 6,
|
||||||
},
|
},
|
||||||
label: {
|
label: {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
requiredIndicator: {
|
selectorButton: {
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
right: 0,
|
|
||||||
color: '#E53935',
|
|
||||||
fontSize: 14,
|
|
||||||
},
|
|
||||||
scrollView: {
|
|
||||||
paddingVertical: 4,
|
|
||||||
},
|
|
||||||
scrollContent: {
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
gap: 10,
|
|
||||||
},
|
|
||||||
categoryButton: {
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
paddingVertical: 10,
|
|
||||||
borderRadius: 24,
|
|
||||||
borderWidth: 1,
|
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 8,
|
justifyContent: 'space-between',
|
||||||
minWidth: 90,
|
paddingVertical: 14,
|
||||||
justifyContent: 'center',
|
paddingHorizontal: 12,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
minHeight: 52,
|
||||||
|
},
|
||||||
|
selectorContent: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
selectorRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
},
|
},
|
||||||
colorCircle: {
|
colorCircle: {
|
||||||
width: 12,
|
width: 12,
|
||||||
@@ -99,13 +126,59 @@ const styles = StyleSheet.create({
|
|||||||
height: 14,
|
height: 14,
|
||||||
borderRadius: 7,
|
borderRadius: 7,
|
||||||
},
|
},
|
||||||
categoryName: {
|
selectorValue: {
|
||||||
fontSize: 13,
|
fontSize: 15,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
},
|
},
|
||||||
errorText: {
|
errorText: {
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: '#E53935',
|
marginLeft: 4,
|
||||||
marginLeft: 16,
|
},
|
||||||
|
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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, StyleSheet } from 'react-native';
|
import { StyleSheet } from 'react-native';
|
||||||
import DateTimePicker, { DateTimePickerEvent } from '@react-native-community/datetimepicker';
|
import DateTimePicker, { DateTimePickerEvent } from '@react-native-community/datetimepicker';
|
||||||
|
|
||||||
interface NativeDateTimeInputProps {
|
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 { View, Text, TouchableOpacity, StyleSheet, Modal } from 'react-native';
|
||||||
import { DateTimePickerEvent } from '@react-native-community/datetimepicker';
|
import { DateTimePickerEvent } from '@react-native-community/datetimepicker';
|
||||||
|
|
||||||
@@ -33,10 +33,12 @@ export default function WebDateTimeInput({
|
|||||||
is24Hour,
|
is24Hour,
|
||||||
}: WebDateTimeInputProps) {
|
}: WebDateTimeInputProps) {
|
||||||
const [inputValue, setInputValue] = useState(() => dateInputValue(value));
|
const [inputValue, setInputValue] = useState(() => dateInputValue(value));
|
||||||
|
const [prevValue, setPrevValue] = useState(value);
|
||||||
|
|
||||||
useEffect(() => {
|
if (value !== prevValue) {
|
||||||
|
setPrevValue(value);
|
||||||
setInputValue(dateInputValue(value));
|
setInputValue(dateInputValue(value));
|
||||||
}, [value]);
|
}
|
||||||
|
|
||||||
const emit = (raw: string) => {
|
const emit = (raw: string) => {
|
||||||
if (!raw) return;
|
if (!raw) return;
|
||||||
|
|||||||
@@ -15,14 +15,12 @@ interface DateTimePickerComponentProps {
|
|||||||
export function DateTimePickerComponent({ control }: DateTimePickerComponentProps) {
|
export function DateTimePickerComponent({ control }: DateTimePickerComponentProps) {
|
||||||
const { theme } = useSettings();
|
const { theme } = useSettings();
|
||||||
const [picker, setPicker] = React.useState<'date' | 'time' | 'endTime' | null>(null);
|
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 dateRef = useRef<((value: Date | null) => void) | null>(null);
|
||||||
const timeRef = useRef<((value: string) => void) | null>(null);
|
const timeRef = useRef<((value: string) => void) | null>(null);
|
||||||
const endTimeRef = useRef<((value: string) => void) | null>(null);
|
const endTimeRef = useRef<((value: string) => void) | null>(null);
|
||||||
|
|
||||||
const allDay = useWatch({ control, name: 'allDay' }) ?? false;
|
const allDay = useWatch({ control, name: 'allDay' }) ?? false;
|
||||||
|
const dueDate = useWatch({ control, name: 'dueDate' }) ?? null;
|
||||||
const startTime = useWatch({ control, name: 'dueTime' }) ?? '';
|
const startTime = useWatch({ control, name: 'dueTime' }) ?? '';
|
||||||
const endTime = useWatch({ control, name: 'endTime' }) ?? '';
|
const endTime = useWatch({ control, name: 'endTime' }) ?? '';
|
||||||
|
|
||||||
@@ -37,7 +35,6 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
|
|||||||
const renderTimeButton = (
|
const renderTimeButton = (
|
||||||
fieldName: 'dueTime' | 'endTime',
|
fieldName: 'dueTime' | 'endTime',
|
||||||
ref: React.MutableRefObject<((value: string) => void) | null>,
|
ref: React.MutableRefObject<((value: string) => void) | null>,
|
||||||
valueRef: React.MutableRefObject<string>,
|
|
||||||
placeholder: string,
|
placeholder: string,
|
||||||
onPress: () => void
|
onPress: () => void
|
||||||
) => (
|
) => (
|
||||||
@@ -46,7 +43,6 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
|
|||||||
name={fieldName}
|
name={fieldName}
|
||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
ref.current = field.onChange;
|
ref.current = field.onChange;
|
||||||
valueRef.current = field.value ?? '';
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[
|
style={[
|
||||||
@@ -105,7 +101,6 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
|
|||||||
name="dueDate"
|
name="dueDate"
|
||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
dateRef.current = field.onChange;
|
dateRef.current = field.onChange;
|
||||||
dateValueRef.current = field.value;
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[
|
style={[
|
||||||
@@ -181,8 +176,8 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
|
|||||||
{!allDay && (
|
{!allDay && (
|
||||||
<>
|
<>
|
||||||
<View style={styles.timeRow}>
|
<View style={styles.timeRow}>
|
||||||
{renderTimeButton('dueTime', timeRef, timeValueRef, 'Start Time', () => setPicker('time'))}
|
{renderTimeButton('dueTime', timeRef, 'Start Time', () => setPicker('time'))}
|
||||||
{renderTimeButton('endTime', endTimeRef, endTimeValueRef, 'End Time', () => setPicker('endTime'))}
|
{renderTimeButton('endTime', endTimeRef, 'End Time', () => setPicker('endTime'))}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{endTimeInvalid && (
|
{endTimeInvalid && (
|
||||||
@@ -203,7 +198,7 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
|
|||||||
|
|
||||||
<DateTimeInput
|
<DateTimeInput
|
||||||
testID="date-picker"
|
testID="date-picker"
|
||||||
value={dateValueRef.current ?? new Date()}
|
value={dueDate ?? new Date()}
|
||||||
mode="date"
|
mode="date"
|
||||||
is24Hour={false}
|
is24Hour={false}
|
||||||
isVisible={picker === 'date'}
|
isVisible={picker === 'date'}
|
||||||
@@ -220,8 +215,8 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
|
|||||||
title={picker === 'endTime' ? 'End Time' : 'Start Time'}
|
title={picker === 'endTime' ? 'End Time' : 'Start Time'}
|
||||||
initialTime={
|
initialTime={
|
||||||
picker === 'endTime'
|
picker === 'endTime'
|
||||||
? endTimeValueRef.current || timeValueRef.current
|
? endTime || startTime
|
||||||
: timeValueRef.current
|
: startTime
|
||||||
}
|
}
|
||||||
onConfirm={(time) => {
|
onConfirm={(time) => {
|
||||||
if (picker === 'endTime') {
|
if (picker === 'endTime') {
|
||||||
@@ -261,7 +256,7 @@ const styles = StyleSheet.create({
|
|||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
paddingVertical: 12,
|
paddingVertical: 14,
|
||||||
},
|
},
|
||||||
allDayLabel: {
|
allDayLabel: {
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
@@ -293,7 +288,7 @@ const styles = StyleSheet.create({
|
|||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
},
|
},
|
||||||
clearButton: {
|
clearButton: {
|
||||||
padding: 2,
|
padding: 4,
|
||||||
},
|
},
|
||||||
warningRow: {
|
warningRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, Text, StyleSheet, TextInput } from 'react-native';
|
import { View, Text, StyleSheet, TextInput , TextInputProps } from 'react-native';
|
||||||
import { TextInputProps } from 'react-native';
|
|
||||||
import { useSettings } from '@/theme';
|
import { useSettings } from '@/theme';
|
||||||
|
|
||||||
interface DescriptionInputProps extends TextInputProps {
|
interface DescriptionInputProps extends TextInputProps {
|
||||||
@@ -38,30 +37,30 @@ export function DescriptionInput({ error, ...props }: DescriptionInputProps) {
|
|||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
gap: 6,
|
gap: 5,
|
||||||
},
|
},
|
||||||
label: {
|
label: {
|
||||||
fontSize: 14,
|
fontSize: 13,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
input: {
|
input: {
|
||||||
minHeight: 100,
|
minHeight: 88,
|
||||||
padding: 16,
|
padding: 14,
|
||||||
borderRadius: 12,
|
borderRadius: 10,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
fontSize: 15,
|
fontSize: 14,
|
||||||
},
|
},
|
||||||
inputError: {
|
inputError: {
|
||||||
borderColor: '#E53935',
|
borderColor: '#E53935',
|
||||||
borderWidth: 1.5,
|
borderWidth: 1.5,
|
||||||
},
|
},
|
||||||
charCount: {
|
charCount: {
|
||||||
fontSize: 11,
|
fontSize: 10,
|
||||||
textAlign: 'right',
|
textAlign: 'right',
|
||||||
marginTop: -4,
|
marginTop: -3,
|
||||||
},
|
},
|
||||||
errorText: {
|
errorText: {
|
||||||
fontSize: 12,
|
fontSize: 11,
|
||||||
color: '#E53935',
|
color: '#E53935',
|
||||||
marginLeft: 4,
|
marginLeft: 4,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
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 { useRouter } from 'expo-router';
|
||||||
import Svg, { Path } from 'react-native-svg';
|
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 { useFormContext } from 'react-hook-form';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import { useSettings } from '@/theme';
|
import { useSettings } from '@/theme';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
interface FormButtonsProps {
|
interface FormButtonsProps {
|
||||||
onSubmit: (data: any) => void;
|
onSubmit: (data: any) => void;
|
||||||
@@ -13,6 +14,7 @@ export function FormButtons({ onSubmit, submitLabel = 'Submit' }: FormButtonsPro
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { theme } = useSettings();
|
const { theme } = useSettings();
|
||||||
const { handleSubmit, formState: { isSubmitting } } = useFormContext();
|
const { handleSubmit, formState: { isSubmitting } } = useFormContext();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
const cancel = () => {
|
const cancel = () => {
|
||||||
if (router.canGoBack()) {
|
if (router.canGoBack()) {
|
||||||
@@ -23,7 +25,7 @@ export function FormButtons({ onSubmit, submitLabel = 'Submit' }: FormButtonsPro
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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
|
<TouchableOpacity
|
||||||
style={[styles.cancelButton, { borderColor: theme.borderStrong, backgroundColor: theme.card }]}
|
style={[styles.cancelButton, { borderColor: theme.borderStrong, backgroundColor: theme.card }]}
|
||||||
onPress={cancel}
|
onPress={cancel}
|
||||||
@@ -52,45 +54,45 @@ const styles = StyleSheet.create({
|
|||||||
bottom: 0,
|
bottom: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 12,
|
||||||
paddingVertical: 16,
|
paddingVertical: 12,
|
||||||
borderTopWidth: 1,
|
borderTopWidth: 1,
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
gap: 12,
|
gap: 10,
|
||||||
shadowColor: '#000',
|
shadowColor: '#000',
|
||||||
shadowOffset: { width: 0, height: -2 },
|
shadowOffset: { width: 0, height: -2 },
|
||||||
shadowOpacity: 0.05,
|
shadowOpacity: 0.05,
|
||||||
shadowRadius: 8,
|
shadowRadius: 6,
|
||||||
elevation: 4,
|
elevation: 3,
|
||||||
},
|
},
|
||||||
cancelButton: {
|
cancelButton: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
paddingVertical: 14,
|
paddingVertical: 12,
|
||||||
borderRadius: 12,
|
borderRadius: 10,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
cancelButtonText: {
|
cancelButtonText: {
|
||||||
fontSize: 16,
|
fontSize: 15,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
submitButton: {
|
submitButton: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
paddingVertical: 14,
|
paddingVertical: 12,
|
||||||
borderRadius: 12,
|
borderRadius: 10,
|
||||||
backgroundColor: '#E53935',
|
backgroundColor: '#E53935',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
shadowColor: '#E53935',
|
shadowColor: '#E53935',
|
||||||
shadowOffset: { width: 0, height: 2 },
|
shadowOffset: { width: 0, height: 2 },
|
||||||
shadowOpacity: 0.3,
|
shadowOpacity: 0.3,
|
||||||
shadowRadius: 8,
|
shadowRadius: 6,
|
||||||
elevation: 3,
|
elevation: 3,
|
||||||
},
|
},
|
||||||
submitButtonText: {
|
submitButtonText: {
|
||||||
fontSize: 16,
|
fontSize: 15,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
color: '#FFFFFF',
|
color: '#FFFFFF',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Alert,
|
Alert,
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
Platform,
|
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useSettings } from '@/theme';
|
import { useSettings } from '@/theme';
|
||||||
import { useFriends } from '@/hooks/useFriends';
|
import { useFriends } from '@/hooks/useFriends';
|
||||||
@@ -24,10 +23,18 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
|
|||||||
const { theme } = useSettings();
|
const { theme } = useSettings();
|
||||||
const { friends, incoming, outgoing, loading, sendRequest, acceptRequest, declineRequest, removeFriend, searchUsers } = useFriends();
|
const { friends, incoming, outgoing, loading, sendRequest, acceptRequest, declineRequest, removeFriend, searchUsers } = useFriends();
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
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 [searching, setSearching] = useState(false);
|
||||||
const [selectedTab, setSelectedTab] = useState<'friends' | 'incoming' | 'outgoing' | 'add'>('friends');
|
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(() => {
|
useEffect(() => {
|
||||||
if (selectedTab === 'add' && searchQuery.length >= 2) {
|
if (selectedTab === 'add' && searchQuery.length >= 2) {
|
||||||
const timeout = setTimeout(async () => {
|
const timeout = setTimeout(async () => {
|
||||||
@@ -42,23 +49,9 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
|
|||||||
}
|
}
|
||||||
}, 300);
|
}, 300);
|
||||||
return () => clearTimeout(timeout);
|
return () => clearTimeout(timeout);
|
||||||
} else {
|
|
||||||
setSearchResults([]);
|
|
||||||
}
|
}
|
||||||
}, [searchQuery, selectedTab, friends, outgoing, searchUsers]);
|
}, [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) => {
|
const handleAccept = async (requestId: string) => {
|
||||||
try {
|
try {
|
||||||
await acceptRequest(requestId);
|
await acceptRequest(requestId);
|
||||||
@@ -94,7 +87,7 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
|
|||||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
style={[styles.overlay, { backgroundColor: 'rgba(0,0,0,0.5)' }]}
|
style={[styles.overlay, { backgroundColor: 'rgba(0,0,0,0.5)' }]}
|
||||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
behavior="padding"
|
||||||
>
|
>
|
||||||
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
|
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
@@ -142,7 +135,7 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
|
|||||||
) : friends.length === 0 ? (
|
) : friends.length === 0 ? (
|
||||||
<View style={styles.empty}>
|
<View style={styles.empty}>
|
||||||
<Text style={[styles.emptyText, { color: theme.textMuted }]}>No friends yet</Text>
|
<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>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<FlatList
|
<FlatList
|
||||||
@@ -263,16 +256,14 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
|
|||||||
<Text style={[styles.friendName, { color: theme.text }]}>{item.username}</Text>
|
<Text style={[styles.friendName, { color: theme.text }]}>{item.username}</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.addBtn, { backgroundColor: theme.accent }]}
|
style={[styles.addBtn, { backgroundColor: theme.accent }]}
|
||||||
onPress={async () => {
|
onPress={() => sendRequest(item.username)
|
||||||
try {
|
.then(() => {
|
||||||
await sendRequest(item.username);
|
|
||||||
setSearchQuery('');
|
setSearchQuery('');
|
||||||
setSearchResults([]);
|
setSearchResults([]);
|
||||||
setSelectedTab('friends');
|
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>
|
<Text style={styles.addBtnText}>Add</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -287,7 +278,7 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</KeyboardAvoidingView>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, Text, StyleSheet } from 'react-native';
|
import { View, Text, StyleSheet, StatusBar, Platform } from 'react-native';
|
||||||
import { useSettings } from '@/theme';
|
import { useSettings } from '@/theme';
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
@@ -10,9 +10,10 @@ interface HeaderProps {
|
|||||||
|
|
||||||
export function Header({ title, showLogo, rightAction }: HeaderProps) {
|
export function Header({ title, showLogo, rightAction }: HeaderProps) {
|
||||||
const { theme } = useSettings();
|
const { theme } = useSettings();
|
||||||
|
const topInset = Platform.OS === 'android' ? (StatusBar.currentHeight ?? 24) : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.header, { backgroundColor: theme.background }]}>
|
<View style={[styles.header, { backgroundColor: theme.background, paddingTop: topInset }]}>
|
||||||
<View style={styles.headerContent}>
|
<View style={styles.headerContent}>
|
||||||
{showLogo && (
|
{showLogo && (
|
||||||
<View style={styles.logoContainer}>
|
<View style={styles.logoContainer}>
|
||||||
@@ -28,22 +29,20 @@ export function Header({ title, showLogo, rightAction }: HeaderProps) {
|
|||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
header: {
|
header: {
|
||||||
borderBottomLeftRadius: 24,
|
borderBottomLeftRadius: 16,
|
||||||
borderBottomRightRadius: 24,
|
borderBottomRightRadius: 16,
|
||||||
shadowColor: '#000',
|
shadowColor: '#000',
|
||||||
shadowOffset: { width: 0, height: 2 },
|
shadowOffset: { width: 0, height: 1 },
|
||||||
shadowOpacity: 0.05,
|
shadowOpacity: 0.04,
|
||||||
shadowRadius: 8,
|
shadowRadius: 4,
|
||||||
elevation: 2,
|
elevation: 1,
|
||||||
},
|
},
|
||||||
headerContent: {
|
headerContent: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
paddingHorizontal: 20,
|
paddingHorizontal: 20,
|
||||||
paddingTop: 2,
|
height: 48,
|
||||||
paddingBottom: 6,
|
|
||||||
height: 44,
|
|
||||||
},
|
},
|
||||||
logoContainer: {
|
logoContainer: {
|
||||||
width: 32,
|
width: 32,
|
||||||
@@ -69,10 +68,4 @@ const styles = StyleSheet.create({
|
|||||||
width: 32,
|
width: 32,
|
||||||
alignItems: 'flex-end',
|
alignItems: 'flex-end',
|
||||||
},
|
},
|
||||||
bottomRounded: {
|
|
||||||
height: 16,
|
|
||||||
borderBottomLeftRadius: 16,
|
|
||||||
borderBottomRightRadius: 16,
|
|
||||||
marginTop: -16,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
@@ -123,12 +123,12 @@ For questions about these Terms, contact us through the app's feedback channel.
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
export function LegalModal({ visible, type, onClose }: LegalModalProps) {
|
export function LegalModal({ visible, type, onClose }: LegalModalProps) {
|
||||||
if (!visible || !type) return null;
|
|
||||||
|
|
||||||
const { theme } = useSettings();
|
const { theme } = useSettings();
|
||||||
const content = type === 'privacy' ? PRIVACY_POLICY : TERMS_OF_SERVICE;
|
const content = type === 'privacy' ? PRIVACY_POLICY : TERMS_OF_SERVICE;
|
||||||
const title = type === 'privacy' ? 'Privacy Policy' : 'Terms of Service';
|
const title = type === 'privacy' ? 'Privacy Policy' : 'Terms of Service';
|
||||||
|
|
||||||
|
if (!visible || !type) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={[styles.overlay, { backgroundColor: theme.overlay }]}>
|
<SafeAreaView style={[styles.overlay, { backgroundColor: theme.overlay }]}>
|
||||||
<View style={[styles.modal, { backgroundColor: theme.card }]}>
|
<View style={[styles.modal, { backgroundColor: theme.card }]}>
|
||||||
@@ -192,6 +192,5 @@ const styles = StyleSheet.create({
|
|||||||
body: {
|
body: {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
lineHeight: 22,
|
lineHeight: 22,
|
||||||
whiteSpace: 'pre-wrap' as const,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, Text, StyleSheet, Modal, TouchableOpacity, FlatList } from 'react-native';
|
import { View, Text, StyleSheet, Modal, TouchableOpacity, FlatList } from 'react-native';
|
||||||
import { useSettings } from '@/theme';
|
import { useSettings } from '@/theme';
|
||||||
import Svg, { Path } from 'react-native-svg';
|
import Svg, { Path, Circle } from 'react-native-svg';
|
||||||
|
|
||||||
export interface PickerOption {
|
export interface PickerOption {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -13,14 +13,17 @@ interface OptionPickerModalProps {
|
|||||||
visible: boolean;
|
visible: boolean;
|
||||||
title: string;
|
title: string;
|
||||||
options: PickerOption[];
|
options: PickerOption[];
|
||||||
selectedValue?: string;
|
selectedValue?: string | string[];
|
||||||
onSelect: (value: string) => void;
|
onSelect: (value: string | string[]) => void;
|
||||||
onClose: () => 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 { theme } = useSettings();
|
||||||
|
|
||||||
|
const selectedValues = Array.isArray(selectedValue) ? selectedValue : selectedValue ? [selectedValue] : [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||||||
<View style={[styles.overlay, { backgroundColor: theme.overlay }]}>
|
<View style={[styles.overlay, { backgroundColor: theme.overlay }]}>
|
||||||
@@ -30,7 +33,7 @@ export function OptionPickerModal({ visible, title, options, selectedValue, onSe
|
|||||||
data={options}
|
data={options}
|
||||||
keyExtractor={(item) => item.value}
|
keyExtractor={(item) => item.value}
|
||||||
renderItem={({ item }) => {
|
renderItem={({ item }) => {
|
||||||
const selected = item.value === selectedValue;
|
const selected = selectedValues.includes(item.value);
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[
|
style={[
|
||||||
@@ -39,8 +42,15 @@ export function OptionPickerModal({ visible, title, options, selectedValue, onSe
|
|||||||
selected && { backgroundColor: theme.accentSoft },
|
selected && { backgroundColor: theme.accentSoft },
|
||||||
]}
|
]}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
onSelect(item.value);
|
if (multiSelect) {
|
||||||
onClose();
|
const newValues = selected
|
||||||
|
? selectedValues.filter((v) => v !== item.value)
|
||||||
|
: [...selectedValues, item.value];
|
||||||
|
onSelect(newValues);
|
||||||
|
} else {
|
||||||
|
onSelect(item.value);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
activeOpacity={0.7}
|
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 }]}>
|
<Text style={[styles.optionText, { color: selected ? theme.accent : theme.textSecondary }]}>
|
||||||
{item.label}
|
{item.label}
|
||||||
</Text>
|
</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">
|
<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" />
|
<Path d="M5 12l5 5 9-10" stroke={theme.accent} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||||
</Svg>
|
</Svg>
|
||||||
|
|||||||
@@ -3,14 +3,13 @@ import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
|||||||
import { PRIORITY_COLORS } from '@/constants';
|
import { PRIORITY_COLORS } from '@/constants';
|
||||||
import { Priority } from '@/types';
|
import { Priority } from '@/types';
|
||||||
import { useSettings } from '@/theme';
|
import { useSettings } from '@/theme';
|
||||||
import Svg, { Circle } from 'react-native-svg';
|
|
||||||
|
|
||||||
interface PrioritySelectorProps {
|
interface PrioritySelectorProps {
|
||||||
value: Priority;
|
value: Priority;
|
||||||
onChange: (value: Priority) => void;
|
onChange: (value: Priority) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const priorities: Array<{ value: Priority; label: string }> = [
|
const priorities: { value: Priority; label: string }[] = [
|
||||||
{ value: 'none', label: 'None' },
|
{ value: 'none', label: 'None' },
|
||||||
{ value: 'low', label: 'Low' },
|
{ value: 'low', label: 'Low' },
|
||||||
{ value: 'medium', label: 'Medium' },
|
{ value: 'medium', label: 'Medium' },
|
||||||
@@ -57,15 +56,15 @@ export function PrioritySelector({ value, onChange }: PrioritySelectorProps) {
|
|||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
gap: 10,
|
gap: 6,
|
||||||
},
|
},
|
||||||
label: {
|
label: {
|
||||||
fontSize: 14,
|
fontSize: 13,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
gap: 8,
|
gap: 6,
|
||||||
},
|
},
|
||||||
option: {
|
option: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -73,16 +72,17 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
gap: 6,
|
gap: 6,
|
||||||
paddingVertical: 12,
|
paddingVertical: 10,
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 8,
|
||||||
borderRadius: 12,
|
borderRadius: 10,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
|
minHeight: 40,
|
||||||
},
|
},
|
||||||
colorIndicator: {
|
colorIndicator: {
|
||||||
width: 10,
|
width: 10,
|
||||||
height: 10,
|
height: 10,
|
||||||
borderRadius: 5,
|
borderRadius: 5,
|
||||||
opacity: 0.5,
|
opacity: 0.6,
|
||||||
},
|
},
|
||||||
colorIndicatorSelected: {
|
colorIndicatorSelected: {
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { View, Text, StyleSheet, TextInput, TouchableOpacity, KeyboardAvoidingView, Platform } from 'react-native';
|
import { View, StyleSheet, TextInput, TouchableOpacity, Platform, Keyboard, Animated, Easing } from 'react-native';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { database, collections } from '@/database';
|
import { database, collections } from '@/database';
|
||||||
import { useCategories } from '@/hooks/useDatabase';
|
import { useCategories } from '@/hooks/useDatabase';
|
||||||
import { useSettings } from '@/theme';
|
import { useSettings } from '@/theme';
|
||||||
import { OptionPickerModal } from '@/components/OptionPickerModal';
|
import { OptionPickerModal } from '@/components/OptionPickerModal';
|
||||||
import Svg, { Path } from 'react-native-svg';
|
import Svg, { Path } from 'react-native-svg';
|
||||||
|
import { subscribeToQuickAdd } from '@/utils/quickAddFocus';
|
||||||
|
|
||||||
interface QuickAddBarProps {
|
interface QuickAddBarProps {
|
||||||
dueDate?: number;
|
dueDate?: number;
|
||||||
@@ -17,17 +18,46 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
|
|||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const categories = useCategories();
|
const categories = useCategories();
|
||||||
const [title, setTitle] = useState('');
|
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 [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';
|
const categoryColor = categories.find((c) => c.id === categoryId)?.color ?? '#9E9E9E';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!categoryId) {
|
return subscribeToQuickAdd(() => {
|
||||||
setCategoryId(defaultCategoryId || categories[0]?.id || '');
|
setTimeout(() => {
|
||||||
}
|
inputRef.current?.focus();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, 100);
|
||||||
}, [categories, defaultCategoryId]);
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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 handleAdd = async () => {
|
||||||
const trimmed = title.trim();
|
const trimmed = title.trim();
|
||||||
@@ -56,11 +86,14 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
|
|||||||
setTitle('');
|
setTitle('');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const animatedBottom = keyboardHeight.interpolate({
|
||||||
|
inputRange: [0, 500],
|
||||||
|
outputRange: [insets.bottom + 0, insets.bottom + 0 + 500],
|
||||||
|
extrapolate: 'clamp',
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<KeyboardAvoidingView
|
<Animated.View style={[styles.wrapper, { bottom: animatedBottom }]}>
|
||||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
|
||||||
style={[styles.wrapper, { bottom: insets.bottom + 16 }]}
|
|
||||||
>
|
|
||||||
<View style={[styles.bar, { backgroundColor: theme.card, borderColor: theme.border }]}>
|
<View style={[styles.bar, { backgroundColor: theme.card, borderColor: theme.border }]}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.categoryButton, { borderColor: theme.borderStrong, backgroundColor: theme.cardAlt }]}
|
style={[styles.categoryButton, { borderColor: theme.borderStrong, backgroundColor: theme.cardAlt }]}
|
||||||
@@ -74,6 +107,7 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
|
|||||||
</Svg>
|
</Svg>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TextInput
|
<TextInput
|
||||||
|
ref={inputRef}
|
||||||
style={[styles.input, { color: theme.text }]}
|
style={[styles.input, { color: theme.text }]}
|
||||||
placeholder={placeholder ?? 'Add a task'}
|
placeholder={placeholder ?? 'Add a task'}
|
||||||
placeholderTextColor={theme.textMuted}
|
placeholderTextColor={theme.textMuted}
|
||||||
@@ -83,8 +117,8 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
|
|||||||
returnKeyType="done"
|
returnKeyType="done"
|
||||||
/>
|
/>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.submit, { backgroundColor: theme.accent }, !title.trim() && styles.submitDisabled]}
|
style={[styles.submit, { backgroundColor: theme.accent }, title.trim() ? {} : styles.submitDisabled]}
|
||||||
onPress={handleAdd}
|
onPress={title.trim() ? handleAdd : undefined}
|
||||||
disabled={!title.trim()}
|
disabled={!title.trim()}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
accessibilityLabel="Add task"
|
accessibilityLabel="Add task"
|
||||||
@@ -106,57 +140,59 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
|
|||||||
title="Select Category"
|
title="Select Category"
|
||||||
options={categories.map((c) => ({ value: c.id, label: c.name, color: c.color }))}
|
options={categories.map((c) => ({ value: c.id, label: c.name, color: c.color }))}
|
||||||
selectedValue={categoryId}
|
selectedValue={categoryId}
|
||||||
onSelect={(value) => setCategoryId(value)}
|
onSelect={(value) => setCategoryId(Array.isArray(value) ? value[0] : value)}
|
||||||
onClose={() => setCategoryPickerVisible(false)}
|
onClose={() => setCategoryPickerVisible(false)}
|
||||||
/>
|
/>
|
||||||
</KeyboardAvoidingView>
|
</Animated.View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
wrapper: {
|
wrapper: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: 16,
|
left: 8,
|
||||||
right: 16,
|
right: 8,
|
||||||
bottom: 24,
|
bottom: 12,
|
||||||
},
|
},
|
||||||
bar: {
|
bar: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 10,
|
gap: 6,
|
||||||
paddingHorizontal: 10,
|
paddingHorizontal: 8,
|
||||||
paddingVertical: 10,
|
paddingVertical: 8,
|
||||||
borderRadius: 16,
|
borderRadius: 14,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
shadowColor: '#000',
|
shadowColor: '#000',
|
||||||
shadowOffset: { width: 0, height: 4 },
|
shadowOffset: { width: 0, height: 2 },
|
||||||
shadowOpacity: 0.12,
|
shadowOpacity: 0.08,
|
||||||
shadowRadius: 12,
|
shadowRadius: 6,
|
||||||
elevation: 8,
|
elevation: 4,
|
||||||
},
|
},
|
||||||
categoryButton: {
|
categoryButton: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 6,
|
gap: 3,
|
||||||
paddingVertical: 10,
|
paddingVertical: 6,
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 6,
|
||||||
borderRadius: 12,
|
borderRadius: 8,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
|
height: 40,
|
||||||
},
|
},
|
||||||
categoryButtonDot: {
|
categoryButtonDot: {
|
||||||
width: 12,
|
width: 8,
|
||||||
height: 12,
|
height: 8,
|
||||||
borderRadius: 6,
|
borderRadius: 4,
|
||||||
},
|
},
|
||||||
input: {
|
input: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
paddingVertical: 10,
|
paddingVertical: 10,
|
||||||
|
minHeight: 40,
|
||||||
},
|
},
|
||||||
submit: {
|
submit: {
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
borderRadius: 12,
|
borderRadius: 8,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
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 { useSettings } from '@/theme';
|
||||||
import { OptionPickerModal } from '@/components/OptionPickerModal';
|
import { OptionPickerModal } from '@/components/OptionPickerModal';
|
||||||
import Svg, { Path, Circle } from 'react-native-svg';
|
import Svg, { Path, Circle, Rect } from 'react-native-svg';
|
||||||
|
|
||||||
interface ReminderSelectorProps {
|
interface ReminderSelectorProps {
|
||||||
value: Reminder;
|
value: string;
|
||||||
onChange: (value: Reminder) => void;
|
onChange: (value: string) => void;
|
||||||
hasDueDate: boolean;
|
hasDueDate: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -15,17 +15,21 @@ export function ReminderSelector({ value, onChange, hasDueDate }: ReminderSelect
|
|||||||
const { theme } = useSettings();
|
const { theme } = useSettings();
|
||||||
const [showPicker, setShowPicker] = useState(false);
|
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 disabled = !hasDueDate;
|
||||||
|
|
||||||
|
const reminderLabels = selectedReminders.length > 0
|
||||||
|
? selectedReminders.map(r => REMINDER_OPTIONS.find(o => o.value === r)?.label).filter(Boolean).join(', ')
|
||||||
|
: 'No reminder';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<Text style={[styles.label, { color: theme.text }]}>Reminder</Text>
|
<Text style={[styles.label, { color: theme.text }]}>Reminders</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[
|
style={[
|
||||||
styles.row,
|
styles.row,
|
||||||
{ backgroundColor: theme.card, borderColor: theme.borderStrong },
|
{ 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 },
|
disabled && { opacity: 0.5 },
|
||||||
]}
|
]}
|
||||||
onPress={() => setShowPicker(true)}
|
onPress={() => setShowPicker(true)}
|
||||||
@@ -35,22 +39,22 @@ export function ReminderSelector({ value, onChange, hasDueDate }: ReminderSelect
|
|||||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||||
<Path
|
<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"
|
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}
|
strokeWidth={1.5}
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
fill="none"
|
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>
|
</Svg>
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
styles.valueText,
|
styles.valueText,
|
||||||
{ color: value !== 'none' ? theme.text : theme.textMuted },
|
{ color: selectedReminders.length > 0 ? theme.text : theme.textMuted },
|
||||||
value !== 'none' && styles.valueTextFilled,
|
selectedReminders.length > 0 && styles.valueTextFilled,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{value !== 'none' ? selected.label : 'No reminder'}
|
{reminderLabels}
|
||||||
</Text>
|
</Text>
|
||||||
<View style={styles.chevron}>
|
<View style={styles.chevron}>
|
||||||
<Svg width={16} height={16} viewBox="0 0 24 24">
|
<Svg width={16} height={16} viewBox="0 0 24 24">
|
||||||
@@ -59,16 +63,17 @@ export function ReminderSelector({ value, onChange, hasDueDate }: ReminderSelect
|
|||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
{!hasDueDate && (
|
{!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
|
<OptionPickerModal
|
||||||
visible={showPicker}
|
visible={showPicker}
|
||||||
title="Reminder"
|
title="Reminders"
|
||||||
options={REMINDER_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
|
options={REMINDER_OPTIONS.filter((o) => o.value !== 'none').map((o) => ({ value: o.value, label: o.label }))}
|
||||||
selectedValue={value}
|
selectedValue={selectedReminders}
|
||||||
onSelect={(v) => onChange(v as Reminder)}
|
onSelect={(v) => onChange(toRemindersString(v as Reminder[]))}
|
||||||
onClose={() => setShowPicker(false)}
|
onClose={() => setShowPicker(false)}
|
||||||
|
multiSelect
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -76,10 +81,10 @@ export function ReminderSelector({ value, onChange, hasDueDate }: ReminderSelect
|
|||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
gap: 8,
|
gap: 6,
|
||||||
},
|
},
|
||||||
label: {
|
label: {
|
||||||
fontSize: 14,
|
fontSize: 13,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
row: {
|
row: {
|
||||||
@@ -93,7 +98,7 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
valueText: {
|
valueText: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
fontSize: 15,
|
fontSize: 14,
|
||||||
},
|
},
|
||||||
valueTextFilled: {
|
valueTextFilled: {
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
@@ -102,7 +107,7 @@ const styles = StyleSheet.create({
|
|||||||
transform: [{ rotate: '-90deg' }],
|
transform: [{ rotate: '-90deg' }],
|
||||||
},
|
},
|
||||||
hint: {
|
hint: {
|
||||||
fontSize: 12,
|
fontSize: 11,
|
||||||
marginLeft: 4,
|
marginLeft: 4,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
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 { Repeat, REPEAT_OPTIONS, WEEKDAY_LABELS, repeatDaysFromString } from '@/types';
|
||||||
import { useSettings } from '@/theme';
|
import { useSettings } from '@/theme';
|
||||||
import { useRepeatProfiles } from '@/hooks/useDatabase';
|
import { useRepeatProfiles } from '@/hooks/useDatabase';
|
||||||
@@ -246,43 +246,48 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
|
|||||||
animationType="fade"
|
animationType="fade"
|
||||||
onRequestClose={() => setSaveModalVisible(false)}
|
onRequestClose={() => setSaveModalVisible(false)}
|
||||||
>
|
>
|
||||||
<Pressable style={styles.modalBackdrop} onPress={() => setSaveModalVisible(false)}>
|
<KeyboardAvoidingView
|
||||||
<Pressable style={[styles.modalCard, { backgroundColor: theme.card, borderColor: theme.border }]}>
|
style={styles.modalBackdrop}
|
||||||
<Text style={[styles.modalTitle, { color: theme.text }]}>Save repeat profile</Text>
|
behavior="padding"
|
||||||
<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' : ''}`}
|
<Pressable style={styles.modalBackdrop} onPress={() => setSaveModalVisible(false)}>
|
||||||
{isDaysBased(value) && days.length > 0 ? ` · ${days.map((d) => WEEKDAY_LABELS[d]).join(' ')}` : ''}
|
<Pressable style={[styles.modalCard, { backgroundColor: theme.card, borderColor: theme.border }]}>
|
||||||
</Text>
|
<Text style={[styles.modalTitle, { color: theme.text }]}>Save repeat profile</Text>
|
||||||
<TextInput
|
<Text style={[styles.modalHint, { color: theme.textFaint }]}>
|
||||||
style={[styles.modalInput, { backgroundColor: theme.cardAlt, borderColor: theme.borderStrong, color: theme.text }]}
|
{`${REPEAT_OPTIONS.find((o) => o.value === value)?.label.replace('No Repeat', 'None')}, every ${interval} ${unitLabel(value)}${interval > 1 ? 's' : ''}`}
|
||||||
value={profileName}
|
{isDaysBased(value) && days.length > 0 ? ` · ${days.map((d) => WEEKDAY_LABELS[d]).join(' ')}` : ''}
|
||||||
onChangeText={setProfileName}
|
</Text>
|
||||||
placeholder="Profile name (e.g. Every weekday)"
|
<TextInput
|
||||||
placeholderTextColor={theme.textFaint}
|
style={[styles.modalInput, { backgroundColor: theme.cardAlt, borderColor: theme.borderStrong, color: theme.text }]}
|
||||||
autoFocus
|
value={profileName}
|
||||||
returnKeyType="done"
|
onChangeText={setProfileName}
|
||||||
onSubmitEditing={handleSaveProfile}
|
placeholder="Profile name (e.g. Every weekday)"
|
||||||
maxLength={50}
|
placeholderTextColor={theme.textFaint}
|
||||||
/>
|
autoFocus
|
||||||
<View style={styles.modalButtons}>
|
returnKeyType="done"
|
||||||
<TouchableOpacity
|
onSubmitEditing={handleSaveProfile}
|
||||||
style={[styles.modalButton, { borderColor: theme.borderStrong }]}
|
maxLength={50}
|
||||||
onPress={() => setSaveModalVisible(false)}
|
/>
|
||||||
activeOpacity={0.8}
|
<View style={styles.modalButtons}>
|
||||||
>
|
<TouchableOpacity
|
||||||
<Text style={[styles.modalButtonText, { color: theme.textSecondary }]}>Cancel</Text>
|
style={[styles.modalButton, { borderColor: theme.borderStrong }]}
|
||||||
</TouchableOpacity>
|
onPress={() => setSaveModalVisible(false)}
|
||||||
<TouchableOpacity
|
activeOpacity={0.8}
|
||||||
style={[styles.modalButton, styles.modalButtonPrimary, { backgroundColor: theme.accent }]}
|
>
|
||||||
onPress={handleSaveProfile}
|
<Text style={[styles.modalButtonText, { color: theme.textSecondary }]}>Cancel</Text>
|
||||||
disabled={!profileName.trim()}
|
</TouchableOpacity>
|
||||||
activeOpacity={0.8}
|
<TouchableOpacity
|
||||||
>
|
style={[styles.modalButton, styles.modalButtonPrimary, { backgroundColor: theme.accent }]}
|
||||||
<Text style={[styles.modalButtonText, { color: '#FFFFFF', fontWeight: '600' }]}>Save</Text>
|
onPress={handleSaveProfile}
|
||||||
</TouchableOpacity>
|
disabled={!profileName.trim()}
|
||||||
</View>
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<Text style={[styles.modalButtonText, { color: '#FFFFFF', fontWeight: '600' }]}>Save</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</Pressable>
|
</KeyboardAvoidingView>
|
||||||
</Modal>
|
</Modal>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -290,115 +295,115 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
|
|||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
gap: 10,
|
gap: 8,
|
||||||
},
|
},
|
||||||
label: {
|
label: {
|
||||||
fontSize: 14,
|
fontSize: 13,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
chipRow: {
|
chipRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
flexWrap: 'wrap',
|
flexWrap: 'wrap',
|
||||||
gap: 8,
|
gap: 6,
|
||||||
},
|
},
|
||||||
chip: {
|
chip: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 6,
|
gap: 5,
|
||||||
paddingVertical: 10,
|
paddingVertical: 8,
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 10,
|
||||||
borderRadius: 20,
|
borderRadius: 18,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
},
|
},
|
||||||
chipText: {
|
chipText: {
|
||||||
fontSize: 13,
|
fontSize: 12,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
gap: 10,
|
gap: 8,
|
||||||
},
|
},
|
||||||
intervalRow: {
|
intervalRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 10,
|
gap: 8,
|
||||||
paddingHorizontal: 14,
|
paddingHorizontal: 12,
|
||||||
paddingVertical: 10,
|
paddingVertical: 8,
|
||||||
borderRadius: 12,
|
borderRadius: 10,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
},
|
},
|
||||||
intervalLabel: {
|
intervalLabel: {
|
||||||
fontSize: 14,
|
fontSize: 13,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
},
|
},
|
||||||
intervalValue: {
|
intervalValue: {
|
||||||
fontSize: 16,
|
fontSize: 15,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
minWidth: 24,
|
minWidth: 22,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
},
|
},
|
||||||
stepButton: {
|
stepButton: {
|
||||||
width: 32,
|
width: 28,
|
||||||
height: 32,
|
height: 28,
|
||||||
borderRadius: 8,
|
borderRadius: 7,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
stepButtonText: {
|
stepButtonText: {
|
||||||
fontSize: 18,
|
fontSize: 16,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
lineHeight: 20,
|
lineHeight: 18,
|
||||||
},
|
},
|
||||||
dayRow: {
|
dayRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
gap: 6,
|
gap: 5,
|
||||||
},
|
},
|
||||||
dayChip: {
|
dayChip: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
height: 40,
|
height: 36,
|
||||||
borderRadius: 10,
|
borderRadius: 9,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
dayChipLast: {},
|
dayChipLast: {},
|
||||||
dayChipText: {
|
dayChipText: {
|
||||||
fontSize: 13,
|
fontSize: 12,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
profileRow: {
|
profileRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'flex-start',
|
alignItems: 'flex-start',
|
||||||
gap: 8,
|
gap: 6,
|
||||||
},
|
},
|
||||||
profileLabel: {
|
profileLabel: {
|
||||||
fontSize: 12,
|
fontSize: 11,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
paddingTop: 8,
|
paddingTop: 6,
|
||||||
},
|
},
|
||||||
profileChips: {
|
profileChips: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
flexWrap: 'wrap',
|
flexWrap: 'wrap',
|
||||||
gap: 8,
|
gap: 6,
|
||||||
},
|
},
|
||||||
profileChip: {
|
profileChip: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 6,
|
gap: 5,
|
||||||
paddingVertical: 6,
|
paddingVertical: 5,
|
||||||
paddingHorizontal: 10,
|
paddingHorizontal: 8,
|
||||||
borderRadius: 12,
|
borderRadius: 10,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
},
|
},
|
||||||
profileChipText: {
|
profileChipText: {
|
||||||
fontSize: 13,
|
fontSize: 12,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
},
|
},
|
||||||
profileChipX: {
|
profileChipX: {
|
||||||
fontSize: 14,
|
fontSize: 13,
|
||||||
lineHeight: 16,
|
lineHeight: 15,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
paddingHorizontal: 2,
|
paddingHorizontal: 2,
|
||||||
},
|
},
|
||||||
@@ -406,14 +411,14 @@ const styles = StyleSheet.create({
|
|||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
gap: 6,
|
gap: 5,
|
||||||
paddingVertical: 10,
|
paddingVertical: 8,
|
||||||
borderRadius: 12,
|
borderRadius: 10,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderStyle: 'dashed',
|
borderStyle: 'dashed',
|
||||||
},
|
},
|
||||||
saveProfileText: {
|
saveProfileText: {
|
||||||
fontSize: 13,
|
fontSize: 12,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
modalBackdrop: {
|
modalBackdrop: {
|
||||||
@@ -421,45 +426,45 @@ const styles = StyleSheet.create({
|
|||||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
padding: 24,
|
padding: 20,
|
||||||
},
|
},
|
||||||
modalCard: {
|
modalCard: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
maxWidth: 400,
|
maxWidth: 360,
|
||||||
borderRadius: 16,
|
borderRadius: 14,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
padding: 20,
|
padding: 16,
|
||||||
gap: 12,
|
gap: 10,
|
||||||
},
|
},
|
||||||
modalTitle: {
|
modalTitle: {
|
||||||
fontSize: 16,
|
fontSize: 15,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
},
|
},
|
||||||
modalHint: {
|
modalHint: {
|
||||||
fontSize: 13,
|
fontSize: 12,
|
||||||
},
|
},
|
||||||
modalInput: {
|
modalInput: {
|
||||||
borderRadius: 10,
|
borderRadius: 9,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 10,
|
||||||
paddingVertical: 10,
|
paddingVertical: 8,
|
||||||
fontSize: 14,
|
fontSize: 13,
|
||||||
},
|
},
|
||||||
modalButtons: {
|
modalButtons: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
gap: 10,
|
gap: 8,
|
||||||
},
|
},
|
||||||
modalButton: {
|
modalButton: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingVertical: 11,
|
paddingVertical: 10,
|
||||||
borderRadius: 10,
|
borderRadius: 9,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
},
|
},
|
||||||
modalButtonPrimary: {
|
modalButtonPrimary: {
|
||||||
borderWidth: 0,
|
borderWidth: 0,
|
||||||
},
|
},
|
||||||
modalButtonText: {
|
modalButtonText: {
|
||||||
fontSize: 14,
|
fontSize: 13,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Modal, View, Text, StyleSheet, TextInput, TouchableOpacity, KeyboardAvoidingView, Platform } from 'react-native';
|
import { Modal, View, Text, StyleSheet, TextInput, TouchableOpacity, KeyboardAvoidingView } from 'react-native';
|
||||||
import { useSettings } from '@/theme';
|
import { useSettings } from '@/theme';
|
||||||
import { DEFAULT_API_BASE_URL } from '@/services/auth';
|
import { DEFAULT_API_BASE_URL } from '@/services/auth';
|
||||||
import Svg, { Path } from 'react-native-svg';
|
import Svg, { Path } from 'react-native-svg';
|
||||||
@@ -12,12 +12,14 @@ interface ServerUrlModalProps {
|
|||||||
export function ServerUrlModal({ visible, onClose }: ServerUrlModalProps) {
|
export function ServerUrlModal({ visible, onClose }: ServerUrlModalProps) {
|
||||||
const { theme, apiUrl, setApiUrl } = useSettings();
|
const { theme, apiUrl, setApiUrl } = useSettings();
|
||||||
const [value, setValue] = useState(apiUrl);
|
const [value, setValue] = useState(apiUrl);
|
||||||
|
const [prevVisible, setPrevVisible] = useState(visible);
|
||||||
|
|
||||||
useEffect(() => {
|
if (prevVisible !== visible) {
|
||||||
|
setPrevVisible(visible);
|
||||||
if (visible) {
|
if (visible) {
|
||||||
setValue(apiUrl);
|
setValue(apiUrl);
|
||||||
}
|
}
|
||||||
}, [visible, apiUrl]);
|
}
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
const trimmed = value.trim().replace(/\/+$/, '');
|
const trimmed = value.trim().replace(/\/+$/, '');
|
||||||
@@ -33,7 +35,7 @@ export function ServerUrlModal({ visible, onClose }: ServerUrlModalProps) {
|
|||||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
style={styles.overlay}
|
style={styles.overlay}
|
||||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
behavior="padding"
|
||||||
>
|
>
|
||||||
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
|
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
|
|||||||
@@ -1,145 +1,96 @@
|
|||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
import { View, StyleSheet } from 'react-native';
|
||||||
import { SubtaskData } from '@/types';
|
import { SubtaskData } from '@/types';
|
||||||
import { PRIORITY_COLORS } from '@/constants';
|
import { TaskItem } from './TaskItem';
|
||||||
import { useSettings } from '@/theme';
|
import { useSettings } from '@/theme';
|
||||||
import Svg, { Path } from 'react-native-svg';
|
|
||||||
|
|
||||||
interface SubtaskItemProps {
|
interface SubtaskItemProps {
|
||||||
subtask: SubtaskData;
|
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 { theme } = useSettings();
|
||||||
|
const [expanded, setExpanded] = useState(true);
|
||||||
|
const hasChildren = subtask.subtasks && subtask.subtasks.length > 0;
|
||||||
|
|
||||||
|
const handleExpand = () => setExpanded(!expanded);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.container, { backgroundColor: theme.cardAlt }]}>
|
<View>
|
||||||
<TouchableOpacity
|
<TaskItem
|
||||||
style={styles.checkCircle}
|
task={subtask}
|
||||||
onPress={onToggle}
|
indented
|
||||||
activeOpacity={0.7}
|
depth={depth}
|
||||||
accessibilityLabel={subtask.completed ? 'Mark incomplete' : 'Mark complete'}
|
onToggle={onToggle}
|
||||||
>
|
onDelete={onDelete}
|
||||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
onPress={onPress ?? (() => {})}
|
||||||
{subtask.completed ? (
|
onLongPress={onLongPress}
|
||||||
<>
|
onMenuOpen={onMenuOpen}
|
||||||
<Path
|
selected={selected}
|
||||||
d="M20 6L9 17l-5-5"
|
selectionMode={selectionMode}
|
||||||
stroke={theme.accent}
|
completedSection={subtask.completed}
|
||||||
strokeWidth={2.5}
|
draggable={draggable}
|
||||||
strokeLinecap="round"
|
onDragStart={onDragStart}
|
||||||
strokeLinejoin="round"
|
onDragUpdate={onDragUpdate}
|
||||||
fill="none"
|
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>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{subtask.dueDate && subtask.dueDate > 0 && (
|
|
||||||
<Text style={[styles.dueText, { color: theme.textFaint }]} numberOfLines={1}>
|
|
||||||
{formatDueDate(subtask.dueDate, subtask.dueTime, subtask.endTime || '')}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
</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({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
nestedSubtasks: {
|
||||||
flexDirection: 'row',
|
marginTop: 4,
|
||||||
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%',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -159,35 +159,35 @@ function SubtaskItem({ index, value, onChange, onRemove }: SubtaskItemProps) {
|
|||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
gap: 8,
|
gap: 6,
|
||||||
},
|
},
|
||||||
header: {
|
header: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
paddingVertical: 4,
|
paddingVertical: 2,
|
||||||
},
|
},
|
||||||
headerLeft: {
|
headerLeft: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 8,
|
gap: 6,
|
||||||
},
|
},
|
||||||
label: {
|
label: {
|
||||||
fontSize: 14,
|
fontSize: 13,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
badge: {
|
badge: {
|
||||||
paddingHorizontal: 8,
|
paddingHorizontal: 6,
|
||||||
paddingVertical: 2,
|
paddingVertical: 1,
|
||||||
borderRadius: 10,
|
borderRadius: 8,
|
||||||
},
|
},
|
||||||
badgeText: {
|
badgeText: {
|
||||||
fontSize: 12,
|
fontSize: 11,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
},
|
},
|
||||||
chevron: {
|
chevron: {
|
||||||
width: 16,
|
width: 14,
|
||||||
height: 16,
|
height: 14,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
@@ -195,43 +195,43 @@ const styles = StyleSheet.create({
|
|||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
list: {
|
list: {
|
||||||
gap: 8,
|
gap: 6,
|
||||||
paddingBottom: 8,
|
paddingBottom: 6,
|
||||||
},
|
},
|
||||||
item: {
|
item: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 10,
|
gap: 8,
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 10,
|
||||||
paddingVertical: 10,
|
paddingVertical: 8,
|
||||||
borderRadius: 12,
|
borderRadius: 10,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
},
|
},
|
||||||
checkbox: {
|
checkbox: {
|
||||||
width: 22,
|
width: 20,
|
||||||
height: 22,
|
height: 20,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
input: {
|
input: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
fontSize: 15,
|
fontSize: 14,
|
||||||
paddingVertical: 4,
|
paddingVertical: 2,
|
||||||
},
|
},
|
||||||
removeButton: {
|
removeButton: {
|
||||||
padding: 4,
|
padding: 3,
|
||||||
},
|
},
|
||||||
addButton: {
|
addButton: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
gap: 6,
|
gap: 5,
|
||||||
paddingVertical: 10,
|
paddingVertical: 8,
|
||||||
borderRadius: 12,
|
borderRadius: 10,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
},
|
},
|
||||||
addButtonText: {
|
addButtonText: {
|
||||||
fontSize: 13,
|
fontSize: 12,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -7,13 +7,11 @@ import {
|
|||||||
TextInput,
|
TextInput,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
Platform,
|
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useSettings } from '@/theme';
|
import { useSettings } from '@/theme';
|
||||||
import { AuthUser, getAuthToken, getAuthUser, login, register, signOutAuth } from '@/services/auth';
|
import { AuthUser, getAuthToken, getAuthUser, login, register, signOutAuth } from '@/services/auth';
|
||||||
import { runSync, getLastSyncTime, SyncResult } from '@/database/sync';
|
import { runSyncGuarded, startAutoSync, stopAutoSync, getLastSyncTime, SyncResult } from '@/database/sync';
|
||||||
import Svg, { Path } from 'react-native-svg';
|
|
||||||
|
|
||||||
interface SyncModalProps {
|
interface SyncModalProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@@ -35,14 +33,21 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
|
|||||||
const [status, setStatus] = useState<Status>('idle');
|
const [status, setStatus] = useState<Status>('idle');
|
||||||
const [lastSync, setLastSync] = useState<number | null>(null);
|
const [lastSync, setLastSync] = useState<number | null>(null);
|
||||||
const [result, setResult] = useState<SyncResult | 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(() => {
|
useEffect(() => {
|
||||||
if (!visible) return;
|
if (!visible) return;
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
setChecking(true);
|
|
||||||
setError(null);
|
|
||||||
setStatus('idle');
|
|
||||||
setResult(null);
|
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
const token = await getAuthToken();
|
const token = await getAuthToken();
|
||||||
@@ -77,6 +82,7 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
|
|||||||
? await register(username.trim(), password)
|
? await register(username.trim(), password)
|
||||||
: await login(username.trim(), password);
|
: await login(username.trim(), password);
|
||||||
setUser(authedUser);
|
setUser(authedUser);
|
||||||
|
startAutoSync();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err?.message ?? 'Sign in failed');
|
setError(err?.message ?? 'Sign in failed');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -89,13 +95,15 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
|
|||||||
setError(null);
|
setError(null);
|
||||||
setStatus('syncing');
|
setStatus('syncing');
|
||||||
try {
|
try {
|
||||||
const syncResult = await runSync();
|
const syncResult = await runSyncGuarded();
|
||||||
setResult(syncResult);
|
setResult(syncResult);
|
||||||
setStatus('success');
|
setStatus('success');
|
||||||
setLastSync(Date.now());
|
setLastSync(Date.now());
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setStatus('error');
|
setStatus('idle');
|
||||||
if (err?.message === 'NOT_SIGNED_IN') {
|
if (err?.message === 'SYNC_IN_FLIGHT') {
|
||||||
|
setError('Sync already in progress');
|
||||||
|
} else if (err?.message === 'NOT_SIGNED_IN') {
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setError('Not signed in. Sign in to sync.');
|
setError('Not signed in. Sign in to sync.');
|
||||||
} else {
|
} else {
|
||||||
@@ -106,6 +114,7 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
|
|||||||
|
|
||||||
const handleSignOut = async () => {
|
const handleSignOut = async () => {
|
||||||
await signOutAuth();
|
await signOutAuth();
|
||||||
|
stopAutoSync();
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
setStatus('idle');
|
setStatus('idle');
|
||||||
@@ -116,7 +125,7 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
|
|||||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
style={styles.overlay}
|
style={styles.overlay}
|
||||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
behavior="padding"
|
||||||
>
|
>
|
||||||
<TouchableOpacity style={styles.backdrop} activeOpacity={1} onPress={onClose} />
|
<TouchableOpacity style={styles.backdrop} activeOpacity={1} onPress={onClose} />
|
||||||
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
|
<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 { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
||||||
import { useSettings } from '@/theme';
|
import { useSettings } from '@/theme';
|
||||||
import Svg, { Path, Circle } from 'react-native-svg';
|
import Svg, { Path, Circle } from 'react-native-svg';
|
||||||
import { runSync, getLastSyncTime } from '@/database/sync';
|
import { runSyncGuarded, getLastSyncTime } from '@/database/sync';
|
||||||
|
|
||||||
interface SyncStatusProps {
|
interface SyncStatusProps {
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
@@ -13,21 +13,23 @@ export default function SyncStatus({ compact = false }: SyncStatusProps) {
|
|||||||
const [status, setStatus] = useState<'idle' | 'syncing' | 'success' | 'error'>('idle');
|
const [status, setStatus] = useState<'idle' | 'syncing' | 'success' | 'error'>('idle');
|
||||||
const [lastSync, setLastSync] = useState<number | null>(null);
|
const [lastSync, setLastSync] = useState<number | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [now] = useState(() => Date.now());
|
||||||
useEffect(() => {
|
|
||||||
loadLastSync();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loadLastSync = async () => {
|
const loadLastSync = async () => {
|
||||||
const time = await getLastSyncTime();
|
const time = await getLastSyncTime();
|
||||||
setLastSync(time);
|
setLastSync(time);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
loadLastSync();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleSync = async () => {
|
const handleSync = async () => {
|
||||||
setStatus('syncing');
|
setStatus('syncing');
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await runSync();
|
await runSyncGuarded();
|
||||||
setStatus('success');
|
setStatus('success');
|
||||||
await loadLastSync();
|
await loadLastSync();
|
||||||
setTimeout(() => setStatus('idle'), 3000);
|
setTimeout(() => setStatus('idle'), 3000);
|
||||||
@@ -40,7 +42,7 @@ export default function SyncStatus({ compact = false }: SyncStatusProps) {
|
|||||||
|
|
||||||
const formatTime = (timestamp: number | null): string => {
|
const formatTime = (timestamp: number | null): string => {
|
||||||
if (!timestamp) return 'Never';
|
if (!timestamp) return 'Never';
|
||||||
const diff = Date.now() - timestamp;
|
const diff = now - timestamp;
|
||||||
if (diff < 60000) return 'Just now';
|
if (diff < 60000) return 'Just now';
|
||||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
|
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
|
||||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h 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';
|
import Svg, { Path, Circle } from 'react-native-svg';
|
||||||
|
|
||||||
interface TabBarIconProps {
|
interface TabBarIconProps {
|
||||||
name: 'checklist' | 'calendar' | 'gear';
|
name: 'checklist' | 'calendar' | 'gear' | 'stats';
|
||||||
focused: boolean;
|
focused: boolean;
|
||||||
color: ColorValue;
|
color: ColorValue;
|
||||||
size?: number;
|
size?: number;
|
||||||
@@ -51,6 +51,20 @@ export function TabBarIcon({ name, focused, color, size = 24 }: TabBarIconProps)
|
|||||||
fill="none"
|
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>
|
</Svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -9,16 +9,17 @@ interface TaskDeleteModalProps {
|
|||||||
taskId: string | null;
|
taskId: string | null;
|
||||||
taskTitle?: string;
|
taskTitle?: string;
|
||||||
isRepeating?: boolean;
|
isRepeating?: boolean;
|
||||||
|
subtask?: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onDelete: (scope: TaskDeleteScope) => 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 { theme } = useSettings();
|
||||||
const [counts, setCounts] = useState({ future: 1, all: 1 });
|
const [counts, setCounts] = useState({ future: 1, all: 1 });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visible || !taskId) return;
|
if (!visible || !taskId || subtask) return;
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
getSeriesOccurrenceCounts(taskId)
|
getSeriesOccurrenceCounts(taskId)
|
||||||
.then((c) => {
|
.then((c) => {
|
||||||
@@ -26,11 +27,11 @@ export function TaskDeleteModal({ visible, taskId, taskTitle, isRepeating, onClo
|
|||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
return () => { mounted = false; };
|
return () => { mounted = false; };
|
||||||
}, [visible, taskId]);
|
}, [visible, taskId, subtask]);
|
||||||
|
|
||||||
const options: Array<{ scope: TaskDeleteScope; label: string; hint?: string }> = [
|
const options: { scope: TaskDeleteScope; label: string; hint?: string }[] = subtask
|
||||||
{ scope: 'this', label: 'This task only' },
|
? [{ scope: 'this', label: 'This subtask only' }]
|
||||||
];
|
: [{ scope: 'this', label: 'This task only' }];
|
||||||
|
|
||||||
if (isRepeating) {
|
if (isRepeating) {
|
||||||
options.push({ scope: 'future', label: 'This and future tasks', hint: counts.future > 1 ? `${counts.future} occurrences` : undefined });
|
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.overlay}>
|
||||||
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
|
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
|
||||||
<View style={styles.header}>
|
<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 }}>
|
<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">
|
<Svg width={18} height={18} viewBox="0 0 24 24">
|
||||||
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textMuted} strokeWidth={2} strokeLinecap="round" />
|
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textMuted} strokeWidth={2} strokeLinecap="round" />
|
||||||
|
|||||||
@@ -1,13 +1,27 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, Text, StyleSheet, TouchableOpacity, Animated } from 'react-native';
|
import { View, Text, StyleSheet, TouchableOpacity, Animated, Platform, Dimensions } from 'react-native';
|
||||||
import { Swipeable, Gesture, GestureDetector } from 'react-native-gesture-handler';
|
import { Swipeable, Gesture, GestureDetector, PanGestureHandler } from 'react-native-gesture-handler';
|
||||||
import { TaskData } from '@/types';
|
import { TaskData, SubtaskData, Priority, Repeat, Reminder } from '@/types';
|
||||||
import { PRIORITY_COLORS } from '@/constants';
|
import { PRIORITY_COLORS } from '@/constants';
|
||||||
import { useSettings } from '@/theme';
|
import { useSettings } from '@/theme';
|
||||||
import Svg, { Path, Circle } from 'react-native-svg';
|
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 {
|
interface TaskItemProps {
|
||||||
task: TaskData;
|
task: TaskData | SubtaskData | TaskLike;
|
||||||
onToggle: () => void | Promise<void>;
|
onToggle: () => void | Promise<void>;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
@@ -20,17 +34,22 @@ interface TaskItemProps {
|
|||||||
hovered?: boolean;
|
hovered?: boolean;
|
||||||
onDragStart?: () => void;
|
onDragStart?: () => void;
|
||||||
onDragUpdate?: (absoluteY: number) => void;
|
onDragUpdate?: (absoluteY: number) => void;
|
||||||
onDragEnd?: (absoluteY: number) => void;
|
onDragEnd?: (absoluteY: number) => void;
|
||||||
assigneeUsername?: string;
|
onReorderStart?: () => void;
|
||||||
expanded?: boolean;
|
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 { theme } = useSettings();
|
||||||
const [opacityAnim] = React.useState(new Animated.Value(task.completed ? 0.5 : 1));
|
const [opacityAnim] = React.useState(new Animated.Value(task.completed ? 0.5 : 1));
|
||||||
const [dragTranslateX] = React.useState(new Animated.Value(0));
|
const [dragTranslateX] = React.useState(new Animated.Value(0));
|
||||||
const [dragTranslateY] = 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 swipeableRef = React.useRef<Swipeable>(null);
|
||||||
const [rotateAnim] = React.useState(new Animated.Value(0));
|
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 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(
|
const dragGesture = React.useMemo(
|
||||||
() =>
|
() =>
|
||||||
Gesture.Pan()
|
Gesture.Pan()
|
||||||
.activateAfterLongPress(400)
|
.activateAfterLongPress(400)
|
||||||
.minDistance(2)
|
.minDistance(2)
|
||||||
.runOnJS(true)
|
.runOnJS(true)
|
||||||
. onStart(() => {
|
.onStart(() => {
|
||||||
setDragging(true);
|
setDragging(true);
|
||||||
onDragStart?.();
|
onDragStart?.();
|
||||||
})
|
})
|
||||||
@@ -78,12 +124,24 @@ export function TaskItem({ task, onToggle, onDelete, onPress, onLongPress, onMen
|
|||||||
}).start();
|
}).start();
|
||||||
}, [task.completed, opacityAnim]);
|
}, [task.completed, opacityAnim]);
|
||||||
|
|
||||||
const startOfToday = new Date();
|
const dueInfo = React.useMemo(() => {
|
||||||
startOfToday.setHours(0, 0, 0, 0);
|
const startOfToday = new Date();
|
||||||
const hasDueDate = task.dueDate > 0;
|
startOfToday.setHours(0, 0, 0, 0);
|
||||||
const isOverdue = hasDueDate && !task.completed && task.dueDate < startOfToday.getTime();
|
const endOfToday = new Date();
|
||||||
const isDueToday = hasDueDate && !task.completed && task.dueDate >= startOfToday.getTime() && task.dueDate < new Date().setHours(23, 59, 59, 999);
|
endOfToday.setHours(23, 59, 59, 999);
|
||||||
const canComplete = !hasDueDate || isOverdue || isDueToday;
|
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 renderRightActions = (progress: Animated.AnimatedInterpolation<number>) => {
|
||||||
const translateX = progress.interpolate({ inputRange: [0, 1], outputRange: [80, 0] });
|
const translateX = progress.interpolate({ inputRange: [0, 1], outputRange: [80, 0] });
|
||||||
@@ -138,19 +196,20 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
|
|||||||
overshootRight={false}
|
overshootRight={false}
|
||||||
overshootLeft={false}
|
overshootLeft={false}
|
||||||
>
|
>
|
||||||
<GestureDetector gesture={draggable ? dragGesture : Gesture.Native()}>
|
<GestureDetector gesture={draggable ? (onReorderStart ? reorderGesture : dragGesture) : Gesture.Native()}>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[
|
style={[
|
||||||
styles.container,
|
styles.container,
|
||||||
{ backgroundColor: theme.card, borderColor: theme.border },
|
{ backgroundColor: theme.card, borderColor: theme.border },
|
||||||
|
(indented || depth > 0) && { marginLeft: depth > 0 ? depth * 12 : 32, marginBottom: 4 },
|
||||||
task.completed && styles.taskCompleted,
|
task.completed && styles.taskCompleted,
|
||||||
isOverdue && styles.taskOverdue,
|
isOverdue && styles.taskOverdue,
|
||||||
isDueToday && styles.taskDueToday,
|
isDueToday && styles.taskDueToday,
|
||||||
selectionMode && styles.taskSelected,
|
selectionMode && styles.taskSelected,
|
||||||
selected && { borderColor: theme.accent, borderWidth: 2 },
|
selected && { borderColor: theme.accent, borderWidth: 2 },
|
||||||
hovered && { borderColor: theme.accent, borderWidth: 2, backgroundColor: theme.accentSoft },
|
|
||||||
completedSection && { backgroundColor: theme.cardAlt },
|
completedSection && { backgroundColor: theme.cardAlt },
|
||||||
dragging && styles.dragLifted,
|
dragging && styles.dragLifted,
|
||||||
|
hovered && styles.taskHovered,
|
||||||
{ transform: [{ translateX: dragTranslateX }, { translateY: dragTranslateY }] },
|
{ transform: [{ translateX: dragTranslateX }, { translateY: dragTranslateY }] },
|
||||||
]}
|
]}
|
||||||
pointerEvents={dragging ? 'none' : 'auto'}
|
pointerEvents={dragging ? 'none' : 'auto'}
|
||||||
@@ -164,6 +223,24 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
|
|||||||
>
|
>
|
||||||
<View style={styles.content}>
|
<View style={styles.content}>
|
||||||
<View style={styles.titleRow}>
|
<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
|
<TouchableOpacity
|
||||||
style={[styles.checkCircle, !canComplete && !task.completed && styles.checkCircleDisabled]}
|
style={[styles.checkCircle, !canComplete && !task.completed && styles.checkCircleDisabled]}
|
||||||
onPress={canComplete || task.completed ? onToggle : undefined}
|
onPress={canComplete || task.completed ? onToggle : undefined}
|
||||||
@@ -252,7 +329,7 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
|
|||||||
</Svg>
|
</Svg>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</View>
|
</View>
|
||||||
{task.dueDate && (
|
{hasDueDate && (
|
||||||
<View style={styles.dueRow}>
|
<View style={styles.dueRow}>
|
||||||
<Svg width={14} height={14} viewBox="0 0 24 24">
|
<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" />
|
<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,
|
isDueToday && styles.dueTextDueToday,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{formatDueDate(task.dueDate, task.dueTime, task.endTime || '')}
|
{formattedDueDate}
|
||||||
</Animated.Text>
|
</Animated.Text>
|
||||||
{task.reminder && task.reminder !== 'none' && (
|
{task.reminder && task.reminder !== 'none' && (
|
||||||
<View style={styles.reminderIcon}>
|
<View style={styles.reminderIcon}>
|
||||||
@@ -302,7 +379,7 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
|
|||||||
</GestureDetector>
|
</GestureDetector>
|
||||||
</Swipeable>
|
</Swipeable>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
function formatDueDate(dueDate: number, dueTime: string, endTime?: string): string {
|
function formatDueDate(dueDate: number, dueTime: string, endTime?: string): string {
|
||||||
const date = new Date(dueDate);
|
const date = new Date(dueDate);
|
||||||
@@ -338,19 +415,24 @@ const styles = StyleSheet.create({
|
|||||||
container: {
|
container: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 12,
|
||||||
paddingVertical: 14,
|
paddingVertical: 14,
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
|
marginVertical: 4,
|
||||||
shadowColor: '#000',
|
shadowColor: '#000',
|
||||||
shadowOffset: { width: 0, height: 1 },
|
shadowOffset: { width: 0, height: 1 },
|
||||||
shadowOpacity: 0.04,
|
shadowOpacity: 0.05,
|
||||||
shadowRadius: 4,
|
shadowRadius: 6,
|
||||||
elevation: 1,
|
elevation: 1,
|
||||||
},
|
},
|
||||||
taskCompleted: {
|
taskCompleted: {
|
||||||
opacity: 0.5,
|
opacity: 0.5,
|
||||||
},
|
},
|
||||||
|
indented: {
|
||||||
|
marginLeft: 32,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
taskOverdue: {
|
taskOverdue: {
|
||||||
borderColor: '#4A2B2B',
|
borderColor: '#4A2B2B',
|
||||||
},
|
},
|
||||||
@@ -363,6 +445,10 @@ const styles = StyleSheet.create({
|
|||||||
shadowRadius: 6,
|
shadowRadius: 6,
|
||||||
elevation: 2,
|
elevation: 2,
|
||||||
},
|
},
|
||||||
|
taskHovered: {
|
||||||
|
borderColor: '#1E88E5',
|
||||||
|
backgroundColor: 'rgba(30, 136, 229, 0.05)',
|
||||||
|
},
|
||||||
dragLifted: {
|
dragLifted: {
|
||||||
zIndex: 100,
|
zIndex: 100,
|
||||||
elevation: 12,
|
elevation: 12,
|
||||||
@@ -375,18 +461,26 @@ const styles = StyleSheet.create({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
content: {
|
content: {
|
||||||
gap: 4,
|
gap: 12,
|
||||||
},
|
},
|
||||||
titleRow: {
|
titleRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
},
|
},
|
||||||
|
dragHandle: {
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: 14,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
title: {
|
title: {
|
||||||
fontSize: 16,
|
fontSize: 17,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
marginRight: 8,
|
marginRight: 16,
|
||||||
},
|
},
|
||||||
titleCompleted: {
|
titleCompleted: {
|
||||||
textDecorationLine: 'line-through',
|
textDecorationLine: 'line-through',
|
||||||
@@ -444,7 +538,7 @@ const styles = StyleSheet.create({
|
|||||||
dueRow: {
|
dueRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 4,
|
gap: 8,
|
||||||
},
|
},
|
||||||
dueText: {
|
dueText: {
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
|
|||||||
@@ -1,30 +1,25 @@
|
|||||||
import React, { useState, useCallback, useMemo, useRef, useEffect } from 'react';
|
import React, { useState, useCallback, useMemo, useRef } from 'react';
|
||||||
import { View, Text, StyleSheet, FlatList, TouchableOpacity, Animated, RefreshControl, Alert } from 'react-native';
|
import { View, Text, StyleSheet, Animated, RefreshControl, Alert, TouchableOpacity } from 'react-native';
|
||||||
import { useRouter } from 'expo-router';
|
|
||||||
import { useTasks } from '@/hooks/useTasks';
|
import { useTasks } from '@/hooks/useTasks';
|
||||||
|
import { useTaskModals } from '@/hooks/useTaskModals';
|
||||||
import { TaskItem } from './TaskItem';
|
import { TaskItem } from './TaskItem';
|
||||||
import { SubtaskItem } from './SubtaskItem';
|
import { SubtaskItem } from './SubtaskItem';
|
||||||
import { TaskData, SubtaskData } from '@/types';
|
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 { useSettings } from '@/theme';
|
||||||
import { OptionPickerModal } from './OptionPickerModal';
|
|
||||||
import { TaskOverflowMenu } from './TaskOverflowMenu';
|
|
||||||
import { TaskDeleteModal } from './TaskDeleteModal';
|
|
||||||
import {
|
import {
|
||||||
toggleTaskComplete,
|
toggleTaskComplete,
|
||||||
deleteTask,
|
deleteTask,
|
||||||
duplicateTask,
|
|
||||||
setTaskCategory,
|
|
||||||
setTaskPriority,
|
|
||||||
setTaskCompleted,
|
setTaskCompleted,
|
||||||
deleteTaskOccurrences,
|
|
||||||
convertTaskToSubtask,
|
convertTaskToSubtask,
|
||||||
|
convertSubtaskToTask,
|
||||||
|
moveSubtaskToTask,
|
||||||
toggleSubtaskComplete,
|
toggleSubtaskComplete,
|
||||||
TaskDeleteScope,
|
reorderTasks,
|
||||||
} from '@/utils/taskActions';
|
} from '@/utils/taskActions';
|
||||||
import { PRIORITY_LABELS } from '@/constants';
|
|
||||||
import { Q } from '@nozbe/watermelondb';
|
import { Q } from '@nozbe/watermelondb';
|
||||||
import Svg, { Path } from 'react-native-svg';
|
import Svg, { Path, Rect } from 'react-native-svg';
|
||||||
|
|
||||||
interface TaskListProps {
|
interface TaskListProps {
|
||||||
categoryId?: string;
|
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 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) {
|
export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProps) {
|
||||||
const router = useRouter();
|
|
||||||
const { theme, sortBy } = useSettings();
|
const { theme, sortBy } = useSettings();
|
||||||
const { collections } = useDatabase();
|
const { collections } = useDatabase();
|
||||||
const categories = useCategories();
|
|
||||||
|
|
||||||
const { tasks, loading } = useTasks(categoryId, false);
|
const { tasks, loading } = useTasks(categoryId, false);
|
||||||
const { tasks: completedTasks } = useTasks(categoryId, true);
|
const { tasks: completedTasks } = useTasks(categoryId, true);
|
||||||
@@ -45,16 +49,30 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
|||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [selectionMode, setSelectionMode] = useState(false);
|
const [selectionMode, setSelectionMode] = useState(false);
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
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 [hoverTaskId, setHoverTaskId] = useState<string | null>(null);
|
||||||
const [expandedTasks, setExpandedTasks] = useState<Set<string>>(new Set());
|
const [expandedTasks, setExpandedTasks] = useState<Set<string>>(new Set());
|
||||||
const [subtasksMap, setSubtasksMap] = useState<Map<string, SubtaskData[]>>(new Map());
|
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 itemRefs = useRef<Map<string, View>>(new Map());
|
||||||
const dragStateRef = useRef<{ taskId: string; positions: Record<string, { top: number; bottom: number }> } | null>(null);
|
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 sortedTasks = useMemo(() => {
|
||||||
const sorted = [...tasks];
|
const sorted = [...tasks];
|
||||||
@@ -75,16 +93,6 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
|||||||
return sorted;
|
return sorted;
|
||||||
}, [tasks, sortBy]);
|
}, [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(() => {
|
const onRefresh = useCallback(() => {
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
setTimeout(() => setRefreshing(false), 600);
|
setTimeout(() => setRefreshing(false), 600);
|
||||||
@@ -118,22 +126,12 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
|||||||
});
|
});
|
||||||
}, [onSelectionChange]);
|
}, [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 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) => ({
|
const mapped: SubtaskData[] = subs.map((s: any) => ({
|
||||||
id: s.id,
|
id: s.id,
|
||||||
taskId: s.taskId,
|
taskId: s.taskId,
|
||||||
|
parentSubtaskId: s.parentSubtaskId || null,
|
||||||
title: s.title,
|
title: s.title,
|
||||||
description: s.description || '',
|
description: s.description || '',
|
||||||
priority: (s.priority || 'none') as SubtaskData['priority'],
|
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'],
|
reminder: (s.reminder || 'none') as SubtaskData['reminder'],
|
||||||
assigneeId: s.assigneeId ?? null,
|
assigneeId: s.assigneeId ?? null,
|
||||||
order: s.order,
|
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]);
|
}, [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) => {
|
const toggleExpand = useCallback(async (taskId: string) => {
|
||||||
setExpandedTasks((prev) => {
|
setExpandedTasks((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
@@ -182,23 +231,6 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
|||||||
await fetchSubtasks(taskId);
|
await fetchSubtasks(taskId);
|
||||||
}, [fetchSubtasks]);
|
}, [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(() => {
|
const handleBulkDelete = useCallback(() => {
|
||||||
Alert.alert(`Delete ${selectedIds.size} task${selectedIds.size > 1 ? 's' : ''}?`, 'This cannot be undone.', [
|
Alert.alert(`Delete ${selectedIds.size} task${selectedIds.size > 1 ? 's' : ''}?`, 'This cannot be undone.', [
|
||||||
{ text: 'Cancel', style: 'cancel' },
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
@@ -206,9 +238,7 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
|||||||
text: 'Delete',
|
text: 'Delete',
|
||||||
style: 'destructive',
|
style: 'destructive',
|
||||||
onPress: async () => {
|
onPress: async () => {
|
||||||
for (const taskId of selectedIds) {
|
await Promise.all(Array.from(selectedIds).map(taskId => deleteTask(taskId)));
|
||||||
await deleteTask(taskId);
|
|
||||||
}
|
|
||||||
exitSelection();
|
exitSelection();
|
||||||
refreshAll();
|
refreshAll();
|
||||||
},
|
},
|
||||||
@@ -217,27 +247,11 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
|||||||
}, [selectedIds, exitSelection, refreshAll]);
|
}, [selectedIds, exitSelection, refreshAll]);
|
||||||
|
|
||||||
const handleBulkComplete = useCallback(async () => {
|
const handleBulkComplete = useCallback(async () => {
|
||||||
for (const taskId of selectedIds) {
|
await Promise.all(Array.from(selectedIds).map(taskId => setTaskCompleted(taskId, true)));
|
||||||
await setTaskCompleted(taskId, true);
|
|
||||||
}
|
|
||||||
exitSelection();
|
exitSelection();
|
||||||
refreshAll();
|
refreshAll();
|
||||||
}, [selectedIds, 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 measureItems = useCallback(async () => {
|
||||||
const positions: Record<string, { top: number; bottom: number }> = {};
|
const positions: Record<string, { top: number; bottom: number }> = {};
|
||||||
const entries = Array.from(itemRefs.current.entries());
|
const entries = Array.from(itemRefs.current.entries());
|
||||||
@@ -262,6 +276,13 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
|||||||
return null;
|
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) => {
|
const handleDragStart = useCallback(async (taskId: string) => {
|
||||||
dragStateRef.current = { taskId, positions: await measureItems() };
|
dragStateRef.current = { taskId, positions: await measureItems() };
|
||||||
}, [measureItems]);
|
}, [measureItems]);
|
||||||
@@ -271,12 +292,26 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
|||||||
if (!state) return;
|
if (!state) return;
|
||||||
const target = findHoverTarget(absoluteY, state.taskId, state.positions);
|
const target = findHoverTarget(absoluteY, state.taskId, state.positions);
|
||||||
setHoverTaskId((prev) => (prev === target ? prev : target));
|
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 handleDragEnd = useCallback((absoluteY: number) => {
|
||||||
const state = dragStateRef.current;
|
const state = dragStateRef.current;
|
||||||
dragStateRef.current = null;
|
dragStateRef.current = null;
|
||||||
setHoverTaskId(null);
|
setHoverTaskId(null);
|
||||||
|
setDropIndicator(null);
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
|
|
||||||
const target = findHoverTarget(absoluteY, state.taskId, state.positions);
|
const target = findHoverTarget(absoluteY, state.taskId, state.positions);
|
||||||
@@ -288,6 +323,222 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
|||||||
}
|
}
|
||||||
}, [findHoverTarget, 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) {
|
if (loading && !refreshing) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.loadingContainer}>
|
<View style={styles.loadingContainer}>
|
||||||
@@ -296,87 +547,16 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const priorityOptions = Object.entries(PRIORITY_LABELS).map(([value, label]) => ({ value, label }));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<Animated.FlatList
|
<Animated.FlatList
|
||||||
data={sortedTasks}
|
data={sortedTasks}
|
||||||
keyExtractor={(item) => item.id}
|
keyExtractor={(item) => item.id}
|
||||||
renderItem={({ item }) => {
|
renderItem={renderItem}
|
||||||
const isExpanded = expandedTasks.has(item.id);
|
extraData={{ expandedTasks, subtasksMap, selectedIds, selectionMode, hoverTaskId, dropIndicator }}
|
||||||
const itemSubtasks = subtasksMap.get(item.id) ?? [];
|
ItemSeparatorComponent={MemoSeparator}
|
||||||
return (
|
ListHeaderComponent={listHeader}
|
||||||
<View
|
ListFooterComponent={listFooter}
|
||||||
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) : toggleExpand(item.id)}
|
|
||||||
onLongPress={selectionMode ? undefined : () => enterSelection(item.id)}
|
|
||||||
onMenuOpen={() => setMenuTaskId(item.id)}
|
|
||||||
selected={selectedIds.has(item.id)}
|
|
||||||
selectionMode={selectionMode}
|
|
||||||
expanded={isExpanded}
|
|
||||||
draggable
|
|
||||||
hovered={hoverTaskId === item.id}
|
|
||||||
onDragStart={() => handleDragStart(item.id)}
|
|
||||||
onDragUpdate={handleDragUpdate}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
/>
|
|
||||||
{isExpanded && itemSubtasks.length > 0 && (
|
|
||||||
<View style={styles.subtaskList}>
|
|
||||||
{itemSubtasks
|
|
||||||
.slice()
|
|
||||||
.sort((a, b) => a.order - b.order)
|
|
||||||
.map((sub) => (
|
|
||||||
<SubtaskItem
|
|
||||||
key={sub.id}
|
|
||||||
subtask={sub}
|
|
||||||
onToggle={() => handleSubtaskToggle(sub.id, item.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
ItemSeparatorComponent={() => <View style={styles.separator} />}
|
|
||||||
ListHeaderComponent={
|
|
||||||
sortedTasks.length === 0 && completedTasks.length === 0 ? (
|
|
||||||
<View style={styles.emptyState}>
|
|
||||||
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>No tasks yet</Text>
|
|
||||||
<Text style={[styles.emptySubtext, { color: theme.textMuted }]}>Tap + to add your first task</Text>
|
|
||||||
</View>
|
|
||||||
) : sortedTasks.length === 0 ? (
|
|
||||||
<View style={styles.emptyState}>
|
|
||||||
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>All caught up!</Text>
|
|
||||||
<Text style={[styles.emptySubtext, { color: theme.textMuted }]}>No pending tasks</Text>
|
|
||||||
</View>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
ListFooterComponent={
|
|
||||||
completedTasks.length > 0 ? (
|
|
||||||
<CompletedSection
|
|
||||||
tasks={completedTasks}
|
|
||||||
onToggle={(task) => handleToggle(task.id)}
|
|
||||||
onDelete={(task) => handleDeleteOne(task.id)}
|
|
||||||
onMenuOpen={(task) => setMenuTaskId(task.id)}
|
|
||||||
onLongPress={(task) => enterSelection(task.id)}
|
|
||||||
selectionMode={selectionMode}
|
|
||||||
selectedIds={selectedIds}
|
|
||||||
onSelect={(taskId) => toggleSelect(taskId)}
|
|
||||||
/>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl
|
<RefreshControl
|
||||||
refreshing={refreshing}
|
refreshing={refreshing}
|
||||||
@@ -389,44 +569,7 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
|||||||
contentContainerStyle={styles.listContent}
|
contentContainerStyle={styles.listContent}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TaskDeleteModal
|
{modals(refreshAll)}
|
||||||
visible={deleteTarget !== null}
|
|
||||||
taskId={deleteTarget?.id ?? null}
|
|
||||||
taskTitle={deleteTarget?.title}
|
|
||||||
isRepeating={deleteTarget ? deleteTarget.repeat !== 'none' : false}
|
|
||||||
onClose={() => setDeleteTaskId(null)}
|
|
||||||
onDelete={handleDeleteScope}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TaskOverflowMenu
|
|
||||||
visible={menuTask !== null}
|
|
||||||
task={menuTask}
|
|
||||||
onClose={() => setMenuTaskId(null)}
|
|
||||||
onEdit={() => menuTask && handleEdit(menuTask.id)}
|
|
||||||
onDelete={() => menuTask && handleDeleteOne(menuTask.id)}
|
|
||||||
onDuplicate={() => menuTask && handleDuplicate(menuTask.id)}
|
|
||||||
onToggleComplete={() => menuTask && handleToggle(menuTask.id)}
|
|
||||||
onChangeCategory={() => menuTask && setPicker({ type: 'category', taskId: menuTask.id })}
|
|
||||||
onChangePriority={() => menuTask && setPicker({ type: 'priority', taskId: menuTask.id })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<OptionPickerModal
|
|
||||||
visible={picker?.type === 'category'}
|
|
||||||
title="Change Category"
|
|
||||||
options={categories.map((c) => ({ value: c.id, label: c.name, color: c.color }))}
|
|
||||||
selectedValue={menuTask?.categoryId}
|
|
||||||
onSelect={handleSingleCategory}
|
|
||||||
onClose={() => setPicker(null)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<OptionPickerModal
|
|
||||||
visible={picker?.type === 'priority'}
|
|
||||||
title="Change Priority"
|
|
||||||
options={priorityOptions}
|
|
||||||
selectedValue={menuTask?.priority}
|
|
||||||
onSelect={handleSinglePriority}
|
|
||||||
onClose={() => setPicker(null)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{selectionMode && (
|
{selectionMode && (
|
||||||
<View style={[styles.selectionBar, { backgroundColor: theme.sheetBg, borderTopColor: theme.border }]}>
|
<View style={[styles.selectionBar, { backgroundColor: theme.sheetBg, borderTopColor: theme.border }]}>
|
||||||
@@ -447,8 +590,7 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectionButton({ label, color, onPress }: { label: string; color: string; onPress: () => void }) {
|
const SelectionButton = React.memo(function SelectionButton({ label, color, onPress }: { label: string; color: string; onPress: () => void }) {
|
||||||
const { theme } = useSettings();
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.selectionButton, { backgroundColor: color }]}
|
style={[styles.selectionButton, { backgroundColor: color }]}
|
||||||
@@ -458,8 +600,117 @@ function SelectionButton({ label, color, onPress }: { label: string; color: stri
|
|||||||
<Text style={styles.selectionButtonText}>{label}</Text>
|
<Text style={styles.selectionButtonText}>{label}</Text>
|
||||||
</TouchableOpacity>
|
</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 {
|
interface CompletedSectionProps {
|
||||||
tasks: TaskData[];
|
tasks: TaskData[];
|
||||||
onToggle: (task: TaskData) => void;
|
onToggle: (task: TaskData) => void;
|
||||||
@@ -471,7 +722,7 @@ interface CompletedSectionProps {
|
|||||||
onSelect: (taskId: string) => void;
|
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 { theme } = useSettings();
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
@@ -508,7 +759,7 @@ function CompletedSection({ tasks, onToggle, onDelete, onMenuOpen, onLongPress,
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
@@ -516,8 +767,8 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
listContent: {
|
listContent: {
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
paddingTop: 2,
|
paddingTop: 8,
|
||||||
paddingBottom: 100,
|
paddingBottom: 120,
|
||||||
},
|
},
|
||||||
loadingContainer: {
|
loadingContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -533,6 +784,25 @@ const styles = StyleSheet.create({
|
|||||||
subtaskList: {
|
subtaskList: {
|
||||||
paddingLeft: 8,
|
paddingLeft: 8,
|
||||||
paddingRight: 4,
|
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: {
|
emptyState: {
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, Text, StyleSheet, TextInput } from 'react-native';
|
import { View, Text, StyleSheet, TextInput , TextInputProps } from 'react-native';
|
||||||
import { TextInputProps } from 'react-native';
|
|
||||||
import { useSettings } from '@/theme';
|
import { useSettings } from '@/theme';
|
||||||
|
|
||||||
interface TaskNameInputProps extends TextInputProps {
|
interface TaskNameInputProps extends TextInputProps {
|
||||||
@@ -35,34 +34,34 @@ export function TaskNameInput({ error, ...props }: TaskNameInputProps) {
|
|||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
gap: 6,
|
gap: 5,
|
||||||
},
|
},
|
||||||
labelRow: {
|
labelRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
label: {
|
label: {
|
||||||
fontSize: 14,
|
fontSize: 13,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
required: {
|
required: {
|
||||||
color: '#E53935',
|
color: '#E53935',
|
||||||
fontSize: 14,
|
fontSize: 13,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
input: {
|
input: {
|
||||||
height: 52,
|
height: 48,
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 14,
|
||||||
borderRadius: 12,
|
borderRadius: 10,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
fontSize: 16,
|
fontSize: 15,
|
||||||
},
|
},
|
||||||
inputError: {
|
inputError: {
|
||||||
borderColor: '#E53935',
|
borderColor: '#E53935',
|
||||||
borderWidth: 1.5,
|
borderWidth: 1.5,
|
||||||
},
|
},
|
||||||
errorText: {
|
errorText: {
|
||||||
fontSize: 12,
|
fontSize: 11,
|
||||||
color: '#E53935',
|
color: '#E53935',
|
||||||
marginLeft: 4,
|
marginLeft: 4,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, Text, StyleSheet, Modal, TouchableOpacity } from 'react-native';
|
import { View, Text, StyleSheet, Modal, TouchableOpacity } from 'react-native';
|
||||||
import { useSettings } from '@/theme';
|
import { useSettings } from '@/theme';
|
||||||
import { TaskData } from '@/types';
|
import { TaskData, SubtaskData } from '@/types';
|
||||||
import Svg, { Path } from 'react-native-svg';
|
import Svg, { Path } from 'react-native-svg';
|
||||||
|
|
||||||
interface TaskOverflowMenuProps {
|
interface TaskOverflowMenuProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
task: TaskData | null;
|
task: TaskData | SubtaskData | null;
|
||||||
|
subtask?: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onDuplicate: () => void;
|
onDuplicate: () => void;
|
||||||
onToggleComplete: () => void;
|
onToggleComplete: () => void;
|
||||||
onChangeCategory: () => void;
|
onChangeCategory?: () => void;
|
||||||
onChangePriority: () => void;
|
onChangePriority: () => void;
|
||||||
|
onAddSubtask?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MenuAction {
|
interface MenuAction {
|
||||||
@@ -27,6 +29,7 @@ interface MenuAction {
|
|||||||
export function TaskOverflowMenu({
|
export function TaskOverflowMenu({
|
||||||
visible,
|
visible,
|
||||||
task,
|
task,
|
||||||
|
subtask,
|
||||||
onClose,
|
onClose,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
@@ -34,6 +37,7 @@ export function TaskOverflowMenu({
|
|||||||
onToggleComplete,
|
onToggleComplete,
|
||||||
onChangeCategory,
|
onChangeCategory,
|
||||||
onChangePriority,
|
onChangePriority,
|
||||||
|
onAddSubtask,
|
||||||
}: TaskOverflowMenuProps) {
|
}: TaskOverflowMenuProps) {
|
||||||
const { theme } = useSettings();
|
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>,
|
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,
|
onPress: onDuplicate,
|
||||||
},
|
},
|
||||||
{
|
];
|
||||||
|
|
||||||
|
if (!subtask) {
|
||||||
|
actions.push({
|
||||||
key: 'category',
|
key: 'category',
|
||||||
label: 'Change Category',
|
label: 'Change Category',
|
||||||
icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Circle2 /></Svg>,
|
icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Circle2 /></Svg>,
|
||||||
onPress: onChangeCategory,
|
onPress: onChangeCategory ?? (() => {}),
|
||||||
},
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
actions.push(
|
||||||
{
|
{
|
||||||
key: 'priority',
|
key: 'priority',
|
||||||
label: 'Change 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>,
|
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,
|
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 (
|
return (
|
||||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
<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]
|
[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(
|
const renderItem = useCallback(
|
||||||
({ item, index }: ListRenderItemInfo<number>) => (
|
({ item, index }: ListRenderItemInfo<number>) => (
|
||||||
<WheelRow
|
<WheelRow
|
||||||
@@ -143,10 +137,12 @@ function WheelColumn({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleScrollEnd = useCallback(() => {
|
const handleScrollEnd = useCallback(() => {
|
||||||
// Rest the wheel exactly on the snapped row so the selection band
|
// Let FlatList's native snap handle the alignment
|
||||||
// and the highlighted text always line up (FlatList doesn't snap on web).
|
// Just update the indexRef from the scroll position
|
||||||
snapToNearest();
|
const current = listRef.current;
|
||||||
}, [snapToNearest]);
|
if (!current) return;
|
||||||
|
// The native snap will handle positioning, we just sync the index
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FlatList
|
<FlatList
|
||||||
@@ -161,7 +157,7 @@ function WheelColumn({
|
|||||||
})}
|
})}
|
||||||
initialScrollIndex={initialIndex}
|
initialScrollIndex={initialIndex}
|
||||||
snapToOffsets={data.map((_, i) => i * ITEM_HEIGHT)}
|
snapToOffsets={data.map((_, i) => i * ITEM_HEIGHT)}
|
||||||
decelerationRate="fast"
|
decelerationRate="normal"
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
onScrollEndDrag={handleScrollEnd}
|
onScrollEndDrag={handleScrollEnd}
|
||||||
@@ -169,6 +165,8 @@ function WheelColumn({
|
|||||||
scrollEventThrottle={16}
|
scrollEventThrottle={16}
|
||||||
style={[styles.column, { width }]}
|
style={[styles.column, { width }]}
|
||||||
contentContainerStyle={{ paddingBottom: (VISIBLE_ITEMS - 1) * ITEM_HEIGHT, alignItems: 'stretch' }}
|
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({
|
export const migrations = schemaMigrations({
|
||||||
migrations: [
|
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';
|
import { appSchema, tableSchema } from '@nozbe/watermelondb';
|
||||||
|
|
||||||
export const schema = appSchema({
|
export const schema = appSchema({
|
||||||
version: 12,
|
version: 15,
|
||||||
tables: [
|
tables: [
|
||||||
tableSchema({
|
tableSchema({
|
||||||
name: 'categories',
|
name: 'categories',
|
||||||
@@ -10,7 +10,7 @@ export const schema = appSchema({
|
|||||||
{ name: 'color', type: 'string' },
|
{ name: 'color', type: 'string' },
|
||||||
{ name: 'order', type: 'number' },
|
{ name: 'order', type: 'number' },
|
||||||
{ name: 'created_at', type: 'number' },
|
{ name: 'created_at', type: 'number' },
|
||||||
{ name: 'updated_at', type: 'number' },
|
{ name: 'updated_at', type: 'number', isIndexed: true },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
tableSchema({
|
tableSchema({
|
||||||
@@ -20,7 +20,8 @@ export const schema = appSchema({
|
|||||||
{ name: 'description', type: 'string' },
|
{ name: 'description', type: 'string' },
|
||||||
{ name: 'category_id', type: 'string', isIndexed: true },
|
{ name: 'category_id', type: 'string', isIndexed: true },
|
||||||
{ name: 'priority', type: 'string' },
|
{ 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_date', type: 'number', isIndexed: true },
|
||||||
{ name: 'due_time', type: 'string' },
|
{ name: 'due_time', type: 'string' },
|
||||||
{ name: 'end_time', type: 'string' },
|
{ name: 'end_time', type: 'string' },
|
||||||
@@ -33,14 +34,15 @@ export const schema = appSchema({
|
|||||||
{ name: 'reminder', type: 'string' },
|
{ name: 'reminder', type: 'string' },
|
||||||
{ name: 'reminders', type: 'string' },
|
{ name: 'reminders', type: 'string' },
|
||||||
{ name: 'assignee_id', type: 'string', isOptional: true },
|
{ name: 'assignee_id', type: 'string', isOptional: true },
|
||||||
{ name: 'created_at', type: 'number' },
|
{ name: 'created_at', type: 'number', isIndexed: true },
|
||||||
{ name: 'updated_at', type: 'number' },
|
{ name: 'updated_at', type: 'number', isIndexed: true },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
tableSchema({
|
tableSchema({
|
||||||
name: 'subtasks',
|
name: 'subtasks',
|
||||||
columns: [
|
columns: [
|
||||||
{ name: 'task_id', type: 'string', isIndexed: true },
|
{ name: 'task_id', type: 'string', isIndexed: true },
|
||||||
|
{ name: 'parent_subtask_id', type: 'string', isIndexed: true, isOptional: true },
|
||||||
{ name: 'title', type: 'string' },
|
{ name: 'title', type: 'string' },
|
||||||
{ name: 'description', type: 'string', isOptional: true },
|
{ name: 'description', type: 'string', isOptional: true },
|
||||||
{ name: 'priority', 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: 'reminders', type: 'string', isOptional: true },
|
||||||
{ name: 'assignee_id', type: 'string', isOptional: true },
|
{ name: 'assignee_id', type: 'string', isOptional: true },
|
||||||
{ name: 'order', type: 'number' },
|
{ name: 'order', type: 'number' },
|
||||||
{ name: 'created_at', type: 'number' },
|
{ name: 'created_at', type: 'number', isIndexed: true },
|
||||||
{ name: 'updated_at', type: 'number' },
|
{ name: 'updated_at', type: 'number', isIndexed: true },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
tableSchema({
|
tableSchema({
|
||||||
@@ -69,7 +71,7 @@ export const schema = appSchema({
|
|||||||
{ name: 'repeat_interval', type: 'number' },
|
{ name: 'repeat_interval', type: 'number' },
|
||||||
{ name: 'repeat_days', type: 'string' },
|
{ name: 'repeat_days', type: 'string' },
|
||||||
{ name: 'created_at', type: 'number' },
|
{ name: 'created_at', type: 'number' },
|
||||||
{ name: 'updated_at', type: 'number' },
|
{ name: 'updated_at', type: 'number', isIndexed: true },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
tableSchema({
|
tableSchema({
|
||||||
@@ -79,7 +81,7 @@ export const schema = appSchema({
|
|||||||
{ name: 'friend_id', type: 'string', isIndexed: true },
|
{ name: 'friend_id', type: 'string', isIndexed: true },
|
||||||
{ name: 'status', type: 'string' },
|
{ name: 'status', type: 'string' },
|
||||||
{ name: 'created_at', type: 'number' },
|
{ 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 { AppState, AppStateStatus } from 'react-native';
|
||||||
import { database, collections } from './index';
|
import { database, collections } from './index';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import { Q } from '@nozbe/watermelondb';
|
||||||
import { apiFetch, getAuthToken } from '@/services/auth';
|
import { apiFetch, getAuthToken } from '@/services/auth';
|
||||||
import Category from '@/models/Category';
|
import Category from '@/models/Category';
|
||||||
import Task from '@/models/Task';
|
import Task from '@/models/Task';
|
||||||
@@ -8,15 +9,6 @@ import Subtask from '@/models/Subtask';
|
|||||||
import RepeatProfile from '@/models/RepeatProfile';
|
import RepeatProfile from '@/models/RepeatProfile';
|
||||||
import Friendship from '@/models/Friendship';
|
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_PULLED_AT_KEY = 'sync:lastPulledAt';
|
||||||
const LAST_RUN_AT_KEY = 'sync:lastRunAt';
|
const LAST_RUN_AT_KEY = 'sync:lastRunAt';
|
||||||
|
|
||||||
@@ -108,17 +100,13 @@ export async function runSync(): Promise<SyncResult> {
|
|||||||
async function pushChanges(): Promise<PushConflict[]> {
|
async function pushChanges(): Promise<PushConflict[]> {
|
||||||
const lastPulledAt = await getLastPulledAt();
|
const lastPulledAt = await getLastPulledAt();
|
||||||
|
|
||||||
const [categories, tasks, subtasks, repeatProfiles, friendships] = await Promise.all([
|
const [tasks, subtasks, repeatProfiles, friendships] = await Promise.all([
|
||||||
collections.categories.query().fetch(),
|
collections.tasks.query(Q.where('updated_at', Q.gt(lastPulledAt))).fetch(),
|
||||||
collections.tasks.query().fetch(),
|
collections.subtasks.query(Q.where('updated_at', Q.gt(lastPulledAt))).fetch(),
|
||||||
collections.subtasks.query().fetch(),
|
collections.repeatProfiles.query(Q.where('updated_at', Q.gt(lastPulledAt))).fetch(),
|
||||||
collections.repeatProfiles.query().fetch(),
|
collections.friendships.query(Q.where('updated_at', Q.gt(lastPulledAt))).fetch(),
|
||||||
collections.friendships.query().fetch(),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const localTaskIds = new Set(tasks.map((t) => t.id));
|
|
||||||
const taskById = new Map(tasks.map((t) => [t.id, t]));
|
|
||||||
|
|
||||||
const taskPayload = (t: Task) => ({
|
const taskPayload = (t: Task) => ({
|
||||||
id: t.id,
|
id: t.id,
|
||||||
title: t.title,
|
title: t.title,
|
||||||
@@ -141,81 +129,97 @@ async function pushChanges(): Promise<PushConflict[]> {
|
|||||||
updatedAt: t.updatedAt.getTime(),
|
updatedAt: t.updatedAt.getTime(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const changedTasks = tasks
|
const changedTasks: ReturnType<typeof taskPayload>[] = [];
|
||||||
.filter((t) => t.updatedAt.getTime() > lastPulledAt)
|
const includedTaskIds = new Set<string>();
|
||||||
.map(taskPayload);
|
const missingTaskIds = new Set<string>();
|
||||||
|
const referencedCategoryIds = new Set<string>();
|
||||||
|
|
||||||
const changedSubtasks = subtasks
|
for (const t of tasks) {
|
||||||
.filter((s) => localTaskIds.has(s.taskId) && s.updatedAt.getTime() > lastPulledAt)
|
changedTasks.push(taskPayload(t));
|
||||||
.map((s) => ({
|
includedTaskIds.add(t.id);
|
||||||
id: s.id,
|
if (t.categoryId) referencedCategoryIds.add(t.categoryId);
|
||||||
taskId: s.taskId,
|
|
||||||
title: s.title,
|
|
||||||
description: s.description ?? '',
|
|
||||||
priority: s.priority ?? 'none',
|
|
||||||
completed: s.completed,
|
|
||||||
dueDate: s.dueDate ?? 0,
|
|
||||||
dueTime: s.dueTime ?? '',
|
|
||||||
endTime: s.endTime ?? '',
|
|
||||||
allDay: s.allDay ?? false,
|
|
||||||
repeat: s.repeat ?? 'none',
|
|
||||||
repeatInterval: s.repeatInterval ?? 1,
|
|
||||||
repeatDays: s.repeatDays ?? '',
|
|
||||||
seriesId: s.seriesId ?? '',
|
|
||||||
reminder: s.reminder ?? 'none',
|
|
||||||
reminders: s.reminders ?? '',
|
|
||||||
assigneeId: s.assigneeId ?? null,
|
|
||||||
order: s.order,
|
|
||||||
createdAt: s.createdAt.getTime(),
|
|
||||||
updatedAt: s.updatedAt.getTime(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const referencedCategoryIds = new Set(
|
|
||||||
changedTasks.map((t) => t.categoryId).filter((id) => id && id.length > 0)
|
|
||||||
);
|
|
||||||
const includedTaskIds = new Set(changedTasks.map((t) => t.id));
|
|
||||||
|
|
||||||
for (const sub of changedSubtasks) {
|
|
||||||
if (includedTaskIds.has(sub.taskId) || !taskById.has(sub.taskId)) continue;
|
|
||||||
const task = taskById.get(sub.taskId)!;
|
|
||||||
includedTaskIds.add(sub.taskId);
|
|
||||||
changedTasks.push(taskPayload(task));
|
|
||||||
if (task.categoryId) referencedCategoryIds.add(task.categoryId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const changedCategories = categories
|
const changedSubtasks = subtasks.map((s) => ({
|
||||||
.filter((c) => c.updatedAt.getTime() > lastPulledAt || referencedCategoryIds.has(c.id))
|
id: s.id,
|
||||||
.map((c) => ({
|
taskId: s.taskId,
|
||||||
id: c.id,
|
title: s.title,
|
||||||
name: c.name,
|
description: s.description ?? '',
|
||||||
color: c.color,
|
priority: s.priority ?? 'none',
|
||||||
order: c.order,
|
completed: s.completed,
|
||||||
createdAt: c.createdAt.getTime(),
|
dueDate: s.dueDate ?? 0,
|
||||||
updatedAt: c.updatedAt.getTime(),
|
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
|
for (const sub of changedSubtasks) {
|
||||||
.filter((p) => p.updatedAt.getTime() > lastPulledAt)
|
if (!includedTaskIds.has(sub.taskId)) {
|
||||||
.map((p) => ({
|
missingTaskIds.add(sub.taskId);
|
||||||
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
|
if (missingTaskIds.size > 0) {
|
||||||
.filter((f) => f.updatedAt.getTime() > lastPulledAt)
|
const parentTasks = await collections.tasks
|
||||||
.map((f) => ({
|
.query(Q.where('id', Q.oneOf(Array.from(missingTaskIds))))
|
||||||
id: f.id,
|
.fetch();
|
||||||
userId: f.userId,
|
for (const t of parentTasks) {
|
||||||
friendId: f.friendId,
|
includedTaskIds.add(t.id);
|
||||||
status: f.status,
|
changedTasks.push(taskPayload(t));
|
||||||
createdAt: f.createdAt.getTime(),
|
if (t.categoryId) referencedCategoryIds.add(t.categoryId);
|
||||||
updatedAt: f.updatedAt.getTime(),
|
}
|
||||||
}));
|
}
|
||||||
|
|
||||||
|
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 (
|
if (
|
||||||
changedCategories.length === 0 &&
|
changedCategories.length === 0 &&
|
||||||
@@ -542,6 +546,30 @@ export function stopWatchingForUpdates(): void {
|
|||||||
watcherStop?.();
|
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 {
|
export function isWatchingForUpdates(): boolean {
|
||||||
return watcherActive;
|
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 { map } from 'rxjs/operators';
|
||||||
import { database, collections } from '../database';
|
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 { getAuthToken } from '../services/auth';
|
||||||
import { DEFAULT_CATEGORIES } from '../constants';
|
import { DEFAULT_CATEGORIES } from '../constants';
|
||||||
import Category from '../models/Category';
|
import Category from '../models/Category';
|
||||||
import RepeatProfile from '../models/RepeatProfile';
|
import RepeatProfile from '../models/RepeatProfile';
|
||||||
|
import { Q } from '@nozbe/watermelondb';
|
||||||
|
|
||||||
interface DatabaseContextType {
|
interface DatabaseContextType {
|
||||||
database: typeof database;
|
database: typeof database;
|
||||||
@@ -23,7 +24,43 @@ export function DatabaseProvider({ children }: { children: ReactNode }) {
|
|||||||
try {
|
try {
|
||||||
const existingCategories = await collections.categories.query().fetch();
|
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 () => {
|
await database.write(async () => {
|
||||||
for (const cat of DEFAULT_CATEGORIES) {
|
for (const cat of DEFAULT_CATEGORIES) {
|
||||||
await collections.categories.create((c) => {
|
await collections.categories.create((c) => {
|
||||||
@@ -41,13 +78,10 @@ export function DatabaseProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const token = await getAuthToken();
|
const token = await getAuthToken();
|
||||||
if (token) {
|
if (token) {
|
||||||
runSync()
|
runSyncGuarded().catch(() => {});
|
||||||
.catch(() => {})
|
startAutoSync();
|
||||||
.finally(() => {
|
|
||||||
watchForUpdates();
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
stopWatchingForUpdates();
|
stopAutoSync();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to initialize database:', error);
|
console.error('Failed to initialize database:', error);
|
||||||
@@ -56,9 +90,10 @@ export function DatabaseProvider({ children }: { children: ReactNode }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
initializeDatabase();
|
initializeDatabase();
|
||||||
return () => {
|
return () => {
|
||||||
stopWatchingForUpdates();
|
stopAutoSync();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -103,9 +138,17 @@ export function useCategories(): Category[] {
|
|||||||
return categories;
|
return categories;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCategory(categoryId: string) {
|
export function useUniqueCategories(): Category[] {
|
||||||
const { collections } = useDatabase();
|
const categories = useCategories();
|
||||||
return collections.categories.find(categoryId);
|
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[] {
|
export function useRepeatProfiles(): RepeatProfile[] {
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export function FriendsProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
fetchFriends();
|
fetchFriends();
|
||||||
}, [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 { useDatabase } from './useDatabase';
|
||||||
import { Q } from '@nozbe/watermelondb';
|
import { Q } from '@nozbe/watermelondb';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useState, useMemo } from 'react';
|
||||||
import Task from '../models/Task';
|
import Task from '../models/Task';
|
||||||
|
|
||||||
export function useTasks(categoryId?: string, showCompleted = false) {
|
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));
|
conditions.push(Q.where('category_id', categoryId));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!showCompleted) {
|
if (showCompleted) {
|
||||||
|
conditions.push(Q.where('completed', true));
|
||||||
|
} else {
|
||||||
conditions.push(Q.where('completed', false));
|
conditions.push(Q.where('completed', false));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,21 +49,21 @@ export function useTasks(categoryId?: string, showCompleted = false) {
|
|||||||
return { tasks, loading };
|
return { tasks, loading };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTask(taskId: string) {
|
|
||||||
const { collections } = useDatabase();
|
|
||||||
return collections.tasks.find(taskId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useTasksByDate(date: Date) {
|
export function useTasksByDate(date: Date) {
|
||||||
const { collections } = useDatabase();
|
const { collections } = useDatabase();
|
||||||
const [tasks, setTasks] = useState<Task[]>([]);
|
const [tasks, setTasks] = useState<Task[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
const startOfDay = new Date(date);
|
const startOfDay = useMemo(() => {
|
||||||
startOfDay.setHours(0, 0, 0, 0);
|
const d = new Date(date);
|
||||||
|
d.setHours(0, 0, 0, 0);
|
||||||
const endOfDay = new Date(date);
|
return d;
|
||||||
endOfDay.setHours(23, 59, 59, 999);
|
}, [date]);
|
||||||
|
const endOfDay = useMemo(() => {
|
||||||
|
const d = new Date(date);
|
||||||
|
d.setHours(23, 59, 59, 999);
|
||||||
|
return d;
|
||||||
|
}, [date]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
@@ -87,83 +89,7 @@ export function useTasksByDate(date: Date) {
|
|||||||
mounted = false;
|
mounted = false;
|
||||||
subscription.unsubscribe();
|
subscription.unsubscribe();
|
||||||
};
|
};
|
||||||
}, [collections, startOfDay.getTime(), endOfDay.getTime()]);
|
}, [collections, startOfDay, endOfDay]);
|
||||||
|
|
||||||
return { tasks, loading };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useOverdueTasks() {
|
|
||||||
const { collections } = useDatabase();
|
|
||||||
const [tasks, setTasks] = useState<Task[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const now = useMemo(() => Date.now(), []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let mounted = true;
|
|
||||||
const subscription = collections.tasks
|
|
||||||
.query(
|
|
||||||
Q.where('completed', false),
|
|
||||||
Q.where('due_date', Q.lt(now)),
|
|
||||||
Q.sortBy('due_date', 'asc')
|
|
||||||
)
|
|
||||||
.observe()
|
|
||||||
.subscribe({
|
|
||||||
next: (result) => {
|
|
||||||
if (mounted) {
|
|
||||||
setTasks(result);
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
error: () => {
|
|
||||||
if (mounted) setLoading(false);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
mounted = false;
|
|
||||||
subscription.unsubscribe();
|
|
||||||
};
|
|
||||||
}, [collections, now]);
|
|
||||||
|
|
||||||
return { tasks, loading };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useTasksDueToday() {
|
|
||||||
const { collections } = useDatabase();
|
|
||||||
const [tasks, setTasks] = useState<Task[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const today = new Date();
|
|
||||||
today.setHours(0, 0, 0, 0);
|
|
||||||
const tomorrow = new Date(today);
|
|
||||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
||||||
|
|
||||||
let mounted = true;
|
|
||||||
const subscription = collections.tasks
|
|
||||||
.query(
|
|
||||||
Q.where('completed', false),
|
|
||||||
Q.where('due_date', Q.between(today.getTime(), tomorrow.getTime() - 1)),
|
|
||||||
Q.sortBy('due_date', 'asc')
|
|
||||||
)
|
|
||||||
.observe()
|
|
||||||
.subscribe({
|
|
||||||
next: (result) => {
|
|
||||||
if (mounted) {
|
|
||||||
setTasks(result);
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
error: () => {
|
|
||||||
if (mounted) setLoading(false);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
mounted = false;
|
|
||||||
subscription.unsubscribe();
|
|
||||||
};
|
|
||||||
}, [collections]);
|
|
||||||
|
|
||||||
return { tasks, loading };
|
return { tasks, loading };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Model } from '@nozbe/watermelondb';
|
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 {
|
export default class Category extends Model {
|
||||||
static table = 'categories';
|
static table = 'categories';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Model } from '@nozbe/watermelondb';
|
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 {
|
export default class Friendship extends Model {
|
||||||
static table = 'friendships';
|
static table = 'friendships';
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import { Model } from '@nozbe/watermelondb';
|
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';
|
import { Priority, Repeat, Reminder } from '@/types';
|
||||||
|
|
||||||
export default class Subtask extends Model {
|
export default class Subtask extends Model {
|
||||||
static table = 'subtasks';
|
static table = 'subtasks';
|
||||||
|
|
||||||
|
static associations: Associations = {
|
||||||
|
subtasks: { type: 'has_many', foreignKey: 'parent_subtask_id' },
|
||||||
|
};
|
||||||
|
|
||||||
@field('task_id') taskId!: string;
|
@field('task_id') taskId!: string;
|
||||||
|
@field('parent_subtask_id') parentSubtaskId!: string | null;
|
||||||
@field('title') title!: string;
|
@field('title') title!: string;
|
||||||
@field('description') description!: string;
|
@field('description') description!: string;
|
||||||
@field('priority') priority!: Priority;
|
@field('priority') priority!: Priority;
|
||||||
@@ -24,4 +30,6 @@ export default class Subtask extends Model {
|
|||||||
@field('order') order!: number;
|
@field('order') order!: number;
|
||||||
@date('created_at') createdAt!: Date;
|
@date('created_at') createdAt!: Date;
|
||||||
@date('updated_at') updatedAt!: Date;
|
@date('updated_at') updatedAt!: Date;
|
||||||
|
|
||||||
|
@children('subtasks') subtasks!: any;
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Model } from '@nozbe/watermelondb';
|
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 { Associations } from '@nozbe/watermelondb/Model';
|
||||||
import { Priority, Repeat, Reminder } from '@/types';
|
import { Priority, Repeat, Reminder } from '@/types';
|
||||||
|
|
||||||
@@ -15,6 +15,7 @@ export default class Task extends Model {
|
|||||||
@field('category_id') categoryId!: string;
|
@field('category_id') categoryId!: string;
|
||||||
@field('priority') priority!: Priority;
|
@field('priority') priority!: Priority;
|
||||||
@field('completed') completed!: boolean;
|
@field('completed') completed!: boolean;
|
||||||
|
@field('completed_at') completedAt!: number | null;
|
||||||
@field('due_date') dueDate!: number;
|
@field('due_date') dueDate!: number;
|
||||||
@field('due_time') dueTime!: string;
|
@field('due_time') dueTime!: string;
|
||||||
@field('end_time') endTime!: string;
|
@field('end_time') endTime!: string;
|
||||||
@@ -27,6 +28,7 @@ export default class Task extends Model {
|
|||||||
@field('reminder') reminder!: Reminder;
|
@field('reminder') reminder!: Reminder;
|
||||||
@field('reminders') reminders!: string;
|
@field('reminders') reminders!: string;
|
||||||
@field('assignee_id') assigneeId!: string | null;
|
@field('assignee_id') assigneeId!: string | null;
|
||||||
|
@field('order') order!: number;
|
||||||
@date('created_at') createdAt!: Date;
|
@date('created_at') createdAt!: Date;
|
||||||
@date('updated_at') updatedAt!: Date;
|
@date('updated_at') updatedAt!: Date;
|
||||||
|
|
||||||
|
|||||||
@@ -24,15 +24,14 @@ async function writeIdMap(map: Record<string, string[]>): Promise<void> {
|
|||||||
function getNotificationsModule(): any {
|
function getNotificationsModule(): any {
|
||||||
if (Platform.OS === 'web') return null;
|
if (Platform.OS === 'web') return null;
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
return import('expo-notifications');
|
||||||
return require('expo-notifications');
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function scheduleTaskReminder(task: any): Promise<void> {
|
export async function scheduleTaskReminder(task: any): Promise<void> {
|
||||||
const notifications = getNotificationsModule();
|
const notifications = await getNotificationsModule();
|
||||||
const dueDate = task.dueDate ? new Date(task.dueDate) : null;
|
const dueDate = task.dueDate ? new Date(task.dueDate) : null;
|
||||||
|
|
||||||
if (!notifications) return;
|
if (!notifications) return;
|
||||||
@@ -99,7 +98,7 @@ export async function scheduleTaskReminder(task: any): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function cancelTaskReminder(taskId: string): Promise<void> {
|
export async function cancelTaskReminder(taskId: string): Promise<void> {
|
||||||
const notifications = getNotificationsModule();
|
const notifications = await getNotificationsModule();
|
||||||
if (!notifications) return;
|
if (!notifications) return;
|
||||||
|
|
||||||
const idMap = await readIdMap();
|
const idMap = await readIdMap();
|
||||||
@@ -118,7 +117,7 @@ export async function cancelTaskReminder(taskId: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function requestNotificationPermission(): Promise<boolean> {
|
export async function requestNotificationPermission(): Promise<boolean> {
|
||||||
const notifications = getNotificationsModule();
|
const notifications = await getNotificationsModule();
|
||||||
if (!notifications) return false;
|
if (!notifications) return false;
|
||||||
try {
|
try {
|
||||||
const settings = await notifications.getPermissionsAsync();
|
const settings = await notifications.getPermissionsAsync();
|
||||||
@@ -134,7 +133,7 @@ export async function requestNotificationPermission(): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function rescheduleAllReminders(tasks: any[]): Promise<void> {
|
export async function rescheduleAllReminders(tasks: any[]): Promise<void> {
|
||||||
const notifications = getNotificationsModule();
|
const notifications = await getNotificationsModule();
|
||||||
if (!notifications) return;
|
if (!notifications) return;
|
||||||
try {
|
try {
|
||||||
await notifications.cancelAllScheduledNotificationsAsync();
|
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,4 +1,4 @@
|
|||||||
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 AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import { DEFAULT_API_BASE_URL, API_URL_KEY } from '@/services/auth';
|
import { DEFAULT_API_BASE_URL, API_URL_KEY } from '@/services/auth';
|
||||||
|
|
||||||
@@ -99,10 +99,13 @@ function useStoredSetting<T>(key: string, initialValue: T): [T, (value: T) => vo
|
|||||||
.finally(() => setLoaded(true));
|
.finally(() => setLoaded(true));
|
||||||
}, [key]);
|
}, [key]);
|
||||||
|
|
||||||
const update = (next: T) => {
|
const update = useCallback(
|
||||||
setValue(next);
|
(next: T) => {
|
||||||
AsyncStorage.setItem(key, JSON.stringify(next)).catch(() => {});
|
setValue(next);
|
||||||
};
|
AsyncStorage.setItem(key, JSON.stringify(next)).catch(() => {});
|
||||||
|
},
|
||||||
|
[key]
|
||||||
|
);
|
||||||
|
|
||||||
return [value, update, loaded];
|
return [value, update, loaded];
|
||||||
}
|
}
|
||||||
@@ -119,22 +122,37 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const theme = colors;
|
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 (
|
return (
|
||||||
<SettingsContext.Provider
|
<SettingsContext.Provider value={value}>
|
||||||
value={{
|
|
||||||
notifications,
|
|
||||||
setNotifications,
|
|
||||||
defaultCategoryId,
|
|
||||||
setDefaultCategoryId,
|
|
||||||
sortBy,
|
|
||||||
setSortBy,
|
|
||||||
reminderPreference,
|
|
||||||
setReminderPreference,
|
|
||||||
apiUrl,
|
|
||||||
setApiUrl,
|
|
||||||
theme,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</SettingsContext.Provider>
|
</SettingsContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -86,8 +86,6 @@ export function toRemindersString(reminders: Reminder[]): string {
|
|||||||
return reminders.filter((r) => r !== 'none').join(',');
|
return reminders.filter((r) => r !== 'none').join(',');
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TaskStatus = 'pending' | 'completed' | 'overdue' | 'due_today';
|
|
||||||
|
|
||||||
export interface CategoryData {
|
export interface CategoryData {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -120,6 +118,7 @@ export interface TaskData {
|
|||||||
export interface SubtaskData {
|
export interface SubtaskData {
|
||||||
id: string;
|
id: string;
|
||||||
taskId: string;
|
taskId: string;
|
||||||
|
parentSubtaskId: string | null;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
priority: Priority;
|
priority: Priority;
|
||||||
@@ -135,33 +134,14 @@ export interface SubtaskData {
|
|||||||
reminder: Reminder;
|
reminder: Reminder;
|
||||||
assigneeId: string | null;
|
assigneeId: string | null;
|
||||||
order: number;
|
order: number;
|
||||||
}
|
subtasks: SubtaskData[];
|
||||||
|
|
||||||
export interface TaskWithCategory extends TaskData {
|
|
||||||
category: CategoryData;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateTaskInput {
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
categoryId: string;
|
|
||||||
priority: Priority;
|
|
||||||
dueDate: Date | null;
|
|
||||||
dueTime: string;
|
|
||||||
endTime: string;
|
|
||||||
allDay: boolean;
|
|
||||||
repeat: Repeat;
|
|
||||||
repeatInterval: number;
|
|
||||||
repeatDays: string;
|
|
||||||
reminder: Reminder;
|
|
||||||
reminders: string;
|
|
||||||
assigneeId: string | null;
|
|
||||||
subtasks: { title: string }[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubtaskFormValue {
|
export interface SubtaskFormValue {
|
||||||
title: string;
|
title: string;
|
||||||
_key?: string;
|
_key?: string;
|
||||||
|
parentSubtaskId?: string | null;
|
||||||
|
subtasks?: SubtaskFormValue[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TaskFormData {
|
export interface TaskFormData {
|
||||||
@@ -180,4 +160,21 @@ export interface TaskFormData {
|
|||||||
reminders: string;
|
reminders: string;
|
||||||
assigneeId: string | null;
|
assigneeId: string | null;
|
||||||
subtasks?: SubtaskFormValue[];
|
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 { database, collections } from '@/database';
|
||||||
import { Q } from '@nozbe/watermelondb';
|
import { Q } from '@nozbe/watermelondb';
|
||||||
import { Priority, Repeat } from '@/types';
|
import { Priority, Repeat, Reminder } from '@/types';
|
||||||
import { scheduleTaskReminder, cancelTaskReminder } from '@/services/notifications';
|
import { scheduleTaskReminder, cancelTaskReminder } from '@/services/notifications';
|
||||||
|
|
||||||
function addDays(date: Date, days: number): Date {
|
function addDays(date: Date, days: number): Date {
|
||||||
@@ -155,6 +155,7 @@ export async function toggleTaskComplete(taskId: string): Promise<string | null>
|
|||||||
const completing = !task.completed;
|
const completing = !task.completed;
|
||||||
await task.update((t) => {
|
await task.update((t) => {
|
||||||
t.completed = completing;
|
t.completed = completing;
|
||||||
|
t.completedAt = completing ? Date.now() : null;
|
||||||
t.updatedAt = new Date();
|
t.updatedAt = new Date();
|
||||||
});
|
});
|
||||||
if (completing && task.repeat !== 'none' && task.dueDate) {
|
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> {
|
export async function toggleSubtaskComplete(subtaskId: string): Promise<void> {
|
||||||
const subtask = await collections.subtasks.find(subtaskId);
|
|
||||||
const completing = !subtask.completed;
|
|
||||||
await database.write(async () => {
|
await database.write(async () => {
|
||||||
|
const subtask = await collections.subtasks.find(subtaskId);
|
||||||
await subtask.update((s) => {
|
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();
|
s.updatedAt = new Date();
|
||||||
});
|
});
|
||||||
const task = await collections.tasks.find(subtask.taskId);
|
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> {
|
export async function setTaskCompleted(taskId: string, completed: boolean): Promise<void> {
|
||||||
let nextOccurrence: any = null;
|
let nextOccurrence: any = null;
|
||||||
await database.write(async () => {
|
await database.write(async () => {
|
||||||
const task = await collections.tasks.find(taskId);
|
const task = await collections.tasks.find(taskId);
|
||||||
await task.update((t) => {
|
await task.update((t) => {
|
||||||
t.completed = completed;
|
t.completed = completed;
|
||||||
|
t.completedAt = completed ? Date.now() : null;
|
||||||
t.updatedAt = new Date();
|
t.updatedAt = new Date();
|
||||||
});
|
});
|
||||||
if (completed && task.repeat !== 'none' && task.dueDate) {
|
if (completed && task.repeat !== 'none' && task.dueDate) {
|
||||||
@@ -410,3 +598,68 @@ export async function duplicateTask(taskId: string): Promise<void> {
|
|||||||
});
|
});
|
||||||
await scheduleTaskReminder(clone);
|
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
|
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