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
|
- Performance benchmarks
|
||||||
- Technical debt ratio
|
- Technical debt ratio
|
||||||
- Architecture compliance
|
- 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.
|
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
|
## Features
|
||||||
|
|
||||||
### Core Functionality
|
### Core Functionality
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
# Specifies the JVM arguments used for the daemon process.
|
# Specifies the JVM arguments used for the daemon process.
|
||||||
# The setting is particularly useful for tweaking memory settings.
|
# The setting is particularly useful for tweaking memory settings.
|
||||||
# Default value: -Xmx512m -XX:MaxMetaspaceSize=256m
|
# 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
|
org.gradle.workers.max=2
|
||||||
|
|
||||||
# When configured, Gradle will run in incubating parallel mode.
|
# When configured, Gradle will run in incubating parallel mode.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Tabs } from 'expo-router';
|
import { Tabs } from 'expo-router';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
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 { TabBarIcon } from '@/components/TabBarIcon';
|
||||||
import { useSettings } from '@/theme';
|
import { useSettings } from '@/theme';
|
||||||
import { setLastVisitedTab } from '@/utils/tabHistory';
|
import { setLastVisitedTab } from '@/utils/tabHistory';
|
||||||
@@ -12,29 +12,30 @@ export default function TabLayout() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs
|
<Tabs
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
tabBarActiveTintColor: theme.accent,
|
tabBarActiveTintColor: theme.accent,
|
||||||
tabBarInactiveTintColor: theme.textSecondary,
|
tabBarInactiveTintColor: theme.textSecondary,
|
||||||
tabBarStyle: {
|
tabBarStyle: {
|
||||||
backgroundColor: theme.tabBarBg,
|
backgroundColor: theme.tabBarBg,
|
||||||
borderTopWidth: 0,
|
borderTopWidth: 0,
|
||||||
height: 84 + insets.bottom,
|
height: 56,
|
||||||
paddingBottom: insets.bottom,
|
paddingBottom: insets.bottom,
|
||||||
paddingTop: 12,
|
paddingTop: insets.top,
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
elevation: 0,
|
elevation: 4,
|
||||||
shadowColor: '#000',
|
shadowColor: '#000',
|
||||||
shadowOffset: { width: 0, height: -4 },
|
shadowOffset: { width: 0, height: 2 },
|
||||||
shadowOpacity: 0.12,
|
shadowOpacity: 0.1,
|
||||||
shadowRadius: 20,
|
shadowRadius: 8,
|
||||||
},
|
},
|
||||||
tabBarItemStyle: {
|
tabBarItemStyle: {
|
||||||
paddingVertical: 0,
|
paddingVertical: 6,
|
||||||
|
paddingHorizontal: 12,
|
||||||
},
|
},
|
||||||
tabBarLabelStyle: {
|
tabBarLabelStyle: {
|
||||||
fontSize: 11,
|
fontSize: 12,
|
||||||
fontWeight: '700',
|
fontWeight: '600',
|
||||||
marginTop: 4,
|
marginTop: 2,
|
||||||
},
|
},
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
sceneStyle: { backgroundColor: theme.background },
|
sceneStyle: { backgroundColor: theme.background },
|
||||||
@@ -48,10 +49,10 @@ export default function TabLayout() {
|
|||||||
backgroundColor: theme.tabBarBg,
|
backgroundColor: theme.tabBarBg,
|
||||||
paddingBottom: Math.max(props.insets.bottom, 8),
|
paddingBottom: Math.max(props.insets.bottom, 8),
|
||||||
shadowColor: '#000',
|
shadowColor: '#000',
|
||||||
shadowOffset: { width: 0, height: -4 },
|
shadowOffset: { width: 0, height: 4 },
|
||||||
shadowOpacity: 0.12,
|
shadowOpacity: 0.1,
|
||||||
shadowRadius: 20,
|
shadowRadius: 8,
|
||||||
elevation: 8,
|
elevation: 4,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
@@ -69,6 +70,12 @@ export default function TabLayout() {
|
|||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={route.name === 'index' ? 'ToDo' : route.name.charAt(0).toUpperCase() + route.name.slice(1)}
|
accessibilityLabel={route.name === 'index' ? 'ToDo' : route.name.charAt(0).toUpperCase() + route.name.slice(1)}
|
||||||
accessibilityState={{ selected: focused }}
|
accessibilityState={{ selected: focused }}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.rippleContainer,
|
||||||
|
{ opacity: focused ? 1 : 0.5 },
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
<TabBarIcon
|
<TabBarIcon
|
||||||
name={route.name === 'index' ? 'checklist' : route.name === 'calendar' ? 'calendar' : route.name === 'stats' ? 'stats' : 'gear'}
|
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}
|
color={focused ? theme.accent : theme.textSecondary}
|
||||||
size={24}
|
size={24}
|
||||||
/>
|
/>
|
||||||
|
</Animated.View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -103,22 +111,26 @@ const styles = StyleSheet.create({
|
|||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-around',
|
justifyContent: 'space-around',
|
||||||
paddingTop: 10,
|
paddingTop: 6,
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
},
|
},
|
||||||
tabItem: {
|
tabItem: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingVertical: 8,
|
paddingVertical: 6,
|
||||||
paddingHorizontal: 8,
|
paddingHorizontal: 12,
|
||||||
borderRadius: 16,
|
borderRadius: 8,
|
||||||
minWidth: 70,
|
minWidth: 70,
|
||||||
},
|
},
|
||||||
tabItemActive: {
|
tabItemActive: {
|
||||||
shadowColor: '#000',
|
backgroundColor: theme.accentSoft,
|
||||||
shadowOffset: { width: 0, height: 2 },
|
},
|
||||||
shadowOpacity: 0.1,
|
rippleContainer: {
|
||||||
shadowRadius: 8,
|
width: 44,
|
||||||
elevation: 4,
|
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 { LegalModal } from '@/components/LegalModal';
|
||||||
import { ServerUrlModal } from '@/components/ServerUrlModal';
|
import { ServerUrlModal } from '@/components/ServerUrlModal';
|
||||||
import SyncStatus from '@/components/SyncStatus';
|
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 { useCategories } from '@/hooks/useDatabase';
|
||||||
import { getAuthUser, getAuthToken } from '@/services/auth';
|
import { getAuthUser, getAuthToken } from '@/services/auth';
|
||||||
import { checkForUpdates, getCurrentAppVersion } from '@/services/updates';
|
import { checkForUpdates, getCurrentAppVersion } from '@/services/updates';
|
||||||
@@ -38,16 +38,19 @@ export default function SettingsScreen() {
|
|||||||
const [draftAccentOpacity, setDraftAccentOpacity] = useState(accentOpacity);
|
const [draftAccentOpacity, setDraftAccentOpacity] = useState(accentOpacity);
|
||||||
const [syncSubtitle, setSyncSubtitle] = useState('Checking...');
|
const [syncSubtitle, setSyncSubtitle] = useState('Checking...');
|
||||||
const [updateSubtitle, setUpdateSubtitle] = useState('Tap to check');
|
const [updateSubtitle, setUpdateSubtitle] = useState('Tap to check');
|
||||||
|
const [lastUpdateCheck, setLastUpdateCheck] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleCheckUpdates = async () => {
|
const handleCheckUpdates = async () => {
|
||||||
setUpdateSubtitle('Checking...');
|
setUpdateSubtitle('Checking...');
|
||||||
const update = await checkForUpdates();
|
const update = await checkForUpdates();
|
||||||
if (!update) {
|
if (!update) {
|
||||||
setUpdateSubtitle('Up to date');
|
setUpdateSubtitle('Up to date');
|
||||||
|
setLastUpdateCheck(new Date().toLocaleString());
|
||||||
Alert.alert('Up to date', `You're running the latest version (${getCurrentAppVersion()}).`);
|
Alert.alert('Up to date', `You're running the latest version (${getCurrentAppVersion()}).`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setUpdateSubtitle(`${update.version} available`);
|
setUpdateSubtitle(`${update.version} available`);
|
||||||
|
setLastUpdateCheck(new Date().toLocaleString());
|
||||||
const url = update.apkUrl ?? update.releaseUrl;
|
const url = update.apkUrl ?? update.releaseUrl;
|
||||||
Alert.alert('Update available', `Version ${update.version} is available for download.`, [
|
Alert.alert('Update available', `Version ${update.version} is available for download.`, [
|
||||||
{ text: 'Later', style: 'cancel' },
|
{ 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 refreshSyncStatus = async () => {
|
||||||
const token = await getAuthToken();
|
const token = await getAuthToken();
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@@ -84,6 +98,8 @@ export default function SettingsScreen() {
|
|||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
|
refreshSyncStatus();
|
||||||
|
checkForUpdatesOnLoad();
|
||||||
const sub = BackHandler.addEventListener('hardwareBackPress', () => {
|
const sub = BackHandler.addEventListener('hardwareBackPress', () => {
|
||||||
const target = getLastVisitedTab();
|
const target = getLastVisitedTab();
|
||||||
if (target && target !== 'settings') {
|
if (target && target !== 'settings') {
|
||||||
@@ -92,7 +108,7 @@ export default function SettingsScreen() {
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
return () => sub.remove();
|
return () => sub.remove();
|
||||||
}, [router])
|
}, [refreshSyncStatus, router])
|
||||||
);
|
);
|
||||||
|
|
||||||
const defaultCategory = categories.find((c) => c.id === defaultCategoryId);
|
const defaultCategory = categories.find((c) => c.id === defaultCategoryId);
|
||||||
@@ -302,6 +318,10 @@ export default function SettingsScreen() {
|
|||||||
onPress={handleCheckUpdates}
|
onPress={handleCheckUpdates}
|
||||||
showChevron
|
showChevron
|
||||||
/>
|
/>
|
||||||
|
<ListItem
|
||||||
|
title="Last checked"
|
||||||
|
subtitle={lastUpdateCheck ?? 'Never'}
|
||||||
|
/>
|
||||||
<ListItem
|
<ListItem
|
||||||
title="Version"
|
title="Version"
|
||||||
subtitle={getCurrentAppVersion()}
|
subtitle={getCurrentAppVersion()}
|
||||||
|
|||||||
@@ -57,6 +57,28 @@ function RootNavigator() {
|
|||||||
Linking.getInitialURL().then(handleUrl).catch(() => {});
|
Linking.getInitialURL().then(handleUrl).catch(() => {});
|
||||||
const subscription = Linking.addEventListener('url', ({ url }) => handleUrl(url));
|
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 () => {
|
return () => {
|
||||||
subscription.remove();
|
subscription.remove();
|
||||||
removeNotificationSubscription(responseListener);
|
removeNotificationSubscription(responseListener);
|
||||||
@@ -65,7 +87,7 @@ function RootNavigator() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
<StatusBar style="light" />
|
<StatusBar style="dark" />
|
||||||
<Stack screenOptions={{ headerShown: false }}>
|
<Stack screenOptions={{ headerShown: false }}>
|
||||||
<Stack.Screen name="(tabs)" />
|
<Stack.Screen name="(tabs)" />
|
||||||
<Stack.Screen name="add-task" />
|
<Stack.Screen name="add-task" />
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ export function CategoryEditorModal({
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
<ColorWheel color={color} onChange={setColor} />
|
<ColorWheel color={color} opacity={1} onChange={setColor} />
|
||||||
|
|
||||||
<View style={styles.actions}>
|
<View style={styles.actions}>
|
||||||
{category && (
|
{category && (
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ export function TaskList({ categoryIds = [], showCompleted = false, onSelectionC
|
|||||||
const subtaskRefs = useRef<Map<string, { ref: View; parentTaskId: string }>>(new Map());
|
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 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 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 {
|
const {
|
||||||
modals,
|
modals,
|
||||||
openTaskDelete,
|
openTaskDelete,
|
||||||
@@ -237,6 +238,22 @@ export function TaskList({ categoryIds = [], showCompleted = false, onSelectionC
|
|||||||
return { positions, subPositions };
|
return { positions, subPositions };
|
||||||
}, [measureItems]);
|
}, [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 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);
|
const subEntries = Object.entries(subPositions);
|
||||||
for (let i = subEntries.length - 1; i >= 0; i--) {
|
for (let i = subEntries.length - 1; i >= 0; i--) {
|
||||||
@@ -381,6 +398,49 @@ const handleSubtaskDragEnd = useCallback(async (absoluteY: number) => {
|
|||||||
}
|
}
|
||||||
}, [findHoverTarget, moveSubtaskToTask]);
|
}, [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(
|
const renderItem = useCallback(
|
||||||
({ item, index }: { item: Task; index: number }) => {
|
({ item, index }: { item: Task; index: number }) => {
|
||||||
const isExpanded = expandedTasks.has(item.id);
|
const isExpanded = expandedTasks.has(item.id);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useDatabase } from './useDatabase';
|
|||||||
import { Q } from '@nozbe/watermelondb';
|
import { Q } from '@nozbe/watermelondb';
|
||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import Task from '../models/Task';
|
import Task from '../models/Task';
|
||||||
|
import { Priority, Repeat, Reminder } from '@/types';
|
||||||
import parseICal from '../utils/icalParser';
|
import parseICal from '../utils/icalParser';
|
||||||
import { startOfMonth, endOfMonth, addMonths, format } from 'date-fns';
|
import { startOfMonth, endOfMonth, addMonths, format } from 'date-fns';
|
||||||
|
|
||||||
@@ -138,57 +139,42 @@ export function useICalImport() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const taskData = events.map(mapICalEventToTask);
|
const taskData = events.map(mapICalEventToTask);
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
// Insert tasks into WatermelonDB
|
// Insert tasks into WatermelonDB
|
||||||
const subscription = collections.tasks
|
await Promise.all(
|
||||||
.query()
|
taskData.map((td) =>
|
||||||
.splice(
|
collections.tasks.create((t) => {
|
||||||
0,
|
t.title = td.title;
|
||||||
0,
|
t.description = td.description;
|
||||||
...taskData.map((td) => ({
|
t.categoryId = td.categoryId;
|
||||||
title: td.title,
|
t.priority = td.priority as Priority;
|
||||||
description: td.description,
|
t.completed = td.completed;
|
||||||
category_id: td.categoryId,
|
t.completedAt = td.completedAt;
|
||||||
tags: td.tags,
|
t.dueDate = td.dueDate;
|
||||||
priority: td.priority,
|
t.dueTime = td.dueTime;
|
||||||
completed: td.completed,
|
t.endTime = td.endTime;
|
||||||
completed_at: td.completedAt,
|
t.allDay = td.allDay;
|
||||||
due_date: td.dueDate,
|
t.repeat = td.repeat as Repeat;
|
||||||
due_time: td.dueTime,
|
t.repeatInterval = td.repeatInterval;
|
||||||
end_time: td.endTime,
|
t.repeatDays = td.repeatDays;
|
||||||
all_day: td.allDay,
|
t.seriesId = td.seriesId;
|
||||||
repeat: td.repeat,
|
t.reminder = td.reminder as Reminder;
|
||||||
repeat_interval: td.repeatInterval,
|
t.reminders = td.reminders;
|
||||||
repeat_days: td.repeatDays,
|
t.assigneeId = null;
|
||||||
color: '#9E9E9E',
|
t.createdAt = new Date(now);
|
||||||
series_id: td.seriesId,
|
t.updatedAt = new Date(now);
|
||||||
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);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('ICal import failed:', err);
|
console.error('ICal import failed:', err);
|
||||||
setError(err.message || 'Import failed');
|
setError(err.message || 'Import failed');
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [collections]);
|
}, []);
|
||||||
|
|
||||||
return { importICal, loading, error };
|
return { importICal, loading, error };
|
||||||
}
|
}
|
||||||
@@ -154,6 +154,8 @@ interface SettingsContextType {
|
|||||||
setApiUrl: (value: string) => void;
|
setApiUrl: (value: string) => void;
|
||||||
accentColor: string;
|
accentColor: string;
|
||||||
setAccentColor: (value: string) => void;
|
setAccentColor: (value: string) => void;
|
||||||
|
accentOpacity: number;
|
||||||
|
setAccentOpacity: (value: number) => void;
|
||||||
todoAheadDays: number;
|
todoAheadDays: number;
|
||||||
setTodoAheadDays: (value: number) => void;
|
setTodoAheadDays: (value: number) => void;
|
||||||
showCompleted: boolean;
|
showCompleted: boolean;
|
||||||
@@ -243,6 +245,8 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
|
|||||||
setApiUrl,
|
setApiUrl,
|
||||||
accentColor,
|
accentColor,
|
||||||
setAccentColor,
|
setAccentColor,
|
||||||
|
accentOpacity,
|
||||||
|
setAccentOpacity,
|
||||||
todoAheadDays,
|
todoAheadDays,
|
||||||
setTodoAheadDays,
|
setTodoAheadDays,
|
||||||
showCompleted,
|
showCompleted,
|
||||||
|
|||||||
+35
-2
@@ -1,4 +1,37 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {},
|
"compilerOptions": {
|
||||||
"extends": "expo/tsconfig.base"
|
"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