Implement status picker and payment dialog for rent status updates

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-20 00:12:48 +01:00
parent d1d1d9fb95
commit df7705a224
2 changed files with 205 additions and 0 deletions
+143
View File
@@ -8,6 +8,140 @@ import 'package:rental_income_tracker/state/rent_controller.dart';
class HomeScreen extends StatelessWidget { class HomeScreen extends StatelessWidget {
const HomeScreen({super.key}); const HomeScreen({super.key});
Future<PaymentStatus?> _showStatusPicker(
BuildContext context,
PaymentStatus current,
) async {
return showModalBottomSheet<PaymentStatus>(
context: context,
builder: (context) {
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
ListTile(
title: const Text('Paid on time'),
selected: current == PaymentStatus.onTime,
onTap: () => Navigator.of(context).pop(PaymentStatus.onTime),
),
ListTile(
title: const Text('Paid late'),
selected: current == PaymentStatus.late,
onTap: () => Navigator.of(context).pop(PaymentStatus.late),
),
ListTile(
title: const Text('Not paid'),
selected: current == PaymentStatus.notPaid,
onTap: () => Navigator.of(context).pop(PaymentStatus.notPaid),
),
ListTile(
title: const Text('Pending'),
selected: current == PaymentStatus.pending,
onTap: () => Navigator.of(context).pop(PaymentStatus.pending),
),
],
),
);
},
);
}
Future<double?> _showPaidUsdDialog(
BuildContext context, {
required double initialValue,
}) async {
final controller = TextEditingController(
text: initialValue > 0 ? initialValue.toStringAsFixed(2) : '',
);
return showDialog<double>(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Enter paid amount (USD)'),
content: TextField(
controller: controller,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(labelText: 'USD amount'),
autofocus: true,
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
final value = double.tryParse(controller.text.trim());
if (value == null || value <= 0) {
return;
}
Navigator.of(context).pop(value);
},
child: const Text('Save'),
),
],
);
},
);
}
Future<void> _handleStatusTap(
BuildContext context,
RentController controller,
RentTableRow row,
) async {
if (row.status == PaymentStatus.notOccupied) {
if (!context.mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Use Settings -> Unit occupied to manage not-occupied months.',
),
),
);
return;
}
final nextStatus = await _showStatusPicker(context, row.status);
if (!context.mounted) {
return;
}
if (nextStatus == null || nextStatus == row.status) {
return;
}
double? paidUsd;
if (nextStatus == PaymentStatus.onTime ||
nextStatus == PaymentStatus.late) {
paidUsd = await _showPaidUsdDialog(
context,
initialValue: row.usdAmount ?? controller.settings.rentUsd,
);
if (paidUsd == null) {
return;
}
}
await controller.setMonthStatusManually(
year: row.year,
month: row.month,
status: nextStatus,
paidUsd: paidUsd,
);
if (!context.mounted) {
return;
}
if (controller.errorMessage != null) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(controller.errorMessage!)));
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Consumer<RentController>( return Consumer<RentController>(
@@ -95,8 +229,17 @@ class HomeScreen extends StatelessWidget {
_statusIcon(row.status), _statusIcon(row.status),
const SizedBox(width: 8), const SizedBox(width: 8),
Text(_statusLabel(row.status)), Text(_statusLabel(row.status)),
const SizedBox(width: 8),
const Icon(Icons.edit, size: 16),
], ],
), ),
onTap: () async {
await _handleStatusTap(
context,
controller,
row,
);
},
), ),
DataCell( DataCell(
Text( Text(
+62
View File
@@ -227,6 +227,68 @@ class RentController extends ChangeNotifier {
} }
} }
Future<void> setMonthStatusManually({
required int year,
required int month,
required PaymentStatus status,
double? paidUsd,
}) async {
_errorMessage = null;
_setLoading(true);
try {
final key = TimeService.monthKey(year, month);
if (status == PaymentStatus.notOccupied) {
throw StateError(
'Use Settings -> Unit occupied to manage not-occupied months.',
);
}
if (status == PaymentStatus.notPaid || status == PaymentStatus.pending) {
_records.remove(key);
await _storageService.saveRecords(_records);
await _notificationService.cancelForMonth(year, month);
await _syncNotificationScheduleForCurrentMonth();
return;
}
if (_isNotOccupiedMonth(year, month)) {
throw StateError('Cannot mark a non-occupied month as paid.');
}
final usd = paidUsd ?? _settings.rentUsd;
if (usd <= 0) {
throw StateError('Enter a valid paid amount in USD.');
}
final estDate = DateTime(year, month, 1);
final conversion = await _exchangeService.convertUsdToSek(
estDate: estDate,
amount: usd,
);
_records[key] = MonthlyRentRecord(
year: year,
month: month,
usdAmount: usd,
sekAmount: conversion.result,
usdToSekRate: conversion.quote,
paidAtUtc: DateTime.now().toUtc(),
onTime: status == PaymentStatus.onTime,
source: 'manual-status',
);
await _storageService.saveRecords(_records);
await _notificationService.cancelForMonth(year, month);
await _syncNotificationScheduleForCurrentMonth();
} catch (err) {
_errorMessage = err.toString();
} finally {
_setLoading(false);
}
}
Future<void> backfillLastYear() async { Future<void> backfillLastYear() async {
_errorMessage = null; _errorMessage = null;
_setLoading(true); _setLoading(true);