import 'package:flutter/foundation.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; /// Shows an OS notification when a private message arrives while the app is in /// the foreground. Foreground only by design — when the app is backgrounded the /// message listener stops, so nothing fires (background/push is a later, larger /// concern). Web and Windows have no local-notification support, so there this /// whole service is an inert no-op. /// /// For privacy it never carries message text — the caller passes a generic /// `New message from ` title (see [InboxService]). The peer's pubkey rides /// along as the payload so a tap can open that exact chat via [onTapChat]. class NotificationService { NotificationService({ FlutterLocalNotificationsPlugin? plugin, bool? supported, }) : _plugin = plugin ?? FlutterLocalNotificationsPlugin(), _supported = supported ?? _platformSupported; final FlutterLocalNotificationsPlugin _plugin; final bool _supported; bool _ready = false; /// Called with a peer pubkey when the user taps a message notification. The /// app wires this to `router.push('/chat/')` once the router exists. void Function(String peerPubkey)? onTapChat; /// Called with a saved-search id when the user taps a search-alert /// notification. The app wires this to open the saved-searches list. void Function(String searchId)? onTapSearch; static const _channelId = 'messages'; static const _alertsChannelId = 'alerts'; /// Tap payloads are prefixed so one handler can route by kind. A bare payload /// (no prefix) stays a chat peer pubkey, for backward compatibility. static const _searchPayloadPrefix = 'search:'; static bool get _platformSupported { if (kIsWeb) return false; switch (defaultTargetPlatform) { case TargetPlatform.android: case TargetPlatform.iOS: case TargetPlatform.macOS: case TargetPlatform.linux: return true; case TargetPlatform.windows: case TargetPlatform.fuchsia: return false; } } /// Initialises the plugin and requests permission (Android 13+/iOS/macOS ask /// at runtime). Idempotent and safe to call on any platform. Future initialize() async { if (!_supported || _ready) return; const android = AndroidInitializationSettings('@mipmap/ic_launcher'); const darwin = DarwinInitializationSettings(); const linux = LinuxInitializationSettings(defaultActionName: 'Open'); await _plugin.initialize( const InitializationSettings( android: android, iOS: darwin, macOS: darwin, linux: linux, ), onDidReceiveNotificationResponse: (response) { final payload = response.payload; if (payload == null || payload.isEmpty) return; if (payload.startsWith(_searchPayloadPrefix)) { onTapSearch?.call(payload.substring(_searchPayloadPrefix.length)); } else { onTapChat?.call(payload); } }, ); await _plugin .resolvePlatformSpecificImplementation< AndroidFlutterLocalNotificationsPlugin>() ?.requestNotificationsPermission(); await _plugin .resolvePlatformSpecificImplementation< IOSFlutterLocalNotificationsPlugin>() ?.requestPermissions(alert: true, badge: true, sound: true); await _plugin .resolvePlatformSpecificImplementation< MacOSFlutterLocalNotificationsPlugin>() ?.requestPermissions(alert: true, badge: true, sound: true); _ready = true; } /// Shows a notification for a new message. [title] is a generic, text-free /// line like "New message from Alice"; [peerPubkey] becomes the tap payload. /// No-op on unsupported platforms. Future showMessage({ required String peerPubkey, required String title, }) async { if (!_supported) return; const details = NotificationDetails( android: AndroidNotificationDetails( _channelId, 'Messages', channelDescription: 'New private messages', importance: Importance.high, priority: Priority.high, ), iOS: DarwinNotificationDetails(), macOS: DarwinNotificationDetails(), linux: LinuxNotificationDetails(), ); // One notification per peer (same id replaces the previous), so a chatty // peer doesn't stack a wall of notifications. await _plugin.show(peerPubkey.hashCode, title, null, details, payload: peerPubkey); } /// Shows a notification for a new offer matching a saved search. [title] is a /// generic line built by the caller (e.g. "New match: Tomatoes"); [searchId] /// rides along so a tap opens that search. No-op on unsupported platforms. Future showSearchAlert({ required String searchId, required String title, }) async { if (!_supported) return; const details = NotificationDetails( android: AndroidNotificationDetails( _alertsChannelId, 'Search alerts', channelDescription: 'New offers matching your saved searches', importance: Importance.high, priority: Priority.high, ), iOS: DarwinNotificationDetails(), macOS: DarwinNotificationDetails(), linux: LinuxNotificationDetails(), ); // One notification per search (same id replaces the previous), so repeated // matches for one search don't stack up. await _plugin.show( _searchPayloadPrefix.hashCode ^ searchId.hashCode, title, null, details, payload: '$_searchPayloadPrefix$searchId', ); } }