From 477ca0ee07e7c577258eeb17afb937f412f3c986 Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Fri, 20 Mar 2026 09:32:02 +0100 Subject: [PATCH] Add backup and restore functionality for rental data in settings Signed-off-by: Hans Kokx --- README.md | 5 +- lib/screens/settings_screen.dart | 92 ++++++++++++++++++- lib/state/rent_controller.dart | 67 +++++++++++++- macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.yaml | 1 + 5 files changed, 155 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 3796834..7b9dab7 100644 --- a/README.md +++ b/README.md @@ -46,8 +46,9 @@ flutter run 1. Open Settings and set monthly rent in USD. 2. Keep Unit occupied enabled while the property is rented. 3. Use Backfill last year to import the previous year in one click. -4. Use Export to write an export file to app documents storage. -5. Use year arrows on the main screen to switch yearly views. +4. Use Create backup to export a JSON backup into Downloads or any directory you choose. +5. Use Import backup file to restore from a JSON backup you pick with the file chooser. +6. Use year arrows on the main screen to switch yearly views. ## Notes diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 9e4ad22..25de8b9 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -1,3 +1,4 @@ +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:rental_income_tracker/state/rent_controller.dart'; @@ -12,6 +13,32 @@ class SettingsScreen extends StatefulWidget { class _SettingsScreenState extends State { final TextEditingController _rentController = TextEditingController(); + Future _confirmImport() async { + final result = await showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Import backup file?'), + content: const Text( + 'This will overwrite current local settings and logged entries with the contents of the selected backup file.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Import'), + ), + ], + ); + }, + ); + + return result ?? false; + } + @override void didChangeDependencies() { super.didChangeDependencies(); @@ -103,16 +130,71 @@ class _SettingsScreenState extends State { const SizedBox(height: 12), FilledButton.icon( onPressed: () async { - final path = await controller.exportJson(); + final directoryPath = await FilePicker.platform + .getDirectoryPath(dialogTitle: 'Choose backup folder'); + if (directoryPath == null || !context.mounted) { + return; + } + + final path = await controller.exportBackupToDirectory( + directoryPath, + ); if (!context.mounted) { return; } - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Exported to $path'))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Backup saved to $path')), + ); }, icon: const Icon(Icons.download), - label: const Text('Export JSON'), + label: const Text('Create backup'), + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: () async { + final shouldImport = await _confirmImport(); + if (!shouldImport || !context.mounted) { + return; + } + + final result = await FilePicker.platform.pickFiles( + dialogTitle: 'Choose backup file', + type: FileType.custom, + allowedExtensions: ['json'], + withData: false, + ); + final filePath = result?.files.single.path; + if (filePath == null || !context.mounted) { + return; + } + + try { + final path = await controller.importBackupFromFile( + filePath, + ); + if (!context.mounted) { + return; + } + _rentController.text = controller.settings.rentUsd + .toStringAsFixed(2); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Imported backup from $path')), + ); + } catch (_) { + if (!context.mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + controller.errorMessage ?? 'Restore failed.', + ), + ), + ); + } + }, + icon: const Icon(Icons.restore), + label: const Text('Import backup file'), ), const SizedBox(height: 16), const Text( diff --git a/lib/state/rent_controller.dart b/lib/state/rent_controller.dart index c7079ef..ed827cd 100644 --- a/lib/state/rent_controller.dart +++ b/lib/state/rent_controller.dart @@ -5,7 +5,6 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:http/http.dart' as http; import 'package:intl/intl.dart'; -import 'package:path_provider/path_provider.dart'; import 'package:rental_income_tracker/models/app_settings.dart'; import 'package:rental_income_tracker/models/monthly_rent_record.dart'; import 'package:rental_income_tracker/services/notification_service.dart'; @@ -417,7 +416,7 @@ class RentController extends ChangeNotifier { } } - Future exportJson() async { + String _buildBackupJson() { final records = _records.values.toList() ..sort((a, b) { final byYear = a.year.compareTo(b.year); @@ -433,14 +432,72 @@ class RentController extends ChangeNotifier { 'records': records.map((e) => e.toJson()).toList(), }; - final jsonString = const JsonEncoder.withIndent(' ').convert(payload); - final dir = await getApplicationDocumentsDirectory(); + return const JsonEncoder.withIndent(' ').convert(payload); + } + + Future exportBackupToDirectory(String directoryPath) async { + final jsonString = _buildBackupJson(); final timestamp = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now()); - final file = File('${dir.path}/rent_tracker_export_$timestamp.json'); + final normalizedDirectory = directoryPath.endsWith(Platform.pathSeparator) + ? directoryPath.substring(0, directoryPath.length - 1) + : directoryPath; + final file = File( + '$normalizedDirectory${Platform.pathSeparator}rent_tracker_backup_$timestamp.json', + ); await file.writeAsString(jsonString); return file.path; } + Future _restoreFromPayload(Map payload) async { + final settingsJson = payload['settings'] as Map?; + final recordsJson = payload['records'] as List?; + if (settingsJson == null || recordsJson == null) { + throw StateError('Backup file is missing settings or records.'); + } + + _settings = AppSettings.fromJson(settingsJson); + final restoredRecords = {}; + for (final item in recordsJson) { + final record = MonthlyRentRecord.fromJson(item as Map); + restoredRecords[record.key] = record; + } + _records = restoredRecords; + + await _storageService.saveSettings(_settings); + await _storageService.saveRecords(_records); + await _notificationService.cancelAll(); + await _syncNotificationScheduleForCurrentMonth(); + notifyListeners(); + } + + Future importBackupFromFile(String filePath) async { + _errorMessage = null; + _setLoading(true); + + try { + final file = File(filePath); + if (!await file.exists()) { + throw StateError('Selected backup file could not be found.'); + } + + final raw = await file.readAsString(); + final payload = jsonDecode(raw) as Map; + + await _restoreFromPayload(payload); + + return file.path; + } catch (err, stackTrace) { + if (kDebugMode) { + debugPrint('[RentController] importBackupFromFile failed: $err'); + debugPrint(stackTrace.toString()); + } + _errorMessage = err.toString(); + rethrow; + } finally { + _setLoading(false); + } + } + List buildRowsForSelectedYear() { final now = DateTime.now(); final maxMonth = _selectedYear < now.year diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index ba37932..16773ec 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,11 +5,13 @@ import FlutterMacOS import Foundation +import file_picker import flutter_local_notifications import flutter_timezone import shared_preferences_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) FlutterTimezonePlugin.register(with: registry.registrar(forPlugin: "FlutterTimezonePlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) diff --git a/pubspec.yaml b/pubspec.yaml index c3bc36c..23c7e3c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -9,6 +9,7 @@ environment: dependencies: flutter: sdk: flutter + file_picker: ^10.3.2 flutter_dotenv: ^5.2.1 flutter_local_notifications: ^19.5.0 flutter_timezone: ^4.1.1