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

+6
View File
@@ -13,6 +13,8 @@ android {
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
// flutter_local_notifications 需要 core library desugaring
isCoreLibraryDesugaringEnabled = true
}
kotlinOptions {
@@ -42,3 +44,7 @@ android {
flutter {
source = "../.."
}
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
}
+10
View File
@@ -1,6 +1,11 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 访问局域网后端接口(release 包也需要) -->
<uses-permission android:name="android.permission.INTERNET"/>
<!-- Android 13+ 展示系统通知 -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<!-- 后台保持进程存活,持续轮询派单消息 -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
<application
android:label="smart_grid_iot_app_flutter"
android:name="${applicationName}"
@@ -28,6 +33,11 @@
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- 后台保活:让 App 退到后台后仍能轮询站内消息 -->
<service
android:name=".KeepAliveService"
android:exported="false"
android:foregroundServiceType="dataSync"/>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
@@ -0,0 +1,66 @@
package com.voltlink.smart_grid_iot_app_flutter
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
/**
* 前台服务:仅用于让进程在退到后台后保持存活,
* 从而让 Flutter 侧的 30s 轮询继续运行并弹出系统通知。
*
* 它不是推送通道:用户手动「强行停止」或清理后台后仍会中断。
*/
class KeepAliveService : Service() {
companion object {
private const val CHANNEL_ID = "voltlink_keepalive"
private const val NOTIFICATION_ID = 1001
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
createChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
startForeground(NOTIFICATION_ID, buildNotification())
return START_STICKY
}
private fun createChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (manager.getNotificationChannel(CHANNEL_ID) == null) {
val channel = NotificationChannel(
CHANNEL_ID,
"后台接单提醒",
NotificationManager.IMPORTANCE_MIN
)
channel.description = "保持 App 运行以接收派单消息"
channel.setShowBadge(false)
manager.createNotificationChannel(channel)
}
}
}
private fun buildNotification(): Notification {
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Notification.Builder(this, CHANNEL_ID)
} else {
@Suppress("DEPRECATION")
Notification.Builder(this)
}
return builder
.setContentTitle("VoltLink 正在后台运行")
.setContentText("用于及时接收派单消息")
.setSmallIcon(R.mipmap.ic_launcher)
.setOngoing(true)
.build()
}
}
@@ -1,5 +1,37 @@
package com.voltlink.smart_grid_iot_app_flutter
import android.content.Intent
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity()
class MainActivity : FlutterActivity() {
private val channelName = "voltlink/keepalive"
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channelName)
.setMethodCallHandler { call, result ->
when (call.method) {
"start" -> {
startKeepAlive()
result.success(null)
}
"stop" -> {
stopService(Intent(this, KeepAliveService::class.java))
result.success(null)
}
else -> result.notImplemented()
}
}
}
private fun startKeepAlive() {
val intent = Intent(this, KeepAliveService::class.java)
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
startForegroundService(intent)
} else {
startService(intent)
}
}
}
+50 -2
View File
@@ -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,
+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;
}
@@ -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 {
+4
View File
@@ -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)],
+56
View File
@@ -113,6 +113,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.9"
dbus:
dependency: transitive
description:
name: dbus
sha256: f7db8504e715b835f5ebe5fe74604ccd961d504c73389a5d8e81bafc2e2f3612
url: "https://pub.dev"
source: hosted
version: "0.8.0"
dio:
dependency: "direct main"
description:
@@ -206,6 +214,46 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_local_notifications:
dependency: "direct main"
description:
name: flutter_local_notifications
sha256: "9ee28742343dd13df758bb4185e8ded37ac2d1aeca6648c3ba7862c99797517e"
url: "https://pub.dev"
source: hosted
version: "22.3.2"
flutter_local_notifications_linux:
dependency: transitive
description:
name: flutter_local_notifications_linux
sha256: "3b89a4bf095fa5f8a99beeabf982c5bd506c3ad25e7b9e61da945e994dc5b615"
url: "https://pub.dev"
source: hosted
version: "8.0.2"
flutter_local_notifications_platform_interface:
dependency: transitive
description:
name: flutter_local_notifications_platform_interface
sha256: "43c3761d916c9bd3d5c7ebbc44d82f4990329840c0c5d62ad5260cc1b5d399bd"
url: "https://pub.dev"
source: hosted
version: "12.2.0"
flutter_local_notifications_web:
dependency: transitive
description:
name: flutter_local_notifications_web
sha256: "516afaf97a2d1e67a036c6617321b00d205d72f7a67b6eccf936cd565f985878"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
flutter_local_notifications_windows:
dependency: transitive
description:
name: flutter_local_notifications_windows
sha256: "6f43bdd03b171b7a90f22647506fea33e2bb12294b7c7c7a3d690e960a382945"
url: "https://pub.dev"
source: hosted
version: "3.1.1"
flutter_localizations:
dependency: "direct main"
description: flutter
@@ -730,6 +778,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.6.16"
timezone:
dependency: transitive
description:
name: timezone
sha256: "9f4e5a1a0e4fef7dd3424842c0575db01bfb7a11730e8d55753fcd5c5c98767b"
url: "https://pub.dev"
source: hosted
version: "0.11.2"
typed_data:
dependency: transitive
description:
+1
View File
@@ -21,6 +21,7 @@ dependencies:
flutter_svg: ^2.3.0
image_picker: ^1.2.3
url_launcher: ^6.3.2
flutter_local_notifications: ^22.3.2
dev_dependencies:
flutter_test: