Compare commits
11
Commits
v1.0.17
...
ee6af8fb26
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee6af8fb26 | ||
|
|
c6e441d449 | ||
|
|
5043d9cdff | ||
|
|
7e36244478 | ||
|
|
157fc8d95b | ||
|
|
4d2f92b144 | ||
|
|
8e805316fb | ||
|
|
77051e5206 | ||
|
|
7e06415db4 | ||
|
|
cfb987a305 | ||
|
|
3dad9e93ec |
@@ -41,9 +41,7 @@ jobs:
|
|||||||
printf 'y\n' | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses > /dev/null
|
printf 'y\n' | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses > /dev/null
|
||||||
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" \
|
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" \
|
||||||
"platform-tools" \
|
"platform-tools" \
|
||||||
"platforms;android-35" \
|
|
||||||
"platforms;android-36" \
|
"platforms;android-36" \
|
||||||
"build-tools;35.0.0" \
|
|
||||||
"build-tools;36.0.0" \
|
"build-tools;36.0.0" \
|
||||||
"ndk;27.1.12297006"
|
"ndk;27.1.12297006"
|
||||||
mkdir -p carry-your-live/android/app
|
mkdir -p carry-your-live/android/app
|
||||||
@@ -51,7 +49,10 @@ jobs:
|
|||||||
|
|
||||||
- name: Install JS dependencies
|
- name: Install JS dependencies
|
||||||
working-directory: carry-your-live
|
working-directory: carry-your-live
|
||||||
run: npm ci
|
run: |
|
||||||
|
for i in {1..3}; do
|
||||||
|
npm ci && break || sleep 10
|
||||||
|
done
|
||||||
|
|
||||||
- name: Generate debug keystore (signs the release APK)
|
- name: Generate debug keystore (signs the release APK)
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
name: Build Desktop (deb)
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 22
|
|
||||||
|
|
||||||
- name: Install JS dependencies
|
|
||||||
working-directory: carry-your-live
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Export web bundle
|
|
||||||
working-directory: carry-your-live
|
|
||||||
run: npx expo export --platform web
|
|
||||||
|
|
||||||
- name: Build deb package
|
|
||||||
working-directory: carry-your-live
|
|
||||||
run: npx electron-builder --linux deb --config electron-builder.config.js
|
|
||||||
|
|
||||||
- name: Upload deb
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: carry-your-live-desktop-deb
|
|
||||||
path: carry-your-live/desktop-build/*.deb
|
|
||||||
@@ -10,6 +10,7 @@ permissions:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
|
needs: build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -17,6 +18,12 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Download APK artifact
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: carry-your-live-release
|
||||||
|
path: .
|
||||||
|
|
||||||
- name: Compute next version
|
- name: Compute next version
|
||||||
id: version
|
id: version
|
||||||
run: |
|
run: |
|
||||||
@@ -53,4 +60,5 @@ jobs:
|
|||||||
tag_name: ${{ steps.version.outputs.next }}
|
tag_name: ${{ steps.version.outputs.next }}
|
||||||
name: ${{ steps.version.outputs.next }}
|
name: ${{ steps.version.outputs.next }}
|
||||||
body: ${{ steps.changelog.outputs.body }}
|
body: ${{ steps.changelog.outputs.body }}
|
||||||
generate_release_notes: false
|
files: ./app-release.apk
|
||||||
|
generate_release_notes: false
|
||||||
+12
-1
@@ -14,4 +14,15 @@ System architecture design, technical strategy, and scalability planning.
|
|||||||
- System scalability
|
- System scalability
|
||||||
- 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,13 +49,13 @@ 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,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{props.navigation.getState().routes.map((route) => {
|
{props.navigation.getState().routes.map((route) => {
|
||||||
const focused = props.navigation.getState().index === props.navigation.getState().routes.indexOf(route);
|
const focused = props.navigation.getState().index === props.navigation.getState().routes.indexOf(route);
|
||||||
return (
|
return (
|
||||||
@@ -62,13 +63,18 @@ export default function TabLayout() {
|
|||||||
key={route.name}
|
key={route.name}
|
||||||
style={[
|
style={[
|
||||||
styles.tabItem,
|
styles.tabItem,
|
||||||
focused && styles.tabItemActive,
|
|
||||||
{ backgroundColor: focused ? theme.accentSoft : 'transparent' },
|
{ backgroundColor: focused ? theme.accentSoft : 'transparent' },
|
||||||
]}
|
]}
|
||||||
onPress={() => props.navigation.navigate(route.name as never)}
|
onPress={() => props.navigation.navigate(route.name as never)}
|
||||||
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 +82,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 +110,23 @@ 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: {
|
rippleContainer: {
|
||||||
shadowColor: '#000',
|
width: 44,
|
||||||
shadowOffset: { width: 0, height: 2 },
|
height: 44,
|
||||||
shadowOpacity: 0.1,
|
borderRadius: 22,
|
||||||
shadowRadius: 8,
|
alignItems: 'center',
|
||||||
elevation: 4,
|
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()}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { useEffect } from 'react';
|
|||||||
import { requestQuickAddFocus } from '@/utils/quickAddFocus';
|
import { requestQuickAddFocus } from '@/utils/quickAddFocus';
|
||||||
import { registerForPushNotificationsAsync, setupNotificationChannels, addNotificationResponseReceivedListener, removeNotificationSubscription } from '@/services/pushNotifications';
|
import { registerForPushNotificationsAsync, setupNotificationChannels, addNotificationResponseReceivedListener, removeNotificationSubscription } from '@/services/pushNotifications';
|
||||||
import * as Notifications from 'expo-notifications';
|
import * as Notifications from 'expo-notifications';
|
||||||
import { Platform } from 'react-native';
|
|
||||||
|
|
||||||
Notifications.setNotificationHandler({
|
Notifications.setNotificationHandler({
|
||||||
handleNotification: async () => ({
|
handleNotification: async () => ({
|
||||||
@@ -57,15 +56,24 @@ 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));
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
router.back();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
subscription.remove();
|
subscription.remove();
|
||||||
removeNotificationSubscription(responseListener);
|
removeNotificationSubscription(responseListener);
|
||||||
|
window.removeEventListener('keydown', handleKeyDown);
|
||||||
};
|
};
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
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);
|
||||||
|
|||||||
@@ -220,5 +220,9 @@ export const migrations = schemaMigrations({
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
toVersion: 21,
|
||||||
|
steps: [],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import RepeatProfile from '@/models/RepeatProfile';
|
|||||||
import Friendship from '@/models/Friendship';
|
import Friendship from '@/models/Friendship';
|
||||||
import { TombstoneEntity } from '@/models/Tombstone';
|
import { TombstoneEntity } from '@/models/Tombstone';
|
||||||
import { fetchPendingTombstones, removeTombstonesInBatch } from './tombstones';
|
import { fetchPendingTombstones, removeTombstonesInBatch } from './tombstones';
|
||||||
import { useOnline } from '@/hooks/useOnline';
|
|
||||||
|
|
||||||
const LAST_PULLED_AT_KEY = 'sync:lastPulledAt';
|
const LAST_PULLED_AT_KEY = 'sync:lastPulledAt';
|
||||||
const LAST_PUSHED_AT_KEY = 'sync:lastPushedAt';
|
const LAST_PUSHED_AT_KEY = 'sync:lastPushedAt';
|
||||||
@@ -96,7 +95,7 @@ export async function runSync(): Promise<SyncResult> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If offline, mark timestamps and return early - changes will sync on next foreground launch
|
// If offline, mark timestamps and return early - changes will sync on next foreground launch
|
||||||
const isCurrentlyOnline = await useOnline();
|
const isCurrentlyOnline = navigator.onLine;
|
||||||
if (!isCurrentlyOnline) {
|
if (!isCurrentlyOnline) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
await setLastPulledAt(now);
|
await setLastPulledAt(now);
|
||||||
|
|||||||
@@ -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,49 +139,34 @@ 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) {
|
||||||
@@ -188,7 +174,7 @@ export function useICalImport() {
|
|||||||
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