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:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user