2 Commits
Author SHA1 Message Date
tech08mag 4b6e87c979 docker compose for backend +frontend
Build APK / build (push) Canceled after 0s
2026-08-07 22:30:00 +02:00
tech08mag ed31f24b23 functioning apk with reworked calendar 2026-08-07 22:29:46 +02:00
43 changed files with 1742 additions and 725 deletions
+10
View File
@@ -0,0 +1,10 @@
node_modules
dist
.expo
.git
*.log
.DS_Store
android
ios
coverage
*.local
+18
View File
@@ -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;"]
+529 -181
View File
@@ -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);
if (!isSameMonth(selectedDateRef.current, next)) {
setSelectedDate(startOfMonth(next));
}
setVisibleMonth(next);
translateX.setValue(-target);
Animated.timing(translateX, { toValue: 0, duration: 180, useNativeDriver: false }).start(() => {
animatingRef.current = false;
});
});
},
[translateX]
);
const handleNextMonth = useCallback(() => { const pan = useMemo(
const next = addMonths(visibleMonth, 1); () =>
setVisibleMonth(next); Gesture.Pan()
if (!isSameMonth(selectedDate, next)) { .activeOffsetX([-16, 16])
setSelectedDate(startOfMonth(next)); .minDistance(6)
} .runOnJS(true)
}, [visibleMonth, selectedDate]); .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 }]}>
<Header title="Calendar" showLogo={false} /> <GestureDetector gesture={pan}>
<View style={styles.flex}>
<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 <View style={styles.monthRow}>
key={day.toISOString()} <TouchableOpacity
day={day} onPress={() => transitionTo(-1)}
selected={isSameDay(day, selectedDate)} style={[styles.monthNav, { borderColor: theme.borderStrong, backgroundColor: theme.card }]}
current={isToday(day) && !isSameDay(day, selectedDate)} activeOpacity={0.7}
onPress={handleDayPress} >
theme={theme} <Svg width={20} height={20} viewBox="0 0 24 24">
<Path d="M15 18l-6-6 6-6" stroke={theme.textFaint} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" fill="none" />
</Svg>
</TouchableOpacity>
<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">
<Path d="M9 6l6 6-6 6" stroke={theme.textFaint} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" fill="none" />
</Svg>
</TouchableOpacity>
</View>
<View style={styles.weekdayRow}>
{GERMAN_WEEKDAYS.map((day, i) => (
<Text key={day + i} style={[styles.weekday, { color: theme.textMuted }]}>
{day}
</Text>
))}
</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}>
<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>
) : (
selectedDayTasks.map((task) => (
<View key={task.id} style={[styles.eventCard, { backgroundColor: theme.card, borderColor: theme.border }]}>
<View style={styles.eventRow}>
<TouchableOpacity
style={styles.checkCircle}
onPress={() => handleToggleComplete(task.id)}
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>
<TouchableOpacity
style={styles.eventTitleTouch}
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(() => {})}
<OptionPickerModal
visible={monthPickerVisible}
title="Select Month"
options={monthOptions}
selectedValue={String(visibleMonth.getMonth())}
onSelect={handleSelectMonth}
onClose={() => setMonthPickerVisible(false)}
/> />
))} <OptionPickerModal
</ScrollView> visible={yearPickerVisible}
title="Select Year"
<View style={styles.monthRow}> options={yearOptions}
<TouchableOpacity onPress={handlePrevMonth} style={[styles.monthNav, { borderColor: theme.borderStrong, backgroundColor: theme.card }]} activeOpacity={0.7}> selectedValue={String(visibleMonth.getFullYear())}
<Svg width={20} height={20} viewBox="0 0 24 24"> onSelect={handleSelectYear}
<Path d="M15 18l-6-6 6-6" stroke={theme.textFaint} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" fill="none" /> onClose={() => setYearPickerVisible(false)}
</Svg> />
</TouchableOpacity> </View>
<Text style={[styles.monthLabel, { color: theme.text }]}>{format(visibleMonth, 'MMM yyyy').toUpperCase()}</Text> </GestureDetector>
<TouchableOpacity onPress={handleNextMonth} style={[styles.monthNav, { borderColor: theme.borderStrong, backgroundColor: theme.card }]} activeOpacity={0.7}>
<Svg width={20} height={20} viewBox="0 0 24 24">
<Path d="M9 6l6 6-6 6" stroke={theme.textFaint} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" fill="none" />
</Svg>
</TouchableOpacity>
</View>
<FlatList
data={tasks}
keyExtractor={(item) => item.id}
renderItem={renderTask}
ItemSeparatorComponent={MemoSeparator}
ListEmptyComponent={
loading ? (
<View style={styles.emptyState}>
<Text style={[styles.emptyText, { color: theme.textFaint }]}>Loading...</Text>
</View>
) : (
<View style={styles.emptyState}>
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>No tasks scheduled.</Text>
<Text style={[styles.emptySubtext, { color: theme.textMuted }]}>Use the bar below to add one</Text>
</View>
)
}
contentContainerStyle={styles.listContent}
/>
<QuickAddBar
dueDate={selectedDate.getTime()}
placeholder={`Add task for ${format(selectedDate, 'MMM d')}`}
/>
{modals(() => {})}
</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',
},
}); });
+1 -1
View File
@@ -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>
+179 -4
View File
@@ -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',
},
}); });
+6 -6
View File
@@ -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}
+1 -1
View File
@@ -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}
+3 -3
View File
@@ -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}
+44
View File
@@ -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()
}
})
+21
View File
@@ -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;
}
}
+6 -70
View File
@@ -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,12 +26,15 @@ 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">
<Path d="M9 18l6-6-6-6" stroke={theme.textMuted} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" fill="none" /> <Path d="M9 18l6-6-6-6" stroke={theme.textMuted} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" fill="none" />
@@ -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,
+4 -3
View File
@@ -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,
+10 -6
View File
@@ -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>
+5 -6
View File
@@ -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>
+34 -24
View File
@@ -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',
+52 -245
View File
@@ -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,8 +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>
@@ -711,56 +562,6 @@ const TaskRow = React.memo(function TaskRow({
); );
}); });
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>
);
});
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
@@ -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 }]}>
+2 -1
View File
@@ -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 },
+1 -1
View File
@@ -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)}
+71 -4
View File
@@ -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 };
} }
+84 -24
View File
@@ -36,30 +36,72 @@ 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';
background: '#121212',
card: '#1E1E1E', export const ACCENT_PRESETS: string[] = [
cardAlt: '#262626', '#EF5350',
border: '#2A2A2A', '#E91E63',
borderStrong: '#3A3A3A', '#AB47BC',
text: '#F5F5F5', '#7E57C2',
textSecondary: '#E0E0E0', '#5C6BC0',
textFaint: '#BDBDBD', '#29B6F6',
textMuted: '#8E8E8E', '#26A69A',
accent: '#EF5350', '#66BB6A',
accentSoft: '#2A1D1D', '#FFCA28',
accentBorder: '#4A2B2B', '#FF7043',
inputBg: '#1A1A1A', '#8D6E63',
overlay: 'rgba(0,0,0,0.6)', ];
sheetBg: '#242424',
tabBarBg: '#1A1A1A', 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',
card: '#1E1E1E',
cardAlt: '#262626',
border: '#2A2A2A',
borderStrong: '#3A3A3A',
text: '#F5F5F5',
textSecondary: '#E0E0E0',
textFaint: '#BDBDBD',
textMuted: '#8E8E8E',
accent,
accentSoft: mixHex(accent, '#121212', 0.12),
accentBorder: mixHex(accent, '#121212', 0.25),
accentText: accentTextColor(accent),
inputBg: '#1A1A1A',
overlay: 'rgba(0,0,0,0.6)',
sheetBg: '#242424',
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,10 +169,12 @@ 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>(
() => ({ () => ({
notifications, notifications,
setNotifications, setNotifications,
@@ -132,10 +184,14 @@ const value = useMemo<SettingsContextType>(
setSortBy, setSortBy,
reminderPreference, reminderPreference,
setReminderPreference, setReminderPreference,
apiUrl, apiUrl,
setApiUrl, setApiUrl,
theme, accentColor,
}), setAccentColor,
todoAheadDays,
setTodoAheadDays,
theme,
}),
[ [
notifications, notifications,
setNotifications, setNotifications,
@@ -147,6 +203,10 @@ apiUrl,
setReminderPreference, setReminderPreference,
apiUrl, apiUrl,
setApiUrl, setApiUrl,
accentColor,
setAccentColor,
todoAheadDays,
setTodoAheadDays,
theme, theme,
] ]
); );
+4 -6
View File
@@ -34,12 +34,10 @@ 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);
+42 -1
View File
@@ -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);
+48
View File
@@ -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:
+34 -34
View File
@@ -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)