feat: 新增站内消息系统,支持通知与订单跳转
This commit is contained in:
1 parent
411ec8be23
commit
c799b62f6b
14 files changed
+464
-9
No files matched your search
+50
-2
@@ -1,19 +1,67 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../core/app_keys.dart';
|
||||
import '../core/notifications/keep_alive.dart';
|
||||
import '../core/notifications/local_notifications.dart';
|
||||
import '../core/theme/app_theme.dart';
|
||||
import '../core/theme/app_typography.dart';
|
||||
import '../features/auth/application/session_controller.dart';
|
||||
import '../features/notice/application/notice_alert_controller.dart';
|
||||
import 'router.dart';
|
||||
|
||||
class VoltLinkApp extends ConsumerWidget {
|
||||
class VoltLinkApp extends ConsumerStatefulWidget {
|
||||
const VoltLinkApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<VoltLinkApp> createState() => _VoltLinkAppState();
|
||||
}
|
||||
|
||||
class _VoltLinkAppState extends ConsumerState<VoltLinkApp> {
|
||||
StreamSubscription<String>? _tapSub;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 运行期点击系统通知 → 跳转对应页面
|
||||
_tapSub = LocalNotifications.instance.taps.listen(_openRoute);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// 冷启动由通知拉起时,初始化阶段已把路由暂存下来
|
||||
final route = LocalNotifications.instance.takeLaunchRoute();
|
||||
if (route != null) _openRoute(route);
|
||||
// 已登录则开启 Android 前台服务
|
||||
unawaited(
|
||||
BackgroundKeepAlive.setEnabled(ref.read(sessionProvider).isLoggedIn),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tapSub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _openRoute(String route) {
|
||||
if (!mounted || route.isEmpty) return;
|
||||
ref.read(routerProvider).go(route);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final router = ref.watch(routerProvider);
|
||||
|
||||
// 登录/登出时同步 Android 前台服务开关
|
||||
ref.listen(sessionProvider.select((s) => s.isLoggedIn), (_, loggedIn) {
|
||||
unawaited(BackgroundKeepAlive.setEnabled(loggedIn));
|
||||
});
|
||||
|
||||
// 激活站内消息提醒轮询
|
||||
ref.watch(noticeAlertProvider);
|
||||
|
||||
return MaterialApp.router(
|
||||
title: 'VoltLink',
|
||||
debugShowCheckedModeBanner: false,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/network/api_response.dart';
|
||||
import '../../../core/notifications/local_notifications.dart';
|
||||
import '../../../core/providers.dart';
|
||||
import '../../auth/application/session_controller.dart';
|
||||
import 'notice_routes.dart';
|
||||
|
||||
/// 轮询未读站内消息,并在 App 不处于前台时弹出系统通知。
|
||||
///
|
||||
/// 登录后每 30s 拉一次未读列表;已提醒过的最大消息 id 持久化,
|
||||
/// 重启后不会重复提醒历史消息。
|
||||
final noticeAlertProvider =
|
||||
NotifierProvider<NoticeAlertController, int>(NoticeAlertController.new);
|
||||
|
||||
class NoticeAlertController extends Notifier<int> {
|
||||
static const _interval = Duration(seconds: 30);
|
||||
|
||||
Timer? _timer;
|
||||
bool _busy = false;
|
||||
|
||||
@override
|
||||
int build() {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
|
||||
final loggedIn = ref.watch(sessionProvider.select((s) => s.isLoggedIn));
|
||||
if (!loggedIn) return 0;
|
||||
|
||||
ref.onDispose(() {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
});
|
||||
_timer = Timer.periodic(_interval, (_) => unawaited(_poll()));
|
||||
unawaited(_poll());
|
||||
return 0;
|
||||
}
|
||||
|
||||
Future<void> _poll() async {
|
||||
if (_busy) return;
|
||||
_busy = true;
|
||||
try {
|
||||
final res = await ref
|
||||
.read(noticeApiProvider)
|
||||
.list(pageSize: 20, isRead: false);
|
||||
if (!res.isOk || res.data == null || res.data!.list.isEmpty) return;
|
||||
|
||||
final notices = res.data!.list;
|
||||
final latest = notices.fold<int>(0, (m, n) => n.id > m ? n.id : m);
|
||||
final storage = ref.read(tokenStorageProvider);
|
||||
|
||||
// 首次运行只记录基线,避免把历史未读一次性弹成通知
|
||||
final maxId = storage.noticeAlertMaxId;
|
||||
if (maxId < 0) {
|
||||
await storage.setNoticeAlertMaxId(latest);
|
||||
return;
|
||||
}
|
||||
|
||||
final fresh = notices.where((n) => n.id > maxId).toList()
|
||||
..sort((a, b) => a.id.compareTo(b.id));
|
||||
if (fresh.isEmpty) return;
|
||||
|
||||
// 前台时只更新基线(界面内已有红点/消息中心),退到后台才弹通知
|
||||
final inBackground =
|
||||
WidgetsBinding.instance.lifecycleState != AppLifecycleState.resumed;
|
||||
if (inBackground) {
|
||||
for (final notice in fresh) {
|
||||
await LocalNotifications.instance.show(
|
||||
id: notice.id,
|
||||
title: notice.title,
|
||||
body: notice.content,
|
||||
route: noticeRoute(notice) ?? '/notice',
|
||||
);
|
||||
}
|
||||
}
|
||||
await storage.setNoticeAlertMaxId(latest);
|
||||
} on ApiException {
|
||||
// 网络波动忽略,下一轮再试
|
||||
} finally {
|
||||
_busy = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import '../data/notice_api.dart';
|
||||
|
||||
/// 消息可跳转的订单路由;无法识别业务类型时返回 null。
|
||||
///
|
||||
/// 与后端通知投递的 `bizType` 约定一致:
|
||||
/// `survey_order` / `repair_dispatch` / `install_order` / `maintenance_order`。
|
||||
String? noticeRoute(Notice notice) {
|
||||
final id = notice.bizId;
|
||||
if (id == null || id <= 0) return null;
|
||||
final source = switch (notice.bizType) {
|
||||
'survey_order' => 'survey',
|
||||
'repair_dispatch' || 'repair_order' => 'repair',
|
||||
'install_order' => 'install',
|
||||
'maintenance_order' => 'maintenance',
|
||||
_ => null,
|
||||
};
|
||||
return source == null ? null : '/order/$source/$id';
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import '../../../shared/widgets/app_feedback.dart';
|
||||
import '../../../shared/widgets/x_card.dart';
|
||||
import '../../../shared/widgets/x_common.dart';
|
||||
import '../../../shared/widgets/x_top_bar.dart';
|
||||
import '../application/notice_routes.dart';
|
||||
import '../data/notice_api.dart';
|
||||
|
||||
/// 消息中心:站内通知列表 + 已读标记 + 分页加载。
|
||||
@@ -108,14 +109,11 @@ class _NoticeCenterPageState extends ConsumerState<NoticeCenterPage> {
|
||||
|
||||
Future<void> _loadMore() => _load();
|
||||
|
||||
/// 点击消息:先标记已读;勘察指派消息(`bizType == "survey_order"`)
|
||||
/// 带 `bizId` 时跳转到对应勘察订单详情,其余保持仅标记已读。
|
||||
/// 点击消息:先标记已读;能识别业务来源时跳转到对应订单详情。
|
||||
Future<void> _onNoticeTap(Notice notice) async {
|
||||
if (!notice.isRead) await _markRead(notice);
|
||||
final bizId = notice.bizId;
|
||||
if (notice.bizType == 'survey_order' && bizId != null && mounted) {
|
||||
context.push('/order/survey/$bizId');
|
||||
}
|
||||
final route = noticeRoute(notice);
|
||||
if (route != null && mounted) context.push(route);
|
||||
}
|
||||
|
||||
Future<void> _markRead(Notice notice) async {
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'app/app.dart';
|
||||
import 'core/notifications/local_notifications.dart';
|
||||
import 'core/providers.dart';
|
||||
import 'core/storage/token_storage.dart';
|
||||
|
||||
@@ -11,6 +12,9 @@ Future<void> main() async {
|
||||
// SharedPreferences 必须在 runApp 之前完成异步读取,再通过 override 注入。
|
||||
final storage = await TokenStorage.open();
|
||||
|
||||
// 系统通知渠道 + Android 13 通知权限;冷启动点击通知的路由也会在此暂存。
|
||||
await LocalNotifications.instance.init();
|
||||
|
||||
runApp(
|
||||
ProviderScope(
|
||||
overrides: [tokenStorageProvider.overrideWithValue(storage)],
|
||||
|
||||
Reference in new issue
Block a user