main
dart 379 lines 12.2 KB
Raw
1 import 'package:hugeicons/hugeicons.dart';
2 import 'dart:convert';
3 import 'dart:io';
4 import 'dart:typed_data';
5
6 import 'package:archive/archive.dart';
7 import 'package:file_picker/file_picker.dart';
8 import 'package:flutter/material.dart';
9 import 'package:flutter_riverpod/flutter_riverpod.dart';
10 import 'package:shadcn_ui/shadcn_ui.dart';
11
12 import 'package:localmind/l10n/app_localizations.dart';
13
14 import '../../../core/providers/app_providers.dart';
15 import '../../../core/providers/storage_providers.dart';
16 import '../../../core/services/data_backup_service.dart';
17 import '../../../core/utils/safe_file_picker.dart';
18 import '../../conversations/providers/conversation_providers.dart';
19 import '../../personas/providers/personas_providers.dart';
20 import '../../saved_messages/providers/saved_message_providers.dart';
21 import '../../servers/providers/server_providers.dart';
22
23 class DataBackupActions extends ConsumerWidget {
24 const DataBackupActions({super.key});
25
26 Future<void> _saveJsonExport(
27 BuildContext context,
28 String dialogTitle,
29 String fileName,
30 String json,
31 ) async {
32 final l10n = AppLocalizations.of(context)!;
33 try {
34 final saved = await SafeFilePicker.saveFile(
35 dialogTitle: dialogTitle,
36 fileName: fileName,
37 type: FileType.custom,
38 allowedExtensions: const ['json'],
39 bytes: Uint8List.fromList(utf8.encode(json)),
40 );
41 if (saved == null) return;
42 if (context.mounted) {
43 ScaffoldMessenger.maybeOf(
44 context,
45 )?.showSnackBar(SnackBar(content: Text(l10n.export_data_success)));
46 }
47 } catch (e) {
48 if (context.mounted) {
49 final msg = SafeFilePicker.getErrorMessage(e, l10n);
50 ScaffoldMessenger.maybeOf(context)?.showSnackBar(
51 SnackBar(
52 content: Text(
53 SafeFilePicker.isExplorerNotFoundError(e)
54 ? msg
55 : l10n.export_data_failed(msg),
56 ),
57 ),
58 );
59 }
60 }
61 }
62
63 Future<void> _saveZipExport(
64 BuildContext context,
65 WidgetRef ref,
66 String dialogTitle,
67 String fileName,
68 ) async {
69 final l10n = AppLocalizations.of(context)!;
70 try {
71 final db = ref.read(databaseProvider);
72 final settings = ref.read(settingsProvider);
73 final prefs = ref.read(sharedPreferencesProvider);
74 final service = DataBackupService();
75 final bytes = service.exportAllZip(
76 db.store,
77 settings.toJson(),
78 prefs: prefs,
79 );
80 final saved = await SafeFilePicker.saveFile(
81 dialogTitle: dialogTitle,
82 fileName: fileName,
83 type: FileType.custom,
84 allowedExtensions: const ['zip'],
85 bytes: Uint8List.fromList(bytes),
86 );
87 if (saved == null) return;
88 if (context.mounted) {
89 ScaffoldMessenger.maybeOf(
90 context,
91 )?.showSnackBar(SnackBar(content: Text(l10n.export_data_success)));
92 }
93 } catch (e) {
94 if (context.mounted) {
95 final msg = SafeFilePicker.getErrorMessage(e, l10n);
96 ScaffoldMessenger.maybeOf(context)?.showSnackBar(
97 SnackBar(
98 content: Text(
99 SafeFilePicker.isExplorerNotFoundError(e)
100 ? msg
101 : l10n.export_data_failed(msg),
102 ),
103 ),
104 );
105 }
106 }
107 }
108
109 Future<bool> _confirmImport(BuildContext context, String message) async {
110 final confirmed = await showDialog<bool>(
111 context: context,
112 builder: (dialogContext) {
113 final dialogL10n = AppLocalizations.of(dialogContext)!;
114 return AlertDialog(
115 title: Text(AppLocalizations.of(context)!.import_all_data),
116 content: Text(message),
117 actions: [
118 TextButton(
119 onPressed: () => Navigator.pop(dialogContext, false),
120 child: Text(dialogL10n.cancel),
121 ),
122 TextButton(
123 onPressed: () => Navigator.pop(dialogContext, true),
124 child: Text(dialogL10n.confirm),
125 ),
126 ],
127 );
128 },
129 );
130 return confirmed == true;
131 }
132
133 Future<void> _importJsonFile(
134 BuildContext context,
135 WidgetRef ref,
136 Future<void> Function(String json) importer,
137 String confirmMessage,
138 ) async {
139 final l10n = AppLocalizations.of(context)!;
140 if (!await _confirmImport(context, confirmMessage)) return;
141
142 try {
143 final result = await SafeFilePicker.pickFiles(
144 type: FileType.custom,
145 allowedExtensions: const ['json'],
146 );
147 final path = result?.files.single.path;
148 if (path == null) return;
149
150 final json = await File(path).readAsString();
151 await importer(json);
152
153 if (context.mounted) {
154 ScaffoldMessenger.maybeOf(
155 context,
156 )?.showSnackBar(SnackBar(content: Text(l10n.import_data_success)));
157 }
158 } catch (e) {
159 if (context.mounted) {
160 final msg = SafeFilePicker.getErrorMessage(e, l10n);
161 ScaffoldMessenger.maybeOf(context)?.showSnackBar(
162 SnackBar(
163 content: Text(
164 SafeFilePicker.isExplorerNotFoundError(e)
165 ? msg
166 : l10n.import_data_failed(msg),
167 ),
168 ),
169 );
170 }
171 }
172 }
173
174 Future<void> _applySettingsPayload(
175 WidgetRef ref,
176 Map<String, dynamic> decoded, {
177 required BuildContext context,
178 }) async {
179 final prefs = ref.read(sharedPreferencesProvider);
180 final settingsMap = decoded['settings'];
181 if (settingsMap is Map) {
182 await prefs.setString('appSettings', jsonEncode(settingsMap));
183 }
184 final metadata = decoded['modelMetadata'];
185 if (metadata is Map) {
186 await prefs.setString('modelMetadata', jsonEncode(metadata));
187 }
188 // Guard every ref-touching statement post-await — the
189 // ConsumerWidget may have been deactivated during the writes.
190 if (!context.mounted) return;
191 ref.invalidate(settingsProvider);
192 ref.invalidate(serversProvider);
193 }
194
195 Future<void> _importZipFile(BuildContext context, WidgetRef ref) async {
196 final l10n = AppLocalizations.of(context)!;
197 if (!await _confirmImport(context, l10n.import_data_confirm)) return;
198
199 try {
200 final result = await SafeFilePicker.pickFiles(
201 type: FileType.custom,
202 allowedExtensions: const ['zip'],
203 );
204 final path = result?.files.single.path;
205 if (path == null) return;
206
207 final bytes = await File(path).readAsBytes();
208 final db = ref.read(databaseProvider);
209 await DataBackupService().importZip(db.store, bytes);
210
211 if (!context.mounted) return;
212 final archive = ZipDecoder().decodeBytes(bytes);
213 for (final file in archive.files) {
214 if (!file.isFile || file.name != 'settings.json') continue;
215 final decoded =
216 jsonDecode(utf8.decode(file.content as List<int>))
217 as Map<String, dynamic>;
218 if (!context.mounted) return;
219 await _applySettingsPayload(ref, decoded, context: context);
220 }
221
222 // Guard every ref-touching statement post-await — the
223 // ConsumerWidget may have been deactivated during the import.
224 if (!context.mounted) return;
225
226 // Defer invalidations until after the current build frame so widgets that
227 // watch these providers (e.g. SettingsViews) don't flush them during build.
228 WidgetsBinding.instance.addPostFrameCallback((_) {
229 if (!context.mounted) return;
230 ref.invalidate(conversationsProvider);
231 ref.invalidate(personasNotifierProvider);
232 ref.invalidate(savedMessagesProvider);
233 ref.invalidate(savedMessageFoldersProvider);
234 });
235
236 if (context.mounted) {
237 ScaffoldMessenger.maybeOf(
238 context,
239 )?.showSnackBar(SnackBar(content: Text(l10n.import_data_success)));
240 }
241 } catch (e) {
242 if (context.mounted) {
243 final msg = SafeFilePicker.getErrorMessage(e, l10n);
244 ScaffoldMessenger.maybeOf(context)?.showSnackBar(
245 SnackBar(
246 content: Text(
247 SafeFilePicker.isExplorerNotFoundError(e)
248 ? msg
249 : l10n.import_data_failed(msg),
250 ),
251 ),
252 );
253 }
254 }
255 }
256
257 Widget _categoryRow({
258 required String label,
259 required VoidCallback onExport,
260 required VoidCallback onImport,
261 required AppLocalizations l10n,
262 }) {
263 return Padding(
264 padding: const EdgeInsets.only(bottom: 12),
265 child: Row(
266 children: [
267 Expanded(
268 flex: 2,
269 child: Text(
270 label,
271 style: const TextStyle(fontWeight: FontWeight.w600),
272 ),
273 ),
274 ShadButton.outline(onPressed: onExport, child: Text(l10n.export)),
275 const SizedBox(width: 8),
276 ShadButton.outline(onPressed: onImport, child: Text(l10n.import)),
277 ],
278 ),
279 );
280 }
281
282 @override
283 Widget build(BuildContext context, WidgetRef ref) {
284 final l10n = AppLocalizations.of(context)!;
285 final service = DataBackupService();
286 final db = ref.watch(databaseProvider);
287 final prefs = ref.watch(sharedPreferencesProvider);
288
289 return Column(
290 crossAxisAlignment: CrossAxisAlignment.stretch,
291 children: [
292 _categoryRow(
293 label: l10n.conversations_label,
294 l10n: l10n,
295 onExport: () => _saveJsonExport(
296 context,
297 l10n.export_conversations,
298 'localmind_conversations_${DateTime.now().millisecondsSinceEpoch}.json',
299 service.exportConversationsAsJson(db.store),
300 ),
301 onImport: () => _importJsonFile(context, ref, (json) async {
302 await service.importFromJson(db.store, json);
303 // Guard every ref-touching statement post-await — the
304 // ConsumerWidget may have been deactivated during the import.
305 if (!context.mounted) return;
306 ref.invalidate(conversationsProvider);
307 ref.invalidate(savedMessagesProvider);
308 }, l10n.import_data_confirm),
309 ),
310 _categoryRow(
311 label: l10n.personas_label,
312 l10n: l10n,
313 onExport: () => _saveJsonExport(
314 context,
315 l10n.export_personas,
316 'localmind_personas_${DateTime.now().millisecondsSinceEpoch}.json',
317 service.exportPersonasAsJson(db.store),
318 ),
319 onImport: () => _importJsonFile(context, ref, (json) async {
320 await service.importFromJson(db.store, json);
321 if (!context.mounted) return;
322 ref.invalidate(personasNotifierProvider);
323 }, l10n.import_data_confirm),
324 ),
325 _categoryRow(
326 label: l10n.settings_label,
327 l10n: l10n,
328 onExport: () => _saveJsonExport(
329 context,
330 l10n.export_settings,
331 'localmind_settings_${DateTime.now().millisecondsSinceEpoch}.json',
332 service.exportSettingsAsJson(
333 ref.read(settingsProvider).toJson(),
334 store: db.store,
335 prefs: prefs,
336 ),
337 ),
338 onImport: () => _importJsonFile(context, ref, (json) async {
339 final decoded = jsonDecode(json) as Map<String, dynamic>;
340 await service.importFromJson(db.store, json);
341 if (!context.mounted) return;
342 await _applySettingsPayload(ref, decoded, context: context);
343 }, l10n.import_settings_confirm),
344 ),
345 const SizedBox(height: 8),
346 ShadButton.outline(
347 onPressed: () => _saveZipExport(
348 context,
349 ref,
350 l10n.export_all_zip,
351 'localmind_backup_${DateTime.now().millisecondsSinceEpoch}.zip',
352 ),
353 width: double.infinity,
354 leading: const HugeIcon(
355 icon: HugeIcons.strokeRoundedFolder01,
356 size: 16,
357 ),
358 child: Align(
359 alignment: Alignment.centerLeft,
360 child: Text(l10n.export_all_zip),
361 ),
362 ),
363 const SizedBox(height: 8),
364 ShadButton.outline(
365 onPressed: () => _importZipFile(context, ref),
366 width: double.infinity,
367 leading: const HugeIcon(
368 icon: HugeIcons.strokeRoundedArchive,
369 size: 16,
370 ),
371 child: Align(
372 alignment: Alignment.centerLeft,
373 child: Text(l10n.import_all_zip),
374 ),
375 ),
376 ],
377 );
378 }
379 }