docker compose for backend +frontend
Build APK / build (push) Canceled after 0s

This commit is contained in:
2026-08-07 22:30:00 +02:00
parent ed31f24b23
commit 4b6e87c979
33 changed files with 793 additions and 382 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;"]
+2 -2
View File
@@ -30,7 +30,7 @@ export default function CalendarScreen() {
const { theme } = useSettings();
const { collections } = useDatabase();
const categories = useCategories();
const { modals, openTaskMenu } = useTaskModals();
const { modals, openTaskEdit } = useTaskModals();
const [visibleMonth, setVisibleMonth] = useState(() => new Date());
const [selectedDate, setSelectedDate] = useState(() => new Date());
@@ -301,7 +301,7 @@ export default function CalendarScreen() {
</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.menuButton} onPress={() => openTaskMenu(task)} activeOpacity={0.7}>
<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} />
+26 -2
View File
@@ -19,9 +19,9 @@ 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, accentColor, setAccentColor } = 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);
@@ -92,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;
@@ -156,6 +160,12 @@ 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}>
@@ -256,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}
@@ -313,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';
+1 -1
View File
@@ -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}
+1 -1
View File
@@ -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;
}
}
@@ -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">
@@ -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: {
@@ -26,6 +26,9 @@ 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}>
@@ -48,7 +51,7 @@ 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>
@@ -61,6 +64,9 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
]}
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={[
@@ -86,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}
+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,
@@ -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,6 +124,8 @@ 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
@@ -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={[
@@ -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>
+29 -20
View File
@@ -41,9 +41,10 @@ interface TaskItemProps {
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));
@@ -95,7 +96,6 @@ export const TaskItem = React.memo(function TaskItem({ task, onToggle, onDelete,
Gesture.Pan()
.activateAfterLongPress(400)
.minDistance(2)
.maxDistance(12)
.runOnJS(true)
.onStart(() => {
setDragging(true);
@@ -221,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 ? (
@@ -369,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} />
@@ -477,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',
+27 -175
View File
@@ -1,6 +1,7 @@
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';
@@ -38,17 +39,24 @@ const DropIndicator = ({ theme }: { theme: any }) => (
);
export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProps) {
const { theme, sortBy } = useSettings();
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);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [hoverTaskId, setHoverTaskId] = useState<string | null>(null);
const [expandedTasks, setExpandedTasks] = useState<Set<string>>(new Set());
const [completedShown, setCompletedShown] = useState(false);
const [subtasksMap, setSubtasksMap] = useState<Map<string, SubtaskData[]>>(new Map());
const [dropIndicator, setDropIndicator] = useState<{ targetId: string | null; position: 'above' | 'below' } | null>(null);
const itemRefs = useRef<Map<string, View>>(new Map());
@@ -56,11 +64,10 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
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) => {
@@ -135,9 +142,8 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
}
}, [expandedTasks, fetchSubtasks]);
const handleToggle = useCallback(async (taskId: string, showCompleted = true) => {
const handleToggle = useCallback(async (taskId: string) => {
await toggleTaskComplete(taskId);
if (showCompleted) setCompletedShown(true);
refreshAll();
}, [refreshAll]);
@@ -186,7 +192,6 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
const handleBulkComplete = useCallback(async () => {
await Promise.all(Array.from(selectedIds).map(taskId => setTaskCompleted(taskId, true)));
setCompletedShown(true);
exitSelection();
refreshAll();
}, [selectedIds, exitSelection, refreshAll]);
@@ -315,11 +320,11 @@ 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}
@@ -330,6 +335,7 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
onReorderUpdate={handleDragUpdate}
onReorderEnd={handleDragEnd}
selectedIds={selectedIds}
categoryColor={categoryColors.get(item.categoryId)}
/>
{showDropBelow && <DropIndicator theme={theme} />}
</View>
@@ -349,79 +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}
shown={completedShown}
onShownChange={setCompletedShown}
onToggle={(task) => handleToggle(task.id, false)}
onDelete={openTaskDelete}
onMenuOpen={openTaskMenu}
onLongPress={(task) => enterSelection(task.id)}
selectionMode={selectionMode}
selectedIds={selectedIds}
onSelect={toggleSelect}
onFetchSubtasks={fetchSubtasks}
subtasksMap={subtasksMap}
onSubtaskToggle={handleSubtaskToggle}
onSubtaskDelete={openSubtaskDelete}
onSubtaskEdit={openSubtaskEdit}
onSubtaskMenuOpen={openSubtaskMenu}
/>
);
const showDropAtEnd = dropIndicator && dropIndicator.targetId === null;
return (
<View>
{footerContent}
{showDropAtEnd && <DropIndicator theme={theme} />}
</View>
);
}, [
completedTasks,
completedShown,
handleToggle,
openTaskDelete,
openTaskMenu,
enterSelection,
selectionMode,
selectedIds,
toggleSelect,
fetchSubtasks,
subtasksMap,
handleSubtaskToggle,
openSubtaskDelete,
openSubtaskEdit,
openSubtaskMenu,
dropIndicator,
theme,
]);
return showDropAtEnd ? <DropIndicator theme={theme} /> : null;
}, [dropIndicator, theme]);
if (loading && !refreshing) {
return (
@@ -514,6 +475,7 @@ interface TaskRowProps {
onReorderUpdate: (absoluteY: number) => void;
onReorderEnd: (absoluteY: number, translationY: number) => void;
selectedIds: Set<string>;
categoryColor?: string;
}
const TaskRow = React.memo(function TaskRow({
@@ -544,6 +506,7 @@ const TaskRow = React.memo(function TaskRow({
onReorderUpdate,
onReorderEnd,
selectedIds,
categoryColor,
}: TaskRowProps) {
const sortedSubtasks = useMemo(
() => subtasks.slice().sort((a, b) => a.order - b.order),
@@ -572,6 +535,7 @@ const TaskRow = React.memo(function TaskRow({
onReorderStart={onReorderStart}
onReorderUpdate={onReorderUpdate}
onReorderEnd={onReorderEnd}
categoryColor={categoryColor}
/>
{expanded && subtasks.length > 0 && (
<View style={styles.subtaskList}>
@@ -589,6 +553,7 @@ const TaskRow = React.memo(function TaskRow({
onDragStart={() => onSubtaskDragStart(sub.id, task.id)}
onDragUpdate={onSubtaskDragUpdate}
onDragEnd={onSubtaskDragEnd}
categoryColor={categoryColor}
/>
))}
</View>
@@ -597,119 +562,6 @@ const TaskRow = React.memo(function TaskRow({
);
});
interface CompletedSectionProps {
tasks: TaskData[];
shown: boolean;
onShownChange: (shown: boolean) => void;
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;
onFetchSubtasks: (taskId: string) => Promise<SubtaskData[]>;
subtasksMap: Map<string, SubtaskData[]>;
onSubtaskToggle: (subtaskId: string, taskId: string) => void;
onSubtaskDelete: (subtask: SubtaskData) => void;
onSubtaskEdit: (subtaskId: string) => void;
onSubtaskMenuOpen: (subtask: SubtaskData) => void;
}
const CompletedSection = React.memo(function CompletedSection({ tasks, shown, onShownChange, onToggle, onDelete, onMenuOpen, onLongPress, selectionMode, selectedIds, onSelect, onFetchSubtasks, subtasksMap, onSubtaskToggle, onSubtaskDelete, onSubtaskEdit, onSubtaskMenuOpen }: CompletedSectionProps) {
const { theme } = useSettings();
const [openTasks, setOpenTasks] = useState<Set<string>>(new Set(tasks.map((t) => t.id)));
const fetchedRef = useRef<Set<string>>(new Set());
useEffect(() => {
setOpenTasks((prev) => {
const next = new Set(prev);
let changed = false;
for (const t of tasks) {
if (!next.has(t.id)) {
next.add(t.id);
changed = true;
}
}
return changed ? next : prev;
});
for (const t of tasks) {
if (!fetchedRef.current.has(t.id)) {
fetchedRef.current.add(t.id);
onFetchSubtasks(t.id);
}
}
}, [tasks, onFetchSubtasks]);
const toggleOpen = useCallback((taskId: string) => {
setOpenTasks((prev) => {
const next = new Set(prev);
if (next.has(taskId)) {
next.delete(taskId);
} else {
next.add(taskId);
}
return next;
});
}, []);
return (
<View style={styles.completedSection}>
<TouchableOpacity
style={styles.completedHeader}
onPress={() => onShownChange(!shown)}
>
<Text style={[styles.completedTitle, { color: theme.textFaint }]}>
Completed ({tasks.length})
</Text>
<Text style={[styles.completedToggle, { color: theme.accent }]}>
{shown ? 'Hide' : 'Show'}
</Text>
</TouchableOpacity>
{shown && (
<View style={styles.completedList}>
{tasks.map((task) => {
const isOpen = openTasks.has(task.id);
const subtasks = (subtasksMap.get(task.id) ?? [])
.slice()
.sort((a, b) => a.order - b.order);
return (
<View key={task.id}>
<TaskItem
task={task}
onToggle={() => onToggle(task)}
onDelete={() => onDelete(task)}
onPress={() => (selectionMode ? onSelect(task.id) : toggleOpen(task.id))}
onLongPress={() => onLongPress(task)}
onMenuOpen={() => onMenuOpen(task)}
selected={selectedIds.has(task.id)}
selectionMode={selectionMode}
completedSection
expanded={isOpen}
/>
{isOpen && subtasks.length > 0 && (
<View style={styles.completedSubtasks}>
{subtasks.map((sub) => (
<SubtaskItem
key={sub.id}
subtask={sub}
onToggle={() => onSubtaskToggle(sub.id, task.id)}
onDelete={() => onSubtaskDelete(sub)}
onPress={() => onSubtaskEdit(sub.id)}
onMenuOpen={() => onSubtaskMenuOpen(sub)}
/>
))}
</View>
)}
</View>
);
})}
</View>
)}
</View>
);
});
const styles = StyleSheet.create({
container: {
flex: 1,
@@ -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 }]}>
+16 -56
View File
@@ -58,11 +58,19 @@ export function useTasksInMonth(monthDate: Date) {
return { tasks, byDay, loading };
}
export function useTasks(categoryId?: string, showCompleted = false) {
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[] = [];
@@ -71,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();
@@ -99,7 +111,7 @@ export function useTasks(categoryId?: string, showCompleted = false) {
mounted = false;
subscription.unsubscribe();
};
}, [collections, categoryId, showCompleted]);
}, [collections, categoryId, showCompleted, cutoff]);
return { tasks, loading };
}
@@ -148,55 +160,3 @@ export function useTasksByDate(date: Date) {
return { tasks, loading };
}
export function useTasksInMonth(month: Date) {
const { collections } = useDatabase();
const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true);
const range = useMemo(() => {
const start = startOfMonth(month);
start.setHours(0, 0, 0, 0);
const end = endOfMonth(month);
end.setHours(23, 59, 59, 999);
return { start: start.getTime(), end: end.getTime() };
}, [month]);
useEffect(() => {
let mounted = 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]);
const byDay = useMemo(() => {
const map: Record<number, Task[]> = {};
for (const task of tasks) {
const day = new Date(task.dueDate).getDate();
if (!map[day]) map[day] = [];
map[day].push(task);
}
return map;
}, [tasks]);
return { tasks, byDay, loading };
}
+10
View File
@@ -116,15 +116,20 @@ interface SettingsContextType {
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);
@@ -165,6 +170,7 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
);
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 = useMemo(() => colors(accentColor), [accentColor]);
@@ -182,6 +188,8 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
setApiUrl,
accentColor,
setAccentColor,
todoAheadDays,
setTodoAheadDays,
theme,
}),
[
@@ -197,6 +205,8 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
setApiUrl,
accentColor,
setAccentColor,
todoAheadDays,
setTodoAheadDays,
theme,
]
);
+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)