diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 200095c..dfdfe51 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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") +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 9c9398e..2b19a2d 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,11 @@ + + + + + + + = 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() + } +} diff --git a/android/app/src/main/kotlin/com/voltlink/smart_grid_iot_app_flutter/MainActivity.kt b/android/app/src/main/kotlin/com/voltlink/smart_grid_iot_app_flutter/MainActivity.kt index ff1eecc..e424a96 100644 --- a/android/app/src/main/kotlin/com/voltlink/smart_grid_iot_app_flutter/MainActivity.kt +++ b/android/app/src/main/kotlin/com/voltlink/smart_grid_iot_app_flutter/MainActivity.kt @@ -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) + } + } +} diff --git a/lib/app/app.dart b/lib/app/app.dart index 06723f0..a341330 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -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 createState() => _VoltLinkAppState(); +} + +class _VoltLinkAppState extends ConsumerState { + StreamSubscription? _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, diff --git a/lib/core/notifications/keep_alive.dart b/lib/core/notifications/keep_alive.dart new file mode 100644 index 0000000..befa63b --- /dev/null +++ b/lib/core/notifications/keep_alive.dart @@ -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 setEnabled(bool enabled) async { + if (!Platform.isAndroid) return; + try { + await _channel.invokeMethod(enabled ? 'start' : 'stop'); + } on PlatformException { + // 无通知权限或系统限制时静默失败,不影响主流程。 + } on MissingPluginException { + // 非 Android 宿主或插件未注册。 + } + } +} diff --git a/lib/core/notifications/local_notifications.dart b/lib/core/notifications/local_notifications.dart new file mode 100644 index 0000000..29f1731 --- /dev/null +++ b/lib/core/notifications/local_notifications.dart @@ -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.broadcast(); + + /// 冷启动时由通知拉起的路由(初始化完成时读取一次)。 + String? _launchRoute; + + bool _ready = false; + + /// 运行期点击通知的路由流。 + Stream get taps => _taps.stream; + + /// 在 `runApp` 之前调用。 + Future 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 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); + } +} diff --git a/lib/core/storage/token_storage.dart b/lib/core/storage/token_storage.dart index 3f89f7d..9ebd93a 100644 --- a/lib/core/storage/token_storage.dart +++ b/lib/core/storage/token_storage.dart @@ -13,6 +13,7 @@ class TokenStorage { static const _kUserInfo = 'userInfo'; static const _kServerHost = 'serverHost'; static const _kServerPort = 'serverPort'; + static const _kNoticeAlertMaxId = 'noticeAlertMaxId'; static Future 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 setNoticeAlertMaxId(int id) => + _prefs.setInt(_kNoticeAlertMaxId, id); + bool get hasSession => accessToken.isNotEmpty; } diff --git a/lib/features/notice/application/notice_alert_controller.dart b/lib/features/notice/application/notice_alert_controller.dart new file mode 100644 index 0000000..b9476d9 --- /dev/null +++ b/lib/features/notice/application/notice_alert_controller.dart @@ -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.new); + +class NoticeAlertController extends Notifier { + 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 _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(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; + } + } +} diff --git a/lib/features/notice/application/notice_routes.dart b/lib/features/notice/application/notice_routes.dart new file mode 100644 index 0000000..b33722d --- /dev/null +++ b/lib/features/notice/application/notice_routes.dart @@ -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'; +} diff --git a/lib/features/notice/presentation/notice_center_page.dart b/lib/features/notice/presentation/notice_center_page.dart index 3d5cb5a..ea65443 100644 --- a/lib/features/notice/presentation/notice_center_page.dart +++ b/lib/features/notice/presentation/notice_center_page.dart @@ -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 { Future _loadMore() => _load(); - /// 点击消息:先标记已读;勘察指派消息(`bizType == "survey_order"`) - /// 带 `bizId` 时跳转到对应勘察订单详情,其余保持仅标记已读。 + /// 点击消息:先标记已读;能识别业务来源时跳转到对应订单详情。 Future _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 _markRead(Notice notice) async { diff --git a/lib/main.dart b/lib/main.dart index 368ce17..7283782 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 main() async { // SharedPreferences 必须在 runApp 之前完成异步读取,再通过 override 注入。 final storage = await TokenStorage.open(); + // 系统通知渠道 + Android 13 通知权限;冷启动点击通知的路由也会在此暂存。 + await LocalNotifications.instance.init(); + runApp( ProviderScope( overrides: [tokenStorageProvider.overrideWithValue(storage)], diff --git a/pubspec.lock b/pubspec.lock index f858dfb..ec8bbdc 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index f4e4189..678a777 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: