One discovery subscription over the shared connection for the user's current zone; matches each incoming offer against saved searches and fires a text-free OS alert on the new 'alerts' channel for fresh matches. Skips own/blocked/hidden/non-active offers and dedups via seen keys. The replay-on-connect doubles as the catch-up scan. Notification payloads are now prefixed so taps route to chat or the saved search.
148 lines
5.5 KiB
Dart
148 lines
5.5 KiB
Dart
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 <name>` 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/<pubkey>')` 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<void> 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<void> 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<void> 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',
|
|
);
|
|
}
|
|
}
|