From 3dad9e93eca527761e54784167b8f833e9a113c7 Mon Sep 17 00:00:00 2001 From: tech08mag Date: Wed, 26 Aug 2026 23:48:42 +0200 Subject: [PATCH 1/2] layout --- .opencode/architekt.md | 13 +++- README.md | 9 +++ carry-your-live/app/(tabs)/_layout.tsx | 64 ++++++++++------- carry-your-live/app/(tabs)/settings.tsx | 24 ++++++- carry-your-live/app/_layout.tsx | 24 ++++++- .../src/components/CategoryEditorModal.tsx | 2 +- carry-your-live/src/components/TaskList.tsx | 60 ++++++++++++++++ carry-your-live/src/hooks/useICalImport.ts | 70 ++++++++----------- carry-your-live/src/theme.tsx | 4 ++ tsconfig.json | 37 +++++++++- 10 files changed, 232 insertions(+), 75 deletions(-) diff --git a/.opencode/architekt.md b/.opencode/architekt.md index 7bcd2fd..04b05e0 100644 --- a/.opencode/architekt.md +++ b/.opencode/architekt.md @@ -14,4 +14,15 @@ System architecture design, technical strategy, and scalability planning. - System scalability - Performance benchmarks - Technical debt ratio -- Architecture compliance \ No newline at end of file +- 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 \ No newline at end of file diff --git a/README.md b/README.md index 7b2203f..1546f05 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/carry-your-live/app/(tabs)/_layout.tsx b/carry-your-live/app/(tabs)/_layout.tsx index 8219f70..5164635 100644 --- a/carry-your-live/app/(tabs)/_layout.tsx +++ b/carry-your-live/app/(tabs)/_layout.tsx @@ -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'; @@ -12,29 +12,30 @@ export default function TabLayout() { return ( + > {props.navigation.getState().routes.map((route) => { const focused = props.navigation.getState().index === props.navigation.getState().routes.indexOf(route); return ( @@ -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 }} + > + + ); })} @@ -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', }, }); \ No newline at end of file diff --git a/carry-your-live/app/(tabs)/settings.tsx b/carry-your-live/app/(tabs)/settings.tsx index 41ce3db..2bd484d 100644 --- a/carry-your-live/app/(tabs)/settings.tsx +++ b/carry-your-live/app/(tabs)/settings.tsx @@ -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(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 /> + {}); 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 ( - + diff --git a/carry-your-live/src/components/CategoryEditorModal.tsx b/carry-your-live/src/components/CategoryEditorModal.tsx index 82fcbe1..9b23938 100644 --- a/carry-your-live/src/components/CategoryEditorModal.tsx +++ b/carry-your-live/src/components/CategoryEditorModal.tsx @@ -161,7 +161,7 @@ export function CategoryEditorModal({ ))} - + {category && ( diff --git a/carry-your-live/src/components/TaskList.tsx b/carry-your-live/src/components/TaskList.tsx index 9e3ea86..77b0a5b 100644 --- a/carry-your-live/src/components/TaskList.tsx +++ b/carry-your-live/src/components/TaskList.tsx @@ -67,6 +67,7 @@ export function TaskList({ categoryIds = [], showCompleted = false, onSelectionC const subtaskRefs = useRef>(new Map()); const dragStateRef = useRef<{ taskId: string; positions: Record; subPositions: Record } | null>(null); const subtaskDragRef = useRef<{ subtaskId: string; parentTaskId: string; parentSubtaskId: string | null; positions: Record; subPositions: Record } | null>(null); + const taskDragRef = useRef<{ taskId: string; positions: Record } | 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) => { + 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, subPositions: Record) => { 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; + 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); diff --git a/carry-your-live/src/hooks/useICalImport.ts b/carry-your-live/src/hooks/useICalImport.ts index a134612..292fbba 100644 --- a/carry-your-live/src/hooks/useICalImport.ts +++ b/carry-your-live/src/hooks/useICalImport.ts @@ -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,49 +139,34 @@ 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, - })) - ); - - // Observe the changes - subscription.subscribe({ - next: (result) => { - // Tasks have been imported - }, - error: (err) => { - console.error('Import error:', err); - setError(err.message); - }, - }); + 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); + }) + ) + ); setLoading(false); } catch (err: any) { @@ -188,7 +174,7 @@ export function useICalImport() { setError(err.message || 'Import failed'); setLoading(false); } - }, [collections]); + }, []); return { importICal, loading, error }; } \ No newline at end of file diff --git a/carry-your-live/src/theme.tsx b/carry-your-live/src/theme.tsx index b811cec..d3bddbf 100644 --- a/carry-your-live/src/theme.tsx +++ b/carry-your-live/src/theme.tsx @@ -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, diff --git a/tsconfig.json b/tsconfig.json index 0e6371f..37f88fe 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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" + ] } From cfb987a3059594b928cd5f57b3cffbc54271ac89 Mon Sep 17 00:00:00 2001 From: tech08mag Date: Thu, 27 Aug 2026 06:35:06 +0200 Subject: [PATCH 2/2] Fix OutOfMemoryError: increase MaxMetaspaceSize from 384m to 1024m --- carry-your-live/android/gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/carry-your-live/android/gradle.properties b/carry-your-live/android/gradle.properties index 0810d82..ff30013 100644 --- a/carry-your-live/android/gradle.properties +++ b/carry-your-live/android/gradle.properties @@ -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.