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:
@@ -46,8 +46,9 @@ flutter run
|
|||||||
1. Open Settings and set monthly rent in USD.
|
1. Open Settings and set monthly rent in USD.
|
||||||
2. Keep Unit occupied enabled while the property is rented.
|
2. Keep Unit occupied enabled while the property is rented.
|
||||||
3. Use Backfill last year to import the previous year in one click.
|
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.
|
4. Use Create backup to export a JSON backup into Downloads or any directory you choose.
|
||||||
5. Use year arrows on the main screen to switch yearly views.
|
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
|
## Notes
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:rental_income_tracker/state/rent_controller.dart';
|
import 'package:rental_income_tracker/state/rent_controller.dart';
|
||||||
@@ -12,6 +13,32 @@ class SettingsScreen extends StatefulWidget {
|
|||||||
class _SettingsScreenState extends State<SettingsScreen> {
|
class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
final TextEditingController _rentController = TextEditingController();
|
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
|
@override
|
||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
super.didChangeDependencies();
|
super.didChangeDependencies();
|
||||||
@@ -103,16 +130,71 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: () async {
|
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) {
|
if (!context.mounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
context,
|
SnackBar(content: Text('Backup saved to $path')),
|
||||||
).showSnackBar(SnackBar(content: Text('Exported to $path')));
|
);
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.download),
|
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 SizedBox(height: 16),
|
||||||
const Text(
|
const Text(
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:intl/intl.dart';
|
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/app_settings.dart';
|
||||||
import 'package:rental_income_tracker/models/monthly_rent_record.dart';
|
import 'package:rental_income_tracker/models/monthly_rent_record.dart';
|
||||||
import 'package:rental_income_tracker/services/notification_service.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()
|
final records = _records.values.toList()
|
||||||
..sort((a, b) {
|
..sort((a, b) {
|
||||||
final byYear = a.year.compareTo(b.year);
|
final byYear = a.year.compareTo(b.year);
|
||||||
@@ -433,14 +432,72 @@ class RentController extends ChangeNotifier {
|
|||||||
'records': records.map((e) => e.toJson()).toList(),
|
'records': records.map((e) => e.toJson()).toList(),
|
||||||
};
|
};
|
||||||
|
|
||||||
final jsonString = const JsonEncoder.withIndent(' ').convert(payload);
|
return const JsonEncoder.withIndent(' ').convert(payload);
|
||||||
final dir = await getApplicationDocumentsDirectory();
|
}
|
||||||
|
|
||||||
|
Future<String> exportBackupToDirectory(String directoryPath) async {
|
||||||
|
final jsonString = _buildBackupJson();
|
||||||
final timestamp = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now());
|
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);
|
await file.writeAsString(jsonString);
|
||||||
return file.path;
|
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() {
|
List<RentTableRow> buildRowsForSelectedYear() {
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
final maxMonth = _selectedYear < now.year
|
final maxMonth = _selectedYear < now.year
|
||||||
|
|||||||
@@ -5,11 +5,13 @@
|
|||||||
import FlutterMacOS
|
import FlutterMacOS
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
import file_picker
|
||||||
import flutter_local_notifications
|
import flutter_local_notifications
|
||||||
import flutter_timezone
|
import flutter_timezone
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
|
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
||||||
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||||
FlutterTimezonePlugin.register(with: registry.registrar(forPlugin: "FlutterTimezonePlugin"))
|
FlutterTimezonePlugin.register(with: registry.registrar(forPlugin: "FlutterTimezonePlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ environment:
|
|||||||
dependencies:
|
dependencies:
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
file_picker: ^10.3.2
|
||||||
flutter_dotenv: ^5.2.1
|
flutter_dotenv: ^5.2.1
|
||||||
flutter_local_notifications: ^19.5.0
|
flutter_local_notifications: ^19.5.0
|
||||||
flutter_timezone: ^4.1.1
|
flutter_timezone: ^4.1.1
|
||||||
|
|||||||
Reference in New Issue
Block a user