Skip to content

Commit 4243aa5

Browse files
committed
refactor: improve notification scheduling and debt reminder management for clarity and functionality
1 parent 2cfd110 commit 4243aa5

7 files changed

Lines changed: 86 additions & 46 deletions

File tree

mobile/lib/core/services/notifications_service.dart

Lines changed: 40 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,18 @@ class NotificationsService {
226226
);
227227
}
228228

229+
/// Schedules a notification for a future time.
230+
///
231+
/// Note: this does NOT record an inbox entry now — a scheduled notification
232+
/// hasn't been shown yet. The OS displays it at [whenLocal]; recording it as
233+
/// "shown" at schedule time wrongly surfaced it in the in-app inbox instantly.
234+
///
235+
/// Exact alarms (`exactAllowWhileIdle`) need the `SCHEDULE_EXACT_ALARM` /
236+
/// `USE_EXACT_ALARM` permission on Android 12+, which we don't request (and
237+
/// Play restricts to clock/alarm apps). Without it the plugin throws
238+
/// `exact_alarms_not_permitted`. So we schedule **inexact** by default
239+
/// (fine for debt nudges) and fall back to inexact if an exact request is
240+
/// ever rejected — the call never throws for a permission reason.
229241
Future<void> scheduleAt({
230242
required int id,
231243
required AppNotificationType type,
@@ -235,33 +247,41 @@ class NotificationsService {
235247
required AndroidNotificationDetails android,
236248
String route = '/home',
237249
String? entityId,
238-
bool exact = true,
250+
bool exact = false,
239251
}) async {
240252
await _initTimezone();
241253
final payload = jsonEncode(
242254
AppNotificationPayload(type: type, route: route, entityId: entityId)
243255
.toJson(),
244256
);
245-
await _recordShown(
246-
notificationId: id,
247-
type: type,
248-
title: title,
249-
body: body,
250-
route: route,
251-
entityId: entityId,
252-
payloadJson: payload,
253-
);
254257
final scheduled = tz.TZDateTime.from(whenLocal, tz.local);
255-
await _plugin.zonedSchedule(
256-
id,
257-
title,
258-
body,
259-
scheduled,
260-
NotificationDetails(android: android),
261-
payload: payload,
262-
androidScheduleMode:
263-
exact ? AndroidScheduleMode.exactAllowWhileIdle : AndroidScheduleMode.inexact,
264-
);
258+
259+
Future<void> schedule(AndroidScheduleMode mode) {
260+
return _plugin.zonedSchedule(
261+
id,
262+
title,
263+
body,
264+
scheduled,
265+
NotificationDetails(android: android),
266+
payload: payload,
267+
androidScheduleMode: mode,
268+
);
269+
}
270+
271+
final preferred = exact
272+
? AndroidScheduleMode.exactAllowWhileIdle
273+
: AndroidScheduleMode.inexactAllowWhileIdle;
274+
try {
275+
await schedule(preferred);
276+
} on Exception {
277+
// Most commonly `exact_alarms_not_permitted`. Retry inexact so the
278+
// reminder is still scheduled and the caller never sees a false error.
279+
if (preferred != AndroidScheduleMode.inexactAllowWhileIdle) {
280+
await schedule(AndroidScheduleMode.inexactAllowWhileIdle);
281+
} else {
282+
rethrow;
283+
}
284+
}
265285
}
266286

267287
Future<void> scheduleDailyAtTime({

mobile/lib/features/debts/data/debt_reminders_repository.dart

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,9 @@ class DebtRemindersRepository {
100100
);
101101
}
102102

103-
Future<void> cancel({required String reminderId}) async {
103+
/// Cancels the pending OS notification (if any) and removes the row entirely
104+
/// so cancelled reminders don't linger and clutter the list.
105+
Future<void> delete({required String reminderId}) async {
104106
final db = await _appDb.database;
105107
final rows = await db.query(
106108
'local_debt_reminders',
@@ -111,9 +113,8 @@ class DebtRemindersRepository {
111113
if (rows.isEmpty) return;
112114
final reminder = LocalDebtReminder.fromRow(rows.first);
113115
await _notifications.cancel(reminder.notificationId);
114-
await db.update(
116+
await db.delete(
115117
'local_debt_reminders',
116-
{'status': 'cancelled'},
117118
where: 'id = ?',
118119
whereArgs: [reminderId],
119120
);

mobile/lib/features/debts/presentation/widgets/debt_payment_link_panel.dart

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,7 +369,24 @@ class _DebtPaymentLinkPanelState extends ConsumerState<DebtPaymentLinkPanel> {
369369
}
370370
}
371371

372+
/// The amount baked into the existing link no longer matches what the
373+
/// merchant typed — they edited the field, so the active link is stale and
374+
/// the QR/share would collect the wrong amount.
375+
bool get _amountChangedFromLink {
376+
final entered = DebtsUiUtils.amountToMinor(_amountCtrl.text.trim());
377+
if (entered <= 0) return false;
378+
final linkAmount = widget.record.paymentAmount;
379+
if (linkAmount == null || linkAmount.isEmpty) return false;
380+
return entered != DebtsUiUtils.amountToMinor(linkAmount);
381+
}
382+
372383
Future<void> _openExistingQr() async {
384+
// Regenerate first if the merchant changed the amount, so the QR reflects
385+
// what they typed instead of the stale link's baked amount.
386+
if (_amountChangedFromLink) {
387+
await _generate(openQrAfter: true);
388+
return;
389+
}
373390
final link = widget.record.paymentLink;
374391
if (link == null || link.isEmpty) return;
375392
if (_linkState.isExpired) {
@@ -390,6 +407,10 @@ class _DebtPaymentLinkPanelState extends ConsumerState<DebtPaymentLinkPanel> {
390407
}
391408

392409
Future<void> _openExistingShare() async {
410+
if (_amountChangedFromLink) {
411+
await _generate(openQrAfter: false);
412+
return;
413+
}
393414
final link = widget.record.paymentLink;
394415
if (link == null || link.isEmpty) return;
395416
if (_linkState.isExpired) {

mobile/lib/features/debts/presentation/widgets/debt_paystack_momo_sheet/debt_paystack_momo_sheet.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -463,15 +463,15 @@ class _DebtPaystackMomoSheetState extends ConsumerState<DebtPaystackMomoSheet> {
463463
child: Row(
464464
children: [
465465
const Text(
466-
'Outstanding ',
466+
'To collect ',
467467
style: TextStyle(
468468
color: DebtsUi.textMuted,
469469
fontSize: 13,
470470
fontWeight: FontWeight.w600,
471471
),
472472
),
473473
Text(
474-
DebtsUiUtils.formatAmount(widget.amountDisplay),
474+
widget.amountDisplay,
475475
style: const TextStyle(
476476
fontWeight: FontWeight.w800,
477477
fontSize: 16,

mobile/lib/features/debts/presentation/widgets/debt_reminder_row.dart

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,11 @@ class DebtReminderRow extends StatelessWidget {
1111
const DebtReminderRow({
1212
super.key,
1313
required this.reminder,
14-
required this.onCancel,
14+
required this.onRemove,
1515
});
1616

1717
final LocalDebtReminder reminder;
18-
final Future<void> Function() onCancel;
18+
final Future<void> Function() onRemove;
1919

2020
@override
2121
Widget build(BuildContext context) {
@@ -113,20 +113,18 @@ class DebtReminderRow extends StatelessWidget {
113113
],
114114
),
115115
),
116-
if (isActive) ...[
117-
const SizedBox(width: 8),
118-
IconButton(
119-
tooltip: 'Cancel reminder',
120-
icon: const Icon(
121-
Icons.close_rounded,
122-
size: 18,
123-
color: DebtsUi.textMuted,
124-
),
125-
onPressed: () => onCancel(),
126-
padding: EdgeInsets.zero,
127-
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
116+
const SizedBox(width: 8),
117+
IconButton(
118+
tooltip: isActive ? 'Cancel & remove reminder' : 'Remove reminder',
119+
icon: const Icon(
120+
Icons.close_rounded,
121+
size: 18,
122+
color: DebtsUi.textMuted,
128123
),
129-
],
124+
onPressed: () => onRemove(),
125+
padding: EdgeInsets.zero,
126+
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
127+
),
130128
],
131129
),
132130
);

mobile/lib/features/debts/presentation/widgets/debt_reminders_section.dart

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ class DebtRemindersSection extends ConsumerWidget {
100100
if (i > 0) const SizedBox(height: 8),
101101
DebtReminderRow(
102102
reminder: reminders[i],
103-
onCancel: () => _handleCancel(context, ref, reminders[i]),
103+
onRemove: () => _handleDelete(context, ref, reminders[i]),
104104
),
105105
],
106106
],
@@ -116,19 +116,19 @@ class DebtRemindersSection extends ConsumerWidget {
116116
);
117117
}
118118

119-
Future<void> _handleCancel(
119+
Future<void> _handleDelete(
120120
BuildContext context,
121121
WidgetRef ref,
122122
LocalDebtReminder reminder,
123123
) async {
124124
final messenger = ScaffoldMessenger.of(context);
125125
try {
126-
await ref.read(debtRemindersControllerProvider).cancel(
126+
await ref.read(debtRemindersControllerProvider).delete(
127127
reminderId: reminder.id,
128128
receivableId: reminder.receivableId,
129129
);
130130
messenger.showSnackBar(
131-
const SnackBar(content: Text('Reminder cancelled.')),
131+
const SnackBar(content: Text('Reminder removed.')),
132132
);
133133
} catch (error) {
134134
messenger.showSnackBar(

mobile/lib/features/debts/providers/debt_reminders_provider.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,12 @@ class DebtRemindersController {
4848
return reminder;
4949
}
5050

51-
Future<void> cancel({
51+
Future<void> delete({
5252
required String reminderId,
5353
required String receivableId,
5454
}) async {
5555
final repo = _ref.read(debtRemindersRepositoryProvider);
56-
await repo.cancel(reminderId: reminderId);
56+
await repo.delete(reminderId: reminderId);
5757
_ref.invalidate(debtRemindersForReceivableProvider(receivableId));
5858
}
5959
}

0 commit comments

Comments
 (0)