Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b6e87c979 | ||
|
|
ed31f24b23 |
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.expo
|
||||||
|
.git
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
android
|
||||||
|
ios
|
||||||
|
coverage
|
||||||
|
*.local
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN npx expo export -p web
|
||||||
|
|
||||||
|
FROM nginx:alpine AS runner
|
||||||
|
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 8081
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@@ -1,224 +1,448 @@
|
|||||||
import React, { useMemo, useRef, useState, useCallback } from 'react';
|
import React, { useMemo, useRef, useState, useCallback, useEffect } from 'react';
|
||||||
import { View, Text, StyleSheet, SafeAreaView, ScrollView, TouchableOpacity, FlatList } from 'react-native';
|
import { View, Text, StyleSheet, SafeAreaView, ScrollView, TouchableOpacity, Animated, Dimensions } from 'react-native';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
|
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
||||||
import { Header } from '@/components/Header';
|
import { Header } from '@/components/Header';
|
||||||
import { useTasksByDate } from '@/hooks/useTasks';
|
import { useTasksByDate, useTasksInMonth } from '@/hooks/useTasks';
|
||||||
import { useTaskModals } from '@/hooks/useTaskModals';
|
import { useTaskModals } from '@/hooks/useTaskModals';
|
||||||
import { toggleTaskComplete } from '@/utils/taskActions';
|
|
||||||
import { useSettings } from '@/theme';
|
import { useSettings } from '@/theme';
|
||||||
import { TaskItem } from '@/components/TaskItem';
|
import { useCategories, useDatabase } from '@/hooks/useDatabase';
|
||||||
|
import { toggleTaskComplete } from '@/utils/taskActions';
|
||||||
import { QuickAddBar } from '@/components/QuickAddBar';
|
import { QuickAddBar } from '@/components/QuickAddBar';
|
||||||
import { TaskData } from '@/types';
|
import { OptionPickerModal } from '@/components/OptionPickerModal';
|
||||||
import { format, startOfMonth, endOfMonth, eachDayOfInterval, isSameDay, isSameMonth, addMonths, isToday, startOfDay } from 'date-fns';
|
import { SubtaskData } from '@/types';
|
||||||
import Svg, { Path } from 'react-native-svg';
|
import Task from '@/models/Task';
|
||||||
|
import { format, addMonths, addDays, startOfMonth, isSameDay, isSameMonth, isToday } from 'date-fns';
|
||||||
|
import { Q } from '@nozbe/watermelondb';
|
||||||
|
import Svg, { Path, Circle } from 'react-native-svg';
|
||||||
import type { ThemeColors } from '@/theme';
|
import type { ThemeColors } from '@/theme';
|
||||||
|
|
||||||
const DAY_WIDTH = 44;
|
const WIDTH = Dimensions.get('window').width;
|
||||||
const DAY_GAP = 6;
|
const ORANGE = '#FF7043';
|
||||||
|
const GERMAN_WEEKDAYS = ['mo', 'di', 'mi', 'do', 'fr', 'sa', 'so'];
|
||||||
|
const MONTH_NAMES = [
|
||||||
|
'January', 'February', 'March', 'April', 'May', 'June',
|
||||||
|
'July', 'August', 'September', 'October', 'November', 'December',
|
||||||
|
];
|
||||||
|
|
||||||
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 { collections } = useDatabase();
|
||||||
|
const categories = useCategories();
|
||||||
|
const { modals, openTaskEdit } = 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 [monthPickerVisible, setMonthPickerVisible] = useState(false);
|
||||||
|
const [yearPickerVisible, setYearPickerVisible] = useState(false);
|
||||||
|
const [subtasksMap, setSubtasksMap] = useState<Record<string, SubtaskData[]>>({});
|
||||||
|
|
||||||
const days = useMemo(
|
const visibleMonthRef = useRef(visibleMonth);
|
||||||
() => eachDayOfInterval({ start: startOfMonth(visibleMonth), end: endOfMonth(visibleMonth) }),
|
visibleMonthRef.current = visibleMonth;
|
||||||
[visibleMonth]
|
const selectedDateRef = useRef(selectedDate);
|
||||||
|
selectedDateRef.current = selectedDate;
|
||||||
|
|
||||||
|
const translateX = useRef(new Animated.Value(0)).current;
|
||||||
|
const animatingRef = useRef(false);
|
||||||
|
const gridWidthRef = useRef<number>(WIDTH);
|
||||||
|
|
||||||
|
const { tasks: selectedDayTasks } = useTasksByDate(selectedDate);
|
||||||
|
const monthTasks = useTasksInMonth(visibleMonth);
|
||||||
|
|
||||||
|
const weeks = useMemo(() => {
|
||||||
|
const first = startOfMonth(visibleMonth);
|
||||||
|
const offset = (first.getDay() + 6) % 7; // week starts Monday
|
||||||
|
const gridStart = addDays(first, -offset);
|
||||||
|
const cells = Array.from({ length: 42 }, (_, i) => addDays(gridStart, i));
|
||||||
|
const rows: Date[][] = [];
|
||||||
|
for (let i = 0; i < 42; i += 7) rows.push(cells.slice(i, i + 7));
|
||||||
|
return rows;
|
||||||
|
}, [visibleMonth]);
|
||||||
|
|
||||||
|
const loadSubtasks = useCallback(
|
||||||
|
async (taskId: string): Promise<[string, SubtaskData[]]> => {
|
||||||
|
const subs = await collections.subtasks
|
||||||
|
.query(Q.where('task_id', taskId), Q.where('parent_subtask_id', null), Q.sortBy('order', 'asc'))
|
||||||
|
.fetch();
|
||||||
|
const mapped: SubtaskData[] = subs.map((s: any) => ({
|
||||||
|
id: s.id,
|
||||||
|
taskId: s.taskId,
|
||||||
|
parentSubtaskId: s.parentSubtaskId || null,
|
||||||
|
title: s.title,
|
||||||
|
description: s.description || '',
|
||||||
|
priority: (s.priority || 'none') as SubtaskData['priority'],
|
||||||
|
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: [],
|
||||||
|
}));
|
||||||
|
return [taskId, mapped];
|
||||||
|
},
|
||||||
|
[collections.subtasks]
|
||||||
);
|
);
|
||||||
|
|
||||||
const { tasks, loading } = useTasksByDate(selectedDate);
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
setSubtasksMap({});
|
||||||
|
Promise.all(selectedDayTasks.map((t) => loadSubtasks(t.id))).then((entries) => {
|
||||||
|
if (!active) return;
|
||||||
|
setSubtasksMap(Object.fromEntries(entries));
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [selectedDayTasks, loadSubtasks]);
|
||||||
|
|
||||||
const handleDayPress = useCallback((day: Date) => {
|
const handleDayPress = useCallback((day: Date) => {
|
||||||
setSelectedDate(day);
|
setSelectedDate(day);
|
||||||
if (!isSameMonth(day, visibleMonth)) {
|
if (!isSameMonth(day, visibleMonthRef.current)) {
|
||||||
setVisibleMonth(day);
|
setVisibleMonth(day);
|
||||||
}
|
}
|
||||||
}, [visibleMonth]);
|
}, []);
|
||||||
|
|
||||||
const handlePrevMonth = useCallback(() => {
|
const transitionTo = useCallback(
|
||||||
const prev = addMonths(visibleMonth, -1);
|
(dir: 1 | -1) => {
|
||||||
setVisibleMonth(prev);
|
if (animatingRef.current) return;
|
||||||
if (!isSameMonth(selectedDate, prev)) {
|
animatingRef.current = true;
|
||||||
setSelectedDate(startOfMonth(prev));
|
const w = gridWidthRef.current || WIDTH;
|
||||||
}
|
const target = dir === 1 ? -w : w;
|
||||||
}, [visibleMonth, selectedDate]);
|
Animated.timing(translateX, { toValue: target, duration: 220, useNativeDriver: false }).start(() => {
|
||||||
|
const next = addMonths(visibleMonthRef.current, dir);
|
||||||
const handleNextMonth = useCallback(() => {
|
if (!isSameMonth(selectedDateRef.current, next)) {
|
||||||
const next = addMonths(visibleMonth, 1);
|
|
||||||
setVisibleMonth(next);
|
|
||||||
if (!isSameMonth(selectedDate, next)) {
|
|
||||||
setSelectedDate(startOfMonth(next));
|
setSelectedDate(startOfMonth(next));
|
||||||
}
|
}
|
||||||
}, [visibleMonth, selectedDate]);
|
setVisibleMonth(next);
|
||||||
|
translateX.setValue(-target);
|
||||||
|
Animated.timing(translateX, { toValue: 0, duration: 180, useNativeDriver: false }).start(() => {
|
||||||
|
animatingRef.current = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[translateX]
|
||||||
|
);
|
||||||
|
|
||||||
|
const pan = useMemo(
|
||||||
|
() =>
|
||||||
|
Gesture.Pan()
|
||||||
|
.activeOffsetX([-16, 16])
|
||||||
|
.minDistance(6)
|
||||||
|
.runOnJS(true)
|
||||||
|
.onUpdate((e) => {
|
||||||
|
if (!animatingRef.current) translateX.setValue(e.translationX);
|
||||||
|
})
|
||||||
|
.onEnd((e) => {
|
||||||
|
if (animatingRef.current) return;
|
||||||
|
const w = gridWidthRef.current || WIDTH;
|
||||||
|
const dx = e.translationX;
|
||||||
|
if (dx <= -w / 4) {
|
||||||
|
translateX.stopAnimation();
|
||||||
|
transitionTo(1);
|
||||||
|
} else if (dx >= w / 4) {
|
||||||
|
translateX.stopAnimation();
|
||||||
|
transitionTo(-1);
|
||||||
|
} else {
|
||||||
|
Animated.spring(translateX, { toValue: 0, useNativeDriver: false }).start();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
[translateX, transitionTo]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSelectMonth = useCallback((value: string | string[]) => {
|
||||||
|
const monthIndex = parseInt(Array.isArray(value) ? value[0] : value, 10);
|
||||||
|
setVisibleMonth(new Date(visibleMonthRef.current.getFullYear(), monthIndex, 1));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSelectYear = useCallback((value: string | string[]) => {
|
||||||
|
const year = parseInt(Array.isArray(value) ? value[0] : value, 10);
|
||||||
|
setVisibleMonth(new Date(year, visibleMonthRef.current.getMonth(), 1));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const monthOptions = MONTH_NAMES.map((label, i) => ({ value: String(i), label }));
|
||||||
|
const yearOptions = useMemo(() => {
|
||||||
|
const options: { value: string; label: string }[] = [];
|
||||||
|
for (let y = currentYear - 20; y <= currentYear + 10; y++) options.push({ value: String(y), label: String(y) });
|
||||||
|
return options;
|
||||||
|
}, [currentYear]);
|
||||||
|
|
||||||
const handleToggleComplete = useCallback(async (taskId: string) => {
|
const handleToggleComplete = useCallback(async (taskId: string) => {
|
||||||
await toggleTaskComplete(taskId);
|
await toggleTaskComplete(taskId);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const scrollToDay = useCallback((day: Date) => {
|
|
||||||
const index = days.findIndex((d) => isSameDay(d, day));
|
|
||||||
if (index >= 0) {
|
|
||||||
stripRef.current?.scrollTo({ x: Math.max(0, index * (DAY_WIDTH + DAY_GAP) - 24), animated: true });
|
|
||||||
}
|
|
||||||
}, [days]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const target = isSameMonth(selectedDate, visibleMonth) ? selectedDate : startOfDay(new Date());
|
|
||||||
scrollToDay(target);
|
|
||||||
}, [visibleMonth, scrollToDay, selectedDate]);
|
|
||||||
|
|
||||||
const renderTask = useCallback(
|
|
||||||
({ item }: { item: TaskData }) => (
|
|
||||||
<TaskItem
|
|
||||||
task={item}
|
|
||||||
onToggle={() => handleToggleComplete(item.id)}
|
|
||||||
onDelete={() => openTaskDelete(item)}
|
|
||||||
onPress={() => router.push({ pathname: '/task-detail', params: { id: item.id } })}
|
|
||||||
onMenuOpen={() => openTaskMenu(item)}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
[handleToggleComplete, openTaskDelete, openTaskMenu, router]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
|
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
|
||||||
|
<GestureDetector gesture={pan}>
|
||||||
|
<View style={styles.flex}>
|
||||||
<Header title="Calendar" showLogo={false} />
|
<Header title="Calendar" showLogo={false} />
|
||||||
|
|
||||||
<ScrollView
|
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.scrollContent}>
|
||||||
ref={stripRef}
|
<Animated.View
|
||||||
horizontal
|
onLayout={(e) => {
|
||||||
showsHorizontalScrollIndicator={false}
|
gridWidthRef.current = e.nativeEvent.layout.width;
|
||||||
contentContainerStyle={styles.dateStrip}
|
}}
|
||||||
|
style={[styles.calendarArea, { transform: [{ translateX }] }]}
|
||||||
>
|
>
|
||||||
{days.map((day) => (
|
|
||||||
<DayButton
|
|
||||||
key={day.toISOString()}
|
|
||||||
day={day}
|
|
||||||
selected={isSameDay(day, selectedDate)}
|
|
||||||
current={isToday(day) && !isSameDay(day, selectedDate)}
|
|
||||||
onPress={handleDayPress}
|
|
||||||
theme={theme}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ScrollView>
|
|
||||||
|
|
||||||
<View style={styles.monthRow}>
|
<View style={styles.monthRow}>
|
||||||
<TouchableOpacity onPress={handlePrevMonth} style={[styles.monthNav, { borderColor: theme.borderStrong, backgroundColor: theme.card }]} activeOpacity={0.7}>
|
<TouchableOpacity
|
||||||
|
onPress={() => transitionTo(-1)}
|
||||||
|
style={[styles.monthNav, { borderColor: theme.borderStrong, backgroundColor: theme.card }]}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||||
<Path d="M15 18l-6-6 6-6" stroke={theme.textFaint} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
<Path d="M15 18l-6-6 6-6" stroke={theme.textFaint} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||||
</Svg>
|
</Svg>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<Text style={[styles.monthLabel, { color: theme.text }]}>{format(visibleMonth, 'MMM yyyy').toUpperCase()}</Text>
|
|
||||||
<TouchableOpacity onPress={handleNextMonth} style={[styles.monthNav, { borderColor: theme.borderStrong, backgroundColor: theme.card }]} activeOpacity={0.7}>
|
<View style={styles.monthSelectorGroup}>
|
||||||
|
<TouchableOpacity onPress={() => setMonthPickerVisible(true)} activeOpacity={0.7} style={styles.monthButton}>
|
||||||
|
<Text style={[styles.monthLabel, { color: theme.text }]}>{format(visibleMonth, 'MMMM')}</Text>
|
||||||
|
<Svg width={14} height={14} 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>
|
||||||
|
<TouchableOpacity onPress={() => setYearPickerVisible(true)} activeOpacity={0.7} style={styles.yearButton}>
|
||||||
|
<Text style={[styles.yearLabel, { color: theme.textMuted }]}>{format(visibleMonth, 'yyyy')}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => transitionTo(1)}
|
||||||
|
style={[styles.monthNav, { borderColor: theme.borderStrong, backgroundColor: theme.card }]}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||||
<Path d="M9 6l6 6-6 6" stroke={theme.textFaint} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
<Path d="M9 6l6 6-6 6" stroke={theme.textFaint} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||||
</Svg>
|
</Svg>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<FlatList
|
<View style={styles.weekdayRow}>
|
||||||
data={tasks}
|
{GERMAN_WEEKDAYS.map((day, i) => (
|
||||||
keyExtractor={(item) => item.id}
|
<Text key={day + i} style={[styles.weekday, { color: theme.textMuted }]}>
|
||||||
renderItem={renderTask}
|
{day}
|
||||||
ItemSeparatorComponent={MemoSeparator}
|
</Text>
|
||||||
ListEmptyComponent={
|
))}
|
||||||
loading ? (
|
</View>
|
||||||
|
|
||||||
|
{weeks.map((week, wi) => (
|
||||||
|
<View key={wi} style={styles.weekRow}>
|
||||||
|
{week.map((day) => (
|
||||||
|
<DayCell
|
||||||
|
key={day.toISOString()}
|
||||||
|
day={day}
|
||||||
|
label={cellTitle(byDayOf(monthTasks.byDay, day))}
|
||||||
|
dotColor={cellColor(byDayOf(monthTasks.byDay, day), categories)}
|
||||||
|
selected={isSameDay(day, selectedDate)}
|
||||||
|
today={isToday(day)}
|
||||||
|
inMonth={isSameMonth(day, visibleMonth)}
|
||||||
|
theme={theme}
|
||||||
|
onPress={handleDayPress}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
<View style={styles.panelHeader}>
|
||||||
|
<Text style={[styles.panelDate, { color: theme.text }]}>{format(selectedDate, 'EEEE, MMMM d')}</Text>
|
||||||
|
{selectedDayTasks.length > 0 && (
|
||||||
|
<Text style={[styles.panelCount, { color: theme.textFaint }]}>
|
||||||
|
{selectedDayTasks.length} event{selectedDayTasks.length === 1 ? '' : 's'}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{selectedDayTasks.length === 0 ? (
|
||||||
<View style={styles.emptyState}>
|
<View style={styles.emptyState}>
|
||||||
<Text style={[styles.emptyText, { color: theme.textFaint }]}>Loading...</Text>
|
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>No events on this day</Text>
|
||||||
|
<Text style={[styles.emptySubtext, { color: theme.textMuted }]}>Tap a date or use the bar below</Text>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.emptyState}>
|
selectedDayTasks.map((task) => (
|
||||||
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>No tasks scheduled.</Text>
|
<View key={task.id} style={[styles.eventCard, { backgroundColor: theme.card, borderColor: theme.border }]}>
|
||||||
<Text style={[styles.emptySubtext, { color: theme.textMuted }]}>Use the bar below to add one</Text>
|
<View style={styles.eventRow}>
|
||||||
</View>
|
<TouchableOpacity
|
||||||
)
|
style={styles.checkCircle}
|
||||||
}
|
onPress={() => handleToggleComplete(task.id)}
|
||||||
contentContainerStyle={styles.listContent}
|
activeOpacity={0.7}
|
||||||
/>
|
>
|
||||||
|
<Svg width={22} height={22} viewBox="0 0 24 24">
|
||||||
|
{task.completed ? (
|
||||||
|
<>
|
||||||
|
<Circle cx={12} cy={12} r={10} fill={theme.accent} />
|
||||||
|
<Path d="M7 12l3 3 7-7" stroke="#FFFFFF" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Circle cx={12} cy={12} r={10} stroke={theme.textMuted} strokeWidth={2} fill="none" />
|
||||||
|
)}
|
||||||
|
</Svg>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
<QuickAddBar
|
<TouchableOpacity
|
||||||
dueDate={selectedDate.getTime()}
|
style={styles.eventTitleTouch}
|
||||||
placeholder={`Add task for ${format(selectedDate, 'MMM d')}`}
|
onPress={() => router.push({ pathname: '/task-detail', params: { id: task.id } })}
|
||||||
/>
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[styles.eventTitle, { color: theme.text }, task.completed && styles.eventCompleted]}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{task.title}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity style={styles.menuButton} onPress={() => openTaskEdit(task.id)} activeOpacity={0.7} accessibilityRole="button" accessibilityLabel={`Edit ${task.title}`}>
|
||||||
|
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||||
|
<Circle cx={12} cy={5} r={1.5} fill={theme.textMuted} />
|
||||||
|
<Circle cx={12} cy={12} r={1.5} fill={theme.textMuted} />
|
||||||
|
<Circle cx={12} cy={19} r={1.5} fill={theme.textMuted} />
|
||||||
|
</Svg>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{(subtasksMap[task.id] ?? []).length > 0 && (
|
||||||
|
<View style={styles.bullets}>
|
||||||
|
{(subtasksMap[task.id] ?? []).map((sub) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={sub.id}
|
||||||
|
style={styles.bulletRow}
|
||||||
|
onPress={() => router.push({ pathname: '/subtask-detail', params: { id: sub.id } })}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Svg width={5} height={5} viewBox="0 0 6 6" style={styles.bulletDot as any}>
|
||||||
|
<Circle cx={3} cy={3} r={3} fill={theme.textMuted} />
|
||||||
|
</Svg>
|
||||||
|
<Text
|
||||||
|
style={[styles.bulletText, { color: theme.textFaint }, sub.completed && styles.bulletCompleted]}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{sub.title}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<QuickAddBar dueDate={selectedDate.getTime()} placeholder={`Add event for ${format(selectedDate, 'MMM d')}`} />
|
||||||
|
|
||||||
{modals(() => {})}
|
{modals(() => {})}
|
||||||
|
|
||||||
|
<OptionPickerModal
|
||||||
|
visible={monthPickerVisible}
|
||||||
|
title="Select Month"
|
||||||
|
options={monthOptions}
|
||||||
|
selectedValue={String(visibleMonth.getMonth())}
|
||||||
|
onSelect={handleSelectMonth}
|
||||||
|
onClose={() => setMonthPickerVisible(false)}
|
||||||
|
/>
|
||||||
|
<OptionPickerModal
|
||||||
|
visible={yearPickerVisible}
|
||||||
|
title="Select Year"
|
||||||
|
options={yearOptions}
|
||||||
|
selectedValue={String(visibleMonth.getFullYear())}
|
||||||
|
onSelect={handleSelectYear}
|
||||||
|
onClose={() => setYearPickerVisible(false)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</GestureDetector>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DayButtonProps {
|
function byDayOf(byDay: Record<number, Task[]>, day: Date): Task[] {
|
||||||
day: Date;
|
return byDay[day.getDate()] ?? [];
|
||||||
selected: boolean;
|
|
||||||
current: boolean;
|
|
||||||
onPress: (day: Date) => void;
|
|
||||||
theme: ThemeColors;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const DayButton = React.memo(function DayButton({ day, selected, current, onPress, theme }: DayButtonProps) {
|
function cellTitle(tasks: Task[]): string | null {
|
||||||
|
if (tasks.length === 0) return null;
|
||||||
|
if (tasks.length === 1) return tasks[0].title;
|
||||||
|
return `${tasks[0].title} +${tasks.length - 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cellColor(tasks: Task[], categories: { id: string; color: string }[]): string {
|
||||||
|
if (tasks.length === 0) return '#8E8E8E';
|
||||||
|
const cat = categories.find((c) => c.id === tasks[0].categoryId);
|
||||||
|
return cat?.color ?? '#8E8E8E';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DayCellProps {
|
||||||
|
day: Date;
|
||||||
|
label: string | null;
|
||||||
|
dotColor: string;
|
||||||
|
selected: boolean;
|
||||||
|
today: boolean;
|
||||||
|
inMonth: boolean;
|
||||||
|
theme: ThemeColors;
|
||||||
|
onPress: (day: Date) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DayCell = React.memo(function DayCell({ day, label, dotColor, selected, today, inMonth, theme, onPress }: DayCellProps) {
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[
|
style={[styles.dayCell, selected && { backgroundColor: ORANGE }]}
|
||||||
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)}
|
onPress={() => onPress(day)}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<Text style={[styles.dayWeekday, { color: theme.textMuted }, selected && styles.dayTextSelected]}>
|
<Text
|
||||||
{format(day, 'EEE').charAt(0)}
|
style={[
|
||||||
</Text>
|
styles.dayNumber,
|
||||||
<Text style={[styles.dayNumber, { color: theme.text }, selected && styles.dayTextSelected]}>
|
{ color: theme.text },
|
||||||
|
!inMonth && { color: theme.borderStrong },
|
||||||
|
today && !selected && { color: ORANGE },
|
||||||
|
selected && styles.dayNumberSelected,
|
||||||
|
]}
|
||||||
|
>
|
||||||
{format(day, 'd')}
|
{format(day, 'd')}
|
||||||
</Text>
|
</Text>
|
||||||
|
{label ? (
|
||||||
|
<View style={[styles.chip, { backgroundColor: selected ? 'rgba(0,0,0,0.22)' : dotColor }]}>
|
||||||
|
<Text style={styles.chipText} numberOfLines={1}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : !inMonth ? (
|
||||||
|
<View style={[styles.chipPlaceholder, { backgroundColor: theme.borderStrong }]} />
|
||||||
|
) : null}
|
||||||
</TouchableOpacity>
|
</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,
|
||||||
},
|
},
|
||||||
dateStrip: {
|
flex: {
|
||||||
paddingHorizontal: 16,
|
flex: 1,
|
||||||
paddingTop: 12,
|
|
||||||
gap: DAY_GAP,
|
|
||||||
},
|
},
|
||||||
dayButton: {
|
scrollContent: {
|
||||||
width: DAY_WIDTH,
|
paddingBottom: 96,
|
||||||
height: 60,
|
flexGrow: 1,
|
||||||
borderRadius: 16,
|
|
||||||
borderWidth: 1,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
gap: 2,
|
|
||||||
},
|
},
|
||||||
dayTextSelected: {
|
calendarArea: {
|
||||||
color: '#FFFFFF',
|
paddingHorizontal: 12,
|
||||||
},
|
paddingTop: 6,
|
||||||
dayWeekday: {
|
paddingBottom: 4,
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: '600',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
},
|
|
||||||
dayNumber: {
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
},
|
||||||
monthRow: {
|
monthRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'space-between',
|
||||||
gap: 24,
|
paddingHorizontal: 4,
|
||||||
paddingVertical: 12,
|
paddingBottom: 14,
|
||||||
},
|
},
|
||||||
monthNav: {
|
monthNav: {
|
||||||
width: 36,
|
width: 36,
|
||||||
@@ -228,34 +452,158 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
monthLabel: {
|
monthSelectorGroup: {
|
||||||
fontSize: 15,
|
alignItems: 'center',
|
||||||
fontWeight: '700',
|
|
||||||
letterSpacing: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
textAlign: 'center',
|
|
||||||
},
|
},
|
||||||
listContent: {
|
monthButton: {
|
||||||
paddingHorizontal: 16,
|
flexDirection: 'row',
|
||||||
paddingTop: 4,
|
|
||||||
paddingBottom: 100,
|
|
||||||
flexGrow: 1,
|
|
||||||
},
|
|
||||||
separator: {
|
|
||||||
height: 8,
|
|
||||||
},
|
|
||||||
emptyState: {
|
|
||||||
flex: 1,
|
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
paddingVertical: 64,
|
gap: 6,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
},
|
||||||
|
monthLabel: {
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
yearButton: {
|
||||||
|
marginTop: -2,
|
||||||
|
},
|
||||||
|
yearLabel: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
weekdayRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
marginBottom: 4,
|
||||||
|
paddingHorizontal: 2,
|
||||||
|
},
|
||||||
|
weekday: {
|
||||||
|
flex: 1,
|
||||||
|
textAlign: 'center',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: '600',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
},
|
||||||
|
weekRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 6,
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
dayCell: {
|
||||||
|
flex: 1,
|
||||||
|
height: 56,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'transparent',
|
||||||
|
paddingVertical: 4,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
dayNumber: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
dayNumberSelected: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
},
|
||||||
|
chip: {
|
||||||
|
marginTop: 3,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 5,
|
||||||
|
maxWidth: '92%',
|
||||||
|
},
|
||||||
|
chipText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 8,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
chipPlaceholder: {
|
||||||
|
marginTop: 3,
|
||||||
|
width: 6,
|
||||||
|
height: 2,
|
||||||
|
borderRadius: 1,
|
||||||
|
opacity: 0.4,
|
||||||
|
},
|
||||||
|
panelHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: 18,
|
||||||
|
paddingTop: 15,
|
||||||
|
paddingBottom: 4,
|
||||||
|
},
|
||||||
|
panelDate: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
panelCount: {
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
emptyState: {
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 36,
|
||||||
},
|
},
|
||||||
emptyText: {
|
emptyText: {
|
||||||
fontSize: 16,
|
fontSize: 15,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
marginBottom: 4,
|
marginBottom: 4,
|
||||||
},
|
},
|
||||||
emptySubtext: {
|
emptySubtext: {
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
},
|
},
|
||||||
|
eventCard: {
|
||||||
|
marginHorizontal: 16,
|
||||||
|
marginTop: 6,
|
||||||
|
borderRadius: 16,
|
||||||
|
borderWidth: 1,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 12,
|
||||||
|
},
|
||||||
|
eventRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
checkCircle: {
|
||||||
|
width: 24,
|
||||||
|
marginRight: 10,
|
||||||
|
},
|
||||||
|
eventTitleTouch: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
eventTitle: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
eventCompleted: {
|
||||||
|
textDecorationLine: 'line-through',
|
||||||
|
color: '#9E9E9E',
|
||||||
|
},
|
||||||
|
menuButton: {
|
||||||
|
padding: 4,
|
||||||
|
marginLeft: 6,
|
||||||
|
},
|
||||||
|
bullets: {
|
||||||
|
marginTop: 8,
|
||||||
|
paddingTop: 8,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: 'rgba(255,255,255,0.06)',
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
bulletRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
bulletDot: {
|
||||||
|
marginLeft: 8,
|
||||||
|
},
|
||||||
|
bulletText: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
bulletCompleted: {
|
||||||
|
textDecorationLine: 'line-through',
|
||||||
|
color: '#6E6E6E',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
@@ -22,7 +22,7 @@ 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={false} />
|
||||||
<View style={styles.categoryFilterWrapper}>
|
<View style={styles.categoryFilterWrapper}>
|
||||||
<CategoryFilter selected={selectedCategory} onSelect={setSelectedCategory} />
|
<CategoryFilter selected={selectedCategory} onSelect={setSelectedCategory} />
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { View, Text, StyleSheet, SafeAreaView, ScrollView, Switch, TouchableOpacity, Alert, Linking } from 'react-native';
|
import { View, Text, StyleSheet, SafeAreaView, ScrollView, Switch, TouchableOpacity, Alert, Linking, Modal, Pressable } 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';
|
||||||
@@ -9,23 +9,26 @@ import { FriendsModal } from '@/components/FriendsModal';
|
|||||||
import { LegalModal } from '@/components/LegalModal';
|
import { LegalModal } from '@/components/LegalModal';
|
||||||
import { ServerUrlModal } from '@/components/ServerUrlModal';
|
import { ServerUrlModal } from '@/components/ServerUrlModal';
|
||||||
import SyncStatus from '@/components/SyncStatus';
|
import SyncStatus from '@/components/SyncStatus';
|
||||||
import { useSettings, SORT_OPTIONS, REMINDER_OPTIONS } from '@/theme';
|
import { useSettings, SORT_OPTIONS, REMINDER_OPTIONS, ACCENT_PRESETS, DEFAULT_ACCENT } 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 { 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';
|
||||||
|
import ColorWheel from '@/components/ColorWheel';
|
||||||
|
|
||||||
export default function SettingsScreen() {
|
export default function SettingsScreen() {
|
||||||
const { theme, notifications, setNotifications, defaultCategoryId, setDefaultCategoryId, sortBy, setSortBy, reminderPreference, setReminderPreference, apiUrl } = useSettings();
|
const { theme, notifications, setNotifications, defaultCategoryId, setDefaultCategoryId, sortBy, setSortBy, reminderPreference, setReminderPreference, apiUrl, accentColor, setAccentColor, todoAheadDays, setTodoAheadDays } = useSettings();
|
||||||
const categories = useCategories();
|
const categories = useCategories();
|
||||||
const [picker, setPicker] = useState<null | 'category' | 'sort' | 'reminder'>(null);
|
const [picker, setPicker] = useState<null | 'category' | 'sort' | 'reminder' | 'todoAhead'>(null);
|
||||||
const [editingCategory, setEditingCategory] = useState<Category | null | 'new'>(null);
|
const [editingCategory, setEditingCategory] = useState<Category | null | 'new'>(null);
|
||||||
const [syncVisible, setSyncVisible] = useState(false);
|
const [syncVisible, setSyncVisible] = useState(false);
|
||||||
const [friendsVisible, setFriendsVisible] = useState(false);
|
const [friendsVisible, setFriendsVisible] = useState(false);
|
||||||
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 [customAccentVisible, setCustomAccentVisible] = useState(false);
|
||||||
|
const [draftAccent, setDraftAccent] = useState(accentColor);
|
||||||
const [syncSubtitle, setSyncSubtitle] = useState('Checking...');
|
const [syncSubtitle, setSyncSubtitle] = useState('Checking...');
|
||||||
const [updateSubtitle, setUpdateSubtitle] = useState('Tap to check');
|
const [updateSubtitle, setUpdateSubtitle] = useState('Tap to check');
|
||||||
|
|
||||||
@@ -76,6 +79,11 @@ 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 openCustomAccent = () => {
|
||||||
|
setDraftAccent(accentColor);
|
||||||
|
setCustomAccentVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
const handleDefaultCategory = (value: string | string[]) => {
|
const handleDefaultCategory = (value: string | string[]) => {
|
||||||
setDefaultCategoryId(Array.isArray(value) ? value[0] : value);
|
setDefaultCategoryId(Array.isArray(value) ? value[0] : value);
|
||||||
};
|
};
|
||||||
@@ -84,6 +92,10 @@ export default function SettingsScreen() {
|
|||||||
setReminderPreference((Array.isArray(value) ? value[0] : value) as typeof reminderPreference);
|
setReminderPreference((Array.isArray(value) ? value[0] : value) as typeof reminderPreference);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleTodoAheadDays = (value: string | string[]) => {
|
||||||
|
setTodoAheadDays(parseInt(Array.isArray(value) ? value[0] : value, 10));
|
||||||
|
};
|
||||||
|
|
||||||
const editorVisible = editingCategory !== null;
|
const editorVisible = editingCategory !== null;
|
||||||
const editorCategory = editingCategory === 'new' ? null : editingCategory;
|
const editorCategory = editingCategory === 'new' ? null : editingCategory;
|
||||||
|
|
||||||
@@ -148,6 +160,41 @@ export default function SettingsScreen() {
|
|||||||
onPress={() => setPicker('sort')}
|
onPress={() => setPicker('sort')}
|
||||||
showChevron
|
showChevron
|
||||||
/>
|
/>
|
||||||
|
<ListItem
|
||||||
|
title="Show Calendar Tasks"
|
||||||
|
subtitle={todoAheadDays === 0 ? 'Only today' : `Up to ${todoAheadDays} days ahead`}
|
||||||
|
onPress={() => setPicker('todoAhead')}
|
||||||
|
showChevron
|
||||||
|
/>
|
||||||
|
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>Appearance</Text>
|
||||||
|
<Text style={[styles.sectionHint, { color: theme.textFaint }]}>Accent color</Text>
|
||||||
|
<View style={styles.accentPresets}>
|
||||||
|
{ACCENT_PRESETS.map((c) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={c}
|
||||||
|
style={[
|
||||||
|
styles.accentSwatch,
|
||||||
|
{ backgroundColor: c },
|
||||||
|
accentColor.toUpperCase() === c && styles.accentSwatchSelected,
|
||||||
|
]}
|
||||||
|
onPress={() => setAccentColor(c)}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
{accentColor.toUpperCase() === c && (
|
||||||
|
<Svg width={16} height={16} viewBox="0 0 24 24">
|
||||||
|
<Path d="M5 12l5 5 9-10" stroke={contrastOnSwatch(c)} strokeWidth={3} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||||
|
</Svg>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<ListItem
|
||||||
|
title="Custom Color"
|
||||||
|
subtitle={accentColor}
|
||||||
|
leftElement={<View style={[styles.categoryDot, { backgroundColor: accentColor }]} />}
|
||||||
|
onPress={openCustomAccent}
|
||||||
|
showChevron
|
||||||
|
/>
|
||||||
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>Data</Text>
|
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>Data</Text>
|
||||||
<SyncStatus />
|
<SyncStatus />
|
||||||
<ListItem
|
<ListItem
|
||||||
@@ -219,6 +266,15 @@ export default function SettingsScreen() {
|
|||||||
onClose={() => setPicker(null)}
|
onClose={() => setPicker(null)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<OptionPickerModal
|
||||||
|
visible={picker === 'todoAhead'}
|
||||||
|
title="Calendar Tasks in Todo"
|
||||||
|
options={AHEAD_OPTIONS}
|
||||||
|
selectedValue={String(todoAheadDays)}
|
||||||
|
onSelect={handleTodoAheadDays}
|
||||||
|
onClose={() => setPicker(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
<CategoryEditorModal
|
<CategoryEditorModal
|
||||||
visible={editorVisible}
|
visible={editorVisible}
|
||||||
category={editorCategory}
|
category={editorCategory}
|
||||||
@@ -232,6 +288,41 @@ export default function SettingsScreen() {
|
|||||||
|
|
||||||
<ServerUrlModal visible={serverUrlVisible} onClose={() => setServerUrlVisible(false)} />
|
<ServerUrlModal visible={serverUrlVisible} onClose={() => setServerUrlVisible(false)} />
|
||||||
|
|
||||||
|
<Modal visible={customAccentVisible} transparent animationType="fade" onRequestClose={() => setCustomAccentVisible(false)}>
|
||||||
|
<Pressable
|
||||||
|
style={styles.accentModalOverlay}
|
||||||
|
onPress={() => setCustomAccentVisible(false)}
|
||||||
|
>
|
||||||
|
<Pressable style={[styles.accentModalSheet, { backgroundColor: theme.sheetBg }]} onPress={() => {}}>
|
||||||
|
<View style={styles.accentModalHeader}>
|
||||||
|
<Text style={[styles.accentModalTitle, { color: theme.text }]}>Custom Accent Color</Text>
|
||||||
|
<TouchableOpacity onPress={() => setCustomAccentVisible(false)} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
|
||||||
|
<Svg width={18} height={18} viewBox="0 0 24 24">
|
||||||
|
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textMuted} strokeWidth={2} strokeLinecap="round" />
|
||||||
|
</Svg>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<ColorWheel color={draftAccent} onChange={setDraftAccent} />
|
||||||
|
<View style={styles.accentModalActions}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.accentModalButton, { borderColor: theme.borderStrong }]}
|
||||||
|
onPress={() => { setDraftAccent(DEFAULT_ACCENT); setAccentColor(DEFAULT_ACCENT); }}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text style={[styles.accentModalButtonText, { color: theme.textSecondary }]}>Reset</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.accentModalButton, styles.accentModalButtonPrimary, { backgroundColor: theme.accent }]}
|
||||||
|
onPress={() => { setAccentColor(draftAccent); setCustomAccentVisible(false); }}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<Text style={[styles.accentModalButtonText, { color: theme.accentText }]}>Apply</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<LegalModal
|
<LegalModal
|
||||||
visible={legalVisible !== null}
|
visible={legalVisible !== null}
|
||||||
type={legalVisible}
|
type={legalVisible}
|
||||||
@@ -241,6 +332,11 @@ export default function SettingsScreen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const AHEAD_OPTIONS: { value: string; label: string }[] = Array.from({ length: 29 }, (_, i) => ({
|
||||||
|
value: String(i),
|
||||||
|
label: i === 0 ? 'Only today' : `${i} day${i === 1 ? '' : 's'}`,
|
||||||
|
}));
|
||||||
|
|
||||||
function formatSyncTime(timestamp: number): string {
|
function formatSyncTime(timestamp: number): string {
|
||||||
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
||||||
if (seconds < 60) return 'just now';
|
if (seconds < 60) return 'just now';
|
||||||
@@ -249,6 +345,16 @@ function formatSyncTime(timestamp: number): string {
|
|||||||
return new Date(timestamp).toLocaleDateString();
|
return new Date(timestamp).toLocaleDateString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function contrastOnSwatch(hex: string): string {
|
||||||
|
const h = hex.replace(/^#/, '');
|
||||||
|
const r = parseInt(h.slice(0, 2), 16) / 255;
|
||||||
|
const g = parseInt(h.slice(2, 4), 16) / 255;
|
||||||
|
const b = parseInt(h.slice(4, 6), 16) / 255;
|
||||||
|
const linear = (v: number) => (v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4));
|
||||||
|
const luminance = 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b);
|
||||||
|
return luminance > 0.5 ? '#111111' : '#FFFFFF';
|
||||||
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -294,4 +400,73 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
|
sectionHint: {
|
||||||
|
fontSize: 12,
|
||||||
|
marginLeft: 4,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
accentPresets: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 10,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
accentSwatch: {
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 18,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
accentSwatchSelected: {
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: '#FFFFFF',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOpacity: 0.3,
|
||||||
|
shadowRadius: 3,
|
||||||
|
elevation: 3,
|
||||||
|
},
|
||||||
|
accentModalOverlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.6)',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 24,
|
||||||
|
},
|
||||||
|
accentModalSheet: {
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 400,
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
accentModalHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
accentModalTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
accentModalActions: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 12,
|
||||||
|
marginTop: 16,
|
||||||
|
},
|
||||||
|
accentModalButton: {
|
||||||
|
flex: 1,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
accentModalButtonPrimary: {
|
||||||
|
borderWidth: 0,
|
||||||
|
},
|
||||||
|
accentModalButtonText: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { AssigneeSelector } from '@/components/AssigneeSelector';
|
|||||||
import { useForm, FormProvider, Controller } from 'react-hook-form';
|
import { useForm, FormProvider, Controller } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { useDatabase, useCategories } from '@/hooks/useDatabase';
|
import { useDatabase } from '@/hooks/useDatabase';
|
||||||
import { database, collections } from '@/database';
|
import { database, collections } from '@/database';
|
||||||
import { TaskFormData } from '@/types';
|
import { TaskFormData } from '@/types';
|
||||||
import { useSettings } from '@/theme';
|
import { useSettings } from '@/theme';
|
||||||
@@ -25,7 +25,7 @@ import { useFriends } from '@/hooks/useFriends';
|
|||||||
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),
|
||||||
description: z.string().max(1000).optional(),
|
description: z.string().max(1000).optional(),
|
||||||
categoryId: z.string().min(1, 'Category is required'),
|
categoryId: z.string().optional(),
|
||||||
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']),
|
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']),
|
||||||
dueDate: z.date().nullable().optional(),
|
dueDate: z.date().nullable().optional(),
|
||||||
dueTime: z.string().optional(),
|
dueTime: z.string().optional(),
|
||||||
@@ -42,13 +42,12 @@ const taskSchema = z.object({
|
|||||||
|
|
||||||
export default function AddTaskScreen() {
|
export default function AddTaskScreen() {
|
||||||
const { isReady } = useDatabase();
|
const { isReady } = useDatabase();
|
||||||
const categories = useCategories();
|
|
||||||
const { defaultCategoryId } = useSettings();
|
const { defaultCategoryId } = useSettings();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { theme } = useSettings();
|
const { theme } = useSettings();
|
||||||
const { date: dateParam } = useLocalSearchParams<{ date?: string }>();
|
const { date: dateParam } = useLocalSearchParams<{ date?: string }>();
|
||||||
const { friends } = useFriends();
|
const { friends } = useFriends();
|
||||||
const initialCategory = defaultCategoryId || categories[0]?.id || '';
|
const initialCategory = defaultCategoryId || '';
|
||||||
const initialDate = useMemo(() => {
|
const initialDate = useMemo(() => {
|
||||||
if (!dateParam) return null;
|
if (!dateParam) return null;
|
||||||
const parsed = new Date(Array.isArray(dateParam) ? dateParam[0] : dateParam);
|
const parsed = new Date(Array.isArray(dateParam) ? dateParam[0] : dateParam);
|
||||||
@@ -108,6 +107,7 @@ export default function AddTaskScreen() {
|
|||||||
const seriesId = data.repeat !== 'none'
|
const seriesId = data.repeat !== 'none'
|
||||||
? `series_${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}`
|
? `series_${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}`
|
||||||
: '';
|
: '';
|
||||||
|
const resolvedCategoryId = data.categoryId || '';
|
||||||
|
|
||||||
let createdTask: any = null;
|
let createdTask: any = null;
|
||||||
|
|
||||||
@@ -115,7 +115,7 @@ export default function AddTaskScreen() {
|
|||||||
const task = await collections.tasks.create((t) => {
|
const task = await collections.tasks.create((t) => {
|
||||||
t.title = data.title.trim();
|
t.title = data.title.trim();
|
||||||
t.description = data.description || '';
|
t.description = data.description || '';
|
||||||
t.categoryId = data.categoryId;
|
t.categoryId = resolvedCategoryId;
|
||||||
t.priority = data.priority;
|
t.priority = data.priority;
|
||||||
t.completed = false;
|
t.completed = false;
|
||||||
t.dueDate = dueDateTimestamp;
|
t.dueDate = dueDateTimestamp;
|
||||||
@@ -176,7 +176,7 @@ export default function AddTaskScreen() {
|
|||||||
<FormProvider {...methods}>
|
<FormProvider {...methods}>
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
style={styles.keyboardAvoiding}
|
style={styles.keyboardAvoiding}
|
||||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||||
>
|
>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentContainerStyle={styles.scrollContent}
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ export default function SubtaskDetailScreen() {
|
|||||||
<FormProvider {...methods}>
|
<FormProvider {...methods}>
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
style={styles.keyboardAvoiding}
|
style={styles.keyboardAvoiding}
|
||||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||||
>
|
>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentContainerStyle={styles.scrollContent}
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ 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),
|
||||||
description: z.string().max(1000).optional(),
|
description: z.string().max(1000).optional(),
|
||||||
categoryId: z.string().min(1, 'Category is required'),
|
categoryId: z.string().optional(),
|
||||||
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']),
|
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']),
|
||||||
dueDate: z.date().nullable().optional(),
|
dueDate: z.date().nullable().optional(),
|
||||||
dueTime: z.string().optional(),
|
dueTime: z.string().optional(),
|
||||||
@@ -167,7 +167,7 @@ export default function TaskDetailScreen() {
|
|||||||
await task.update((t) => {
|
await task.update((t) => {
|
||||||
t.title = data.title.trim();
|
t.title = data.title.trim();
|
||||||
t.description = data.description || '';
|
t.description = data.description || '';
|
||||||
t.categoryId = data.categoryId;
|
t.categoryId = data.categoryId || task.categoryId || '';
|
||||||
t.priority = data.priority;
|
t.priority = data.priority;
|
||||||
t.dueDate = dueDateTimestamp;
|
t.dueDate = dueDateTimestamp;
|
||||||
t.dueTime = data.dueTime || '';
|
t.dueTime = data.dueTime || '';
|
||||||
@@ -253,7 +253,7 @@ export default function TaskDetailScreen() {
|
|||||||
<FormProvider {...methods}>
|
<FormProvider {...methods}>
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
style={styles.keyboardAvoiding}
|
style={styles.keyboardAvoiding}
|
||||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||||
>
|
>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentContainerStyle={styles.scrollContent}
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/* global __dirname */
|
||||||
|
const { app, BrowserWindow } = require('electron')
|
||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
function createWindow() {
|
||||||
|
const win = new BrowserWindow({
|
||||||
|
width: 1200,
|
||||||
|
height: 800,
|
||||||
|
minWidth: 800,
|
||||||
|
minHeight: 600,
|
||||||
|
webPreferences: {
|
||||||
|
nodeIntegration: false,
|
||||||
|
contextIsolation: true,
|
||||||
|
webSecurity: true,
|
||||||
|
},
|
||||||
|
icon: path.join(__dirname, '../assets/icon.png'),
|
||||||
|
titleBarStyle: 'default',
|
||||||
|
show: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
win.loadFile(path.join(__dirname, '../dist/index.html'))
|
||||||
|
|
||||||
|
win.once('ready-to-show', () => {
|
||||||
|
win.show()
|
||||||
|
})
|
||||||
|
|
||||||
|
win.on('closed', () => {
|
||||||
|
app.quit()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
app.whenReady().then(createWindow)
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
if (process.platform !== 'darwin') {
|
||||||
|
app.quit()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.on('activate', () => {
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
|
createWindow()
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
server {
|
||||||
|
listen 8081;
|
||||||
|
server_name localhost;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api {
|
||||||
|
proxy_pass http://backend:3000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+6
-70
@@ -28,13 +28,10 @@
|
|||||||
"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": {
|
||||||
@@ -577,6 +574,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.29.7.tgz",
|
||||||
"integrity": "sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==",
|
"integrity": "sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-plugin-utils": "^7.29.7"
|
"@babel/helper-plugin-utils": "^7.29.7"
|
||||||
},
|
},
|
||||||
@@ -1058,6 +1056,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.29.7.tgz",
|
||||||
"integrity": "sha512-I+WYbGBAiCn7nA6xBrlgPH+MB7HWb4u8pv5S0Pv7OtwNvIFvCCb24YlttKEeUFVurfBCEaOTnuhlqsb7f0Z5Dg==",
|
"integrity": "sha512-I+WYbGBAiCn7nA6xBrlgPH+MB7HWb4u8pv5S0Pv7OtwNvIFvCCb24YlttKEeUFVurfBCEaOTnuhlqsb7f0Z5Dg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-plugin-utils": "^7.29.7"
|
"@babel/helper-plugin-utils": "^7.29.7"
|
||||||
},
|
},
|
||||||
@@ -1073,6 +1072,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.29.7.tgz",
|
||||||
"integrity": "sha512-NCSEJ4sLFU2gqAub45HYh4fus2yQ36rr6ei6vpU7NdoJqCpxvEG8E6eJpscGyXP3VHD2Ny+fSXr04k1hoUrFqA==",
|
"integrity": "sha512-NCSEJ4sLFU2gqAub45HYh4fus2yQ36rr6ei6vpU7NdoJqCpxvEG8E6eJpscGyXP3VHD2Ny+fSXr04k1hoUrFqA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-plugin-utils": "^7.29.7"
|
"@babel/helper-plugin-utils": "^7.29.7"
|
||||||
},
|
},
|
||||||
@@ -1191,28 +1191,6 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@callstack/react-theme-provider": {
|
|
||||||
"version": "3.0.9",
|
|
||||||
"resolved": "https://registry.npmjs.org/@callstack/react-theme-provider/-/react-theme-provider-3.0.9.tgz",
|
|
||||||
"integrity": "sha512-tTQ0uDSCL0ypeMa8T/E9wAZRGKWj8kXP7+6RYgPTfOPs9N07C9xM8P02GJ3feETap4Ux5S69D9nteq9mEj86NA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"deepmerge": "^3.2.0",
|
|
||||||
"hoist-non-react-statics": "^3.3.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": ">=16.3.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@callstack/react-theme-provider/node_modules/deepmerge": {
|
|
||||||
"version": "3.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-3.3.0.tgz",
|
|
||||||
"integrity": "sha512-GRQOafGHwMHpjPx9iCvTgpu9NojZ49q794EEL94JVEw6VaeA8XTUyBKvAkOOjBX9oJNiV6G3P+T+tihFjo2TqA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@egjs/hammerjs": {
|
"node_modules/@egjs/hammerjs": {
|
||||||
"version": "2.0.17",
|
"version": "2.0.17",
|
||||||
"resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz",
|
"resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz",
|
||||||
@@ -10285,61 +10263,18 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.3.1.tgz",
|
||||||
"integrity": "sha512-NIXU/iT5+ORyCc7p0z2nnlkouYKX425vuU1OEm6bMMtWWR9yvb+Xg5AZmImTKoF9abxCPqrKC3rOZsKzUYgYZA==",
|
"integrity": "sha512-NIXU/iT5+ORyCc7p0z2nnlkouYKX425vuU1OEm6bMMtWWR9yvb+Xg5AZmImTKoF9abxCPqrKC3rOZsKzUYgYZA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "*",
|
"react": "*",
|
||||||
"react-native": "*"
|
"react-native": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-native-paper": {
|
|
||||||
"version": "5.15.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/react-native-paper/-/react-native-paper-5.15.3.tgz",
|
|
||||||
"integrity": "sha512-GEyNTmWElIZgnYw09AjjCNupRYzCmP79uAAyGSyCEUZz7KBz1wtJcC0wVUkozR1Rn3PK/td/9LlR6+F1hzmYvA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"workspaces": [
|
|
||||||
"example",
|
|
||||||
"docs"
|
|
||||||
],
|
|
||||||
"dependencies": {
|
|
||||||
"@callstack/react-theme-provider": "^3.0.9",
|
|
||||||
"color": "^3.1.2",
|
|
||||||
"use-latest-callback": "^0.2.3"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "*",
|
|
||||||
"react-native": "*",
|
|
||||||
"react-native-safe-area-context": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/react-native-paper/node_modules/color": {
|
|
||||||
"version": "3.2.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz",
|
|
||||||
"integrity": "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"color-convert": "^1.9.3",
|
|
||||||
"color-string": "^1.6.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/react-native-paper/node_modules/color-convert": {
|
|
||||||
"version": "1.9.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
|
|
||||||
"integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"color-name": "1.1.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/react-native-paper/node_modules/color-name": {
|
|
||||||
"version": "1.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
|
|
||||||
"integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/react-native-reanimated": {
|
"node_modules/react-native-reanimated": {
|
||||||
"version": "4.5.1",
|
"version": "4.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.5.1.tgz",
|
||||||
"integrity": "sha512-RnMvtDuR+68ig864gAvZCOdZehqhC5rFmMo0kn+ARfgVSTvFeF6IFLBVgMPUu0KwihaapEyW24WRi6nEyy1kSA==",
|
"integrity": "sha512-RnMvtDuR+68ig864gAvZCOdZehqhC5rFmMo0kn+ARfgVSTvFeF6IFLBVgMPUu0KwihaapEyW24WRi6nEyy1kSA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react-native-is-edge-to-edge": "^1.3.1",
|
"react-native-is-edge-to-edge": "^1.3.1",
|
||||||
"semver": "^7.7.3"
|
"semver": "^7.7.3"
|
||||||
@@ -10426,6 +10361,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.10.1.tgz",
|
||||||
"integrity": "sha512-62mRM19bDpfpdI8HLkEErcdOsrAPDtE9lA/sw+5lLRpzBHNhxaoj9QyY2KjXqUmirelxkX4zuPGTC3VdA0feJA==",
|
"integrity": "sha512-62mRM19bDpfpdI8HLkEErcdOsrAPDtE9lA/sw+5lLRpzBHNhxaoj9QyY2KjXqUmirelxkX4zuPGTC3VdA0feJA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/plugin-transform-arrow-functions": "^7.27.1",
|
"@babel/plugin-transform-arrow-functions": "^7.27.1",
|
||||||
"@babel/plugin-transform-class-properties": "^7.28.6",
|
"@babel/plugin-transform-class-properties": "^7.28.6",
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose
|
|||||||
<Text style={[styles.title, { color: theme.text }]}>
|
<Text style={[styles.title, { color: theme.text }]}>
|
||||||
{category ? 'Edit Category' : 'New Category'}
|
{category ? 'Edit Category' : 'New Category'}
|
||||||
</Text>
|
</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 }} accessibilityRole="button" accessibilityLabel="Close category editor">
|
||||||
<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" />
|
||||||
</Svg>
|
</Svg>
|
||||||
@@ -102,6 +102,9 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose
|
|||||||
]}
|
]}
|
||||||
onPress={() => setColor(c)}
|
onPress={() => setColor(c)}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
|
accessibilityRole="radio"
|
||||||
|
accessibilityLabel={`Color ${c}`}
|
||||||
|
accessibilityState={{ selected: color === c }}
|
||||||
>
|
>
|
||||||
{color === c && (
|
{color === c && (
|
||||||
<Svg width={16} height={16} viewBox="0 0 24 24">
|
<Svg width={16} height={16} viewBox="0 0 24 24">
|
||||||
@@ -132,7 +135,7 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose
|
|||||||
disabled={!name.trim()}
|
disabled={!name.trim()}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
<Text style={styles.saveButtonText}>Save</Text>
|
<Text style={[styles.saveButtonText, { color: theme.accentText }]}>Save</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -231,6 +234,5 @@ const styles = StyleSheet.create({
|
|||||||
saveButtonText: {
|
saveButtonText: {
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
color: '#FFFFFF',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
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 } from 'react-native';
|
||||||
import { useUniqueCategories } 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';
|
||||||
@@ -33,9 +33,11 @@ export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) {
|
|||||||
theme={theme}
|
theme={theme}
|
||||||
/>
|
/>
|
||||||
{categories.map((category) => (
|
{categories.map((category) => (
|
||||||
<AnimatedCategoryButton
|
<CategoryButton
|
||||||
key={category.id}
|
key={category.id}
|
||||||
category={category}
|
id={category.id}
|
||||||
|
name={category.name}
|
||||||
|
color={category.color}
|
||||||
selected={selected === category.id}
|
selected={selected === category.id}
|
||||||
onPress={() => onSelect(category.id)}
|
onPress={() => onSelect(category.id)}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
@@ -54,7 +56,7 @@ interface CategoryButtonProps {
|
|||||||
theme: ThemeColors;
|
theme: ThemeColors;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CategoryButton({ id, name, color, selected, onPress, theme }: CategoryButtonProps) {
|
function CategoryButton({ name, color, selected, onPress, theme }: CategoryButtonProps) {
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[
|
style={[
|
||||||
@@ -83,71 +85,6 @@ function CategoryButton({ id, name, color, selected, onPress, theme }: CategoryB
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AnimatedCategoryButtonProps {
|
|
||||||
category: Category;
|
|
||||||
selected: boolean;
|
|
||||||
onPress: () => void;
|
|
||||||
theme: ThemeColors;
|
|
||||||
}
|
|
||||||
|
|
||||||
function AnimatedCategoryButton({ category, selected, onPress, theme }: AnimatedCategoryButtonProps) {
|
|
||||||
const [scaleAnim] = React.useState(() => new Animated.Value(selected ? 1.05 : 1));
|
|
||||||
const [borderWidthAnim] = React.useState(() => new Animated.Value(selected ? 2 : 1));
|
|
||||||
const [shadowOpacityAnim] = React.useState(() => new Animated.Value(selected ? 0.15 : 0));
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
Animated.timing(scaleAnim, {
|
|
||||||
toValue: selected ? 1.05 : 1,
|
|
||||||
duration: 150,
|
|
||||||
easing: Easing.out(Easing.cubic),
|
|
||||||
useNativeDriver: false,
|
|
||||||
}).start();
|
|
||||||
|
|
||||||
Animated.timing(borderWidthAnim, {
|
|
||||||
toValue: selected ? 2 : 1,
|
|
||||||
duration: 150,
|
|
||||||
useNativeDriver: false,
|
|
||||||
}).start();
|
|
||||||
|
|
||||||
Animated.timing(shadowOpacityAnim, {
|
|
||||||
toValue: selected ? 0.15 : 0,
|
|
||||||
duration: 150,
|
|
||||||
useNativeDriver: false,
|
|
||||||
}).start();
|
|
||||||
}, [selected, scaleAnim, borderWidthAnim, shadowOpacityAnim]);
|
|
||||||
|
|
||||||
const animatedStyle = {
|
|
||||||
transform: [{ scale: scaleAnim }],
|
|
||||||
borderWidth: borderWidthAnim,
|
|
||||||
shadowOpacity: shadowOpacityAnim,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Animated.View style={[styles.button, styles.animatedButton, { backgroundColor: theme.card, borderColor: theme.borderStrong }, animatedStyle]}>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.buttonInner}
|
|
||||||
onPress={onPress}
|
|
||||||
activeOpacity={0.8}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.colorDot,
|
|
||||||
{ backgroundColor: category.color },
|
|
||||||
selected && styles.colorDotSelected,
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<Text style={[
|
|
||||||
styles.buttonText,
|
|
||||||
{ color: theme.textSecondary },
|
|
||||||
selected && { color: theme.accent, fontWeight: '600' },
|
|
||||||
]}>
|
|
||||||
{category.name}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</Animated.View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
scrollView: {
|
scrollView: {
|
||||||
paddingVertical: 0,
|
paddingVertical: 0,
|
||||||
@@ -169,16 +106,6 @@ const styles = StyleSheet.create({
|
|||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
minWidth: 64,
|
minWidth: 64,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
|
||||||
animatedButton: {
|
|
||||||
shadowColor: '#000',
|
|
||||||
shadowOffset: { width: 0, height: 1 },
|
|
||||||
shadowRadius: 4,
|
|
||||||
elevation: 2,
|
|
||||||
},
|
|
||||||
buttonInner: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 6,
|
gap: 6,
|
||||||
},
|
},
|
||||||
colorDot: {
|
colorDot: {
|
||||||
|
|||||||
@@ -26,11 +26,14 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
|
|||||||
]}
|
]}
|
||||||
onPress={() => setShowModal(true)}
|
onPress={() => setShowModal(true)}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Select category"
|
||||||
|
accessibilityHint="Opens a list of categories to choose from"
|
||||||
>
|
>
|
||||||
<View style={styles.selectorContent}>
|
<View style={styles.selectorContent}>
|
||||||
<View style={styles.selectorRow}>
|
<View style={styles.selectorRow}>
|
||||||
<View style={[styles.colorCircle, { backgroundColor: selectedCategory?.color || '#9E9E9E' }]} />
|
<View style={[styles.colorCircle, { backgroundColor: selectedCategory?.color || '#9E9E9E' }]} />
|
||||||
<Text style={[styles.selectorValue, { color: theme.text }]}>{selectedCategory?.name || 'Select category'}</Text>
|
<Text style={[styles.selectorValue, { color: theme.text }]}>{selectedCategory?.name || 'None'}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||||
@@ -48,11 +51,37 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
|
|||||||
<Pressable style={[styles.modalSheet, { backgroundColor: theme.sheetBg }]}>
|
<Pressable style={[styles.modalSheet, { backgroundColor: theme.sheetBg }]}>
|
||||||
<View style={styles.modalHeader}>
|
<View style={styles.modalHeader}>
|
||||||
<Text style={[styles.modalTitle, { color: theme.text }]}>Select Category</Text>
|
<Text style={[styles.modalTitle, { color: theme.text }]}>Select Category</Text>
|
||||||
<TouchableOpacity onPress={() => setShowModal(false)} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
|
<TouchableOpacity onPress={() => setShowModal(false)} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} accessibilityRole="button" accessibilityLabel="Close category picker">
|
||||||
<Text style={[styles.closeText, { color: theme.textMuted }]}>✕</Text>
|
<Text style={[styles.closeText, { color: theme.textMuted }]}>✕</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
<ScrollView contentContainerStyle={styles.modalContent}>
|
<ScrollView contentContainerStyle={styles.modalContent}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.modalOption,
|
||||||
|
{ backgroundColor: theme.card, borderColor: theme.borderStrong },
|
||||||
|
!value && { borderColor: theme.accent, backgroundColor: theme.accentSoft, borderWidth: 2 },
|
||||||
|
]}
|
||||||
|
onPress={() => { onChange(''); setShowModal(false); }}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
accessibilityRole="radio"
|
||||||
|
accessibilityLabel="No category"
|
||||||
|
accessibilityState={{ selected: !value }}
|
||||||
|
>
|
||||||
|
<View style={[styles.colorCircle, { backgroundColor: '#9E9E9E' }, !value && styles.colorCircleSelected]} />
|
||||||
|
<Text style={[
|
||||||
|
styles.categoryName,
|
||||||
|
{ color: theme.textSecondary },
|
||||||
|
!value && { color: theme.accent, fontWeight: '600' },
|
||||||
|
]}>
|
||||||
|
None
|
||||||
|
</Text>
|
||||||
|
{!value && (
|
||||||
|
<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>
|
||||||
{categories.map((category) => (
|
{categories.map((category) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={category.id}
|
key={category.id}
|
||||||
@@ -63,6 +92,9 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
|
|||||||
]}
|
]}
|
||||||
onPress={() => { onChange(category.id); setShowModal(false); }}
|
onPress={() => { onChange(category.id); setShowModal(false); }}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
|
accessibilityRole="radio"
|
||||||
|
accessibilityLabel={`Category ${category.name}`}
|
||||||
|
accessibilityState={{ selected: value === category.id }}
|
||||||
>
|
>
|
||||||
<View style={[styles.colorCircle, { backgroundColor: category.color }, value === category.id && styles.colorCircleSelected]} />
|
<View style={[styles.colorCircle, { backgroundColor: category.color }, value === category.id && styles.colorCircleSelected]} />
|
||||||
<Text style={[
|
<Text style={[
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { View, Text as RNText, StyleSheet, PanResponder, Dimensions } from 'react-native';
|
||||||
|
import Svg, { Circle, Rect, Defs, LinearGradient, Stop } from 'react-native-svg';
|
||||||
|
import { useSettings } from '@/theme';
|
||||||
|
|
||||||
|
const DefsElement = Defs as unknown as React.ComponentType<{ children?: React.ReactNode }>;
|
||||||
|
|
||||||
|
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
||||||
|
const WHEEL_SIZE = Math.min(SCREEN_WIDTH - 64, 280);
|
||||||
|
const THUMB_SIZE = 24;
|
||||||
|
|
||||||
|
function hexToHsv(hex: string) {
|
||||||
|
const r = parseInt(hex.slice(1, 3), 16) / 255;
|
||||||
|
const g = parseInt(hex.slice(3, 5), 16) / 255;
|
||||||
|
const b = parseInt(hex.slice(5, 7), 16) / 255;
|
||||||
|
const max = Math.max(r, g, b);
|
||||||
|
const min = Math.min(r, g, b);
|
||||||
|
const delta = max - min;
|
||||||
|
let h = 0;
|
||||||
|
if (delta !== 0) {
|
||||||
|
if (max === r) h = ((g - b) / delta) % 6;
|
||||||
|
else if (max === g) h = (b - r) / delta + 2;
|
||||||
|
else h = (r - g) / delta + 4;
|
||||||
|
h = Math.round(h * 60);
|
||||||
|
if (h < 0) h += 360;
|
||||||
|
}
|
||||||
|
const s = max === 0 ? 0 : delta / max;
|
||||||
|
const v = max;
|
||||||
|
return { h, s, v };
|
||||||
|
}
|
||||||
|
|
||||||
|
function hsvToHex(h: number, s: number, v: number) {
|
||||||
|
const c = v * s;
|
||||||
|
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||||
|
const m = v - c;
|
||||||
|
let r = 0, g = 0, b = 0;
|
||||||
|
if (h < 60) { r = c; g = x; b = 0; }
|
||||||
|
else if (h < 120) { r = x; g = c; b = 0; }
|
||||||
|
else if (h < 180) { r = 0; g = c; b = x; }
|
||||||
|
else if (h < 240) { r = 0; g = x; b = c; }
|
||||||
|
else if (h < 300) { r = x; g = 0; b = c; }
|
||||||
|
else { r = c; g = 0; b = x; }
|
||||||
|
const toHex = (n: number) => Math.round((n + m) * 255).toString(16).padStart(2, '0');
|
||||||
|
return `#${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ColorWheelProps {
|
||||||
|
color: string;
|
||||||
|
onChange: (color: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ColorWheel({ color, onChange }: ColorWheelProps) {
|
||||||
|
const { theme } = useSettings();
|
||||||
|
const initialHsv = useMemo(() => hexToHsv(color), [color]);
|
||||||
|
const [hue, setHue] = useState(initialHsv.h);
|
||||||
|
const [saturation, setSaturation] = useState(initialHsv.s);
|
||||||
|
const [value, setValue] = useState(initialHsv.v);
|
||||||
|
const [prevColor, setPrevColor] = useState(color);
|
||||||
|
|
||||||
|
if (prevColor !== color) {
|
||||||
|
setPrevColor(color);
|
||||||
|
setHue(initialHsv.h);
|
||||||
|
setSaturation(initialHsv.s);
|
||||||
|
setValue(initialHsv.v);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const newColor = hsvToHex(hue, saturation, value);
|
||||||
|
onChange(newColor);
|
||||||
|
}, [hue, saturation, value, onChange]);
|
||||||
|
|
||||||
|
const wheelPanResponder = React.useMemo(() => PanResponder.create({
|
||||||
|
onStartShouldSetPanResponder: () => true,
|
||||||
|
onMoveShouldSetPanResponder: () => true,
|
||||||
|
onPanResponderMove: (_, gesture) => {
|
||||||
|
const center = WHEEL_SIZE / 2;
|
||||||
|
const dx = gesture.moveX - center;
|
||||||
|
const dy = gesture.moveY - center;
|
||||||
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
const radius = (WHEEL_SIZE - THUMB_SIZE) / 2;
|
||||||
|
if (distance > radius) return;
|
||||||
|
const angle = Math.atan2(dy, dx) * (180 / Math.PI);
|
||||||
|
let h = angle + 180;
|
||||||
|
if (h >= 360) h -= 360;
|
||||||
|
setHue(h);
|
||||||
|
setSaturation(distance / radius);
|
||||||
|
setValue(1 - distance / radius);
|
||||||
|
},
|
||||||
|
}), []);
|
||||||
|
|
||||||
|
const huePanResponder = React.useMemo(() => PanResponder.create({
|
||||||
|
onStartShouldSetPanResponder: () => true,
|
||||||
|
onMoveShouldSetPanResponder: () => true,
|
||||||
|
onPanResponderMove: (_, gesture) => {
|
||||||
|
const h = (gesture.moveX / WHEEL_SIZE) * 360;
|
||||||
|
setHue(Math.min(360, Math.max(0, h)));
|
||||||
|
},
|
||||||
|
}), []);
|
||||||
|
|
||||||
|
const thumbX = ((WHEEL_SIZE - THUMB_SIZE) / 2) * saturation * Math.cos((hue - 180) * Math.PI / 180) + (WHEEL_SIZE / 2) - (THUMB_SIZE / 2);
|
||||||
|
const thumbY = ((WHEEL_SIZE - THUMB_SIZE) / 2) * saturation * Math.sin((hue - 180) * Math.PI / 180) + (WHEEL_SIZE / 2) - (THUMB_SIZE / 2);
|
||||||
|
const hueThumbX = (hue / 360) * WHEEL_SIZE - (THUMB_SIZE / 2);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.wheelContainer}>
|
||||||
|
<Svg width={WHEEL_SIZE} height={WHEEL_SIZE}>
|
||||||
|
<DefsElement>
|
||||||
|
<LinearGradient id="satGradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<Stop offset="0%" stopColor={`hsl(${hue}, 100%, 50%)`} stopOpacity={1} />
|
||||||
|
<Stop offset="100%" stopColor="#FFFFFF" stopOpacity={1} />
|
||||||
|
</LinearGradient>
|
||||||
|
<LinearGradient id="valGradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<Stop offset="0%" stopColor="transparent" stopOpacity={0} />
|
||||||
|
<Stop offset="100%" stopColor="#000000" stopOpacity={1} />
|
||||||
|
</LinearGradient>
|
||||||
|
</DefsElement>
|
||||||
|
<Circle
|
||||||
|
cx={WHEEL_SIZE / 2}
|
||||||
|
cy={WHEEL_SIZE / 2}
|
||||||
|
r={(WHEEL_SIZE - THUMB_SIZE) / 2}
|
||||||
|
fill="url(#satGradient)"
|
||||||
|
/>
|
||||||
|
<Circle
|
||||||
|
cx={WHEEL_SIZE / 2}
|
||||||
|
cy={WHEEL_SIZE / 2}
|
||||||
|
r={(WHEEL_SIZE - THUMB_SIZE) / 2}
|
||||||
|
fill="url(#valGradient)"
|
||||||
|
/>
|
||||||
|
<Circle
|
||||||
|
cx={thumbX + THUMB_SIZE / 2}
|
||||||
|
cy={thumbY + THUMB_SIZE / 2}
|
||||||
|
r={THUMB_SIZE / 2}
|
||||||
|
fill="#FFFFFF"
|
||||||
|
stroke="#000000"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
<View {...wheelPanResponder.panHandlers} style={StyleSheet.absoluteFill} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.hueContainer}>
|
||||||
|
<Svg width={WHEEL_SIZE} height={36}>
|
||||||
|
<DefsElement>
|
||||||
|
<LinearGradient id="hueGradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<Stop offset="0%" stopColor="#FF0000" />
|
||||||
|
<Stop offset="17%" stopColor="#FFFF00" />
|
||||||
|
<Stop offset="33%" stopColor="#00FF00" />
|
||||||
|
<Stop offset="50%" stopColor="#00FFFF" />
|
||||||
|
<Stop offset="67%" stopColor="#0000FF" />
|
||||||
|
<Stop offset="83%" stopColor="#FF00FF" />
|
||||||
|
<Stop offset="100%" stopColor="#FF0000" />
|
||||||
|
</LinearGradient>
|
||||||
|
</DefsElement>
|
||||||
|
<Rect x={0} y={0} width={WHEEL_SIZE} height={36} rx={18} fill="url(#hueGradient)" />
|
||||||
|
<Circle
|
||||||
|
cx={hueThumbX + THUMB_SIZE / 2}
|
||||||
|
cy={18}
|
||||||
|
r={THUMB_SIZE / 2}
|
||||||
|
fill="#FFFFFF"
|
||||||
|
stroke="#000000"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
<View {...huePanResponder.panHandlers} style={StyleSheet.absoluteFill} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.previewContainer}>
|
||||||
|
<View style={[styles.preview, { backgroundColor: hsvToHex(hue, saturation, value) }]} />
|
||||||
|
<RNText style={[styles.previewText, { color: theme.text }]}>{hsvToHex(hue, saturation, value)}</RNText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 16,
|
||||||
|
},
|
||||||
|
wheelContainer: {
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
hueContainer: {
|
||||||
|
position: 'relative',
|
||||||
|
width: WHEEL_SIZE,
|
||||||
|
},
|
||||||
|
previewContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#00000020',
|
||||||
|
},
|
||||||
|
previewText: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '500',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './ColorWheel.native';
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||||
|
import { View, Text, StyleSheet } from 'react-native';
|
||||||
|
import { useSettings } from '@/theme';
|
||||||
|
import Svg, { Rect, Circle, Defs, LinearGradient, Stop } from 'react-native-svg';
|
||||||
|
|
||||||
|
const DefsElement = Defs as unknown as React.ComponentType<{ children?: React.ReactNode }>;
|
||||||
|
|
||||||
|
const WHEEL_SIZE = 280;
|
||||||
|
const THUMB_SIZE = 24;
|
||||||
|
|
||||||
|
function hexToHsv(hex: string) {
|
||||||
|
const r = parseInt(hex.slice(1, 3), 16) / 255;
|
||||||
|
const g = parseInt(hex.slice(3, 5), 16) / 255;
|
||||||
|
const b = parseInt(hex.slice(5, 7), 16) / 255;
|
||||||
|
const max = Math.max(r, g, b);
|
||||||
|
const min = Math.min(r, g, b);
|
||||||
|
const delta = max - min;
|
||||||
|
let h = 0;
|
||||||
|
if (delta !== 0) {
|
||||||
|
if (max === r) h = ((g - b) / delta) % 6;
|
||||||
|
else if (max === g) h = (b - r) / delta + 2;
|
||||||
|
else h = (r - g) / delta + 4;
|
||||||
|
h = Math.round(h * 60);
|
||||||
|
if (h < 0) h += 360;
|
||||||
|
}
|
||||||
|
const s = max === 0 ? 0 : delta / max;
|
||||||
|
const v = max;
|
||||||
|
return { h, s, v };
|
||||||
|
}
|
||||||
|
|
||||||
|
function hsvToHex(h: number, s: number, v: number) {
|
||||||
|
const c = v * s;
|
||||||
|
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||||
|
const m = v - c;
|
||||||
|
let r = 0, g = 0, b = 0;
|
||||||
|
if (h < 60) { r = c; g = x; b = 0; }
|
||||||
|
else if (h < 120) { r = x; g = c; b = 0; }
|
||||||
|
else if (h < 180) { r = 0; g = c; b = x; }
|
||||||
|
else if (h < 240) { r = 0; g = x; b = c; }
|
||||||
|
else if (h < 300) { r = x; g = 0; b = c; }
|
||||||
|
else { r = c; g = 0; b = x; }
|
||||||
|
const toHex = (n: number) => Math.round((n + m) * 255).toString(16).padStart(2, '0');
|
||||||
|
return `#${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ColorWheelProps {
|
||||||
|
color: string;
|
||||||
|
onChange: (color: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ColorWheel({ color, onChange }: ColorWheelProps) {
|
||||||
|
const { theme } = useSettings();
|
||||||
|
const initialHsv = useMemo(() => hexToHsv(color), [color]);
|
||||||
|
const [hue, setHue] = useState(initialHsv.h);
|
||||||
|
const [saturation, setSaturation] = useState(initialHsv.s);
|
||||||
|
const [value, setValue] = useState(initialHsv.v);
|
||||||
|
const [prevColor, setPrevColor] = useState(color);
|
||||||
|
const wheelRef = useRef<Svg>(null);
|
||||||
|
const hueRef = useRef<Svg>(null);
|
||||||
|
|
||||||
|
if (prevColor !== color) {
|
||||||
|
setPrevColor(color);
|
||||||
|
setHue(initialHsv.h);
|
||||||
|
setSaturation(initialHsv.s);
|
||||||
|
setValue(initialHsv.v);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const newColor = hsvToHex(hue, saturation, value);
|
||||||
|
onChange(newColor);
|
||||||
|
}, [hue, saturation, value, onChange]);
|
||||||
|
|
||||||
|
const handleWheelMouseDown = (e: React.MouseEvent) => {
|
||||||
|
const handleMove = (moveEvent: MouseEvent) => {
|
||||||
|
if (!wheelRef.current) return;
|
||||||
|
const rect = (wheelRef.current as unknown as HTMLElement).getBoundingClientRect();
|
||||||
|
const center = WHEEL_SIZE / 2;
|
||||||
|
const dx = moveEvent.clientX - rect.left - center;
|
||||||
|
const dy = moveEvent.clientY - rect.top - center;
|
||||||
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
const radius = (WHEEL_SIZE - THUMB_SIZE) / 2;
|
||||||
|
if (distance > radius) return;
|
||||||
|
const angle = Math.atan2(dy, dx) * (180 / Math.PI);
|
||||||
|
let h = angle + 180;
|
||||||
|
if (h >= 360) h -= 360;
|
||||||
|
setHue(h);
|
||||||
|
setSaturation(distance / radius);
|
||||||
|
setValue(1 - distance / radius);
|
||||||
|
};
|
||||||
|
const handleUp = () => {
|
||||||
|
window.removeEventListener('mousemove', handleMove);
|
||||||
|
window.removeEventListener('mouseup', handleUp);
|
||||||
|
};
|
||||||
|
window.addEventListener('mousemove', handleMove);
|
||||||
|
window.addEventListener('mouseup', handleUp);
|
||||||
|
handleMove(e.nativeEvent);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHueMouseDown = (e: React.MouseEvent) => {
|
||||||
|
const handleMove = (moveEvent: MouseEvent) => {
|
||||||
|
if (!hueRef.current) return;
|
||||||
|
const rect = (hueRef.current as unknown as HTMLElement).getBoundingClientRect();
|
||||||
|
const h = ((moveEvent.clientX - rect.left) / WHEEL_SIZE) * 360;
|
||||||
|
setHue(Math.min(360, Math.max(0, h)));
|
||||||
|
};
|
||||||
|
const handleUp = () => {
|
||||||
|
window.removeEventListener('mousemove', handleMove);
|
||||||
|
window.removeEventListener('mouseup', handleUp);
|
||||||
|
};
|
||||||
|
window.addEventListener('mousemove', handleMove);
|
||||||
|
window.addEventListener('mouseup', handleUp);
|
||||||
|
handleMove(e.nativeEvent);
|
||||||
|
};
|
||||||
|
|
||||||
|
const thumbX = ((WHEEL_SIZE - THUMB_SIZE) / 2) * saturation * Math.cos((hue - 180) * Math.PI / 180) + (WHEEL_SIZE / 2) - (THUMB_SIZE / 2);
|
||||||
|
const thumbY = ((WHEEL_SIZE - THUMB_SIZE) / 2) * saturation * Math.sin((hue - 180) * Math.PI / 180) + (WHEEL_SIZE / 2) - (THUMB_SIZE / 2);
|
||||||
|
const hueThumbX = (hue / 360) * WHEEL_SIZE - (THUMB_SIZE / 2);
|
||||||
|
const currentColor = hsvToHex(hue, saturation, value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.wheelContainer}>
|
||||||
|
<Svg width={WHEEL_SIZE} height={WHEEL_SIZE} ref={wheelRef} {...({ onMouseDown: handleWheelMouseDown } as object)}>
|
||||||
|
<DefsElement>
|
||||||
|
<LinearGradient id="satGradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<Stop offset="0%" stopColor={`hsl(${hue}, 100%, 50%)`} stopOpacity={1} />
|
||||||
|
<Stop offset="100%" stopColor="#FFFFFF" stopOpacity={1} />
|
||||||
|
</LinearGradient>
|
||||||
|
<LinearGradient id="valGradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<Stop offset="0%" stopColor="transparent" stopOpacity={0} />
|
||||||
|
<Stop offset="100%" stopColor="#000000" stopOpacity={1} />
|
||||||
|
</LinearGradient>
|
||||||
|
</DefsElement>
|
||||||
|
<Circle
|
||||||
|
cx={WHEEL_SIZE / 2}
|
||||||
|
cy={WHEEL_SIZE / 2}
|
||||||
|
r={(WHEEL_SIZE - THUMB_SIZE) / 2}
|
||||||
|
fill="url(#satGradient)"
|
||||||
|
/>
|
||||||
|
<Circle
|
||||||
|
cx={WHEEL_SIZE / 2}
|
||||||
|
cy={WHEEL_SIZE / 2}
|
||||||
|
r={(WHEEL_SIZE - THUMB_SIZE) / 2}
|
||||||
|
fill="url(#valGradient)"
|
||||||
|
/>
|
||||||
|
<Circle
|
||||||
|
cx={thumbX + THUMB_SIZE / 2}
|
||||||
|
cy={thumbY + THUMB_SIZE / 2}
|
||||||
|
r={THUMB_SIZE / 2}
|
||||||
|
fill="#FFFFFF"
|
||||||
|
stroke="#000000"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.hueContainer}>
|
||||||
|
<Svg width={WHEEL_SIZE} height={36} ref={hueRef} {...({ onMouseDown: handleHueMouseDown } as object)}>
|
||||||
|
<DefsElement>
|
||||||
|
<LinearGradient id="hueGradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<Stop offset="0%" stopColor="#FF0000" />
|
||||||
|
<Stop offset="17%" stopColor="#FFFF00" />
|
||||||
|
<Stop offset="33%" stopColor="#00FF00" />
|
||||||
|
<Stop offset="50%" stopColor="#00FFFF" />
|
||||||
|
<Stop offset="67%" stopColor="#0000FF" />
|
||||||
|
<Stop offset="83%" stopColor="#FF00FF" />
|
||||||
|
<Stop offset="100%" stopColor="#FF0000" />
|
||||||
|
</LinearGradient>
|
||||||
|
</DefsElement>
|
||||||
|
<Rect x={0} y={0} width={WHEEL_SIZE} height={36} rx={18} fill="url(#hueGradient)" />
|
||||||
|
<Circle
|
||||||
|
cx={hueThumbX + THUMB_SIZE / 2}
|
||||||
|
cy={18}
|
||||||
|
r={THUMB_SIZE / 2}
|
||||||
|
fill="#FFFFFF"
|
||||||
|
stroke="#000000"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.previewContainer}>
|
||||||
|
<View style={[styles.preview, { backgroundColor: currentColor }]} />
|
||||||
|
<Text style={[styles.previewText, { color: theme.text }]}>{currentColor}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 16,
|
||||||
|
},
|
||||||
|
wheelContainer: {},
|
||||||
|
hueContainer: {
|
||||||
|
width: WHEEL_SIZE,
|
||||||
|
},
|
||||||
|
previewContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#00000020',
|
||||||
|
},
|
||||||
|
previewText: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '500',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -57,6 +57,9 @@ export function FloatingActionButton() {
|
|||||||
onPressIn={handlePressIn}
|
onPressIn={handlePressIn}
|
||||||
onPressOut={handlePressOut}
|
onPressOut={handlePressOut}
|
||||||
activeOpacity={1}
|
activeOpacity={1}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Add new task"
|
||||||
|
hitSlop={12}
|
||||||
>
|
>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ export function FormButtons({ onSubmit, submitLabel = 'Submit' }: FormButtonsPro
|
|||||||
onPress={cancel}
|
onPress={cancel}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Cancel"
|
||||||
|
accessibilityState={{ disabled: isSubmitting }}
|
||||||
>
|
>
|
||||||
<Text style={[styles.cancelButtonText, { color: theme.textSecondary }]}>Cancel</Text>
|
<Text style={[styles.cancelButtonText, { color: theme.textSecondary }]}>Cancel</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -39,6 +42,9 @@ export function FormButtons({ onSubmit, submitLabel = 'Submit' }: FormButtonsPro
|
|||||||
onPress={handleSubmit(onSubmit)}
|
onPress={handleSubmit(onSubmit)}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={isSubmitting ? 'Saving' : submitLabel}
|
||||||
|
accessibilityState={{ disabled: isSubmitting, busy: isSubmitting }}
|
||||||
>
|
>
|
||||||
<Text style={styles.submitButtonText}>
|
<Text style={styles.submitButtonText}>
|
||||||
{isSubmitting ? 'Saving...' : submitLabel}
|
{isSubmitting ? 'Saving...' : submitLabel}
|
||||||
|
|||||||
@@ -111,15 +111,15 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
|
|||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
styles.tabText,
|
styles.tabText,
|
||||||
{ color: selectedTab === tab ? '#FFFFFF' : theme.textMuted },
|
{ color: selectedTab === tab ? theme.accentText : theme.textMuted },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{tab.charAt(0).toUpperCase() + tab.slice(1)}
|
{tab.charAt(0).toUpperCase() + tab.slice(1)}
|
||||||
{tab === 'friends' && friends.length > 0 && (
|
{tab === 'friends' && friends.length > 0 && (
|
||||||
<Text style={[styles.badge, { color: '#FFFFFF' }]}>{friends.length}</Text>
|
<Text style={[styles.badge, { color: theme.accentText }]}>{friends.length}</Text>
|
||||||
)}
|
)}
|
||||||
{tab === 'incoming' && incoming.length > 0 && (
|
{tab === 'incoming' && incoming.length > 0 && (
|
||||||
<Text style={[styles.badge, { color: '#FFFFFF' }]}>{incoming.length}</Text>
|
<Text style={[styles.badge, { color: theme.accentText }]}>{incoming.length}</Text>
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -179,7 +179,7 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
|
|||||||
style={[styles.actionBtn, { backgroundColor: theme.accent }]}
|
style={[styles.actionBtn, { backgroundColor: theme.accent }]}
|
||||||
onPress={() => handleAccept(item.requestId)}
|
onPress={() => handleAccept(item.requestId)}
|
||||||
>
|
>
|
||||||
<Text style={styles.actionBtnText}>Accept</Text>
|
<Text style={[styles.actionBtnText, { color: theme.accentText }]}>Accept</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.actionBtn, { backgroundColor: 'transparent', borderColor: theme.borderStrong, borderWidth: 1 }]}
|
style={[styles.actionBtn, { backgroundColor: 'transparent', borderColor: theme.borderStrong, borderWidth: 1 }]}
|
||||||
@@ -265,7 +265,7 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
|
|||||||
.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, { color: theme.accentText }]}>Add</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -399,7 +399,6 @@ const styles = StyleSheet.create({
|
|||||||
addBtnText: {
|
addBtnText: {
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
color: '#FFFFFF',
|
|
||||||
},
|
},
|
||||||
requestActions: {
|
requestActions: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -413,7 +412,6 @@ const styles = StyleSheet.create({
|
|||||||
actionBtnText: {
|
actionBtnText: {
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
color: '#FFFFFF',
|
|
||||||
},
|
},
|
||||||
cancelBtn: {
|
cancelBtn: {
|
||||||
paddingVertical: 6,
|
paddingVertical: 6,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function Header({ title, showLogo, rightAction }: HeaderProps) {
|
|||||||
<Text style={styles.logoText}>✓</Text>
|
<Text style={styles.logoText}>✓</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
<Text style={[styles.title, { color: theme.text }]}>{title}</Text>
|
<Text style={[styles.title, { color: theme.text }]} accessibilityRole="header">{title}</Text>
|
||||||
<View style={styles.spacer}>{rightAction}</View>
|
<View style={styles.spacer}>{rightAction}</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -61,8 +61,9 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: '50%',
|
left: 0,
|
||||||
marginLeft: -30,
|
right: 0,
|
||||||
|
textAlign: 'center',
|
||||||
},
|
},
|
||||||
spacer: {
|
spacer: {
|
||||||
width: 32,
|
width: 32,
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ export function ListItem({ title, subtitle, leftElement, rightElement, onPress,
|
|||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
disabled={!isInteractive}
|
disabled={!isInteractive}
|
||||||
|
accessibilityRole={isInteractive ? 'button' : 'none'}
|
||||||
|
accessibilityLabel={title}
|
||||||
|
accessibilityHint={subtitle ? subtitle : undefined}
|
||||||
|
accessibilityState={{ disabled: !isInteractive }}
|
||||||
>
|
>
|
||||||
{leftElement && <View style={styles.leftElement}>{leftElement}</View>}
|
{leftElement && <View style={styles.leftElement}>{leftElement}</View>}
|
||||||
<View style={styles.leftContent}>
|
<View style={styles.leftContent}>
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ export function OptionPickerModal({ visible, title, options, selectedValue, onSe
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
|
accessibilityRole={multiSelect ? 'checkbox' : 'button'}
|
||||||
|
accessibilityLabel={item.label}
|
||||||
|
accessibilityState={multiSelect ? { checked: selected } : { selected }}
|
||||||
>
|
>
|
||||||
{item.color && (
|
{item.color && (
|
||||||
<View style={[styles.dot, { backgroundColor: item.color }]} />
|
<View style={[styles.dot, { backgroundColor: item.color }]} />
|
||||||
@@ -81,6 +84,8 @@ export function OptionPickerModal({ visible, title, options, selectedValue, onSe
|
|||||||
style={[styles.cancelButton, { borderColor: theme.borderStrong }]}
|
style={[styles.cancelButton, { borderColor: theme.borderStrong }]}
|
||||||
onPress={onClose}
|
onPress={onClose}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Cancel"
|
||||||
>
|
>
|
||||||
<Text style={[styles.cancelText, { color: theme.textFaint }]}>Cancel</Text>
|
<Text style={[styles.cancelText, { color: theme.textFaint }]}>Cancel</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export function PrioritySelector({ value, onChange }: PrioritySelectorProps) {
|
|||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<Text style={[styles.label, { color: theme.text }]}>Priority</Text>
|
<Text style={[styles.label, { color: theme.text }]}>Priority</Text>
|
||||||
<View style={styles.options}>
|
<View style={styles.options} accessibilityRole="radiogroup" accessibilityLabel="Priority">
|
||||||
{priorities.map((priority) => (
|
{priorities.map((priority) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={priority.value}
|
key={priority.value}
|
||||||
@@ -34,6 +34,9 @@ export function PrioritySelector({ value, onChange }: PrioritySelectorProps) {
|
|||||||
]}
|
]}
|
||||||
onPress={() => onChange(priority.value)}
|
onPress={() => onChange(priority.value)}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
|
accessibilityRole="radio"
|
||||||
|
accessibilityLabel={`${priority.label} priority`}
|
||||||
|
accessibilityState={{ selected: value === priority.value }}
|
||||||
>
|
>
|
||||||
<View style={[
|
<View style={[
|
||||||
styles.colorIndicator,
|
styles.colorIndicator,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ 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 || '');
|
||||||
const [categoryPickerVisible, setCategoryPickerVisible] = useState(false);
|
const [categoryPickerVisible, setCategoryPickerVisible] = useState(false);
|
||||||
const inputRef = useRef<TextInput>(null);
|
const inputRef = useRef<TextInput>(null);
|
||||||
const keyboardHeight = useRef(new Animated.Value(0)).current;
|
const keyboardHeight = useRef(new Animated.Value(0)).current;
|
||||||
@@ -61,14 +61,14 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
|
|||||||
|
|
||||||
const handleAdd = async () => {
|
const handleAdd = async () => {
|
||||||
const trimmed = title.trim();
|
const trimmed = title.trim();
|
||||||
if (!trimmed || !categoryId) return;
|
if (!trimmed) return;
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
await database.write(async () => {
|
await database.write(async () => {
|
||||||
await collections.tasks.create((t) => {
|
await collections.tasks.create((t) => {
|
||||||
t.title = trimmed;
|
t.title = trimmed;
|
||||||
t.description = '';
|
t.description = '';
|
||||||
t.categoryId = categoryId;
|
t.categoryId = categoryId || '';
|
||||||
t.priority = 'none';
|
t.priority = 'none';
|
||||||
t.completed = false;
|
t.completed = false;
|
||||||
t.dueDate = dueDate;
|
t.dueDate = dueDate;
|
||||||
@@ -88,7 +88,7 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
|
|||||||
|
|
||||||
const animatedBottom = keyboardHeight.interpolate({
|
const animatedBottom = keyboardHeight.interpolate({
|
||||||
inputRange: [0, 500],
|
inputRange: [0, 500],
|
||||||
outputRange: [insets.bottom + 0, insets.bottom + 0 + 500],
|
outputRange: [0, insets.bottom + 500],
|
||||||
extrapolate: 'clamp',
|
extrapolate: 'clamp',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -115,6 +115,8 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
|
|||||||
onChangeText={setTitle}
|
onChangeText={setTitle}
|
||||||
onSubmitEditing={handleAdd}
|
onSubmitEditing={handleAdd}
|
||||||
returnKeyType="done"
|
returnKeyType="done"
|
||||||
|
accessibilityLabel="Quick add task"
|
||||||
|
accessibilityHint="Enter a task name and press the add button"
|
||||||
/>
|
/>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.submit, { backgroundColor: theme.accent }, title.trim() ? {} : styles.submitDisabled]}
|
style={[styles.submit, { backgroundColor: theme.accent }, title.trim() ? {} : styles.submitDisabled]}
|
||||||
@@ -122,11 +124,13 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
|
|||||||
disabled={!title.trim()}
|
disabled={!title.trim()}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
accessibilityLabel="Add task"
|
accessibilityLabel="Add task"
|
||||||
|
accessibilityHint="Adds the entered task to the list"
|
||||||
|
accessibilityState={{ disabled: !title.trim() }}
|
||||||
>
|
>
|
||||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||||
<Path
|
<Path
|
||||||
d="M12 5v14M5 12h14"
|
d="M12 5v14M5 12h14"
|
||||||
stroke="#FFFFFF"
|
stroke={theme.accentText}
|
||||||
strokeWidth={2.5}
|
strokeWidth={2.5}
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
@@ -138,7 +142,7 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
|
|||||||
<OptionPickerModal
|
<OptionPickerModal
|
||||||
visible={categoryPickerVisible}
|
visible={categoryPickerVisible}
|
||||||
title="Select Category"
|
title="Select Category"
|
||||||
options={categories.map((c) => ({ value: c.id, label: c.name, color: c.color }))}
|
options={[{ value: '', label: 'None', color: '#9E9E9E' }, ...categories.map((c) => ({ value: c.id, label: c.name, color: c.color }))]}
|
||||||
selectedValue={categoryId}
|
selectedValue={categoryId}
|
||||||
onSelect={(value) => setCategoryId(Array.isArray(value) ? value[0] : value)}
|
onSelect={(value) => setCategoryId(Array.isArray(value) ? value[0] : value)}
|
||||||
onClose={() => setCategoryPickerVisible(false)}
|
onClose={() => setCategoryPickerVisible(false)}
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ export function ReminderSelector({ value, onChange, hasDueDate }: ReminderSelect
|
|||||||
onPress={() => setShowPicker(true)}
|
onPress={() => setShowPicker(true)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Reminders"
|
||||||
|
accessibilityHint="Opens a list of reminder options"
|
||||||
|
accessibilityState={{ disabled }}
|
||||||
>
|
>
|
||||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||||
<Path
|
<Path
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ function RepeatIcon({ repeat, color }: { repeat: Repeat; color: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const WEEKDAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||||
|
|
||||||
function unitLabel(value: Repeat): string {
|
function unitLabel(value: Repeat): string {
|
||||||
switch (value) {
|
switch (value) {
|
||||||
case 'daily':
|
case 'daily':
|
||||||
@@ -129,12 +131,14 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<TouchableOpacity onPress={() => applyProfile(profile)} activeOpacity={0.8}>
|
<TouchableOpacity onPress={() => applyProfile(profile)} activeOpacity={0.8} accessibilityRole="button" accessibilityLabel={`Apply repeat profile ${profile.name}`}>
|
||||||
<Text style={[styles.profileChipText, { color: theme.textSecondary }]}>{profile.name}</Text>
|
<Text style={[styles.profileChipText, { color: theme.textSecondary }]}>{profile.name}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => handleDeleteProfile(profile.id, profile.name)}
|
onPress={() => handleDeleteProfile(profile.id, profile.name)}
|
||||||
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`Delete repeat profile ${profile.name}`}
|
||||||
>
|
>
|
||||||
<Text style={[styles.profileChipX, { color: theme.textFaint }]}>×</Text>
|
<Text style={[styles.profileChipX, { color: theme.textFaint }]}>×</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -144,7 +148,7 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<View style={styles.chipRow}>
|
<View style={styles.chipRow} accessibilityRole="radiogroup" accessibilityLabel="Repeat">
|
||||||
{REPEAT_OPTIONS.map((option) => (
|
{REPEAT_OPTIONS.map((option) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={option.value}
|
key={option.value}
|
||||||
@@ -155,6 +159,9 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
|
|||||||
]}
|
]}
|
||||||
onPress={() => selectRepeat(option.value)}
|
onPress={() => selectRepeat(option.value)}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
|
accessibilityRole="radio"
|
||||||
|
accessibilityLabel={`Repeat ${option.label.replace('No Repeat', 'none')}`}
|
||||||
|
accessibilityState={{ selected: value === option.value }}
|
||||||
>
|
>
|
||||||
<RepeatIcon repeat={option.value} color={value === option.value ? theme.accent : theme.textFaint} />
|
<RepeatIcon repeat={option.value} color={value === option.value ? theme.accent : theme.textFaint} />
|
||||||
<Text
|
<Text
|
||||||
@@ -179,6 +186,9 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
|
|||||||
onPress={() => bumpInterval(-1)}
|
onPress={() => bumpInterval(-1)}
|
||||||
disabled={interval <= 1}
|
disabled={interval <= 1}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Decrease repeat interval"
|
||||||
|
accessibilityState={{ disabled: interval <= 1 }}
|
||||||
>
|
>
|
||||||
<Text style={[styles.stepButtonText, { color: interval <= 1 ? theme.textMuted : theme.text }]}>−</Text>
|
<Text style={[styles.stepButtonText, { color: interval <= 1 ? theme.textMuted : theme.text }]}>−</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -188,6 +198,9 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
|
|||||||
onPress={() => bumpInterval(1)}
|
onPress={() => bumpInterval(1)}
|
||||||
disabled={interval >= 30}
|
disabled={interval >= 30}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Increase repeat interval"
|
||||||
|
accessibilityState={{ disabled: interval >= 30 }}
|
||||||
>
|
>
|
||||||
<Text style={[styles.stepButtonText, { color: interval >= 30 ? theme.textMuted : theme.text }]}>+</Text>
|
<Text style={[styles.stepButtonText, { color: interval >= 30 ? theme.textMuted : theme.text }]}>+</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -210,6 +223,9 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
|
|||||||
]}
|
]}
|
||||||
onPress={() => toggleDay(day)}
|
onPress={() => toggleDay(day)}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
|
accessibilityRole="checkbox"
|
||||||
|
accessibilityLabel={WEEKDAY_NAMES[day]}
|
||||||
|
accessibilityState={{ checked: days.includes(day) }}
|
||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
@@ -282,7 +298,7 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
|
|||||||
disabled={!profileName.trim()}
|
disabled={!profileName.trim()}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
<Text style={[styles.modalButtonText, { color: '#FFFFFF', fontWeight: '600' }]}>Save</Text>
|
<Text style={[styles.modalButtonText, { color: theme.accentText, fontWeight: '600' }]}>Save</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export function ServerUrlModal({ visible, onClose }: ServerUrlModalProps) {
|
|||||||
disabled={!isValid}
|
disabled={!isValid}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
<Text style={styles.saveButtonText}>Save</Text>
|
<Text style={[styles.saveButtonText, { color: theme.accentText }]}>Save</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -160,6 +160,5 @@ const styles = StyleSheet.create({
|
|||||||
saveButtonText: {
|
saveButtonText: {
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
color: '#FFFFFF',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ interface SubtaskItemProps {
|
|||||||
onDragUpdate?: (absoluteY: number) => void;
|
onDragUpdate?: (absoluteY: number) => void;
|
||||||
onDragEnd?: (absoluteY: number) => void;
|
onDragEnd?: (absoluteY: number) => void;
|
||||||
depth?: number;
|
depth?: number;
|
||||||
|
categoryColor?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SubtaskItem = React.memo(function SubtaskItem({
|
export const SubtaskItem = React.memo(function SubtaskItem({
|
||||||
@@ -33,7 +34,8 @@ export const SubtaskItem = React.memo(function SubtaskItem({
|
|||||||
onDragStart,
|
onDragStart,
|
||||||
onDragUpdate,
|
onDragUpdate,
|
||||||
onDragEnd,
|
onDragEnd,
|
||||||
depth = 1
|
depth = 1,
|
||||||
|
categoryColor
|
||||||
}: SubtaskItemProps) {
|
}: SubtaskItemProps) {
|
||||||
const { theme } = useSettings();
|
const { theme } = useSettings();
|
||||||
const [expanded, setExpanded] = useState(true);
|
const [expanded, setExpanded] = useState(true);
|
||||||
@@ -59,6 +61,7 @@ export const SubtaskItem = React.memo(function SubtaskItem({
|
|||||||
onDragStart={onDragStart}
|
onDragStart={onDragStart}
|
||||||
onDragUpdate={onDragUpdate}
|
onDragUpdate={onDragUpdate}
|
||||||
onDragEnd={onDragEnd}
|
onDragEnd={onDragEnd}
|
||||||
|
categoryColor={categoryColor}
|
||||||
/>
|
/>
|
||||||
{hasChildren && expanded && (
|
{hasChildren && expanded && (
|
||||||
<View style={[styles.nestedSubtasks, { marginLeft: depth * 12 }]}>
|
<View style={[styles.nestedSubtasks, { marginLeft: depth * 12 }]}>
|
||||||
@@ -81,6 +84,7 @@ export const SubtaskItem = React.memo(function SubtaskItem({
|
|||||||
onDragUpdate={onDragUpdate}
|
onDragUpdate={onDragUpdate}
|
||||||
onDragEnd={onDragEnd}
|
onDragEnd={onDragEnd}
|
||||||
depth={depth + 1}
|
depth={depth + 1}
|
||||||
|
categoryColor={categoryColor}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -152,9 +152,9 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
|
|||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
{status === 'syncing' ? (
|
{status === 'syncing' ? (
|
||||||
<ActivityIndicator color="#FFFFFF" />
|
<ActivityIndicator color={theme.accentText} />
|
||||||
) : (
|
) : (
|
||||||
<Text style={styles.primaryButtonText}>Sync Now</Text>
|
<Text style={[styles.primaryButtonText, { color: theme.accentText }]}>Sync Now</Text>
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
@@ -202,7 +202,7 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
|
|||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
styles.segmentText,
|
styles.segmentText,
|
||||||
{ color: mode === m ? '#FFFFFF' : theme.textSecondary },
|
{ color: mode === m ? theme.accentText : theme.textSecondary },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{m === 'login' ? 'Sign In' : 'Create Account'}
|
{m === 'login' ? 'Sign In' : 'Create Account'}
|
||||||
@@ -243,9 +243,9 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
|
|||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
{busy ? (
|
{busy ? (
|
||||||
<ActivityIndicator color="#FFFFFF" />
|
<ActivityIndicator color={theme.accentText} />
|
||||||
) : (
|
) : (
|
||||||
<Text style={styles.primaryButtonText}>
|
<Text style={[styles.primaryButtonText, { color: theme.accentText }]}>
|
||||||
{mode === 'login' ? 'Sign In' : 'Create Account'}
|
{mode === 'login' ? 'Sign In' : 'Create Account'}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
@@ -349,7 +349,6 @@ const styles = StyleSheet.create({
|
|||||||
minHeight: 50,
|
minHeight: 50,
|
||||||
},
|
},
|
||||||
primaryButtonText: {
|
primaryButtonText: {
|
||||||
color: '#FFFFFF',
|
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ export default function SyncStatus({ compact = false }: SyncStatusProps) {
|
|||||||
disabled={status === 'syncing'}
|
disabled={status === 'syncing'}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
<Text style={[styles.syncButtonText, { color: status === 'syncing' ? theme.accent : '#FFFFFF' }]}>
|
<Text style={[styles.syncButtonText, { color: status === 'syncing' ? theme.accent : theme.accentText }]}>
|
||||||
{status === 'syncing' ? 'Syncing...' : 'Sync Now'}
|
{status === 'syncing' ? 'Syncing...' : 'Sync Now'}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
@@ -36,14 +36,15 @@ interface TaskItemProps {
|
|||||||
onDragUpdate?: (absoluteY: number) => void;
|
onDragUpdate?: (absoluteY: number) => void;
|
||||||
onDragEnd?: (absoluteY: number) => void;
|
onDragEnd?: (absoluteY: number) => void;
|
||||||
onReorderStart?: () => void;
|
onReorderStart?: () => void;
|
||||||
onReorderUpdate?: (translationY: number) => void;
|
onReorderUpdate?: (absoluteY: number) => void;
|
||||||
onReorderEnd?: (translationY: number) => void;
|
onReorderEnd?: (absoluteY: number, translationY: number) => void;
|
||||||
expanded?: boolean;
|
expanded?: boolean;
|
||||||
indented?: boolean;
|
indented?: boolean;
|
||||||
depth?: number;
|
depth?: number;
|
||||||
|
categoryColor?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
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, categoryColor }: 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));
|
||||||
@@ -66,7 +67,7 @@ export const TaskItem = React.memo(function TaskItem({ task, onToggle, onDelete,
|
|||||||
const reorderGesture = React.useMemo(
|
const reorderGesture = React.useMemo(
|
||||||
() =>
|
() =>
|
||||||
Gesture.Pan()
|
Gesture.Pan()
|
||||||
.activateAfterLongPress(0)
|
.activateAfterLongPress(400)
|
||||||
.minDistance(5)
|
.minDistance(5)
|
||||||
.runOnJS(true)
|
.runOnJS(true)
|
||||||
.onStart((e) => {
|
.onStart((e) => {
|
||||||
@@ -77,10 +78,10 @@ export const TaskItem = React.memo(function TaskItem({ task, onToggle, onDelete,
|
|||||||
.onUpdate((e) => {
|
.onUpdate((e) => {
|
||||||
dragTranslateX.setValue(e.translationX);
|
dragTranslateX.setValue(e.translationX);
|
||||||
dragTranslateY.setValue(e.translationY);
|
dragTranslateY.setValue(e.translationY);
|
||||||
onReorderUpdate?.(e.translationY);
|
onReorderUpdate?.(e.absoluteY);
|
||||||
})
|
})
|
||||||
.onEnd((e) => {
|
.onEnd((e) => {
|
||||||
onReorderEnd?.(e.translationY);
|
onReorderEnd?.(e.absoluteY, e.translationY);
|
||||||
})
|
})
|
||||||
.onFinalize(() => {
|
.onFinalize(() => {
|
||||||
setDragging(false);
|
setDragging(false);
|
||||||
@@ -220,32 +221,25 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
|
|||||||
onLongPress={onLongPress}
|
onLongPress={onLongPress}
|
||||||
delayLongPress={350}
|
delayLongPress={350}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
|
accessibilityRole={selectionMode ? 'checkbox' : 'button'}
|
||||||
|
accessibilityLabel={selectionMode ? `Select ${task.title}` : task.title}
|
||||||
|
accessibilityState={selectionMode ? { checked: selected } : { expanded }}
|
||||||
|
accessibilityHint={selectionMode ? undefined : 'Expands the task to show subtasks'}
|
||||||
>
|
>
|
||||||
<View style={styles.content}>
|
<View style={styles.content}>
|
||||||
<View style={styles.titleRow}>
|
<View style={styles.titleRow}>
|
||||||
<TouchableOpacity
|
<View style={styles.categoryDotSlot}>
|
||||||
style={[styles.dragHandle, { opacity: draggable ? 1 : 0 }]}
|
{categoryColor ? (
|
||||||
accessible={false}
|
<View style={[styles.categoryDot, { backgroundColor: categoryColor }]} />
|
||||||
onPressIn={() => {}}
|
) : null}
|
||||||
onPressOut={() => {}}
|
</View>
|
||||||
>
|
|
||||||
<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}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
|
accessibilityRole="checkbox"
|
||||||
accessibilityLabel={task.completed ? 'Mark incomplete' : canComplete ? 'Mark complete' : 'Task not due yet'}
|
accessibilityLabel={task.completed ? 'Mark incomplete' : canComplete ? 'Mark complete' : 'Task not due yet'}
|
||||||
|
accessibilityState={{ checked: task.completed, disabled: !canComplete && !task.completed }}
|
||||||
>
|
>
|
||||||
<Svg width={24} height={24} viewBox="0 0 24 24">
|
<Svg width={24} height={24} viewBox="0 0 24 24">
|
||||||
{task.completed ? (
|
{task.completed ? (
|
||||||
@@ -368,6 +362,10 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
|
|||||||
style={styles.menuButton}
|
style={styles.menuButton}
|
||||||
onPress={onMenuOpen}
|
onPress={onMenuOpen}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`Edit ${task.title}`}
|
||||||
|
accessibilityHint="Opens the task editor"
|
||||||
|
hitSlop={8}
|
||||||
>
|
>
|
||||||
<Svg width={24} height={24} viewBox="0 0 24 24">
|
<Svg width={24} height={24} viewBox="0 0 24 24">
|
||||||
<Circle cx={12} cy={12} r={1.5} fill={theme.textMuted} />
|
<Circle cx={12} cy={12} r={1.5} fill={theme.textMuted} />
|
||||||
@@ -476,6 +474,18 @@ const styles = StyleSheet.create({
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
marginRight: 8,
|
marginRight: 8,
|
||||||
},
|
},
|
||||||
|
categoryDotSlot: {
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
categoryDot: {
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
borderRadius: 5,
|
||||||
|
},
|
||||||
title: {
|
title: {
|
||||||
fontSize: 17,
|
fontSize: 17,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import React, { useState, useCallback, useMemo, useRef } from 'react';
|
import React, { useState, useCallback, useMemo, useRef, useEffect } from 'react';
|
||||||
import { View, Text, StyleSheet, Animated, RefreshControl, Alert, TouchableOpacity } from 'react-native';
|
import { View, Text, StyleSheet, Animated, RefreshControl, Alert, TouchableOpacity } from 'react-native';
|
||||||
import { useTasks } from '@/hooks/useTasks';
|
import { useTasks } from '@/hooks/useTasks';
|
||||||
|
import { useCategories } from '@/hooks/useDatabase';
|
||||||
import { useTaskModals } from '@/hooks/useTaskModals';
|
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 Task from '@/models/Task';
|
import Task from '@/models/Task';
|
||||||
import { useDatabase } from '@/hooks/useDatabase';
|
|
||||||
import { useSettings } from '@/theme';
|
import { useSettings } from '@/theme';
|
||||||
import {
|
import {
|
||||||
toggleTaskComplete,
|
toggleTaskComplete,
|
||||||
@@ -16,9 +16,8 @@ import {
|
|||||||
convertSubtaskToTask,
|
convertSubtaskToTask,
|
||||||
moveSubtaskToTask,
|
moveSubtaskToTask,
|
||||||
toggleSubtaskComplete,
|
toggleSubtaskComplete,
|
||||||
reorderTasks,
|
fetchSubtaskTree,
|
||||||
} from '@/utils/taskActions';
|
} from '@/utils/taskActions';
|
||||||
import { Q } from '@nozbe/watermelondb';
|
|
||||||
import Svg, { Path, Rect } from 'react-native-svg';
|
import Svg, { Path, Rect } from 'react-native-svg';
|
||||||
|
|
||||||
interface TaskListProps {
|
interface TaskListProps {
|
||||||
@@ -40,11 +39,18 @@ const DropIndicator = ({ theme }: { theme: any }) => (
|
|||||||
);
|
);
|
||||||
|
|
||||||
export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProps) {
|
export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProps) {
|
||||||
const { theme, sortBy } = useSettings();
|
const { theme, sortBy, todoAheadDays } = useSettings();
|
||||||
const { collections } = useDatabase();
|
|
||||||
|
|
||||||
const { tasks, loading } = useTasks(categoryId, false);
|
const { tasks, loading } = useTasks(categoryId, 'all', todoAheadDays);
|
||||||
const { tasks: completedTasks } = useTasks(categoryId, true);
|
|
||||||
|
const categories = useCategories();
|
||||||
|
const categoryColors = useMemo(() => {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
for (const c of categories) {
|
||||||
|
map.set(c.id, c.color);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [categories]);
|
||||||
|
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [selectionMode, setSelectionMode] = useState(false);
|
const [selectionMode, setSelectionMode] = useState(false);
|
||||||
@@ -52,18 +58,16 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
|||||||
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 [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 subtaskDragRef = useRef<{ subtaskId: string; parentTaskId: string; positions: Record<string, { top: number; bottom: number }> } | null>(null);
|
||||||
const {
|
const {
|
||||||
modals,
|
modals,
|
||||||
openTaskMenu,
|
|
||||||
openTaskDelete,
|
openTaskDelete,
|
||||||
openSubtaskMenu,
|
|
||||||
openSubtaskDelete,
|
openSubtaskDelete,
|
||||||
openSubtaskEdit,
|
openSubtaskEdit,
|
||||||
|
openTaskEdit,
|
||||||
} = useTaskModals();
|
} = useTaskModals();
|
||||||
|
|
||||||
const registerRef = useCallback((taskId: string, ref: View | null) => {
|
const registerRef = useCallback((taskId: string, ref: View | null) => {
|
||||||
@@ -127,70 +131,10 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
|||||||
}, [onSelectionChange]);
|
}, [onSelectionChange]);
|
||||||
|
|
||||||
const fetchSubtasks = useCallback(async (taskId: string) => {
|
const fetchSubtasks = useCallback(async (taskId: string) => {
|
||||||
const subs = await collections.subtasks.query(Q.where('task_id', taskId), Q.where('parent_subtask_id', null)).fetch();
|
const withNested = await fetchSubtaskTree(taskId);
|
||||||
const mapped: SubtaskData[] = subs.map((s: any) => ({
|
|
||||||
id: s.id,
|
|
||||||
taskId: s.taskId,
|
|
||||||
parentSubtaskId: s.parentSubtaskId || null,
|
|
||||||
title: s.title,
|
|
||||||
description: s.description || '',
|
|
||||||
priority: (s.priority || 'none') as SubtaskData['priority'],
|
|
||||||
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: [],
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 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));
|
setSubtasksMap((prev) => new Map(prev).set(taskId, withNested));
|
||||||
return withNested;
|
return withNested;
|
||||||
}, [collections.subtasks]);
|
}, []);
|
||||||
|
|
||||||
const refreshAll = useCallback(() => {
|
const refreshAll = useCallback(() => {
|
||||||
for (const taskId of expandedTasks) {
|
for (const taskId of expandedTasks) {
|
||||||
@@ -323,89 +267,17 @@ 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) => {
|
const handleSubtaskDragStart = useCallback(async (subtaskId: string, parentTaskId: string) => {
|
||||||
subtaskDragRef.current = { subtaskId, parentTaskId, positions: await measureItems() };
|
subtaskDragRef.current = { subtaskId, parentTaskId, positions: await measureItems() };
|
||||||
}, [measureItems]);
|
}, [measureItems]);
|
||||||
|
|
||||||
|
const handleSubtaskDragUpdate = useCallback((absoluteY: number) => {
|
||||||
|
const state = subtaskDragRef.current;
|
||||||
|
if (!state) return;
|
||||||
|
const target = findHoverTarget(absoluteY, state.subtaskId, state.positions);
|
||||||
|
setHoverTaskId((prev) => (prev === target ? prev : target));
|
||||||
|
}, [findHoverTarget]);
|
||||||
|
|
||||||
const handleSubtaskDragEnd = useCallback(async (absoluteY: number) => {
|
const handleSubtaskDragEnd = useCallback(async (absoluteY: number) => {
|
||||||
const state = subtaskDragRef.current;
|
const state = subtaskDragRef.current;
|
||||||
subtaskDragRef.current = null;
|
subtaskDragRef.current = null;
|
||||||
@@ -448,20 +320,22 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
|||||||
onExpand={toggleExpand}
|
onExpand={toggleExpand}
|
||||||
onSelect={toggleSelect}
|
onSelect={toggleSelect}
|
||||||
onEnterSelection={enterSelection}
|
onEnterSelection={enterSelection}
|
||||||
onMenuOpen={openTaskMenu}
|
onMenuOpen={(task) => openTaskEdit(task.id)}
|
||||||
onSubtaskToggle={handleSubtaskToggle}
|
onSubtaskToggle={handleSubtaskToggle}
|
||||||
onSubtaskDelete={openSubtaskDelete}
|
onSubtaskDelete={openSubtaskDelete}
|
||||||
onSubtaskEdit={openSubtaskEdit}
|
onSubtaskEdit={openSubtaskEdit}
|
||||||
onSubtaskMenuOpen={openSubtaskMenu}
|
onSubtaskMenuOpen={(subtask) => openSubtaskEdit(subtask.id)}
|
||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
onDragUpdate={handleDragUpdate}
|
onDragUpdate={handleDragUpdate}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
onSubtaskDragStart={handleSubtaskDragStart}
|
onSubtaskDragStart={handleSubtaskDragStart}
|
||||||
|
onSubtaskDragUpdate={handleSubtaskDragUpdate}
|
||||||
onSubtaskDragEnd={handleSubtaskDragEnd}
|
onSubtaskDragEnd={handleSubtaskDragEnd}
|
||||||
onReorderStart={handleDragStart}
|
onReorderStart={() => handleDragStart(item.id)}
|
||||||
onReorderUpdate={handleDragUpdate}
|
onReorderUpdate={handleDragUpdate}
|
||||||
onReorderEnd={handleDragEnd}
|
onReorderEnd={handleDragEnd}
|
||||||
selectedIds={selectedIds}
|
selectedIds={selectedIds}
|
||||||
|
categoryColor={categoryColors.get(item.categoryId)}
|
||||||
/>
|
/>
|
||||||
{showDropBelow && <DropIndicator theme={theme} />}
|
{showDropBelow && <DropIndicator theme={theme} />}
|
||||||
</View>
|
</View>
|
||||||
@@ -481,63 +355,34 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
|||||||
toggleExpand,
|
toggleExpand,
|
||||||
toggleSelect,
|
toggleSelect,
|
||||||
enterSelection,
|
enterSelection,
|
||||||
openTaskMenu,
|
openTaskEdit,
|
||||||
handleSubtaskToggle,
|
handleSubtaskToggle,
|
||||||
openSubtaskDelete,
|
openSubtaskDelete,
|
||||||
openSubtaskEdit,
|
openSubtaskEdit,
|
||||||
openSubtaskMenu,
|
|
||||||
handleDragStart,
|
handleDragStart,
|
||||||
handleDragUpdate,
|
handleDragUpdate,
|
||||||
handleDragEnd,
|
handleDragEnd,
|
||||||
handleSubtaskDragStart,
|
handleSubtaskDragStart,
|
||||||
|
handleSubtaskDragUpdate,
|
||||||
handleSubtaskDragEnd,
|
handleSubtaskDragEnd,
|
||||||
|
categoryColors,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
const listHeader = useMemo(() => {
|
const listHeader = useMemo(() => {
|
||||||
if (sortedTasks.length > 0 || completedTasks.length > 0) return null;
|
if (sortedTasks.length > 0) return null;
|
||||||
return (
|
return (
|
||||||
<View style={styles.emptyState}>
|
<View style={styles.emptyState}>
|
||||||
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>No tasks yet</Text>
|
<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>
|
<Text style={[styles.emptySubtext, { color: theme.textMuted }]}>Tap + to add your first task</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}, [sortedTasks.length, completedTasks.length, theme.textSecondary, theme.textMuted]);
|
}, [sortedTasks.length, theme.textSecondary, theme.textMuted]);
|
||||||
|
|
||||||
const listFooter = useMemo(() => {
|
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;
|
const showDropAtEnd = dropIndicator && dropIndicator.targetId === null;
|
||||||
|
return showDropAtEnd ? <DropIndicator theme={theme} /> : null;
|
||||||
return (
|
}, [dropIndicator, theme]);
|
||||||
<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 (
|
||||||
@@ -624,11 +469,13 @@ interface TaskRowProps {
|
|||||||
onDragUpdate: (absoluteY: number) => void;
|
onDragUpdate: (absoluteY: number) => void;
|
||||||
onDragEnd: (absoluteY: number) => void;
|
onDragEnd: (absoluteY: number) => void;
|
||||||
onSubtaskDragStart: (subtaskId: string, parentTaskId: string) => void;
|
onSubtaskDragStart: (subtaskId: string, parentTaskId: string) => void;
|
||||||
|
onSubtaskDragUpdate: (absoluteY: number) => void;
|
||||||
onSubtaskDragEnd: (absoluteY: number) => void;
|
onSubtaskDragEnd: (absoluteY: number) => void;
|
||||||
onReorderStart: (taskId: string) => void;
|
onReorderStart: () => void;
|
||||||
onReorderUpdate: (absoluteY: number) => void;
|
onReorderUpdate: (absoluteY: number) => void;
|
||||||
onReorderEnd: (translationY: number) => void;
|
onReorderEnd: (absoluteY: number, translationY: number) => void;
|
||||||
selectedIds: Set<string>;
|
selectedIds: Set<string>;
|
||||||
|
categoryColor?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TaskRow = React.memo(function TaskRow({
|
const TaskRow = React.memo(function TaskRow({
|
||||||
@@ -653,11 +500,13 @@ const TaskRow = React.memo(function TaskRow({
|
|||||||
onDragUpdate,
|
onDragUpdate,
|
||||||
onDragEnd,
|
onDragEnd,
|
||||||
onSubtaskDragStart,
|
onSubtaskDragStart,
|
||||||
|
onSubtaskDragUpdate,
|
||||||
onSubtaskDragEnd,
|
onSubtaskDragEnd,
|
||||||
onReorderStart,
|
onReorderStart,
|
||||||
onReorderUpdate,
|
onReorderUpdate,
|
||||||
onReorderEnd,
|
onReorderEnd,
|
||||||
selectedIds,
|
selectedIds,
|
||||||
|
categoryColor,
|
||||||
}: TaskRowProps) {
|
}: TaskRowProps) {
|
||||||
const sortedSubtasks = useMemo(
|
const sortedSubtasks = useMemo(
|
||||||
() => subtasks.slice().sort((a, b) => a.order - b.order),
|
() => subtasks.slice().sort((a, b) => a.order - b.order),
|
||||||
@@ -683,9 +532,10 @@ const TaskRow = React.memo(function TaskRow({
|
|||||||
onDragStart={() => onDragStart(task.id)}
|
onDragStart={() => onDragStart(task.id)}
|
||||||
onDragUpdate={onDragUpdate}
|
onDragUpdate={onDragUpdate}
|
||||||
onDragEnd={onDragEnd}
|
onDragEnd={onDragEnd}
|
||||||
onReorderStart={() => onReorderStart(task.id)}
|
onReorderStart={onReorderStart}
|
||||||
onReorderUpdate={onReorderUpdate}
|
onReorderUpdate={onReorderUpdate}
|
||||||
onReorderEnd={onReorderEnd}
|
onReorderEnd={onReorderEnd}
|
||||||
|
categoryColor={categoryColor}
|
||||||
/>
|
/>
|
||||||
{expanded && subtasks.length > 0 && (
|
{expanded && subtasks.length > 0 && (
|
||||||
<View style={styles.subtaskList}>
|
<View style={styles.subtaskList}>
|
||||||
@@ -701,58 +551,9 @@ const TaskRow = React.memo(function TaskRow({
|
|||||||
selectionMode={selectionMode}
|
selectionMode={selectionMode}
|
||||||
draggable
|
draggable
|
||||||
onDragStart={() => onSubtaskDragStart(sub.id, task.id)}
|
onDragStart={() => onSubtaskDragStart(sub.id, task.id)}
|
||||||
onDragUpdate={onDragUpdate}
|
onDragUpdate={onSubtaskDragUpdate}
|
||||||
onDragEnd={onSubtaskDragEnd}
|
onDragEnd={onSubtaskDragEnd}
|
||||||
/>
|
categoryColor={categoryColor}
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
interface CompletedSectionProps {
|
|
||||||
tasks: TaskData[];
|
|
||||||
onToggle: (task: TaskData) => void;
|
|
||||||
onDelete: (task: TaskData) => void;
|
|
||||||
onMenuOpen: (task: TaskData) => void;
|
|
||||||
onLongPress: (task: TaskData) => void;
|
|
||||||
selectionMode: boolean;
|
|
||||||
selectedIds: Set<string>;
|
|
||||||
onSelect: (taskId: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CompletedSection = React.memo(function CompletedSection({ tasks, onToggle, onDelete, onMenuOpen, onLongPress, selectionMode, selectedIds, onSelect }: CompletedSectionProps) {
|
|
||||||
const { theme } = useSettings();
|
|
||||||
const [expanded, setExpanded] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.completedSection}>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.completedHeader}
|
|
||||||
onPress={() => setExpanded(!expanded)}
|
|
||||||
>
|
|
||||||
<Text style={[styles.completedTitle, { color: theme.textFaint }]}>
|
|
||||||
Completed ({tasks.length})
|
|
||||||
</Text>
|
|
||||||
<Text style={[styles.completedToggle, { color: theme.accent }]}>
|
|
||||||
{expanded ? 'Hide' : 'Show'}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
{expanded && (
|
|
||||||
<View style={styles.completedList}>
|
|
||||||
{tasks.map((task) => (
|
|
||||||
<TaskItem
|
|
||||||
key={task.id}
|
|
||||||
task={task}
|
|
||||||
onToggle={() => onToggle(task)}
|
|
||||||
onDelete={() => onDelete(task)}
|
|
||||||
onPress={() => {}}
|
|
||||||
onLongPress={() => onLongPress(task)}
|
|
||||||
onMenuOpen={() => onMenuOpen(task)}
|
|
||||||
selected={selectedIds.has(task.id)}
|
|
||||||
selectionMode={selectionMode}
|
|
||||||
completedSection
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
@@ -786,6 +587,12 @@ const styles = StyleSheet.create({
|
|||||||
paddingRight: 4,
|
paddingRight: 4,
|
||||||
paddingTop: 8,
|
paddingTop: 8,
|
||||||
},
|
},
|
||||||
|
completedSubtasks: {
|
||||||
|
paddingLeft: 8,
|
||||||
|
paddingRight: 4,
|
||||||
|
paddingTop: 4,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
dragContainer: {
|
dragContainer: {
|
||||||
},
|
},
|
||||||
dropIndicatorContainer: {
|
dropIndicatorContainer: {
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ export function TaskNameInput({ error, ...props }: TaskNameInputProps) {
|
|||||||
placeholderTextColor={theme.textMuted}
|
placeholderTextColor={theme.textMuted}
|
||||||
maxLength={100}
|
maxLength={100}
|
||||||
autoCapitalize="sentences"
|
autoCapitalize="sentences"
|
||||||
|
accessibilityLabel="Task name"
|
||||||
|
accessibilityHint="Required field. Enter a name for the task"
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
{error && <Text style={styles.errorText}>{error}</Text>}
|
{error && <Text style={styles.errorText}>{error}</Text>}
|
||||||
|
|||||||
@@ -119,6 +119,8 @@ export function TaskOverflowMenu({
|
|||||||
action.onPress();
|
action.onPress();
|
||||||
}}
|
}}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`${action.label}${action.destructive ? ' (dangerous)' : ''}`}
|
||||||
>
|
>
|
||||||
{action.icon}
|
{action.icon}
|
||||||
<Text style={[styles.actionText, action.destructive ? styles.destructiveText : { color: theme.textSecondary }]}>
|
<Text style={[styles.actionText, action.destructive ? styles.destructiveText : { color: theme.textSecondary }]}>
|
||||||
|
|||||||
@@ -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: 15,
|
version: 16,
|
||||||
tables: [
|
tables: [
|
||||||
tableSchema({
|
tableSchema({
|
||||||
name: 'categories',
|
name: 'categories',
|
||||||
@@ -31,6 +31,7 @@ export const schema = appSchema({
|
|||||||
{ name: 'repeat_days', type: 'string' },
|
{ name: 'repeat_days', type: 'string' },
|
||||||
{ name: 'color', type: 'string' },
|
{ name: 'color', type: 'string' },
|
||||||
{ name: 'series_id', type: 'string', isIndexed: true },
|
{ name: 'series_id', type: 'string', isIndexed: true },
|
||||||
|
{ name: 'order', type: 'number', isOptional: true },
|
||||||
{ 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 },
|
||||||
|
|||||||
@@ -304,7 +304,7 @@ export function useTaskModals() {
|
|||||||
<OptionPickerModal
|
<OptionPickerModal
|
||||||
visible={picker?.type === 'category'}
|
visible={picker?.type === 'category'}
|
||||||
title="Change Category"
|
title="Change Category"
|
||||||
options={categories.map((c) => ({ value: c.id, label: c.name, color: c.color }))}
|
options={[{ value: '', label: 'None', color: '#9E9E9E' }, ...categories.map((c) => ({ value: c.id, label: c.name, color: c.color }))]}
|
||||||
selectedValue={menuTask?.categoryId}
|
selectedValue={menuTask?.categoryId}
|
||||||
onSelect={handleSingleCategory}
|
onSelect={handleSingleCategory}
|
||||||
onClose={() => setPicker(null)}
|
onClose={() => setPicker(null)}
|
||||||
|
|||||||
@@ -2,12 +2,75 @@ import { useDatabase } from './useDatabase';
|
|||||||
import { Q } from '@nozbe/watermelondb';
|
import { Q } from '@nozbe/watermelondb';
|
||||||
import { useEffect, useState, useMemo } from 'react';
|
import { useEffect, useState, useMemo } from 'react';
|
||||||
import Task from '../models/Task';
|
import Task from '../models/Task';
|
||||||
|
import { startOfMonth, endOfMonth } from 'date-fns';
|
||||||
|
|
||||||
export function useTasks(categoryId?: string, showCompleted = false) {
|
export function useTasksInMonth(monthDate: 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 range = useMemo(() => {
|
||||||
|
const s = startOfMonth(monthDate);
|
||||||
|
s.setHours(0, 0, 0, 0);
|
||||||
|
const e = endOfMonth(monthDate);
|
||||||
|
e.setHours(23, 59, 59, 999);
|
||||||
|
return { start: s.getTime(), end: e.getTime() };
|
||||||
|
}, [monthDate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
setLoading(true);
|
||||||
|
const subscription = collections.tasks
|
||||||
|
.query(
|
||||||
|
Q.where('due_date', Q.between(range.start, range.end)),
|
||||||
|
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, range.start, range.end]);
|
||||||
|
|
||||||
|
const byDay = useMemo(() => {
|
||||||
|
const map: Record<number, Task[]> = {};
|
||||||
|
for (const t of tasks) {
|
||||||
|
const key = new Date(t.dueDate).getDate();
|
||||||
|
const bucket = map[key] ?? [];
|
||||||
|
bucket.push(t);
|
||||||
|
map[key] = bucket;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [tasks]);
|
||||||
|
|
||||||
|
return { tasks, byDay, loading };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTasks(categoryId?: string, showCompleted: boolean | 'all' = false, maxAheadDays?: number) {
|
||||||
|
const { collections } = useDatabase();
|
||||||
|
const [tasks, setTasks] = useState<Task[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const cutoff = useMemo(() => {
|
||||||
|
if (maxAheadDays === undefined) return null;
|
||||||
|
const d = new Date();
|
||||||
|
d.setHours(23, 59, 59, 999);
|
||||||
|
d.setDate(d.getDate() + maxAheadDays);
|
||||||
|
return d.getTime();
|
||||||
|
}, [maxAheadDays]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
const conditions: any[] = [];
|
const conditions: any[] = [];
|
||||||
@@ -16,12 +79,16 @@ 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 === true) {
|
||||||
conditions.push(Q.where('completed', true));
|
conditions.push(Q.where('completed', true));
|
||||||
} else {
|
} else if (showCompleted === false) {
|
||||||
conditions.push(Q.where('completed', false));
|
conditions.push(Q.where('completed', false));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (cutoff !== null) {
|
||||||
|
conditions.push(Q.where('due_date', Q.lte(cutoff)));
|
||||||
|
}
|
||||||
|
|
||||||
const query = conditions.length > 0
|
const query = conditions.length > 0
|
||||||
? collections.tasks.query(Q.and(...conditions))
|
? collections.tasks.query(Q.and(...conditions))
|
||||||
: collections.tasks.query();
|
: collections.tasks.query();
|
||||||
@@ -44,7 +111,7 @@ export function useTasks(categoryId?: string, showCompleted = false) {
|
|||||||
mounted = false;
|
mounted = false;
|
||||||
subscription.unsubscribe();
|
subscription.unsubscribe();
|
||||||
};
|
};
|
||||||
}, [collections, categoryId, showCompleted]);
|
}, [collections, categoryId, showCompleted, cutoff]);
|
||||||
|
|
||||||
return { tasks, loading };
|
return { tasks, loading };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,13 +36,53 @@ export interface ThemeColors {
|
|||||||
accent: string;
|
accent: string;
|
||||||
accentSoft: string;
|
accentSoft: string;
|
||||||
accentBorder: string;
|
accentBorder: string;
|
||||||
|
accentText: string;
|
||||||
inputBg: string;
|
inputBg: string;
|
||||||
overlay: string;
|
overlay: string;
|
||||||
sheetBg: string;
|
sheetBg: string;
|
||||||
tabBarBg: string;
|
tabBarBg: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const colors: ThemeColors = {
|
export const DEFAULT_ACCENT = '#EF5350';
|
||||||
|
|
||||||
|
export const ACCENT_PRESETS: string[] = [
|
||||||
|
'#EF5350',
|
||||||
|
'#E91E63',
|
||||||
|
'#AB47BC',
|
||||||
|
'#7E57C2',
|
||||||
|
'#5C6BC0',
|
||||||
|
'#29B6F6',
|
||||||
|
'#26A69A',
|
||||||
|
'#66BB6A',
|
||||||
|
'#FFCA28',
|
||||||
|
'#FF7043',
|
||||||
|
'#8D6E63',
|
||||||
|
];
|
||||||
|
|
||||||
|
function parseHex(hex: string): [number, number, number] {
|
||||||
|
const h = hex.replace(/^#/, '');
|
||||||
|
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toHexByte(n: number): string {
|
||||||
|
return Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mixHex(color: string, target: string, ratio: number): string {
|
||||||
|
const [r1, g1, b1] = parseHex(color);
|
||||||
|
const [r2, g2, b2] = parseHex(target);
|
||||||
|
return `#${toHexByte(r1 + (r2 - r1) * ratio)}${toHexByte(g1 + (g2 - g1) * ratio)}${toHexByte(b1 + (b2 - b1) * ratio)}`.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function accentTextColor(accent: string): string {
|
||||||
|
const [r, g, b] = parseHex(accent).map((v) => v / 255);
|
||||||
|
const linear = (v: number) => (v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4));
|
||||||
|
const luminance = 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b);
|
||||||
|
return luminance > 0.5 ? '#111111' : '#FFFFFF';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function colors(accent: string = DEFAULT_ACCENT): ThemeColors {
|
||||||
|
return {
|
||||||
background: '#121212',
|
background: '#121212',
|
||||||
card: '#1E1E1E',
|
card: '#1E1E1E',
|
||||||
cardAlt: '#262626',
|
cardAlt: '#262626',
|
||||||
@@ -52,14 +92,16 @@ export const colors: ThemeColors = {
|
|||||||
textSecondary: '#E0E0E0',
|
textSecondary: '#E0E0E0',
|
||||||
textFaint: '#BDBDBD',
|
textFaint: '#BDBDBD',
|
||||||
textMuted: '#8E8E8E',
|
textMuted: '#8E8E8E',
|
||||||
accent: '#EF5350',
|
accent,
|
||||||
accentSoft: '#2A1D1D',
|
accentSoft: mixHex(accent, '#121212', 0.12),
|
||||||
accentBorder: '#4A2B2B',
|
accentBorder: mixHex(accent, '#121212', 0.25),
|
||||||
|
accentText: accentTextColor(accent),
|
||||||
inputBg: '#1A1A1A',
|
inputBg: '#1A1A1A',
|
||||||
overlay: 'rgba(0,0,0,0.6)',
|
overlay: 'rgba(0,0,0,0.6)',
|
||||||
sheetBg: '#242424',
|
sheetBg: '#242424',
|
||||||
tabBarBg: '#1A1A1A',
|
tabBarBg: '#1A1A1A',
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
interface SettingsContextType {
|
interface SettingsContextType {
|
||||||
notifications: boolean;
|
notifications: boolean;
|
||||||
@@ -72,14 +114,22 @@ interface SettingsContextType {
|
|||||||
setReminderPreference: (value: ReminderPreference) => void;
|
setReminderPreference: (value: ReminderPreference) => void;
|
||||||
apiUrl: string;
|
apiUrl: string;
|
||||||
setApiUrl: (value: string) => void;
|
setApiUrl: (value: string) => void;
|
||||||
|
accentColor: string;
|
||||||
|
setAccentColor: (value: string) => void;
|
||||||
|
todoAheadDays: number;
|
||||||
|
setTodoAheadDays: (value: number) => void;
|
||||||
theme: ThemeColors;
|
theme: ThemeColors;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_TODO_AHEAD_DAYS = 7;
|
||||||
|
|
||||||
const STORAGE_KEYS = {
|
const STORAGE_KEYS = {
|
||||||
notifications: 'settings:notifications',
|
notifications: 'settings:notifications',
|
||||||
defaultCategoryId: 'settings:defaultCategoryId',
|
defaultCategoryId: 'settings:defaultCategoryId',
|
||||||
sortBy: 'settings:sortBy',
|
sortBy: 'settings:sortBy',
|
||||||
reminderPreference: 'settings:reminderPreference',
|
reminderPreference: 'settings:reminderPreference',
|
||||||
|
accentColor: 'settings:accentColor',
|
||||||
|
todoAheadDays: 'settings:todoAheadDays',
|
||||||
};
|
};
|
||||||
|
|
||||||
const SettingsContext = createContext<SettingsContextType | null>(null);
|
const SettingsContext = createContext<SettingsContextType | null>(null);
|
||||||
@@ -119,8 +169,10 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
|
|||||||
'15m',
|
'15m',
|
||||||
);
|
);
|
||||||
const [apiUrl, setApiUrl] = useStoredSetting<string>(API_URL_KEY, DEFAULT_API_BASE_URL);
|
const [apiUrl, setApiUrl] = useStoredSetting<string>(API_URL_KEY, DEFAULT_API_BASE_URL);
|
||||||
|
const [accentColor, setAccentColor] = useStoredSetting<string>(STORAGE_KEYS.accentColor, DEFAULT_ACCENT);
|
||||||
|
const [todoAheadDays, setTodoAheadDays] = useStoredSetting<number>(STORAGE_KEYS.todoAheadDays, DEFAULT_TODO_AHEAD_DAYS);
|
||||||
|
|
||||||
const theme = colors;
|
const theme = useMemo(() => colors(accentColor), [accentColor]);
|
||||||
|
|
||||||
const value = useMemo<SettingsContextType>(
|
const value = useMemo<SettingsContextType>(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -134,6 +186,10 @@ const value = useMemo<SettingsContextType>(
|
|||||||
setReminderPreference,
|
setReminderPreference,
|
||||||
apiUrl,
|
apiUrl,
|
||||||
setApiUrl,
|
setApiUrl,
|
||||||
|
accentColor,
|
||||||
|
setAccentColor,
|
||||||
|
todoAheadDays,
|
||||||
|
setTodoAheadDays,
|
||||||
theme,
|
theme,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
@@ -147,6 +203,10 @@ apiUrl,
|
|||||||
setReminderPreference,
|
setReminderPreference,
|
||||||
apiUrl,
|
apiUrl,
|
||||||
setApiUrl,
|
setApiUrl,
|
||||||
|
accentColor,
|
||||||
|
setAccentColor,
|
||||||
|
todoAheadDays,
|
||||||
|
setTodoAheadDays,
|
||||||
theme,
|
theme,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -34,13 +34,11 @@ export async function deleteCategory(categoryId: string): Promise<void> {
|
|||||||
|
|
||||||
const tasks = await collections.tasks.query(Q.where('category_id', categoryId)).fetch();
|
const tasks = await collections.tasks.query(Q.where('category_id', categoryId)).fetch();
|
||||||
for (const task of tasks) {
|
for (const task of tasks) {
|
||||||
if (fallback) {
|
|
||||||
await task.update((t) => {
|
await task.update((t) => {
|
||||||
t.categoryId = fallback.id;
|
t.categoryId = fallback?.id ?? '';
|
||||||
t.updatedAt = new Date();
|
t.updatedAt = new Date();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const category = await collections.categories.find(categoryId);
|
const category = await collections.categories.find(categoryId);
|
||||||
await category.destroyPermanently();
|
await category.destroyPermanently();
|
||||||
|
|||||||
@@ -1,8 +1,49 @@
|
|||||||
import { database, collections } from '@/database';
|
import { database, collections } from '@/database';
|
||||||
import { Q } from '@nozbe/watermelondb';
|
import { Q } from '@nozbe/watermelondb';
|
||||||
import { Priority, Repeat, Reminder } from '@/types';
|
import { Priority, Repeat, Reminder, SubtaskData } from '@/types';
|
||||||
import { scheduleTaskReminder, cancelTaskReminder } from '@/services/notifications';
|
import { scheduleTaskReminder, cancelTaskReminder } from '@/services/notifications';
|
||||||
|
|
||||||
|
function mapSubtaskRow(s: any, taskId?: string): SubtaskData {
|
||||||
|
return {
|
||||||
|
id: s.id,
|
||||||
|
taskId: s.taskId ?? taskId ?? '',
|
||||||
|
parentSubtaskId: s.parentSubtaskId || null,
|
||||||
|
title: s.title,
|
||||||
|
description: s.description || '',
|
||||||
|
priority: (s.priority || 'none') as Priority,
|
||||||
|
completed: s.completed,
|
||||||
|
dueDate: s.dueDate || 0,
|
||||||
|
dueTime: s.dueTime || '',
|
||||||
|
endTime: s.endTime || '',
|
||||||
|
allDay: s.allDay ?? false,
|
||||||
|
repeat: (s.repeat || 'none') as Repeat,
|
||||||
|
repeatInterval: s.repeatInterval ?? 1,
|
||||||
|
repeatDays: s.repeatDays || '',
|
||||||
|
seriesId: s.seriesId || '',
|
||||||
|
reminder: (s.reminder || 'none') as Reminder,
|
||||||
|
assigneeId: s.assigneeId ?? null,
|
||||||
|
order: s.order,
|
||||||
|
subtasks: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSubtaskTree(taskId: string): Promise<SubtaskData[]> {
|
||||||
|
const topLevel = await collections.subtasks.query(Q.where('task_id', taskId), Q.where('parent_subtask_id', null)).fetch();
|
||||||
|
const items: SubtaskData[] = topLevel.map((s: any) => mapSubtaskRow(s, taskId));
|
||||||
|
|
||||||
|
const decorate = async (subtasks: SubtaskData[]): Promise<SubtaskData[]> => {
|
||||||
|
for (const sub of subtasks) {
|
||||||
|
const children = await collections.subtasks.query(Q.where('parent_subtask_id', sub.id)).fetch();
|
||||||
|
if (children.length > 0) {
|
||||||
|
sub.subtasks = await decorate(children.map((c: any) => mapSubtaskRow(c, taskId)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return subtasks;
|
||||||
|
};
|
||||||
|
|
||||||
|
return decorate(items);
|
||||||
|
}
|
||||||
|
|
||||||
function addDays(date: Date, days: number): Date {
|
function addDays(date: Date, days: number): Date {
|
||||||
const next = new Date(date);
|
const next = new Date(date);
|
||||||
next.setDate(next.getDate() + days);
|
next.setDate(next.getDate() + days);
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: carry-your-live-db
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
POSTGRES_DB: carry_your_live
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
target: prod
|
||||||
|
container_name: carry-your-live-api
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/carry_your_live
|
||||||
|
JWT_SECRET: your-super-secret-jwt-key-change-in-production-min-32-chars
|
||||||
|
PORT: 3000
|
||||||
|
NODE_ENV: production
|
||||||
|
FRONTEND_URL: http://localhost:8081
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./carry-your-live
|
||||||
|
container_name: carry-your-live-web
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
ports:
|
||||||
|
- "8081:8081"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
@@ -1,34 +1,34 @@
|
|||||||
add a circle bevor the tasks so they can be checked ✓
|
- [x] Tasks should be renamed to ToDo
|
||||||
|
- [x] top of ToDo page should say ToDo instead of TODO
|
||||||
change behavior on click ✓
|
- [ ] Calendar rework
|
||||||
|
- [x] when task with subtasks marked as done it hides the whole task and the subtask are gone and can't be seen anymore (subtask should be visible after completion)
|
||||||
apply swipe gestures ✓
|
- [x] when dragging task to become subtask user should hold the task and not tap
|
||||||
|
because now it's very hard to scroll through tasks without randomly creating subtasks
|
||||||
|
- [ ] when creating new task there is a huge gap between the create task box and the keyboard
|
||||||
test server stuff ✓
|
- [ ] after creating task keyboard should automatically be hidden again
|
||||||
|
now it's hard to get rid of the keyboard without clicking on other tasks
|
||||||
tell the user the sync status
|
- [ ] ToDo page button for category: hitbox very weird hard to click
|
||||||
|
when clicked should be framed in accent color and not get bigger like the all
|
||||||
commit the app
|
category now
|
||||||
|
- [ ] category all button doesn't have the correct spacing between the dot and the text
|
||||||
build an android app and notify it for updates on the app
|
- [ ] no way to tell what category the task has -> should be a colored dot in the left of the task box replacing the nine random dots that don't anything
|
||||||
|
- [ ] task from the calendar page should only show up in the ToDo page if it is that day or x days in advance (x can be set in settings)
|
||||||
add an all-day option to tasks ✓
|
- [ ] clicking the three dots should open the "add new task" window instead of the window with the limited options it opens currently
|
||||||
fix the calendar layout
|
- [ ] swiping task for deleting or completing
|
||||||
|
- [x] no category required by creating task
|
||||||
implemennt that just task for today and the past can be checked ✓
|
- [ ] calendar task arrow not centered
|
||||||
|
- [X] page names (up top) are not centered
|
||||||
|
- [X] tick icon in the top left needs to be removed/ replaced/ given a function
|
||||||
implement stats
|
- [ ] settings -> categories -> new / change category window needs to be centered and fully visible
|
||||||
|
exit window button not visible or not existent
|
||||||
|
user should be able to click outside of the window to close it
|
||||||
implemnt an android widget for quickadd tasks
|
- [ ] pressing the go back button android:
|
||||||
|
in settings go to previous page task/ calendar/ stats
|
||||||
|
in other (task/ calendar/ stats) stay on that page
|
||||||
fix spacing for todo cards more space for todos
|
at the moment calendar and stats go to tasks
|
||||||
|
- [ ] ToDo -> select category -> scroll to the right most -> button to go to categories in settings
|
||||||
|
- [x] accent color should be changeable
|
||||||
fix edit todo form layout so it fitts nicely on phone screen
|
- [ ] bottom page buttons need to be reworked looking ass currently.
|
||||||
|
- [ ] add a task button should say "Add a Task" and (the button to add have an arrow pointing upwards instead of a cross) could look better
|
||||||
fix buggy scroll for time selection
|
- [ ] space between add task button and bottom bar should be zero of the task below should be blurred currently task visible between the two looks weird
|
||||||
|
- [ ] Stats page rework (when time is right)
|
||||||
Reference in New Issue
Block a user