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;"]
+530 -182
View File
@@ -1,224 +1,448 @@
import React, { useMemo, useRef, useState, useCallback } from 'react';
import { View, Text, StyleSheet, SafeAreaView, ScrollView, TouchableOpacity, FlatList } from 'react-native';
import React, { useMemo, useRef, useState, useCallback, useEffect } from 'react';
import { View, Text, StyleSheet, SafeAreaView, ScrollView, TouchableOpacity, Animated, Dimensions } from 'react-native';
import { useRouter } from 'expo-router';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import { Header } from '@/components/Header';
import { useTasksByDate } from '@/hooks/useTasks';
import { useTasksByDate, useTasksInMonth } from '@/hooks/useTasks';
import { useTaskModals } from '@/hooks/useTaskModals';
import { toggleTaskComplete } from '@/utils/taskActions';
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 { TaskData } from '@/types';
import { format, startOfMonth, endOfMonth, eachDayOfInterval, isSameDay, isSameMonth, addMonths, isToday, startOfDay } from 'date-fns';
import Svg, { Path } from 'react-native-svg';
import { OptionPickerModal } from '@/components/OptionPickerModal';
import { SubtaskData } from '@/types';
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';
const DAY_WIDTH = 44;
const DAY_GAP = 6;
const WIDTH = Dimensions.get('window').width;
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() {
const router = useRouter();
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 [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(
() => eachDayOfInterval({ start: startOfMonth(visibleMonth), end: endOfMonth(visibleMonth) }),
[visibleMonth]
const visibleMonthRef = useRef(visibleMonth);
visibleMonthRef.current = 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) => {
setSelectedDate(day);
if (!isSameMonth(day, visibleMonth)) {
if (!isSameMonth(day, visibleMonthRef.current)) {
setVisibleMonth(day);
}
}, [visibleMonth]);
}, []);
const handlePrevMonth = useCallback(() => {
const prev = addMonths(visibleMonth, -1);
setVisibleMonth(prev);
if (!isSameMonth(selectedDate, prev)) {
setSelectedDate(startOfMonth(prev));
}
}, [visibleMonth, selectedDate]);
const transitionTo = useCallback(
(dir: 1 | -1) => {
if (animatingRef.current) return;
animatingRef.current = true;
const w = gridWidthRef.current || WIDTH;
const target = dir === 1 ? -w : w;
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 next = addMonths(visibleMonth, 1);
setVisibleMonth(next);
if (!isSameMonth(selectedDate, next)) {
setSelectedDate(startOfMonth(next));
}
}, [visibleMonth, selectedDate]);
const pan = useMemo(
() =>
Gesture.Pan()
.activeOffsetX([-16, 16])
.minDistance(6)
.runOnJS(true)
.onUpdate((e) => {
if (!animatingRef.current) translateX.setValue(e.translationX);
})
.onEnd((e) => {
if (animatingRef.current) return;
const w = gridWidthRef.current || WIDTH;
const dx = e.translationX;
if (dx <= -w / 4) {
translateX.stopAnimation();
transitionTo(1);
} else if (dx >= w / 4) {
translateX.stopAnimation();
transitionTo(-1);
} else {
Animated.spring(translateX, { toValue: 0, useNativeDriver: false }).start();
}
}),
[translateX, transitionTo]
);
const handleSelectMonth = useCallback((value: string | string[]) => {
const monthIndex = parseInt(Array.isArray(value) ? value[0] : value, 10);
setVisibleMonth(new Date(visibleMonthRef.current.getFullYear(), monthIndex, 1));
}, []);
const handleSelectYear = useCallback((value: string | string[]) => {
const year = parseInt(Array.isArray(value) ? value[0] : value, 10);
setVisibleMonth(new Date(year, visibleMonthRef.current.getMonth(), 1));
}, []);
const currentYear = new Date().getFullYear();
const monthOptions = MONTH_NAMES.map((label, i) => ({ value: String(i), label }));
const yearOptions = useMemo(() => {
const options: { value: string; label: string }[] = [];
for (let y = currentYear - 20; y <= currentYear + 10; y++) options.push({ value: String(y), label: String(y) });
return options;
}, [currentYear]);
const handleToggleComplete = useCallback(async (taskId: string) => {
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 (
<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
ref={stripRef}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.dateStrip}
>
{days.map((day) => (
<DayButton
key={day.toISOString()}
day={day}
selected={isSameDay(day, selectedDate)}
current={isToday(day) && !isSameDay(day, selectedDate)}
onPress={handleDayPress}
theme={theme}
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.scrollContent}>
<Animated.View
onLayout={(e) => {
gridWidthRef.current = e.nativeEvent.layout.width;
}}
style={[styles.calendarArea, { transform: [{ translateX }] }]}
>
<View style={styles.monthRow}>
<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="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)}
/>
))}
</ScrollView>
<View style={styles.monthRow}>
<TouchableOpacity onPress={handlePrevMonth} style={[styles.monthNav, { borderColor: theme.borderStrong, backgroundColor: theme.card }]} activeOpacity={0.7}>
<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>
<Text style={[styles.monthLabel, { color: theme.text }]}>{format(visibleMonth, 'MMM yyyy').toUpperCase()}</Text>
<TouchableOpacity onPress={handleNextMonth} style={[styles.monthNav, { borderColor: theme.borderStrong, backgroundColor: theme.card }]} activeOpacity={0.7}>
<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(() => {})}
<OptionPickerModal
visible={yearPickerVisible}
title="Select Year"
options={yearOptions}
selectedValue={String(visibleMonth.getFullYear())}
onSelect={handleSelectYear}
onClose={() => setYearPickerVisible(false)}
/>
</View>
</GestureDetector>
</SafeAreaView>
);
}
interface DayButtonProps {
day: Date;
selected: boolean;
current: boolean;
onPress: (day: Date) => void;
theme: ThemeColors;
function byDayOf(byDay: Record<number, Task[]>, day: Date): Task[] {
return byDay[day.getDate()] ?? [];
}
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 (
<TouchableOpacity
style={[
styles.dayButton,
{ backgroundColor: theme.card, borderColor: theme.borderStrong },
current && { borderColor: theme.accent, borderWidth: 1.5 },
selected && { backgroundColor: theme.accent, borderColor: theme.accent },
]}
style={[styles.dayCell, selected && { backgroundColor: ORANGE }]}
onPress={() => onPress(day)}
activeOpacity={0.7}
>
<Text style={[styles.dayWeekday, { color: theme.textMuted }, selected && styles.dayTextSelected]}>
{format(day, 'EEE').charAt(0)}
</Text>
<Text style={[styles.dayNumber, { color: theme.text }, selected && styles.dayTextSelected]}>
<Text
style={[
styles.dayNumber,
{ color: theme.text },
!inMonth && { color: theme.borderStrong },
today && !selected && { color: ORANGE },
selected && styles.dayNumberSelected,
]}
>
{format(day, 'd')}
</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>
);
});
const MemoSeparator = React.memo(function Separator() {
return <View style={styles.separator} />;
});
const styles = StyleSheet.create({
container: {
flex: 1,
},
dateStrip: {
paddingHorizontal: 16,
paddingTop: 12,
gap: DAY_GAP,
flex: {
flex: 1,
},
dayButton: {
width: DAY_WIDTH,
height: 60,
borderRadius: 16,
borderWidth: 1,
alignItems: 'center',
justifyContent: 'center',
gap: 2,
scrollContent: {
paddingBottom: 96,
flexGrow: 1,
},
dayTextSelected: {
color: '#FFFFFF',
},
dayWeekday: {
fontSize: 11,
fontWeight: '600',
textTransform: 'uppercase',
},
dayNumber: {
fontSize: 16,
fontWeight: '600',
calendarArea: {
paddingHorizontal: 12,
paddingTop: 6,
paddingBottom: 4,
},
monthRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 24,
paddingVertical: 12,
justifyContent: 'space-between',
paddingHorizontal: 4,
paddingBottom: 14,
},
monthNav: {
width: 36,
@@ -228,34 +452,158 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center',
},
monthLabel: {
fontSize: 15,
fontWeight: '700',
letterSpacing: 1,
minWidth: 120,
textAlign: 'center',
monthSelectorGroup: {
alignItems: 'center',
},
listContent: {
paddingHorizontal: 16,
paddingTop: 4,
paddingBottom: 100,
flexGrow: 1,
},
separator: {
height: 8,
},
emptyState: {
flex: 1,
monthButton: {
flexDirection: 'row',
alignItems: '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: {
fontSize: 16,
fontSize: 15,
fontWeight: '600',
marginBottom: 4,
},
emptySubtext: {
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 (
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
<Header title="TODO" showLogo={true} />
<Header title="TODO" showLogo={false} />
<View style={styles.categoryFilterWrapper}>
<CategoryFilter selected={selectedCategory} onSelect={setSelectedCategory} />
</View>
+179 -4
View File
@@ -1,5 +1,5 @@
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 { ListItem } from '@/components/ListItem';
import { OptionPickerModal } from '@/components/OptionPickerModal';
@@ -9,23 +9,26 @@ import { FriendsModal } from '@/components/FriendsModal';
import { LegalModal } from '@/components/LegalModal';
import { ServerUrlModal } from '@/components/ServerUrlModal';
import SyncStatus from '@/components/SyncStatus';
import { useSettings, SORT_OPTIONS, REMINDER_OPTIONS } from '@/theme';
import { useSettings, SORT_OPTIONS, REMINDER_OPTIONS, ACCENT_PRESETS, DEFAULT_ACCENT } from '@/theme';
import { useCategories } from '@/hooks/useDatabase';
import { getAuthUser, getAuthToken } from '@/services/auth';
import { checkForUpdates, getCurrentAppVersion } from '@/services/updates';
import { getLastSyncTime } from '@/database/sync';
import Category from '@/models/Category';
import Svg, { Path } from 'react-native-svg';
import ColorWheel from '@/components/ColorWheel';
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 [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 [syncVisible, setSyncVisible] = useState(false);
const [friendsVisible, setFriendsVisible] = useState(false);
const [legalVisible, setLegalVisible] = useState<null | 'privacy' | 'terms'>(null);
const [serverUrlVisible, setServerUrlVisible] = useState(false);
const [customAccentVisible, setCustomAccentVisible] = useState(false);
const [draftAccent, setDraftAccent] = useState(accentColor);
const [syncSubtitle, setSyncSubtitle] = useState('Checking...');
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 reminderLabel = REMINDER_OPTIONS.find((o) => o.value === reminderPreference)?.label ?? 'No reminder';
const openCustomAccent = () => {
setDraftAccent(accentColor);
setCustomAccentVisible(true);
};
const handleDefaultCategory = (value: string | string[]) => {
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);
};
const handleTodoAheadDays = (value: string | string[]) => {
setTodoAheadDays(parseInt(Array.isArray(value) ? value[0] : value, 10));
};
const editorVisible = editingCategory !== null;
const editorCategory = editingCategory === 'new' ? null : editingCategory;
@@ -148,6 +160,41 @@ export default function SettingsScreen() {
onPress={() => setPicker('sort')}
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>
<SyncStatus />
<ListItem
@@ -219,6 +266,15 @@ export default function SettingsScreen() {
onClose={() => setPicker(null)}
/>
<OptionPickerModal
visible={picker === 'todoAhead'}
title="Calendar Tasks in Todo"
options={AHEAD_OPTIONS}
selectedValue={String(todoAheadDays)}
onSelect={handleTodoAheadDays}
onClose={() => setPicker(null)}
/>
<CategoryEditorModal
visible={editorVisible}
category={editorCategory}
@@ -232,6 +288,41 @@ export default function SettingsScreen() {
<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
visible={legalVisible !== null}
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 {
const seconds = Math.floor((Date.now() - timestamp) / 1000);
if (seconds < 60) return 'just now';
@@ -249,6 +345,16 @@ function formatSyncTime(timestamp: number): string {
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({
container: {
flex: 1,
@@ -294,4 +400,73 @@ const styles = StyleSheet.create({
fontSize: 15,
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 { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useDatabase, useCategories } from '@/hooks/useDatabase';
import { useDatabase } from '@/hooks/useDatabase';
import { database, collections } from '@/database';
import { TaskFormData } from '@/types';
import { useSettings } from '@/theme';
@@ -25,7 +25,7 @@ import { useFriends } from '@/hooks/useFriends';
const taskSchema = z.object({
title: z.string().trim().min(1, 'Task name is required').max(100),
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']),
dueDate: z.date().nullable().optional(),
dueTime: z.string().optional(),
@@ -42,13 +42,12 @@ const taskSchema = z.object({
export default function AddTaskScreen() {
const { isReady } = useDatabase();
const categories = useCategories();
const { defaultCategoryId } = useSettings();
const router = useRouter();
const { theme } = useSettings();
const { date: dateParam } = useLocalSearchParams<{ date?: string }>();
const { friends } = useFriends();
const initialCategory = defaultCategoryId || categories[0]?.id || '';
const initialCategory = defaultCategoryId || '';
const initialDate = useMemo(() => {
if (!dateParam) return null;
const parsed = new Date(Array.isArray(dateParam) ? dateParam[0] : dateParam);
@@ -108,6 +107,7 @@ export default function AddTaskScreen() {
const seriesId = data.repeat !== 'none'
? `series_${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}`
: '';
const resolvedCategoryId = data.categoryId || '';
let createdTask: any = null;
@@ -115,7 +115,7 @@ export default function AddTaskScreen() {
const task = await collections.tasks.create((t) => {
t.title = data.title.trim();
t.description = data.description || '';
t.categoryId = data.categoryId;
t.categoryId = resolvedCategoryId;
t.priority = data.priority;
t.completed = false;
t.dueDate = dueDateTimestamp;
@@ -176,7 +176,7 @@ export default function AddTaskScreen() {
<FormProvider {...methods}>
<KeyboardAvoidingView
style={styles.keyboardAvoiding}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView
contentContainerStyle={styles.scrollContent}
+1 -1
View File
@@ -168,7 +168,7 @@ export default function SubtaskDetailScreen() {
<FormProvider {...methods}>
<KeyboardAvoidingView
style={styles.keyboardAvoiding}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView
contentContainerStyle={styles.scrollContent}
+3 -3
View File
@@ -29,7 +29,7 @@ import Svg, { Path, Circle } from 'react-native-svg';
const taskSchema = z.object({
title: z.string().trim().min(1, 'Task name is required').max(100),
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']),
dueDate: z.date().nullable().optional(),
dueTime: z.string().optional(),
@@ -167,7 +167,7 @@ export default function TaskDetailScreen() {
await task.update((t) => {
t.title = data.title.trim();
t.description = data.description || '';
t.categoryId = data.categoryId;
t.categoryId = data.categoryId || task.categoryId || '';
t.priority = data.priority;
t.dueDate = dueDateTimestamp;
t.dueTime = data.dueTime || '';
@@ -253,7 +253,7 @@ export default function TaskDetailScreen() {
<FormProvider {...methods}>
<KeyboardAvoidingView
style={styles.keyboardAvoiding}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView
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-native": "0.86.2",
"react-native-gesture-handler": "2.32.0",
"react-native-paper": "^5.12.3",
"react-native-reanimated": "4.5.1",
"react-native-safe-area-context": "5.7.0",
"react-native-screens": "4.26.0",
"react-native-svg": "^15.15.4",
"react-native-web": "^0.21.2",
"react-native-worklets": "0.10.1",
"zod": "^3.23.8"
},
"devDependencies": {
@@ -577,6 +574,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.29.7.tgz",
"integrity": "sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@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",
"integrity": "sha512-I+WYbGBAiCn7nA6xBrlgPH+MB7HWb4u8pv5S0Pv7OtwNvIFvCCb24YlttKEeUFVurfBCEaOTnuhlqsb7f0Z5Dg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@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",
"integrity": "sha512-NCSEJ4sLFU2gqAub45HYh4fus2yQ36rr6ei6vpU7NdoJqCpxvEG8E6eJpscGyXP3VHD2Ny+fSXr04k1hoUrFqA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.29.7"
},
@@ -1191,28 +1191,6 @@
"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": {
"version": "2.0.17",
"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",
"integrity": "sha512-NIXU/iT5+ORyCc7p0z2nnlkouYKX425vuU1OEm6bMMtWWR9yvb+Xg5AZmImTKoF9abxCPqrKC3rOZsKzUYgYZA==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"react": "*",
"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": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.5.1.tgz",
"integrity": "sha512-RnMvtDuR+68ig864gAvZCOdZehqhC5rFmMo0kn+ARfgVSTvFeF6IFLBVgMPUu0KwihaapEyW24WRi6nEyy1kSA==",
"license": "MIT",
"peer": true,
"dependencies": {
"react-native-is-edge-to-edge": "^1.3.1",
"semver": "^7.7.3"
@@ -10426,6 +10361,7 @@
"resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.10.1.tgz",
"integrity": "sha512-62mRM19bDpfpdI8HLkEErcdOsrAPDtE9lA/sw+5lLRpzBHNhxaoj9QyY2KjXqUmirelxkX4zuPGTC3VdA0feJA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/plugin-transform-arrow-functions": "^7.27.1",
"@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 }]}>
{category ? 'Edit Category' : 'New Category'}
</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">
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textMuted} strokeWidth={2} strokeLinecap="round" />
</Svg>
@@ -102,6 +102,9 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose
]}
onPress={() => setColor(c)}
activeOpacity={0.8}
accessibilityRole="radio"
accessibilityLabel={`Color ${c}`}
accessibilityState={{ selected: color === c }}
>
{color === c && (
<Svg width={16} height={16} viewBox="0 0 24 24">
@@ -132,7 +135,7 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose
disabled={!name.trim()}
activeOpacity={0.8}
>
<Text style={styles.saveButtonText}>Save</Text>
<Text style={[styles.saveButtonText, { color: theme.accentText }]}>Save</Text>
</TouchableOpacity>
</View>
</View>
@@ -231,6 +234,5 @@ const styles = StyleSheet.create({
saveButtonText: {
fontSize: 15,
fontWeight: '600',
color: '#FFFFFF',
},
});
@@ -1,5 +1,5 @@
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 { useSettings, ThemeColors } from '@/theme';
import Category from '@/models/Category';
@@ -33,9 +33,11 @@ export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) {
theme={theme}
/>
{categories.map((category) => (
<AnimatedCategoryButton
<CategoryButton
key={category.id}
category={category}
id={category.id}
name={category.name}
color={category.color}
selected={selected === category.id}
onPress={() => onSelect(category.id)}
theme={theme}
@@ -54,7 +56,7 @@ interface CategoryButtonProps {
theme: ThemeColors;
}
function CategoryButton({ id, name, color, selected, onPress, theme }: CategoryButtonProps) {
function CategoryButton({ name, color, selected, onPress, theme }: CategoryButtonProps) {
return (
<TouchableOpacity
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({
scrollView: {
paddingVertical: 0,
@@ -169,16 +106,6 @@ const styles = StyleSheet.create({
borderWidth: 1,
minWidth: 64,
justifyContent: 'center',
},
animatedButton: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowRadius: 4,
elevation: 2,
},
buttonInner: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
colorDot: {
@@ -195,4 +122,4 @@ const styles = StyleSheet.create({
fontSize: 12,
fontWeight: '500',
},
});
});
@@ -26,12 +26,15 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
]}
onPress={() => setShowModal(true)}
activeOpacity={0.8}
accessibilityRole="button"
accessibilityLabel="Select category"
accessibilityHint="Opens a list of categories to choose from"
>
<View style={styles.selectorContent}>
<View style={styles.selectorRow}>
<View style={[styles.colorCircle, { backgroundColor: selectedCategory?.color || '#9E9E9E' }]} />
<Text style={[styles.selectorValue, { color: theme.text }]}>{selectedCategory?.name || 'Select category'}</Text>
</View>
<View style={styles.selectorRow}>
<View style={[styles.colorCircle, { backgroundColor: selectedCategory?.color || '#9E9E9E' }]} />
<Text style={[styles.selectorValue, { color: theme.text }]}>{selectedCategory?.name || 'None'}</Text>
</View>
</View>
<Svg width={20} height={20} viewBox="0 0 24 24">
<Path d="M9 18l6-6-6-6" stroke={theme.textMuted} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" fill="none" />
@@ -48,11 +51,37 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
<Pressable style={[styles.modalSheet, { backgroundColor: theme.sheetBg }]}>
<View style={styles.modalHeader}>
<Text style={[styles.modalTitle, { color: theme.text }]}>Select Category</Text>
<TouchableOpacity onPress={() => setShowModal(false)} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
<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>
</TouchableOpacity>
</View>
<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) => (
<TouchableOpacity
key={category.id}
@@ -63,6 +92,9 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
]}
onPress={() => { onChange(category.id); setShowModal(false); }}
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]} />
<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}
onPressOut={handlePressOut}
activeOpacity={1}
accessibilityRole="button"
accessibilityLabel="Add new task"
hitSlop={12}
>
<Animated.View
style={{
@@ -31,6 +31,9 @@ export function FormButtons({ onSubmit, submitLabel = 'Submit' }: FormButtonsPro
onPress={cancel}
disabled={isSubmitting}
activeOpacity={0.7}
accessibilityRole="button"
accessibilityLabel="Cancel"
accessibilityState={{ disabled: isSubmitting }}
>
<Text style={[styles.cancelButtonText, { color: theme.textSecondary }]}>Cancel</Text>
</TouchableOpacity>
@@ -39,6 +42,9 @@ export function FormButtons({ onSubmit, submitLabel = 'Submit' }: FormButtonsPro
onPress={handleSubmit(onSubmit)}
disabled={isSubmitting}
activeOpacity={0.8}
accessibilityRole="button"
accessibilityLabel={isSubmitting ? 'Saving' : submitLabel}
accessibilityState={{ disabled: isSubmitting, busy: isSubmitting }}
>
<Text style={styles.submitButtonText}>
{isSubmitting ? 'Saving...' : submitLabel}
@@ -111,15 +111,15 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
<Text
style={[
styles.tabText,
{ color: selectedTab === tab ? '#FFFFFF' : theme.textMuted },
{ color: selectedTab === tab ? theme.accentText : theme.textMuted },
]}
>
{tab.charAt(0).toUpperCase() + tab.slice(1)}
{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 && (
<Text style={[styles.badge, { color: '#FFFFFF' }]}>{incoming.length}</Text>
<Text style={[styles.badge, { color: theme.accentText }]}>{incoming.length}</Text>
)}
</Text>
</TouchableOpacity>
@@ -179,7 +179,7 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
style={[styles.actionBtn, { backgroundColor: theme.accent }]}
onPress={() => handleAccept(item.requestId)}
>
<Text style={styles.actionBtnText}>Accept</Text>
<Text style={[styles.actionBtnText, { color: theme.accentText }]}>Accept</Text>
</TouchableOpacity>
<TouchableOpacity
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'))
}
>
<Text style={styles.addBtnText}>Add</Text>
<Text style={[styles.addBtnText, { color: theme.accentText }]}>Add</Text>
</TouchableOpacity>
</View>
)}
@@ -399,7 +399,6 @@ const styles = StyleSheet.create({
addBtnText: {
fontSize: 13,
fontWeight: '600',
color: '#FFFFFF',
},
requestActions: {
flexDirection: 'row',
@@ -413,7 +412,6 @@ const styles = StyleSheet.create({
actionBtnText: {
fontSize: 13,
fontWeight: '600',
color: '#FFFFFF',
},
cancelBtn: {
paddingVertical: 6,
+4 -3
View File
@@ -20,7 +20,7 @@ export function Header({ title, showLogo, rightAction }: HeaderProps) {
<Text style={styles.logoText}></Text>
</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>
</View>
@@ -61,8 +61,9 @@ const styles = StyleSheet.create({
fontSize: 20,
fontWeight: '700',
position: 'absolute',
left: '50%',
marginLeft: -30,
left: 0,
right: 0,
textAlign: 'center',
},
spacer: {
width: 32,
@@ -26,6 +26,10 @@ export function ListItem({ title, subtitle, leftElement, rightElement, onPress,
onPress={onPress}
activeOpacity={0.7}
disabled={!isInteractive}
accessibilityRole={isInteractive ? 'button' : 'none'}
accessibilityLabel={title}
accessibilityHint={subtitle ? subtitle : undefined}
accessibilityState={{ disabled: !isInteractive }}
>
{leftElement && <View style={styles.leftElement}>{leftElement}</View>}
<View style={styles.leftContent}>
@@ -53,6 +53,9 @@ export function OptionPickerModal({ visible, title, options, selectedValue, onSe
}
}}
activeOpacity={0.7}
accessibilityRole={multiSelect ? 'checkbox' : 'button'}
accessibilityLabel={item.label}
accessibilityState={multiSelect ? { checked: selected } : { selected }}
>
{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 }]}
onPress={onClose}
activeOpacity={0.7}
accessibilityRole="button"
accessibilityLabel="Cancel"
>
<Text style={[styles.cancelText, { color: theme.textFaint }]}>Cancel</Text>
</TouchableOpacity>
@@ -23,7 +23,7 @@ export function PrioritySelector({ value, onChange }: PrioritySelectorProps) {
return (
<View style={styles.container}>
<Text style={[styles.label, { color: theme.text }]}>Priority</Text>
<View style={styles.options}>
<View style={styles.options} accessibilityRole="radiogroup" accessibilityLabel="Priority">
{priorities.map((priority) => (
<TouchableOpacity
key={priority.value}
@@ -34,6 +34,9 @@ export function PrioritySelector({ value, onChange }: PrioritySelectorProps) {
]}
onPress={() => onChange(priority.value)}
activeOpacity={0.8}
accessibilityRole="radio"
accessibilityLabel={`${priority.label} priority`}
accessibilityState={{ selected: value === priority.value }}
>
<View style={[
styles.colorIndicator,
+10 -6
View File
@@ -18,7 +18,7 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
const insets = useSafeAreaInsets();
const categories = useCategories();
const [title, setTitle] = useState('');
const [categoryId, setCategoryId] = useState(() => defaultCategoryId || categories[0]?.id || '');
const [categoryId, setCategoryId] = useState(() => defaultCategoryId || '');
const [categoryPickerVisible, setCategoryPickerVisible] = useState(false);
const inputRef = useRef<TextInput>(null);
const keyboardHeight = useRef(new Animated.Value(0)).current;
@@ -61,14 +61,14 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
const handleAdd = async () => {
const trimmed = title.trim();
if (!trimmed || !categoryId) return;
if (!trimmed) return;
const now = new Date();
await database.write(async () => {
await collections.tasks.create((t) => {
t.title = trimmed;
t.description = '';
t.categoryId = categoryId;
t.categoryId = categoryId || '';
t.priority = 'none';
t.completed = false;
t.dueDate = dueDate;
@@ -88,7 +88,7 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
const animatedBottom = keyboardHeight.interpolate({
inputRange: [0, 500],
outputRange: [insets.bottom + 0, insets.bottom + 0 + 500],
outputRange: [0, insets.bottom + 500],
extrapolate: 'clamp',
});
@@ -115,6 +115,8 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
onChangeText={setTitle}
onSubmitEditing={handleAdd}
returnKeyType="done"
accessibilityLabel="Quick add task"
accessibilityHint="Enter a task name and press the add button"
/>
<TouchableOpacity
style={[styles.submit, { backgroundColor: theme.accent }, title.trim() ? {} : styles.submitDisabled]}
@@ -122,11 +124,13 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
disabled={!title.trim()}
activeOpacity={0.8}
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">
<Path
d="M12 5v14M5 12h14"
stroke="#FFFFFF"
stroke={theme.accentText}
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
@@ -138,7 +142,7 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
<OptionPickerModal
visible={categoryPickerVisible}
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}
onSelect={(value) => setCategoryId(Array.isArray(value) ? value[0] : value)}
onClose={() => setCategoryPickerVisible(false)}
@@ -35,6 +35,10 @@ export function ReminderSelector({ value, onChange, hasDueDate }: ReminderSelect
onPress={() => setShowPicker(true)}
disabled={disabled}
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">
<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 {
switch (value) {
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>
</TouchableOpacity>
<TouchableOpacity
onPress={() => handleDeleteProfile(profile.id, profile.name)}
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>
</TouchableOpacity>
@@ -144,7 +148,7 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
</View>
)}
<View style={styles.chipRow}>
<View style={styles.chipRow} accessibilityRole="radiogroup" accessibilityLabel="Repeat">
{REPEAT_OPTIONS.map((option) => (
<TouchableOpacity
key={option.value}
@@ -155,6 +159,9 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
]}
onPress={() => selectRepeat(option.value)}
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} />
<Text
@@ -179,6 +186,9 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
onPress={() => bumpInterval(-1)}
disabled={interval <= 1}
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>
</TouchableOpacity>
@@ -188,6 +198,9 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
onPress={() => bumpInterval(1)}
disabled={interval >= 30}
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>
</TouchableOpacity>
@@ -210,6 +223,9 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
]}
onPress={() => toggleDay(day)}
activeOpacity={0.8}
accessibilityRole="checkbox"
accessibilityLabel={WEEKDAY_NAMES[day]}
accessibilityState={{ checked: days.includes(day) }}
>
<Text
style={[
@@ -282,7 +298,7 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
disabled={!profileName.trim()}
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>
</View>
</Pressable>
@@ -80,7 +80,7 @@ export function ServerUrlModal({ visible, onClose }: ServerUrlModalProps) {
disabled={!isValid}
activeOpacity={0.8}
>
<Text style={styles.saveButtonText}>Save</Text>
<Text style={[styles.saveButtonText, { color: theme.accentText }]}>Save</Text>
</TouchableOpacity>
</View>
</View>
@@ -160,6 +160,5 @@ const styles = StyleSheet.create({
saveButtonText: {
fontSize: 15,
fontWeight: '600',
color: '#FFFFFF',
},
});
@@ -18,6 +18,7 @@ interface SubtaskItemProps {
onDragUpdate?: (absoluteY: number) => void;
onDragEnd?: (absoluteY: number) => void;
depth?: number;
categoryColor?: string;
}
export const SubtaskItem = React.memo(function SubtaskItem({
@@ -33,7 +34,8 @@ export const SubtaskItem = React.memo(function SubtaskItem({
onDragStart,
onDragUpdate,
onDragEnd,
depth = 1
depth = 1,
categoryColor
}: SubtaskItemProps) {
const { theme } = useSettings();
const [expanded, setExpanded] = useState(true);
@@ -59,6 +61,7 @@ export const SubtaskItem = React.memo(function SubtaskItem({
onDragStart={onDragStart}
onDragUpdate={onDragUpdate}
onDragEnd={onDragEnd}
categoryColor={categoryColor}
/>
{hasChildren && expanded && (
<View style={[styles.nestedSubtasks, { marginLeft: depth * 12 }]}>
@@ -81,6 +84,7 @@ export const SubtaskItem = React.memo(function SubtaskItem({
onDragUpdate={onDragUpdate}
onDragEnd={onDragEnd}
depth={depth + 1}
categoryColor={categoryColor}
/>
))}
</View>
+5 -6
View File
@@ -152,9 +152,9 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
activeOpacity={0.8}
>
{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>
@@ -202,7 +202,7 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
<Text
style={[
styles.segmentText,
{ color: mode === m ? '#FFFFFF' : theme.textSecondary },
{ color: mode === m ? theme.accentText : theme.textSecondary },
]}
>
{m === 'login' ? 'Sign In' : 'Create Account'}
@@ -243,9 +243,9 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
activeOpacity={0.8}
>
{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'}
</Text>
)}
@@ -349,7 +349,6 @@ const styles = StyleSheet.create({
minHeight: 50,
},
primaryButtonText: {
color: '#FFFFFF',
fontSize: 15,
fontWeight: '700',
},
@@ -121,7 +121,7 @@ export default function SyncStatus({ compact = false }: SyncStatusProps) {
disabled={status === 'syncing'}
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'}
</Text>
</TouchableOpacity>
+34 -24
View File
@@ -36,14 +36,15 @@ interface TaskItemProps {
onDragUpdate?: (absoluteY: number) => void;
onDragEnd?: (absoluteY: number) => void;
onReorderStart?: () => void;
onReorderUpdate?: (translationY: number) => void;
onReorderEnd?: (translationY: number) => void;
onReorderUpdate?: (absoluteY: number) => void;
onReorderEnd?: (absoluteY: number, translationY: number) => void;
expanded?: boolean;
indented?: boolean;
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 [opacityAnim] = React.useState(new Animated.Value(task.completed ? 0.5 : 1));
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(
() =>
Gesture.Pan()
.activateAfterLongPress(0)
.activateAfterLongPress(400)
.minDistance(5)
.runOnJS(true)
.onStart((e) => {
@@ -77,10 +78,10 @@ export const TaskItem = React.memo(function TaskItem({ task, onToggle, onDelete,
.onUpdate((e) => {
dragTranslateX.setValue(e.translationX);
dragTranslateY.setValue(e.translationY);
onReorderUpdate?.(e.translationY);
onReorderUpdate?.(e.absoluteY);
})
.onEnd((e) => {
onReorderEnd?.(e.translationY);
onReorderEnd?.(e.absoluteY, e.translationY);
})
.onFinalize(() => {
setDragging(false);
@@ -220,32 +221,25 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
onLongPress={onLongPress}
delayLongPress={350}
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.titleRow}>
<TouchableOpacity
style={[styles.dragHandle, { opacity: draggable ? 1 : 0 }]}
accessible={false}
onPressIn={() => {}}
onPressOut={() => {}}
>
<Svg width={20} height={20} viewBox="0 0 24 24">
<Circle cx="6" cy="6" r="2" fill={theme.textFaint} />
<Circle cx="6" cy="12" r="2" fill={theme.textFaint} />
<Circle cx="6" cy="18" r="2" fill={theme.textFaint} />
<Circle cx="12" cy="6" r="2" fill={theme.textFaint} />
<Circle cx="12" cy="12" r="2" fill={theme.textFaint} />
<Circle cx="12" cy="18" r="2" fill={theme.textFaint} />
<Circle cx="18" cy="6" r="2" fill={theme.textFaint} />
<Circle cx="18" cy="12" r="2" fill={theme.textFaint} />
<Circle cx="18" cy="18" r="2" fill={theme.textFaint} />
</Svg>
</TouchableOpacity>
<View style={styles.categoryDotSlot}>
{categoryColor ? (
<View style={[styles.categoryDot, { backgroundColor: categoryColor }]} />
) : null}
</View>
<TouchableOpacity
style={[styles.checkCircle, !canComplete && !task.completed && styles.checkCircleDisabled]}
onPress={canComplete || task.completed ? onToggle : undefined}
activeOpacity={0.7}
accessibilityRole="checkbox"
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">
{task.completed ? (
@@ -368,6 +362,10 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
style={styles.menuButton}
onPress={onMenuOpen}
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">
<Circle cx={12} cy={12} r={1.5} fill={theme.textMuted} />
@@ -476,6 +474,18 @@ const styles = StyleSheet.create({
justifyContent: 'center',
marginRight: 8,
},
categoryDotSlot: {
width: 28,
height: 28,
alignItems: 'center',
justifyContent: 'center',
marginRight: 8,
},
categoryDot: {
width: 10,
height: 10,
borderRadius: 5,
},
title: {
fontSize: 17,
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 { useTasks } from '@/hooks/useTasks';
import { useCategories } from '@/hooks/useDatabase';
import { useTaskModals } from '@/hooks/useTaskModals';
import { TaskItem } from './TaskItem';
import { SubtaskItem } from './SubtaskItem';
import { TaskData, SubtaskData } from '@/types';
import Task from '@/models/Task';
import { useDatabase } from '@/hooks/useDatabase';
import { useSettings } from '@/theme';
import {
toggleTaskComplete,
@@ -16,9 +16,8 @@ import {
convertSubtaskToTask,
moveSubtaskToTask,
toggleSubtaskComplete,
reorderTasks,
fetchSubtaskTree,
} from '@/utils/taskActions';
import { Q } from '@nozbe/watermelondb';
import Svg, { Path, Rect } from 'react-native-svg';
interface TaskListProps {
@@ -40,11 +39,18 @@ const DropIndicator = ({ theme }: { theme: any }) => (
);
export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProps) {
const { theme, sortBy } = useSettings();
const { collections } = useDatabase();
const { theme, sortBy, todoAheadDays } = useSettings();
const { tasks, loading } = useTasks(categoryId, false);
const { tasks: completedTasks } = useTasks(categoryId, true);
const { tasks, loading } = useTasks(categoryId, 'all', todoAheadDays);
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 [selectionMode, setSelectionMode] = useState(false);
@@ -52,18 +58,16 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
const [hoverTaskId, setHoverTaskId] = useState<string | null>(null);
const [expandedTasks, setExpandedTasks] = useState<Set<string>>(new Set());
const [subtasksMap, setSubtasksMap] = useState<Map<string, SubtaskData[]>>(new Map());
const [reorderState, setReorderState] = useState<{ draggedId: string; draggedIndex: number; targetIndex: number | null; positions: Record<string, { top: number; bottom: number }> } | null>(null);
const [dropIndicator, setDropIndicator] = useState<{ targetId: string | null; position: 'above' | 'below' } | null>(null);
const itemRefs = useRef<Map<string, View>>(new Map());
const dragStateRef = useRef<{ taskId: string; positions: Record<string, { top: number; bottom: number }> } | null>(null);
const subtaskDragRef = useRef<{ subtaskId: string; parentTaskId: string; positions: Record<string, { top: number; bottom: number }> } | null>(null);
const {
modals,
openTaskMenu,
openTaskDelete,
openSubtaskMenu,
openSubtaskDelete,
openSubtaskEdit,
openTaskEdit,
} = useTaskModals();
const registerRef = useCallback((taskId: string, ref: View | null) => {
@@ -127,70 +131,10 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
}, [onSelectionChange]);
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 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);
const withNested = await fetchSubtaskTree(taskId);
setSubtasksMap((prev) => new Map(prev).set(taskId, withNested));
return withNested;
}, [collections.subtasks]);
}, []);
const refreshAll = useCallback(() => {
for (const taskId of expandedTasks) {
@@ -323,89 +267,17 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
}
}, [findHoverTarget, refreshAll]);
const measureReorderItems = useCallback(async () => {
const positions: Record<string, { top: number; bottom: number }> = {};
const entries = Array.from(itemRefs.current.entries());
await Promise.all(entries.map(([id, ref]) => {
return new Promise<void>((resolve) => {
ref?.measureInWindow((_x, y, _w, h) => {
positions[id] = { top: y, bottom: y + h };
resolve();
});
});
}));
return positions;
}, []);
const findReorderTarget = useCallback((absoluteY: number, draggedId: string, positions: Record<string, { top: number; bottom: number }>) => {
for (const [id, p] of Object.entries(positions)) {
if (id === draggedId) continue;
if (absoluteY >= p.top && absoluteY <= p.bottom) {
return id;
}
}
return null;
}, []);
const calculateReorderDropPosition = useCallback((absoluteY: number, targetId: string, positions: Record<string, { top: number; bottom: number }>) => {
const target = positions[targetId];
if (!target) return 'below' as const;
const middle = (target.top + target.bottom) / 2;
return absoluteY < middle ? 'above' : 'below';
}, []);
const handleReorderStart = useCallback(async (taskId: string) => {
const positions = await measureReorderItems();
const draggedIndex = sortedTasks.findIndex(t => t.id === taskId);
if (draggedIndex === -1) return;
setReorderState({ draggedId: taskId, draggedIndex, targetIndex: null, positions });
}, [measureReorderItems, sortedTasks]);
const handleReorderUpdate = useCallback((absoluteY: number) => {
const state = reorderState;
if (!state) return;
const targetId = findReorderTarget(absoluteY, state.draggedId, state.positions);
let targetIndex = null;
if (targetId) {
targetIndex = sortedTasks.findIndex(t => t.id === targetId);
const position = calculateReorderDropPosition(absoluteY, targetId, state.positions);
setDropIndicator({ targetId, position });
} else {
// Check if below last item
const positions = state.positions;
const lastItem = Object.values(positions).reduce((max, p) => p.bottom > max.bottom ? p : max, { bottom: 0 });
if (absoluteY > lastItem.bottom) {
setDropIndicator({ targetId: null, position: 'below' });
targetIndex = sortedTasks.length; // Insert at end
} else {
setDropIndicator(null);
}
}
setReorderState(prev => prev ? { ...prev, targetIndex } : null);
setHoverTaskId(targetId);
}, [findReorderTarget, calculateReorderDropPosition, reorderState, sortedTasks]);
const handleReorderEnd = useCallback(async (translationY: number) => {
const state = reorderState;
setReorderState(null);
setHoverTaskId(null);
setDropIndicator(null);
if (!state) return;
if (state.targetIndex !== null && state.targetIndex !== state.draggedIndex) {
const newOrder = [...sortedTasks];
const [removed] = newOrder.splice(state.draggedIndex, 1);
newOrder.splice(state.targetIndex, 0, removed);
const newTaskIds = newOrder.map(t => t.id);
await reorderTasks(newTaskIds);
refreshAll();
}
}, [reorderState, sortedTasks, reorderTasks, refreshAll]);
const handleSubtaskDragStart = useCallback(async (subtaskId: string, parentTaskId: string) => {
subtaskDragRef.current = { subtaskId, parentTaskId, positions: await measureItems() };
}, [measureItems]);
const 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 state = subtaskDragRef.current;
subtaskDragRef.current = null;
@@ -448,20 +320,22 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
onExpand={toggleExpand}
onSelect={toggleSelect}
onEnterSelection={enterSelection}
onMenuOpen={openTaskMenu}
onMenuOpen={(task) => openTaskEdit(task.id)}
onSubtaskToggle={handleSubtaskToggle}
onSubtaskDelete={openSubtaskDelete}
onSubtaskEdit={openSubtaskEdit}
onSubtaskMenuOpen={openSubtaskMenu}
onSubtaskMenuOpen={(subtask) => openSubtaskEdit(subtask.id)}
onDragStart={handleDragStart}
onDragUpdate={handleDragUpdate}
onDragEnd={handleDragEnd}
onSubtaskDragStart={handleSubtaskDragStart}
onSubtaskDragUpdate={handleSubtaskDragUpdate}
onSubtaskDragEnd={handleSubtaskDragEnd}
onReorderStart={handleDragStart}
onReorderStart={() => handleDragStart(item.id)}
onReorderUpdate={handleDragUpdate}
onReorderEnd={handleDragEnd}
selectedIds={selectedIds}
categoryColor={categoryColors.get(item.categoryId)}
/>
{showDropBelow && <DropIndicator theme={theme} />}
</View>
@@ -481,63 +355,34 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
toggleExpand,
toggleSelect,
enterSelection,
openTaskMenu,
openTaskEdit,
handleSubtaskToggle,
openSubtaskDelete,
openSubtaskEdit,
openSubtaskMenu,
handleDragStart,
handleDragUpdate,
handleDragEnd,
handleSubtaskDragStart,
handleSubtaskDragUpdate,
handleSubtaskDragEnd,
categoryColors,
]
);
const listHeader = useMemo(() => {
if (sortedTasks.length > 0 || completedTasks.length > 0) return null;
if (sortedTasks.length > 0) return null;
return (
<View style={styles.emptyState}>
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>No tasks yet</Text>
<Text style={[styles.emptySubtext, { color: theme.textMuted }]}>Tap + to add your first task</Text>
</View>
);
}, [sortedTasks.length, completedTasks.length, theme.textSecondary, theme.textMuted]);
}, [sortedTasks.length, theme.textSecondary, theme.textMuted]);
const listFooter = useMemo(() => {
const footerContent = completedTasks.length === 0 ? null : (
<CompletedSection
tasks={completedTasks}
onToggle={(task) => handleToggle(task.id)}
onDelete={openTaskDelete}
onMenuOpen={openTaskMenu}
onLongPress={(task) => enterSelection(task.id)}
selectionMode={selectionMode}
selectedIds={selectedIds}
onSelect={toggleSelect}
/>
);
const showDropAtEnd = dropIndicator && dropIndicator.targetId === null;
return (
<View>
{footerContent}
{showDropAtEnd && <DropIndicator theme={theme} />}
</View>
);
}, [
completedTasks,
handleToggle,
openTaskDelete,
openTaskMenu,
enterSelection,
selectionMode,
selectedIds,
toggleSelect,
dropIndicator,
theme,
]);
return showDropAtEnd ? <DropIndicator theme={theme} /> : null;
}, [dropIndicator, theme]);
if (loading && !refreshing) {
return (
@@ -624,11 +469,13 @@ interface TaskRowProps {
onDragUpdate: (absoluteY: number) => void;
onDragEnd: (absoluteY: number) => void;
onSubtaskDragStart: (subtaskId: string, parentTaskId: string) => void;
onSubtaskDragUpdate: (absoluteY: number) => void;
onSubtaskDragEnd: (absoluteY: number) => void;
onReorderStart: (taskId: string) => void;
onReorderStart: () => void;
onReorderUpdate: (absoluteY: number) => void;
onReorderEnd: (translationY: number) => void;
onReorderEnd: (absoluteY: number, translationY: number) => void;
selectedIds: Set<string>;
categoryColor?: string;
}
const TaskRow = React.memo(function TaskRow({
@@ -653,11 +500,13 @@ const TaskRow = React.memo(function TaskRow({
onDragUpdate,
onDragEnd,
onSubtaskDragStart,
onSubtaskDragUpdate,
onSubtaskDragEnd,
onReorderStart,
onReorderUpdate,
onReorderEnd,
selectedIds,
categoryColor,
}: TaskRowProps) {
const sortedSubtasks = useMemo(
() => subtasks.slice().sort((a, b) => a.order - b.order),
@@ -683,9 +532,10 @@ const TaskRow = React.memo(function TaskRow({
onDragStart={() => onDragStart(task.id)}
onDragUpdate={onDragUpdate}
onDragEnd={onDragEnd}
onReorderStart={() => onReorderStart(task.id)}
onReorderStart={onReorderStart}
onReorderUpdate={onReorderUpdate}
onReorderEnd={onReorderEnd}
categoryColor={categoryColor}
/>
{expanded && subtasks.length > 0 && (
<View style={styles.subtaskList}>
@@ -701,8 +551,9 @@ const TaskRow = React.memo(function TaskRow({
selectionMode={selectionMode}
draggable
onDragStart={() => onSubtaskDragStart(sub.id, task.id)}
onDragUpdate={onDragUpdate}
onDragUpdate={onSubtaskDragUpdate}
onDragEnd={onSubtaskDragEnd}
categoryColor={categoryColor}
/>
))}
</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({
container: {
flex: 1,
@@ -786,6 +587,12 @@ const styles = StyleSheet.create({
paddingRight: 4,
paddingTop: 8,
},
completedSubtasks: {
paddingLeft: 8,
paddingRight: 4,
paddingTop: 4,
marginBottom: 4,
},
dragContainer: {
},
dropIndicatorContainer: {
@@ -25,6 +25,8 @@ export function TaskNameInput({ error, ...props }: TaskNameInputProps) {
placeholderTextColor={theme.textMuted}
maxLength={100}
autoCapitalize="sentences"
accessibilityLabel="Task name"
accessibilityHint="Required field. Enter a name for the task"
{...props}
/>
{error && <Text style={styles.errorText}>{error}</Text>}
@@ -119,6 +119,8 @@ export function TaskOverflowMenu({
action.onPress();
}}
activeOpacity={0.7}
accessibilityRole="button"
accessibilityLabel={`${action.label}${action.destructive ? ' (dangerous)' : ''}`}
>
{action.icon}
<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';
export const schema = appSchema({
version: 15,
version: 16,
tables: [
tableSchema({
name: 'categories',
@@ -31,6 +31,7 @@ export const schema = appSchema({
{ name: 'repeat_days', type: 'string' },
{ name: 'color', type: 'string' },
{ name: 'series_id', type: 'string', isIndexed: true },
{ name: 'order', type: 'number', isOptional: true },
{ name: 'reminder', type: 'string' },
{ name: 'reminders', type: 'string' },
{ name: 'assignee_id', type: 'string', isOptional: true },
+1 -1
View File
@@ -304,7 +304,7 @@ export function useTaskModals() {
<OptionPickerModal
visible={picker?.type === '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}
onSelect={handleSingleCategory}
onClose={() => setPicker(null)}
+71 -4
View File
@@ -2,12 +2,75 @@ import { useDatabase } from './useDatabase';
import { Q } from '@nozbe/watermelondb';
import { useEffect, useState, useMemo } from 'react';
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 [tasks, setTasks] = useState<Task[]>([]);
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(() => {
let mounted = true;
const conditions: any[] = [];
@@ -16,12 +79,16 @@ export function useTasks(categoryId?: string, showCompleted = false) {
conditions.push(Q.where('category_id', categoryId));
}
if (showCompleted) {
if (showCompleted === true) {
conditions.push(Q.where('completed', true));
} else {
} else if (showCompleted === false) {
conditions.push(Q.where('completed', false));
}
if (cutoff !== null) {
conditions.push(Q.where('due_date', Q.lte(cutoff)));
}
const query = conditions.length > 0
? collections.tasks.query(Q.and(...conditions))
: collections.tasks.query();
@@ -44,7 +111,7 @@ export function useTasks(categoryId?: string, showCompleted = false) {
mounted = false;
subscription.unsubscribe();
};
}, [collections, categoryId, showCompleted]);
}, [collections, categoryId, showCompleted, cutoff]);
return { tasks, loading };
}
+84 -24
View File
@@ -36,30 +36,72 @@ export interface ThemeColors {
accent: string;
accentSoft: string;
accentBorder: string;
accentText: string;
inputBg: string;
overlay: string;
sheetBg: string;
tabBarBg: string;
}
export const colors: ThemeColors = {
background: '#121212',
card: '#1E1E1E',
cardAlt: '#262626',
border: '#2A2A2A',
borderStrong: '#3A3A3A',
text: '#F5F5F5',
textSecondary: '#E0E0E0',
textFaint: '#BDBDBD',
textMuted: '#8E8E8E',
accent: '#EF5350',
accentSoft: '#2A1D1D',
accentBorder: '#4A2B2B',
inputBg: '#1A1A1A',
overlay: 'rgba(0,0,0,0.6)',
sheetBg: '#242424',
tabBarBg: '#1A1A1A',
};
export const DEFAULT_ACCENT = '#EF5350';
export const ACCENT_PRESETS: string[] = [
'#EF5350',
'#E91E63',
'#AB47BC',
'#7E57C2',
'#5C6BC0',
'#29B6F6',
'#26A69A',
'#66BB6A',
'#FFCA28',
'#FF7043',
'#8D6E63',
];
function parseHex(hex: string): [number, number, number] {
const h = hex.replace(/^#/, '');
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
}
function toHexByte(n: number): string {
return Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, '0');
}
export function mixHex(color: string, target: string, ratio: number): string {
const [r1, g1, b1] = parseHex(color);
const [r2, g2, b2] = parseHex(target);
return `#${toHexByte(r1 + (r2 - r1) * ratio)}${toHexByte(g1 + (g2 - g1) * ratio)}${toHexByte(b1 + (b2 - b1) * ratio)}`.toUpperCase();
}
function accentTextColor(accent: string): string {
const [r, g, b] = parseHex(accent).map((v) => v / 255);
const linear = (v: number) => (v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4));
const luminance = 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b);
return luminance > 0.5 ? '#111111' : '#FFFFFF';
}
export function colors(accent: string = DEFAULT_ACCENT): ThemeColors {
return {
background: '#121212',
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 {
notifications: boolean;
@@ -72,14 +114,22 @@ interface SettingsContextType {
setReminderPreference: (value: ReminderPreference) => void;
apiUrl: string;
setApiUrl: (value: string) => void;
accentColor: string;
setAccentColor: (value: string) => void;
todoAheadDays: number;
setTodoAheadDays: (value: number) => void;
theme: ThemeColors;
}
export const DEFAULT_TODO_AHEAD_DAYS = 7;
const STORAGE_KEYS = {
notifications: 'settings:notifications',
defaultCategoryId: 'settings:defaultCategoryId',
sortBy: 'settings:sortBy',
reminderPreference: 'settings:reminderPreference',
accentColor: 'settings:accentColor',
todoAheadDays: 'settings:todoAheadDays',
};
const SettingsContext = createContext<SettingsContextType | null>(null);
@@ -119,10 +169,12 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
'15m',
);
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,
setNotifications,
@@ -132,10 +184,14 @@ const value = useMemo<SettingsContextType>(
setSortBy,
reminderPreference,
setReminderPreference,
apiUrl,
setApiUrl,
theme,
}),
apiUrl,
setApiUrl,
accentColor,
setAccentColor,
todoAheadDays,
setTodoAheadDays,
theme,
}),
[
notifications,
setNotifications,
@@ -147,6 +203,10 @@ apiUrl,
setReminderPreference,
apiUrl,
setApiUrl,
accentColor,
setAccentColor,
todoAheadDays,
setTodoAheadDays,
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();
for (const task of tasks) {
if (fallback) {
await task.update((t) => {
t.categoryId = fallback.id;
t.updatedAt = new Date();
});
}
await task.update((t) => {
t.categoryId = fallback?.id ?? '';
t.updatedAt = new Date();
});
}
const category = await collections.categories.find(categoryId);
+42 -1
View File
@@ -1,8 +1,49 @@
import { database, collections } from '@/database';
import { Q } from '@nozbe/watermelondb';
import { Priority, Repeat, Reminder } from '@/types';
import { Priority, Repeat, Reminder, SubtaskData } from '@/types';
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 {
const next = new Date(date);
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 ✓
change behavior on click ✓
apply swipe gestures ✓
test server stuff ✓
tell the user the sync status
commit the app
build an android app and notify it for updates on the app
add an all-day option to tasks ✓
fix the calendar layout
implemennt that just task for today and the past can be checked ✓
implement stats
implemnt an android widget for quickadd tasks
fix spacing for todo cards more space for todos
fix edit todo form layout so it fitts nicely on phone screen
fix buggy scroll for time selection
- [x] Tasks should be renamed to ToDo
- [x] top of ToDo page should say ToDo instead of TODO
- [ ] 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)
- [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
- [ ] after creating task keyboard should automatically be hidden again
now it's hard to get rid of the keyboard without clicking on other tasks
- [ ] 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
category now
- [ ] category all button doesn't have the correct spacing between the dot and the text
- [ ] 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)
- [ ] clicking the three dots should open the "add new task" window instead of the window with the limited options it opens currently
- [ ] swiping task for deleting or completing
- [x] no category required by creating task
- [ ] 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
- [ ] 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
- [ ] pressing the go back button android:
in settings go to previous page task/ calendar/ stats
in other (task/ calendar/ stats) stay on that page
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
- [ ] 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
- [ ] 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)