Add backup and restore functionality for rental data in settings

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-20 09:32:02 +01:00
parent df7705a224
commit 477ca0ee07
5 changed files with 155 additions and 12 deletions
+3 -2
View File
@@ -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
+87 -5
View File
@@ -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<SettingsScreen> {
final TextEditingController _rentController = TextEditingController();
Future<bool> _confirmImport() async {
final result = await showDialog<bool>(
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: <Widget>[
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<SettingsScreen> {
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: <String>['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(
+62 -5
View File
@@ -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<String> 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<String> 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<void> _restoreFromPayload(Map<String, dynamic> payload) async {
final settingsJson = payload['settings'] as Map<String, dynamic>?;
final recordsJson = payload['records'] as List<dynamic>?;
if (settingsJson == null || recordsJson == null) {
throw StateError('Backup file is missing settings or records.');
}
_settings = AppSettings.fromJson(settingsJson);
final restoredRecords = <String, MonthlyRentRecord>{};
for (final item in recordsJson) {
final record = MonthlyRentRecord.fromJson(item as Map<String, dynamic>);
restoredRecords[record.key] = record;
}
_records = restoredRecords;
await _storageService.saveSettings(_settings);
await _storageService.saveRecords(_records);
await _notificationService.cancelAll();
await _syncNotificationScheduleForCurrentMonth();
notifyListeners();
}
Future<String> 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<String, dynamic>;
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<RentTableRow> buildRowsForSelectedYear() {
final now = DateTime.now();
final maxMonth = _selectedYear < now.year
@@ -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"))
+1
View File
@@ -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