This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
dist
|
||||
.expo
|
||||
.git
|
||||
*.log
|
||||
.DS_Store
|
||||
android
|
||||
ios
|
||||
coverage
|
||||
*.local
|
||||
@@ -0,0 +1,18 @@
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npx expo export -p web
|
||||
|
||||
FROM nginx:alpine AS runner
|
||||
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 8081
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -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} />
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/* global __dirname */
|
||||
const { app, BrowserWindow } = require('electron')
|
||||
const path = require('path')
|
||||
|
||||
function createWindow() {
|
||||
const win = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
minWidth: 800,
|
||||
minHeight: 600,
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
webSecurity: true,
|
||||
},
|
||||
icon: path.join(__dirname, '../assets/icon.png'),
|
||||
titleBarStyle: 'default',
|
||||
show: false,
|
||||
})
|
||||
|
||||
win.loadFile(path.join(__dirname, '../dist/index.html'))
|
||||
|
||||
win.once('ready-to-show', () => {
|
||||
win.show()
|
||||
})
|
||||
|
||||
win.on('closed', () => {
|
||||
app.quit()
|
||||
})
|
||||
}
|
||||
|
||||
app.whenReady().then(createWindow)
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow()
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
server {
|
||||
listen 8081;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api {
|
||||
proxy_pass http://backend:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
}
|
||||
@@ -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: {
|
||||
@@ -195,4 +122,4 @@ const styles = StyleSheet.create({
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 }]}>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: carry-your-live-db
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: carry_your_live
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
target: prod
|
||||
container_name: carry-your-live-api
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/carry_your_live
|
||||
JWT_SECRET: your-super-secret-jwt-key-change-in-production-min-32-chars
|
||||
PORT: 3000
|
||||
NODE_ENV: production
|
||||
FRONTEND_URL: http://localhost:8081
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./carry-your-live
|
||||
container_name: carry-your-live-web
|
||||
depends_on:
|
||||
- backend
|
||||
ports:
|
||||
- "8081:8081"
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
@@ -1,34 +1,34 @@
|
||||
add a circle bevor the tasks so they can be checked ✓
|
||||
|
||||
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)
|
||||
Reference in New Issue
Block a user