diet-guard/app/lib/services/notification_backend_io.dart
Krzysztof kuhy Rudnicki f83938bfe3 app: minute-of-day slots (phase 2 of 3: every app consumer)
- FoodEntry.slot -> slotMinute: read via entrySlotMinute, written via
  slotFields (whole-hour stays byte-identical, else slot_min), kept by
  copyWithDeleted and the edit screen, so the phone never republishes a
  stripped copy of a PC entry. Out-of-range peer values echo, never throw.
- Satisfaction is nearest-slot snapping (satisfiedSlots) everywhere:
  loggedSlotsToday, due check, kuchnia occupancy, future-day logged set
  (that day's schedule), slot selector. Schedule edits no longer un-log.
- Future pre-log builds DateTime from slot ~/ 60, slot % 60.
- Schedule history + sync go through scheduleToWire/scheduleFromWire.
- Notifications: ids are slot minutes; syncToSlots cancels whatever the
  platform reports active (getActiveNotifications, reminder channel) that
  is not due, covering legacy hour ids; full 0..1439 sweep only if the
  lookup throws.
- Settings: 15-minute dropdowns + "Custom..." (24h showTimePicker), off-grid
  values always selectable, counts capped by grid capacity; editor moved
  to widgets/meal_schedule_editor.dart + meal_time_dropdown.dart.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015npsYVB2PdtWkG7MPAi4tn
2026-10-10 19:55:26 +02:00

85 lines
3.2 KiB
Dart

/// `flutter_local_notifications`-backed [NotificationBackend] (Android).
library;
import 'package:diet_guard_app/services/notification_backend.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
/// Wraps [FlutterLocalNotificationsPlugin] so the due-slot logic in
/// `notification_service.dart` is unit-testable against a fake platform
/// channel, independent of the real plugin's native implementation.
class LocalNotificationsBackend implements NotificationBackend {
/// Creates a backend over [plugin].
LocalNotificationsBackend(this.plugin);
/// The wrapped plugin. Injectable so a test can point it at a fake channel.
final FlutterLocalNotificationsPlugin plugin;
static const _channelId = 'diet_guard_due_slot';
static const _channelName = 'Meal reminders';
@override
Future<void> initialize() async {
const settings = InitializationSettings(
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
);
await plugin.initialize(settings: settings);
}
/// Requests Android 13+'s runtime `POST_NOTIFICATIONS` permission.
///
/// Returns null on platforms where this Android-specific call doesn't
/// apply -- the caller treats null and false the same way (don't block on
/// it; notifications degrade silently if denied, matching the rest of this
/// service's silent-on-failure stance). Since the desktop target became a
/// web build, `android/` is this backend's only real platform, so in
/// production the non-null path always runs; the fallback is not reachable
/// from `flutter test` without polluting the process-global plugin
/// registration other tests in this file rely on, so it's excluded from
/// coverage rather than chased with a fragile test-ordering trick.
@override
Future<bool?> requestPermission() =>
plugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>()
?.requestNotificationsPermission() ??
// coverage:ignore-line
Future.value();
@override
Future<void> show(int slot, String title, String body) async {
const details = NotificationDetails(
android: AndroidNotificationDetails(
_channelId,
_channelName,
importance: Importance.high,
priority: Priority.high,
),
);
await plugin.show(
id: slot,
title: title,
body: body,
notificationDetails: details,
);
}
@override
Future<void> cancel(int slot) => plugin.cancel(id: slot);
/// Asks Android which notifications this app has in the shade (API 23+;
/// minSdk is 26), restricted to the reminder channel so nothing else the
/// app might ever post is swept up. Older builds posted on this same
/// channel id, so their hour-keyed reminders are included -- which is what
/// makes the upgrade orphan-free.
@override
Future<Set<int>> activeIds() async => {
for (final active in await plugin.getActiveNotifications())
if (active.channelId == _channelId && active.id != null) active.id!,
};
}
/// Opens the platform notification backend.
NotificationBackend openNotificationBackend() =>
LocalNotificationsBackend(FlutterLocalNotificationsPlugin());