import 'dart:io'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import '../about/external/data/Commitment.dart'; import '../about/external/data/LiveSession.dart'; import '../about/internal/application/CommitmentClass.dart'; import '../about/internal/application/EscalationTier.dart'; import '../about/internal/application/ToneLevel.dart'; import '../utils/CommonUtils.dart'; import '../utils/ToneEngine.dart'; /// Notification channels, the ongoing "task running" notification, and the /// full-screen alarm used for non-negotiables. class LocalNotificationEngine { static final FlutterLocalNotificationsPlugin plugin = FlutterLocalNotificationsPlugin(); /// Ordinary reminders — respects quiet hours. static const String reminderChannel = "grounded_reminders"; /// The ongoing notification attached to a running task. Not dismissable, so /// a task in progress is always one tap away from the lock screen. static const String sessionChannel = "grounded_session"; /// Alarm-class, full-screen. Reserved for non-negotiables, and the only /// channel that overrides quiet hours. static const String alarmChannel = "grounded_alarm"; static const int sessionNotificationId = 9000; static bool _ready = false; static Future init() async { if (_ready) { return; } const AndroidInitializationSettings android = AndroidInitializationSettings('@mipmap/ic_launcher'); const DarwinInitializationSettings apple = DarwinInitializationSettings( requestAlertPermission: true, requestBadgePermission: true, requestSoundPermission: true, // Critical alerts need Apple entitlement approval; requesting without it // is simply ignored rather than failing. requestCriticalPermission: true, ); await plugin.initialize( settings: const InitializationSettings( android: android, iOS: apple, macOS: apple), ); if (Platform.isAndroid) { await _createAndroidChannels(); } _ready = true; } static Future _createAndroidChannels() async { final AndroidFlutterLocalNotificationsPlugin? android = plugin.resolvePlatformSpecificImplementation< AndroidFlutterLocalNotificationsPlugin>(); if (android == null) { return; } await android.createNotificationChannel(const AndroidNotificationChannel( reminderChannel, 'Reminders', description: 'Nudges about commitments that are due.', importance: Importance.defaultImportance, )); await android.createNotificationChannel(const AndroidNotificationChannel( sessionChannel, 'Task in progress', description: 'The ongoing notification for a task you are running.', importance: Importance.low, playSound: false, enableVibration: false, )); await android.createNotificationChannel(const AndroidNotificationChannel( alarmChannel, 'Non-negotiables', description: 'Full-screen alarms for non-negotiable commitments. These ignore quiet hours.', importance: Importance.max, playSound: true, enableVibration: true, )); await android.requestNotificationsPermission(); // Exact alarms are what let a window actually close on time rather than // whenever the OS feels like it. await android.requestExactAlarmsPermission(); } /// The ongoing notification for a running task. Shows the live remaining /// time so it is useful from the lock screen without unlocking. static Future showSessionNotification(LiveSession session) async { await init(); final String remaining = session.requiredSeconds > 0 ? "${formatClock(session.remainingSeconds())} remaining" : "${formatClock(session.elapsedSeconds())} elapsed"; final AndroidNotificationDetails android = AndroidNotificationDetails( sessionChannel, 'Task in progress', channelDescription: 'The ongoing notification for a running task.', importance: Importance.low, priority: Priority.low, ongoing: true, autoCancel: false, onlyAlertOnce: true, showWhen: true, usesChronometer: session.requiredSeconds <= 0, category: AndroidNotificationCategory.workout, actions: const [ AndroidNotificationAction('pause', 'Pause'), AndroidNotificationAction('finish', 'Finish'), ], ); await plugin.show( id: sessionNotificationId, title: session.title, body: session.goalTitle.isEmpty ? remaining : "${session.goalTitle} · $remaining", notificationDetails: NotificationDetails( android: android, iOS: const DarwinNotificationDetails(presentBanner: false), ), payload: session.commitmentId, ); } static Future cancelSessionNotification() async { await init(); await plugin.cancel(id: sessionNotificationId); } /// A due-window nudge. Non-negotiables go out full-screen and alarm-class so /// they surface over the lock screen; everything else is an ordinary /// notification. static Future showCommitmentNudge( Commitment commitment, EscalationTier tier, ToneLevel tone, ) async { await init(); final bool nonNegotiable = commitment.commitmentClass == CommitmentClass.NonNegotiable; final AndroidNotificationDetails android = AndroidNotificationDetails( nonNegotiable ? alarmChannel : reminderChannel, nonNegotiable ? 'Non-negotiables' : 'Reminders', importance: nonNegotiable ? Importance.max : Importance.defaultImportance, priority: nonNegotiable ? Priority.max : Priority.defaultPriority, // The full-screen intent is what turns this into a lock-screen takeover // rather than a banner that can be swiped past. fullScreenIntent: nonNegotiable, category: nonNegotiable ? AndroidNotificationCategory.alarm : AndroidNotificationCategory.reminder, actions: [ const AndroidNotificationAction('start', 'Start now'), if (!nonNegotiable) const AndroidNotificationAction('snooze', 'Snooze'), ], ); await plugin.show( id: commitment.id.hashCode, title: commitment.title, body: ToneEngine.nudge(tier, tone, commitment.title), notificationDetails: NotificationDetails( android: android, iOS: DarwinNotificationDetails( // Critical alerts bypass Do Not Disturb, and are the iOS equivalent // of the Android full-screen intent. Requires Apple approval. interruptionLevel: nonNegotiable ? InterruptionLevel.critical : InterruptionLevel.active, ), ), payload: commitment.id, ); } static Future cancelAll() async { await init(); await plugin.cancelAll(); } }