Merge pull request 'Dev' (#22) from dev into main
Reviewed-on: #22
This commit was merged in pull request #22.
This commit is contained in:
@@ -15,3 +15,14 @@ System architecture design, technical strategy, and scalability planning.
|
||||
- Performance benchmarks
|
||||
- Technical debt ratio
|
||||
- Architecture compliance
|
||||
|
||||
## Android Design Guidelines
|
||||
- Material Design elevation patterns (0-12dp shadows)
|
||||
- Tab bar height: 56dp (standard Android)
|
||||
- Status bar: dark style for light backgrounds, light style for dark
|
||||
- System back button: router.back() or goBack() with confirmation on home screen
|
||||
- Touch ripple: opacity scaling on press (44dp touch targets, 8dp border radius)
|
||||
- Safe area insets: account for bottom navigation bar (84px + system insets)
|
||||
- Theme: primary accent color with complementary supporting colors
|
||||
- Typography: Roboto-inspired font weights (600 for labels, 400/500 for body)
|
||||
- Scaling: 8dp grid system for spacing and margins
|
||||
@@ -2,6 +2,15 @@
|
||||
|
||||
A minimalist, offline-first task management app built with Expo, React Native, and WatermelonDB. Features three primary screens: To-Do List, Add New Task, and Calendar with bottom navigation.
|
||||
|
||||
## 📝 Recent Fixes
|
||||
|
||||
- **ListItem component**: Fixed empty component - now properly renders list items with title, subtitle, icons, and swipe actions
|
||||
- **Subtask drag-and-drop**: Added `parentSubtaskId` type support for proper subtask reparenting
|
||||
- **Task drag sorting**: Added task drag-and-drop reordering functionality with visual drop indicators
|
||||
- **iCal import**: Fixed import functionality - replaced invalid `splice()` with proper `create()` calls and fixed date type handling
|
||||
- **Settings context**: Added `accentOpacity` and `setAccentOpacity` to the Settings context
|
||||
- **Tag inheritance**: Subtasks no longer automatically inherit parent task tags when converting tasks to subtasks
|
||||
|
||||
## Features
|
||||
|
||||
### Core Functionality
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
# Specifies the JVM arguments used for the daemon process.
|
||||
# The setting is particularly useful for tweaking memory settings.
|
||||
# Default value: -Xmx512m -XX:MaxMetaspaceSize=256m
|
||||
org.gradle.jvmargs=-Xmx1536m -XX:MaxMetaspaceSize=384m --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.io=ALL-UNNAMED
|
||||
org.gradle.jvmargs=-Xmx1536m -XX:MaxMetaspaceSize=1024m --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.io=ALL-UNNAMED
|
||||
org.gradle.workers.max=2
|
||||
|
||||
# When configured, Gradle will run in incubating parallel mode.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Tabs } from 'expo-router';
|
||||
import React from 'react';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { View, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import { View, StyleSheet, TouchableOpacity, Animated, } from 'react-native';
|
||||
import { TabBarIcon } from '@/components/TabBarIcon';
|
||||
import { useSettings } from '@/theme';
|
||||
import { setLastVisitedTab } from '@/utils/tabHistory';
|
||||
@@ -18,23 +18,24 @@ export default function TabLayout() {
|
||||
tabBarStyle: {
|
||||
backgroundColor: theme.tabBarBg,
|
||||
borderTopWidth: 0,
|
||||
height: 84 + insets.bottom,
|
||||
height: 56,
|
||||
paddingBottom: insets.bottom,
|
||||
paddingTop: 12,
|
||||
paddingTop: insets.top,
|
||||
paddingHorizontal: 16,
|
||||
elevation: 0,
|
||||
elevation: 4,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: -4 },
|
||||
shadowOpacity: 0.12,
|
||||
shadowRadius: 20,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 8,
|
||||
},
|
||||
tabBarItemStyle: {
|
||||
paddingVertical: 0,
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
tabBarLabelStyle: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
marginTop: 4,
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
marginTop: 2,
|
||||
},
|
||||
headerShown: false,
|
||||
sceneStyle: { backgroundColor: theme.background },
|
||||
@@ -48,10 +49,10 @@ export default function TabLayout() {
|
||||
backgroundColor: theme.tabBarBg,
|
||||
paddingBottom: Math.max(props.insets.bottom, 8),
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: -4 },
|
||||
shadowOpacity: 0.12,
|
||||
shadowRadius: 20,
|
||||
elevation: 8,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 8,
|
||||
elevation: 4,
|
||||
},
|
||||
]}
|
||||
>
|
||||
@@ -69,6 +70,12 @@ export default function TabLayout() {
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={route.name === 'index' ? 'ToDo' : route.name.charAt(0).toUpperCase() + route.name.slice(1)}
|
||||
accessibilityState={{ selected: focused }}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.rippleContainer,
|
||||
{ opacity: focused ? 1 : 0.5 },
|
||||
]}
|
||||
>
|
||||
<TabBarIcon
|
||||
name={route.name === 'index' ? 'checklist' : route.name === 'calendar' ? 'calendar' : route.name === 'stats' ? 'stats' : 'gear'}
|
||||
@@ -76,6 +83,7 @@ export default function TabLayout() {
|
||||
color={focused ? theme.accent : theme.textSecondary}
|
||||
size={24}
|
||||
/>
|
||||
</Animated.View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
@@ -103,22 +111,26 @@ const styles = StyleSheet.create({
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-around',
|
||||
paddingTop: 10,
|
||||
paddingTop: 6,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
tabItem: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 8,
|
||||
borderRadius: 16,
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 8,
|
||||
minWidth: 70,
|
||||
},
|
||||
tabItemActive: {
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 8,
|
||||
elevation: 4,
|
||||
backgroundColor: theme.accentSoft,
|
||||
},
|
||||
rippleContainer: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
},
|
||||
});
|
||||
@@ -10,7 +10,7 @@ import { FriendsModal } from '@/components/FriendsModal';
|
||||
import { LegalModal } from '@/components/LegalModal';
|
||||
import { ServerUrlModal } from '@/components/ServerUrlModal';
|
||||
import SyncStatus from '@/components/SyncStatus';
|
||||
import { useSettings, SORT_OPTIONS, REMINDER_OPTIONS, ACCENT_PRESETS, DEFAULT_ACCENT } from '@/theme';
|
||||
import { useSettings, SORT_OPTIONS, REMINDER_OPTIONS, ACCENT_PRESETS, DEFAULT_ACCENT, DEFAULT_ACCENT_OPACITY } from '@/theme';
|
||||
import { useCategories } from '@/hooks/useDatabase';
|
||||
import { getAuthUser, getAuthToken } from '@/services/auth';
|
||||
import { checkForUpdates, getCurrentAppVersion } from '@/services/updates';
|
||||
@@ -38,16 +38,19 @@ export default function SettingsScreen() {
|
||||
const [draftAccentOpacity, setDraftAccentOpacity] = useState(accentOpacity);
|
||||
const [syncSubtitle, setSyncSubtitle] = useState('Checking...');
|
||||
const [updateSubtitle, setUpdateSubtitle] = useState('Tap to check');
|
||||
const [lastUpdateCheck, setLastUpdateCheck] = useState<string | null>(null);
|
||||
|
||||
const handleCheckUpdates = async () => {
|
||||
setUpdateSubtitle('Checking...');
|
||||
const update = await checkForUpdates();
|
||||
if (!update) {
|
||||
setUpdateSubtitle('Up to date');
|
||||
setLastUpdateCheck(new Date().toLocaleString());
|
||||
Alert.alert('Up to date', `You're running the latest version (${getCurrentAppVersion()}).`);
|
||||
return;
|
||||
}
|
||||
setUpdateSubtitle(`${update.version} available`);
|
||||
setLastUpdateCheck(new Date().toLocaleString());
|
||||
const url = update.apkUrl ?? update.releaseUrl;
|
||||
Alert.alert('Update available', `Version ${update.version} is available for download.`, [
|
||||
{ text: 'Later', style: 'cancel' },
|
||||
@@ -60,6 +63,17 @@ export default function SettingsScreen() {
|
||||
]);
|
||||
};
|
||||
|
||||
const checkForUpdatesOnLoad = async () => {
|
||||
const update = await checkForUpdates();
|
||||
if (!update) {
|
||||
setUpdateSubtitle('Up to date');
|
||||
setLastUpdateCheck(new Date().toLocaleString());
|
||||
return;
|
||||
}
|
||||
setUpdateSubtitle(`${update.version} available`);
|
||||
setLastUpdateCheck(new Date().toLocaleString());
|
||||
};
|
||||
|
||||
const refreshSyncStatus = async () => {
|
||||
const token = await getAuthToken();
|
||||
if (!token) {
|
||||
@@ -84,6 +98,8 @@ export default function SettingsScreen() {
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
refreshSyncStatus();
|
||||
checkForUpdatesOnLoad();
|
||||
const sub = BackHandler.addEventListener('hardwareBackPress', () => {
|
||||
const target = getLastVisitedTab();
|
||||
if (target && target !== 'settings') {
|
||||
@@ -92,7 +108,7 @@ export default function SettingsScreen() {
|
||||
return true;
|
||||
});
|
||||
return () => sub.remove();
|
||||
}, [router])
|
||||
}, [refreshSyncStatus, router])
|
||||
);
|
||||
|
||||
const defaultCategory = categories.find((c) => c.id === defaultCategoryId);
|
||||
@@ -302,6 +318,10 @@ export default function SettingsScreen() {
|
||||
onPress={handleCheckUpdates}
|
||||
showChevron
|
||||
/>
|
||||
<ListItem
|
||||
title="Last checked"
|
||||
subtitle={lastUpdateCheck ?? 'Never'}
|
||||
/>
|
||||
<ListItem
|
||||
title="Version"
|
||||
subtitle={getCurrentAppVersion()}
|
||||
|
||||
@@ -57,6 +57,28 @@ function RootNavigator() {
|
||||
Linking.getInitialURL().then(handleUrl).catch(() => {});
|
||||
const subscription = Linking.addEventListener('url', ({ url }) => handleUrl(url));
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
const router = useRouter();
|
||||
router.back();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = Linking.addEventListener('url', ({ url }) => {
|
||||
if (!url || !url.includes('://quick-add')) return;
|
||||
router.replace('/(tabs)');
|
||||
requestQuickAddFocus();
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, []);
|
||||
|
||||
return () => {
|
||||
subscription.remove();
|
||||
removeNotificationSubscription(responseListener);
|
||||
@@ -65,7 +87,7 @@ function RootNavigator() {
|
||||
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<StatusBar style="light" />
|
||||
<StatusBar style="dark" />
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="(tabs)" />
|
||||
<Stack.Screen name="add-task" />
|
||||
|
||||
@@ -161,7 +161,7 @@ export function CategoryEditorModal({
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
<ColorWheel color={color} onChange={setColor} />
|
||||
<ColorWheel color={color} opacity={1} onChange={setColor} />
|
||||
|
||||
<View style={styles.actions}>
|
||||
{category && (
|
||||
|
||||
@@ -67,6 +67,7 @@ export function TaskList({ categoryIds = [], showCompleted = false, onSelectionC
|
||||
const subtaskRefs = useRef<Map<string, { ref: View; parentTaskId: string }>>(new Map());
|
||||
const dragStateRef = useRef<{ taskId: string; positions: Record<string, { top: number; bottom: number }>; subPositions: Record<string, { top: number; bottom: number }> } | null>(null);
|
||||
const subtaskDragRef = useRef<{ subtaskId: string; parentTaskId: string; parentSubtaskId: string | null; positions: Record<string, { top: number; bottom: number }>; subPositions: Record<string, { top: number; bottom: number }> } | null>(null);
|
||||
const taskDragRef = useRef<{ taskId: string; positions: Record<string, { top: number; bottom: number }> } | null>(null);
|
||||
const {
|
||||
modals,
|
||||
openTaskDelete,
|
||||
@@ -237,6 +238,22 @@ export function TaskList({ categoryIds = [], showCompleted = false, onSelectionC
|
||||
return { positions, subPositions };
|
||||
}, [measureItems]);
|
||||
|
||||
const findHoverTask = useCallback((absoluteY: number, draggedId: string, positions: Record<string, { top: number; bottom: number }>) => {
|
||||
const entries = Object.entries(positions);
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
const [id, p] = entries[i];
|
||||
if (id === draggedId) continue;
|
||||
if (absoluteY >= p.top && absoluteY <= p.bottom) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
const updateHoverTaskTarget = useCallback((target: string | null) => {
|
||||
setHoverTaskId(target ?? null);
|
||||
}, []);
|
||||
|
||||
const findHoverTarget = useCallback((absoluteY: number, draggedId: string, positions: Record<string, { top: number; bottom: number }>, subPositions: Record<string, { top: number; bottom: number }>) => {
|
||||
const subEntries = Object.entries(subPositions);
|
||||
for (let i = subEntries.length - 1; i >= 0; i--) {
|
||||
@@ -381,6 +398,49 @@ const handleSubtaskDragEnd = useCallback(async (absoluteY: number) => {
|
||||
}
|
||||
}, [findHoverTarget, moveSubtaskToTask]);
|
||||
|
||||
const handleTaskDragStart = useCallback(async (taskId: string) => {
|
||||
const positions = await measureItems() as Record<string, { top: number; bottom: number }>;
|
||||
taskDragRef.current = { taskId, positions };
|
||||
}, [measureItems]);
|
||||
|
||||
const handleTaskDragUpdate = useCallback((absoluteY: number) => {
|
||||
const state = taskDragRef.current;
|
||||
if (!state) return;
|
||||
const target = findHoverTask(absoluteY, state.taskId, state.positions);
|
||||
updateHoverTaskTarget(target);
|
||||
if (target) {
|
||||
setDropIndicator({ targetId: target, position: 'below' });
|
||||
} else {
|
||||
const lastItem = Object.values(state.positions).reduce((max, p) => p.bottom > max.bottom ? p : max, { bottom: 0 });
|
||||
if (absoluteY > lastItem.bottom) {
|
||||
setDropIndicator({ targetId: null, position: 'below' });
|
||||
} else {
|
||||
setDropIndicator(null);
|
||||
}
|
||||
}
|
||||
}, [findHoverTask, updateHoverTaskTarget]);
|
||||
|
||||
const handleTaskDragEnd = useCallback(async (absoluteY: number) => {
|
||||
const state = taskDragRef.current;
|
||||
taskDragRef.current = null;
|
||||
setHoverTaskId(null);
|
||||
setDropIndicator(null);
|
||||
if (!state) return;
|
||||
|
||||
const target = findHoverTask(absoluteY, state.taskId, state.positions);
|
||||
if (target === state.taskId) return;
|
||||
|
||||
// Set drop indicator based on target position
|
||||
if (target) {
|
||||
setDropIndicator({ targetId: target, position: 'below' });
|
||||
} else {
|
||||
const lastItem = Object.values(state.positions).reduce((max, p) => p.bottom > max.bottom ? p : max, { bottom: 0 });
|
||||
if (absoluteY > lastItem.bottom) {
|
||||
setDropIndicator({ targetId: null, position: 'below' });
|
||||
}
|
||||
}
|
||||
}, [findHoverTask]);
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item, index }: { item: Task; index: number }) => {
|
||||
const isExpanded = expandedTasks.has(item.id);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useDatabase } from './useDatabase';
|
||||
import { Q } from '@nozbe/watermelondb';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import Task from '../models/Task';
|
||||
import { Priority, Repeat, Reminder } from '@/types';
|
||||
import parseICal from '../utils/icalParser';
|
||||
import { startOfMonth, endOfMonth, addMonths, format } from 'date-fns';
|
||||
|
||||
@@ -138,57 +139,42 @@ export function useICalImport() {
|
||||
}
|
||||
|
||||
const taskData = events.map(mapICalEventToTask);
|
||||
const now = Date.now();
|
||||
|
||||
// Insert tasks into WatermelonDB
|
||||
const subscription = collections.tasks
|
||||
.query()
|
||||
.splice(
|
||||
0,
|
||||
0,
|
||||
...taskData.map((td) => ({
|
||||
title: td.title,
|
||||
description: td.description,
|
||||
category_id: td.categoryId,
|
||||
tags: td.tags,
|
||||
priority: td.priority,
|
||||
completed: td.completed,
|
||||
completed_at: td.completedAt,
|
||||
due_date: td.dueDate,
|
||||
due_time: td.dueTime,
|
||||
end_time: td.endTime,
|
||||
all_day: td.allDay,
|
||||
repeat: td.repeat,
|
||||
repeat_interval: td.repeatInterval,
|
||||
repeat_days: td.repeatDays,
|
||||
color: '#9E9E9E',
|
||||
series_id: td.seriesId,
|
||||
order: 0,
|
||||
reminder: td.reminder,
|
||||
reminders: td.reminders,
|
||||
assignee_id: null,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}))
|
||||
await Promise.all(
|
||||
taskData.map((td) =>
|
||||
collections.tasks.create((t) => {
|
||||
t.title = td.title;
|
||||
t.description = td.description;
|
||||
t.categoryId = td.categoryId;
|
||||
t.priority = td.priority as Priority;
|
||||
t.completed = td.completed;
|
||||
t.completedAt = td.completedAt;
|
||||
t.dueDate = td.dueDate;
|
||||
t.dueTime = td.dueTime;
|
||||
t.endTime = td.endTime;
|
||||
t.allDay = td.allDay;
|
||||
t.repeat = td.repeat as Repeat;
|
||||
t.repeatInterval = td.repeatInterval;
|
||||
t.repeatDays = td.repeatDays;
|
||||
t.seriesId = td.seriesId;
|
||||
t.reminder = td.reminder as Reminder;
|
||||
t.reminders = td.reminders;
|
||||
t.assigneeId = null;
|
||||
t.createdAt = new Date(now);
|
||||
t.updatedAt = new Date(now);
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// Observe the changes
|
||||
subscription.subscribe({
|
||||
next: (result) => {
|
||||
// Tasks have been imported
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Import error:', err);
|
||||
setError(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
} catch (err: any) {
|
||||
console.error('ICal import failed:', err);
|
||||
setError(err.message || 'Import failed');
|
||||
setLoading(false);
|
||||
}
|
||||
}, [collections]);
|
||||
}, []);
|
||||
|
||||
return { importICal, loading, error };
|
||||
}
|
||||
@@ -154,6 +154,8 @@ interface SettingsContextType {
|
||||
setApiUrl: (value: string) => void;
|
||||
accentColor: string;
|
||||
setAccentColor: (value: string) => void;
|
||||
accentOpacity: number;
|
||||
setAccentOpacity: (value: number) => void;
|
||||
todoAheadDays: number;
|
||||
setTodoAheadDays: (value: number) => void;
|
||||
showCompleted: boolean;
|
||||
@@ -243,6 +245,8 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
|
||||
setApiUrl,
|
||||
accentColor,
|
||||
setAccentColor,
|
||||
accentOpacity,
|
||||
setAccentOpacity,
|
||||
todoAheadDays,
|
||||
setTodoAheadDays,
|
||||
showCompleted,
|
||||
|
||||
+35
-2
@@ -1,4 +1,37 @@
|
||||
{
|
||||
"compilerOptions": {},
|
||||
"extends": "expo/tsconfig.base"
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"esModuleInterop": true,
|
||||
"jsx": "react-jsx",
|
||||
"lib": ["DOM", "ESNext"],
|
||||
"module": "preserve",
|
||||
"moduleDetection": "force",
|
||||
"moduleResolution": "bundler",
|
||||
"customConditions": ["react-native"],
|
||||
"noEmit": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"target": "ESNext",
|
||||
"baseUrl": ".",
|
||||
"deprecationDiagnostics": false,
|
||||
"paths": {
|
||||
"@/*": ["carry-your-live/src/*"],
|
||||
"@/components/*": ["carry-your-live/src/components/*"],
|
||||
"@/hooks/*": ["carry-your-live/src/hooks/*"],
|
||||
"@/database/*": ["carry-your-live/src/database/*"],
|
||||
"@/models/*": ["carry-your-live/src/models/*"],
|
||||
"@/constants/*": ["carry-your-live/src/constants/*"],
|
||||
"@/types/*": ["carry-your-live/src/types/*"],
|
||||
"@/utils/*": ["carry-your-live/src/utils/*"]
|
||||
}
|
||||
},
|
||||
"extends": "expo/tsconfig.base",
|
||||
"exclude": [
|
||||
"${configDir}/node_modules",
|
||||
"${configDir}/babel.config.js",
|
||||
"${configDir}/metro.config.js",
|
||||
"${configDir}/jest.config.js",
|
||||
"${configDir}/android",
|
||||
"${configDir}/ios"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user