✨ Add finish animation...
Changes
7 files changed, +143 -30
MODIFY
app/(tabs)/calendar.tsx
+10 -2
@@ -102,13 +102,21 @@
102
102
103
103
if (task.completed) {
104
104
await taskService.uncompleteTask(taskId);
105
+ await loadTasks();
105
106
} else {
107
+ setTasks(prevTasks =>
108
+ prevTasks.map(t => t.id === taskId ? { ...t, completed: true } : t)
109
+ );
110
+
106
111
await taskService.completeTask(taskId);
112
+
113
+ setTimeout(() => {
114
+ loadTasks();
115
+ }, 1700);
107
116
}
108
-
109
- await loadTasks();
110
117
} catch (error) {
111
118
Alert.alert('Error', 'Failed to update task');
119
+ await loadTasks();
112
120
}
113
121
};
114
122
MODIFY
app/(tabs)/index.tsx
+12 -2
@@ -49,13 +49,23 @@
49
49
50
50
if (task.completed) {
51
51
await taskService.uncompleteTask(taskId);
52
+ await loadTasks();
52
53
} else {
54
+ // Optimistically update UI for animation
55
+ setTasks(prevTasks =>
56
+ prevTasks.map(t => t.id === taskId ? { ...t, completed: true } : t)
57
+ );
58
+
53
59
await taskService.completeTask(taskId);
60
+
61
+ // Wait for animation, then reload
62
+ setTimeout(() => {
63
+ loadTasks();
64
+ }, 1700);
54
65
}
55
-
56
- await loadTasks();
57
66
} catch (error) {
58
67
Alert.alert('Error', 'Failed to update task');
68
+ await loadTasks(); // Reload on error to revert optimistic update
59
69
}
60
70
};
61
71
MODIFY
app/(tabs)/today.tsx
+10 -2
@@ -37,13 +37,21 @@
37
37
38
38
if (task.completed) {
39
39
await taskService.uncompleteTask(taskId);
40
+ await loadTasks();
40
41
} else {
42
+ setTodayTasks(prevTasks =>
43
+ prevTasks.map(t => t.id === taskId ? { ...t, completed: true } : t)
44
+ );
45
+
41
46
await taskService.completeTask(taskId);
47
+
48
+ setTimeout(() => {
49
+ loadTasks();
50
+ }, 1700);
42
51
}
43
-
44
- await loadTasks();
45
52
} catch (error) {
46
53
Alert.alert('Error', 'Failed to update task');
54
+ await loadTasks();
47
55
}
48
56
};
49
57
MODIFY
app/project-modal.tsx
+2 -5
@@ -23,13 +23,10 @@
23
23
const projectFormRef = React.useRef<any>(null);
24
24
25
25
const handleClose = async () => {
26
- // Try to save before closing
26
+ // Try to save before closing (only if there's content)
27
27
if (projectFormRef.current?.handleSave) {
28
28
const saved = await projectFormRef.current.handleSave();
29
- if (!saved && projectId === 'new') {
30
- // Don't close if save failed on new project
31
- return;
32
- }
29
+ // If save returned false, it means validation failed - just close modal without saving
33
30
}
34
31
35
32
if (router.canDismiss()) {
MODIFY
app/project/[id].tsx
+10 -2
@@ -116,13 +116,21 @@
116
116
117
117
if (task.completed) {
118
118
await taskService.uncompleteTask(taskId);
119
+ await loadData();
119
120
} else {
121
+ setTasks(prevTasks =>
122
+ prevTasks.map(t => t.id === taskId ? { ...t, completed: true } : t)
123
+ );
124
+
120
125
await taskService.completeTask(taskId);
126
+
127
+ setTimeout(() => {
128
+ loadData();
129
+ }, 1700);
121
130
}
122
-
123
- await loadData();
124
131
} catch (error) {
125
132
Alert.alert('Error', 'Failed to update task');
133
+ await loadData();
126
134
}
127
135
};
128
136
MODIFY
components/fab-button.tsx
+1 -1
@@ -43,6 +43,6 @@
43
43
color: '#fff',
44
44
fontSize: 32,
45
45
fontWeight: '300',
46
- marginTop: -2,
46
+ lineHeight: 32,
47
47
},
48
48
});
MODIFY
components/task-item.tsx
+98 -16
@@ -1,5 +1,5 @@
1
-import React from 'react';
2
-import { StyleSheet, TouchableOpacity, View } from 'react-native';
1
+import React, { useEffect, useRef } from 'react';
2
+import { StyleSheet, TouchableOpacity, View, Animated } from 'react-native';
3
3
import { Task } from '@/types/gtd';
4
4
import { ThemedText } from './themed-text';
5
5
import { ThemedView } from './themed-view';
@@ -15,11 +15,54 @@
15
15
export function TaskItem({ task, onPress, onToggleComplete }: TaskItemProps) {
16
16
const colorScheme = useColorScheme() ?? 'light';
17
17
const colors = Colors[colorScheme];
18
+ const fadeAnim = useRef(new Animated.Value(1)).current;
19
+ const scaleAnim = useRef(new Animated.Value(1)).current;
20
+ const strikeAnim = useRef(new Animated.Value(0)).current;
21
+ const [textWidth, setTextWidth] = React.useState(0);
18
22
19
23
const priorityColor = task.priority === 'high' ? '#ef4444' :
20
24
task.priority === 'medium' ? '#f59e0b' :
21
25
colors.subtitle;
22
26
27
+ useEffect(() => {
28
+ if (task.completed) {
29
+ // Animate strikethrough
30
+ Animated.parallel([
31
+ Animated.timing(strikeAnim, {
32
+ toValue: 1,
33
+ duration: 400,
34
+ useNativeDriver: false,
35
+ }),
36
+ Animated.sequence([
37
+ Animated.timing(scaleAnim, {
38
+ toValue: 0.98,
39
+ duration: 100,
40
+ useNativeDriver: true,
41
+ }),
42
+ Animated.timing(scaleAnim, {
43
+ toValue: 1,
44
+ duration: 100,
45
+ useNativeDriver: true,
46
+ }),
47
+ ])
48
+ ]).start(() => {
49
+ // Wait 1 second, then fade out
50
+ setTimeout(() => {
51
+ Animated.timing(fadeAnim, {
52
+ toValue: 0,
53
+ duration: 300,
54
+ useNativeDriver: true,
55
+ }).start();
56
+ }, 1000);
57
+ });
58
+ } else {
59
+ // Reset animations when uncompleted
60
+ fadeAnim.setValue(1);
61
+ scaleAnim.setValue(1);
62
+ strikeAnim.setValue(0);
63
+ }
64
+ }, [task.completed]);
65
+
23
66
const formatDate = (timestamp?: number) => {
24
67
if (!timestamp) return null;
25
68
const date = new Date(timestamp);
@@ -35,8 +78,19 @@
35
78
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });
36
79
};
37
80
81
+ const strikeWidth = strikeAnim.interpolate({
82
+ inputRange: [0, 1],
83
+ outputRange: [0, textWidth],
84
+ });
85
+
38
86
return (
39
- <ThemedView style={styles.container}>
87
+ <Animated.View style={[
88
+ styles.container,
89
+ {
90
+ opacity: fadeAnim,
91
+ transform: [{ scale: scaleAnim }],
92
+ }
93
+ ]}>
40
94
<TouchableOpacity
41
95
style={[
42
96
styles.checkbox,
@@ -53,15 +107,31 @@
53
107
{task.priority && (
54
108
<View style={[styles.priorityBadge, { backgroundColor: priorityColor }]} />
55
109
)}
56
- <ThemedText
57
- style={[
58
- styles.title,
59
- task.completed && styles.completedText
60
- ]}
61
- numberOfLines={1}
62
- >
63
- {task.title}
64
- </ThemedText>
110
+ <View style={styles.titleWrapper}>
111
+ <View style={styles.titleContainer}>
112
+ <ThemedText
113
+ style={[
114
+ styles.title,
115
+ task.completed && styles.completedText
116
+ ]}
117
+ numberOfLines={1}
118
+ onLayout={(e) => setTextWidth(e.nativeEvent.layout.width)}
119
+ >
120
+ {task.title}
121
+ </ThemedText>
122
+ {task.completed && (
123
+ <Animated.View
124
+ style={[
125
+ styles.strikeLine,
126
+ {
127
+ width: strikeWidth,
128
+ backgroundColor: colors.text,
129
+ }
130
+ ]}
131
+ />
132
+ )}
133
+ </View>
134
+ </View>
65
135
</View>
66
136
67
137
{(task.description || task.dueDate) && (
@@ -79,7 +149,7 @@
79
149
</View>
80
150
)}
81
151
</TouchableOpacity>
82
- </ThemedView>
152
+ </Animated.View>
83
153
);
84
154
}
85
155
@@ -121,14 +191,26 @@
121
191
height: 8,
122
192
borderRadius: 4,
123
193
},
124
- title: {
125
- fontSize: 16,
194
+ titleWrapper: {
126
195
flex: 1,
127
196
},
197
+ titleContainer: {
198
+ alignSelf: 'flex-start',
199
+ position: 'relative',
200
+ },
201
+ title: {
202
+ fontSize: 16,
203
+ },
128
204
completedText: {
129
- textDecorationLine: 'line-through',
130
205
opacity: 0.5,
131
206
},
207
+ strikeLine: {
208
+ position: 'absolute',
209
+ top: '50%',
210
+ left: 0,
211
+ height: 2,
212
+ opacity: 0.6,
213
+ },
132
214
meta: {
133
215
marginTop: 4,
134
216
gap: 4,