✨ Add archive and import/export feature
Changes
7 files changed, +268 -14
MODIFY
app.json
+2 -4
@@ -14,9 +14,7 @@
14
14
"android": {
15
15
"adaptiveIcon": {
16
16
"backgroundColor": "#E6F4FE",
17
- "foregroundImage": "./assets/images/android-icon-foreground.png",
18
- "backgroundImage": "./assets/images/android-icon-background.png",
19
- "monochromeImage": "./assets/images/android-icon-monochrome.png"
17
+ "foregroundImage": "./assets/images/Logo.png"
20
18
},
21
19
"edgeToEdgeEnabled": true,
22
20
"predictiveBackGestureEnabled": false,
@@ -31,7 +29,7 @@
31
29
[
32
30
"expo-splash-screen",
33
31
{
34
- "image": "./assets/images/splash-icon.png",
32
+ "image": "./assets/images/Logo.png",
35
33
"imageWidth": 200,
36
34
"resizeMode": "contain",
37
35
"backgroundColor": "#ffffff",
MODIFY
app/(tabs)/settings.tsx
+73 -3
@@ -1,6 +1,8 @@
1
1
import React, { useState } from 'react';
2
2
import { StyleSheet, Alert, TouchableOpacity, ScrollView, Switch } from 'react-native';
3
3
import { SafeAreaView } from 'react-native-safe-area-context';
4
+import { router } from 'expo-router';
5
+import * as DocumentPicker from 'expo-document-picker';
4
6
import { ThemedView } from '@/components/themed-view';
5
7
import { ThemedText } from '@/components/themed-text';
6
8
import { storageService, taskService } from '@/services';
@@ -17,14 +19,65 @@
17
19
18
20
const handleExport = async () => {
19
21
try {
20
- const fileUri = await storageService.exportToFile();
22
+ await storageService.exportToFile();
21
23
Alert.alert(
22
24
'Export Successful',
23
- `Data exported to:\n${fileUri}`,
25
+ 'Your data has been exported as JSON. You can now save or share the file.',
24
26
[{ text: 'OK' }]
25
27
);
26
28
} catch (error) {
27
- Alert.alert('Export Failed', 'Failed to export data');
29
+ Alert.alert('Export Failed', 'Failed to export data. Please try again.');
30
+ }
31
+ };
32
+
33
+ const handleImport = async () => {
34
+ try {
35
+ const result = await DocumentPicker.getDocumentAsync({
36
+ type: 'application/json',
37
+ copyToCacheDirectory: true,
38
+ });
39
+
40
+ if (result.canceled) {
41
+ return;
42
+ }
43
+
44
+ Alert.alert(
45
+ 'Import Data',
46
+ 'This will replace all existing data. Do you want to continue?',
47
+ [
48
+ { text: 'Cancel', style: 'cancel' },
49
+ {
50
+ text: 'Import',
51
+ style: 'destructive',
52
+ onPress: async () => {
53
+ try {
54
+ await storageService.importFromFile(result.assets[0].uri);
55
+ Alert.alert(
56
+ 'Import Successful',
57
+ 'Your data has been imported successfully.',
58
+ [
59
+ {
60
+ text: 'OK',
61
+ onPress: () => {
62
+ // Navigate to inbox to trigger reload
63
+ router.replace('/(tabs)');
64
+ }
65
+ }
66
+ ]
67
+ );
68
+ } catch (error) {
69
+ Alert.alert(
70
+ 'Import Failed',
71
+ 'Failed to import data. Please make sure the file is a valid Taskflow export.',
72
+ [{ text: 'OK' }]
73
+ );
74
+ }
75
+ },
76
+ },
77
+ ]
78
+ );
79
+ } catch (error) {
80
+ Alert.alert('Import Failed', 'Failed to select file. Please try again.');
28
81
}
29
82
};
30
83
@@ -152,6 +205,23 @@
152
205
</ThemedText>
153
206
</TouchableOpacity>
154
207
208
+ <TouchableOpacity style={[styles.option, { borderBottomColor: colors.border }]} onPress={handleImport}>
209
+ <ThemedText style={styles.optionText}>Import Data</ThemedText>
210
+ <ThemedText style={styles.optionDescription}>
211
+ Import data from JSON file
212
+ </ThemedText>
213
+ </TouchableOpacity>
214
+
215
+ <TouchableOpacity
216
+ style={[styles.option, { borderBottomColor: colors.border }]}
217
+ onPress={() => router.push('/archive')}
218
+ >
219
+ <ThemedText style={styles.optionText}>View Archive</ThemedText>
220
+ <ThemedText style={styles.optionDescription}>
221
+ View all completed tasks
222
+ </ThemedText>
223
+ </TouchableOpacity>
224
+
155
225
<TouchableOpacity style={[styles.option, { borderBottomColor: colors.border }]} onPress={handleClearCompleted}>
156
226
<ThemedText style={styles.optionText}>Archive Completed Tasks</ThemedText>
157
227
<ThemedText style={styles.optionDescription}>
MODIFY
app/_layout.tsx
+1 -0
@@ -20,6 +20,7 @@
20
20
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
21
21
<Stack.Screen name="task/[id]" options={{ headerShown: false }} />
22
22
<Stack.Screen name="project/[id]" options={{ headerShown: false }} />
23
+ <Stack.Screen name="archive" options={{ title: 'Archive', headerBackTitle: 'Back' }} />
23
24
<Stack.Screen name="modal" options={{ presentation: 'modal', title: 'Modal' }} />
24
25
</Stack>
25
26
<StatusBar style="auto" />
ADD
app/archive.tsx
+138 -0
@@ -0,0 +1,138 @@
1
+import React, { useState } from 'react';
2
+import { StyleSheet, FlatList } from 'react-native';
3
+import { ThemedView } from '@/components/themed-view';
4
+import { ThemedText } from '@/components/themed-text';
5
+import { TaskItem } from '@/components/task-item';
6
+import { Task } from '@/types/gtd';
7
+import { taskService } from '@/services';
8
+import { Colors } from '@/constants/theme';
9
+import { useColorScheme } from '@/hooks/use-color-scheme';
10
+import { router } from 'expo-router';
11
+
12
+export default function ArchiveScreen() {
13
+ const [tasks, setTasks] = useState<Task[]>([]);
14
+ const [loading, setLoading] = useState(true);
15
+ const colorScheme = useColorScheme();
16
+ const colors = Colors[colorScheme];
17
+
18
+ React.useEffect(() => {
19
+ loadCompletedTasks();
20
+ }, []);
21
+
22
+ const loadCompletedTasks = async () => {
23
+ try {
24
+ const completedTasks = await taskService.getCompletedTasks();
25
+ setTasks(completedTasks);
26
+ } catch (error) {
27
+ console.error('Failed to load completed tasks:', error);
28
+ } finally {
29
+ setLoading(false);
30
+ }
31
+ };
32
+
33
+ const handleTaskPress = (task: Task) => {
34
+ router.push(`/task/${task.id}`);
35
+ };
36
+
37
+ const handleToggleComplete = async (taskId: string) => {
38
+ try {
39
+ await taskService.uncompleteTask(taskId);
40
+ await loadCompletedTasks();
41
+ } catch (error) {
42
+ console.error('Failed to uncomplete task:', error);
43
+ }
44
+ };
45
+
46
+ const formatDate = (timestamp?: number) => {
47
+ if (!timestamp) return '';
48
+ const date = new Date(timestamp);
49
+ const now = new Date();
50
+ const diffInDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
51
+
52
+ if (diffInDays === 0) return 'Today';
53
+ if (diffInDays === 1) return 'Yesterday';
54
+ if (diffInDays < 7) return `${diffInDays} days ago`;
55
+ if (diffInDays < 30) return `${Math.floor(diffInDays / 7)} weeks ago`;
56
+ return `${Math.floor(diffInDays / 30)} months ago`;
57
+ };
58
+
59
+ if (loading) {
60
+ return (
61
+ <ThemedView style={styles.container}>
62
+ <ThemedView style={styles.emptyState}>
63
+ <ThemedText>Loading...</ThemedText>
64
+ </ThemedView>
65
+ </ThemedView>
66
+ );
67
+ }
68
+
69
+ if (tasks.length === 0) {
70
+ return (
71
+ <ThemedView style={styles.container}>
72
+ <ThemedView style={styles.emptyState}>
73
+ <ThemedText style={styles.emptyTitle}>No Completed Tasks</ThemedText>
74
+ <ThemedText style={styles.emptyDescription}>
75
+ Completed tasks will appear here
76
+ </ThemedText>
77
+ </ThemedView>
78
+ </ThemedView>
79
+ );
80
+ }
81
+
82
+ return (
83
+ <ThemedView style={styles.container}>
84
+ <FlatList
85
+ data={tasks}
86
+ keyExtractor={(item) => item.id}
87
+ contentContainerStyle={styles.listContent}
88
+ renderItem={({ item }) => (
89
+ <ThemedView>
90
+ <TaskItem
91
+ task={item}
92
+ onPress={() => handleTaskPress(item)}
93
+ onToggleComplete={() => handleToggleComplete(item.id)}
94
+ />
95
+ {item.completedAt && (
96
+ <ThemedText style={[styles.completedDate, { color: colors.subtitle }]}>
97
+ Completed {formatDate(item.completedAt)}
98
+ </ThemedText>
99
+ )}
100
+ </ThemedView>
101
+ )}
102
+ />
103
+ </ThemedView>
104
+ );
105
+}
106
+
107
+const styles = StyleSheet.create({
108
+ container: {
109
+ flex: 1,
110
+ },
111
+ listContent: {
112
+ paddingHorizontal: 16,
113
+ paddingTop: 8,
114
+ paddingBottom: 32,
115
+ },
116
+ completedDate: {
117
+ fontSize: 12,
118
+ paddingHorizontal: 16,
119
+ paddingBottom: 12,
120
+ marginTop: -8,
121
+ },
122
+ emptyState: {
123
+ flex: 1,
124
+ justifyContent: 'center',
125
+ alignItems: 'center',
126
+ paddingHorizontal: 32,
127
+ },
128
+ emptyTitle: {
129
+ fontSize: 20,
130
+ fontWeight: '600',
131
+ marginBottom: 8,
132
+ },
133
+ emptyDescription: {
134
+ fontSize: 16,
135
+ opacity: 0.6,
136
+ textAlign: 'center',
137
+ },
138
+});
MODIFY
package-lock.json
+20 -0
@@ -16,12 +16,14 @@
16
16
"@react-navigation/native": "^7.1.8",
17
17
"expo": "~54.0.22",
18
18
"expo-constants": "~18.0.10",
19
+ "expo-document-picker": "^14.0.7",
19
20
"expo-file-system": "^19.0.17",
20
21
"expo-font": "~14.0.9",
21
22
"expo-haptics": "~15.0.7",
22
23
"expo-image": "~3.0.10",
23
24
"expo-linking": "~8.0.8",
24
25
"expo-router": "~6.0.14",
26
+ "expo-sharing": "^14.0.7",
25
27
"expo-splash-screen": "~31.0.10",
26
28
"expo-status-bar": "~3.0.8",
27
29
"expo-symbols": "~1.0.7",
@@ -6278,6 +6280,15 @@
6278
6280
"react-native": "*"
6279
6281
}
6280
6282
},
6283
+ "node_modules/expo-document-picker": {
6284
+ "version": "14.0.7",
6285
+ "resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-14.0.7.tgz",
6286
+ "integrity": "sha512-81Jh8RDD0GYBUoSTmIBq30hXXjmkDV1ZY2BNIp1+3HR5PDSh2WmdhD/Ezz5YFsv46hIXHsQc+Kh1q8vn6OLT9Q==",
6287
+ "license": "MIT",
6288
+ "peerDependencies": {
6289
+ "expo": "*"
6290
+ }
6291
+ },
6281
6292
"node_modules/expo-file-system": {
6282
6293
"version": "19.0.17",
6283
6294
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.17.tgz",
@@ -6645,6 +6656,15 @@
6645
6656
"node": ">=20.16.0"
6646
6657
}
6647
6658
},
6659
+ "node_modules/expo-sharing": {
6660
+ "version": "14.0.7",
6661
+ "resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-14.0.7.tgz",
6662
+ "integrity": "sha512-t/5tR8ZJNH6tMkHXlF7453UafNIfrpfTG+THN9EMLC4Wsi4bJuESPm3NdmWDg2D4LDALJI/LQo0iEnLAd5Sp4g==",
6663
+ "license": "MIT",
6664
+ "peerDependencies": {
6665
+ "expo": "*"
6666
+ }
6667
+ },
6648
6668
"node_modules/expo-splash-screen": {
6649
6669
"version": "31.0.10",
6650
6670
"resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-31.0.10.tgz",
MODIFY
package.json
+2 -0
@@ -19,12 +19,14 @@
19
19
"@react-navigation/native": "^7.1.8",
20
20
"expo": "~54.0.22",
21
21
"expo-constants": "~18.0.10",
22
+ "expo-document-picker": "^14.0.7",
22
23
"expo-file-system": "^19.0.17",
23
24
"expo-font": "~14.0.9",
24
25
"expo-haptics": "~15.0.7",
25
26
"expo-image": "~3.0.10",
26
27
"expo-linking": "~8.0.8",
27
28
"expo-router": "~6.0.14",
29
+ "expo-sharing": "^14.0.7",
28
30
"expo-splash-screen": "~31.0.10",
29
31
"expo-status-bar": "~3.0.8",
30
32
"expo-symbols": "~1.0.7",
MODIFY
services/storage.service.ts
+32 -7
@@ -5,7 +5,8 @@
5
5
*/
6
6
7
7
import AsyncStorage from '@react-native-async-storage/async-storage';
8
-import * as FileSystem from 'expo-file-system';
8
+import { Paths, File } from 'expo-file-system';
9
+import * as Sharing from 'expo-sharing';
9
10
import { GTDData, Task, Project, Tag, SyncMetadata } from '../types/gtd';
10
11
11
12
const STORAGE_KEY = '@taskflow_gtd_data';
@@ -77,7 +78,7 @@
77
78
}
78
79
79
80
/**
80
- * Export data as JSON file to device storage
81
+ * Export data as JSON file and allow sharing/downloading
81
82
*/
82
83
async exportToFile(): Promise<string> {
83
84
try {
@@ -86,13 +87,20 @@
86
87
87
88
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
88
89
const filename = `taskflow-export-${timestamp}.json`;
89
- const fileUri = `${FileSystem.documentDirectory}${filename}`;
90
+ const file = new File(Paths.cache, filename);
90
91
91
- await FileSystem.writeAsStringAsync(fileUri, jsonData, {
92
- encoding: FileSystem.EncodingType.UTF8,
93
- });
92
+ await file.write(jsonData);
94
93
95
- return fileUri;
94
+ const isSharingAvailable = await Sharing.isAvailableAsync();
95
+ if (isSharingAvailable) {
96
+ await Sharing.shareAsync(file.uri, {
97
+ mimeType: 'application/json',
98
+ dialogTitle: 'Export Taskflow Data',
99
+ UTI: 'public.json',
100
+ });
101
+ }
102
+
103
+ return file.uri;
96
104
} catch (error) {
97
105
console.error('Error exporting data:', error);
98
106
throw new Error('Failed to export data');
@@ -111,6 +119,9 @@
111
119
throw new Error('Invalid data format');
112
120
}
113
121
122
+ // Ensure version is set
123
+ data.version = data.version || STORAGE_VERSION;
124
+
114
125
await this.saveData(data);
115
126
} catch (error) {
116
127
console.error('Error importing data:', error);
@@ -119,6 +130,20 @@
119
130
}
120
131
121
132
/**
133
+ * Import data from a file URI
134
+ */
135
+ async importFromFile(fileUri: string): Promise<void> {
136
+ try {
137
+ const file = new File(fileUri);
138
+ const content = await file.text();
139
+ await this.importFromJson(content);
140
+ } catch (error) {
141
+ console.error('Error importing from file:', error);
142
+ throw new Error('Failed to import data from file');
143
+ }
144
+ }
145
+
146
+ /**
122
147
* Calculate hash for sync conflict detection
123
148
*/
124
149
private calculateHash(data: GTDData): string {