feat: 新增站内消息系统,支持通知与订单跳转
This commit is contained in:
1 parent
411ec8be23
commit
c799b62f6b
14 files changed
+464
-9
No files matched your search
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+66
@@ -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()
|
||||
}
|
||||
}
|
||||
+33
-1
@@ -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
@@ -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)],
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user