| 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 | } |