feat: 新增站内消息系统,支持通知与订单跳转

This commit is contained in:
andyesfly committed 2026-10-23 17:18:21 +08:00
1 parent 411ec8be23
commit c799b62f6b
14 files changed
+464 -9

No files matched your search

+25
View File
@@ -0,0 +1,25 @@
import 'dart:io';
import 'package:flutter/services.dart';
/// Android 前台服务开关:让 App 在退到后台后仍保持进程存活,
/// 从而继续轮询站内消息并弹系统通知。
///
/// 注意:这不是推送。用户手动「强行停止」/清理后台后服务会终止,
/// 需要被杀进程后仍送达时必须接入厂商推送或极光/个推。
class BackgroundKeepAlive {
BackgroundKeepAlive._();
static const _channel = MethodChannel('voltlink/keepalive');
static Future<void> setEnabled(bool enabled) async {
if (!Platform.isAndroid) return;
try {
await _channel.invokeMethod(enabled ? 'start' : 'stop');
} on PlatformException {
// 无通知权限或系统限制时静默失败,不影响主流程。
} on MissingPluginException {
// 非 Android 宿主或插件未注册。
}
}
}
@@ -0,0 +1,98 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
/// 本地系统通知。Android 专用(当前电工端只在 Android 分发)。
///
/// 只负责「展示通知」与「回传点击 payload」,消息从哪来由调用方决定。
class LocalNotifications {
LocalNotifications._();
static final instance = LocalNotifications._();
static const channelId = 'voltlink_dispatch';
static const channelName = '派单与站内消息';
static const channelDesc = '收到新的派单或站内消息时提醒';
final _plugin = FlutterLocalNotificationsPlugin();
final _taps = StreamController<String>.broadcast();
/// 冷启动时由通知拉起的路由(初始化完成时读取一次)。
String? _launchRoute;
bool _ready = false;
/// 运行期点击通知的路由流。
Stream<String> get taps => _taps.stream;
/// 在 `runApp` 之前调用。
Future<void> init() async {
if (_ready || !Platform.isAndroid) return;
const settings = InitializationSettings(
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
);
await _plugin.initialize(
settings: settings,
onDidReceiveNotificationResponse: _onTap,
);
final android = _plugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>();
// Android 13+ 需要用户授权,否则通知不会展示
await android?.requestNotificationsPermission();
await android?.createNotificationChannel(
const AndroidNotificationChannel(
channelId,
channelName,
description: channelDesc,
importance: Importance.high,
),
);
final launch = await _plugin.getNotificationAppLaunchDetails();
if ((launch?.didNotificationLaunchApp ?? false)) {
final payload = launch?.notificationResponse?.payload;
if (payload != null && payload.isNotEmpty) _launchRoute = payload;
}
_ready = true;
}
/// 取出冷启动通知携带的路由(只返回一次)。
String? takeLaunchRoute() {
final route = _launchRoute;
_launchRoute = null;
return route;
}
Future<void> show({
required int id,
required String title,
required String body,
required String route,
}) async {
if (!_ready) return;
await _plugin.show(
id: id,
title: title,
body: body,
notificationDetails: const NotificationDetails(
android: AndroidNotificationDetails(
channelId,
channelName,
channelDescription: channelDesc,
importance: Importance.high,
priority: Priority.high,
),
),
payload: route,
);
}
void _onTap(NotificationResponse response) {
final payload = response.payload;
if (payload != null && payload.isNotEmpty) _taps.add(payload);
}
}
+7
View File
@@ -13,6 +13,7 @@ class TokenStorage {
static const _kUserInfo = 'userInfo';
static const _kServerHost = 'serverHost';
static const _kServerPort = 'serverPort';
static const _kNoticeAlertMaxId = 'noticeAlertMaxId';
static Future<TokenStorage> open() async =>
TokenStorage(await SharedPreferences.getInstance());
@@ -55,5 +56,11 @@ class TokenStorage {
await _prefs.remove(_kUserInfo);
}
/// 已弹过系统通知的最大消息 id,避免重启后重复提醒历史消息。
int get noticeAlertMaxId => _prefs.getInt(_kNoticeAlertMaxId) ?? -1;
Future<void> setNoticeAlertMaxId(int id) =>
_prefs.setInt(_kNoticeAlertMaxId, id);
bool get hasSession => accessToken.isNotEmpty;
}