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