diff --git a/frontend/admin-app/android/app/src/main/AndroidManifest.xml b/frontend/admin-app/android/app/src/main/AndroidManifest.xml index dba07ce..07b59aa 100644 --- a/frontend/admin-app/android/app/src/main/AndroidManifest.xml +++ b/frontend/admin-app/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ + + + + + when (call.method) { + "installApk" -> { + val filePath = call.argument("filePath") + if (filePath != null) { + installApk(filePath) + result.success(true) + } else { + result.error("INVALID_PATH", "File path is null", null) + } + } + else -> result.notImplemented() + } + } + + // 应用市场检测 + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "cn.gogenex.issuer/app_market") + .setMethodCallHandler { call, result -> + when (call.method) { + "getInstallerPackage" -> { + val installer = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + packageManager.getInstallSourceInfo(packageName).installingPackageName + } else { + @Suppress("DEPRECATION") + packageManager.getInstallerPackageName(packageName) + } + result.success(installer) + } + "isPackageInstalled" -> { + val pkg = call.argument("packageName") + val installed = try { + packageManager.getPackageInfo(pkg!!, 0) + true + } catch (e: Exception) { + false + } + result.success(installed) + } + else -> result.notImplemented() + } + } + } + + private fun installApk(filePath: String) { + val file = File(filePath) + val intent = Intent(Intent.ACTION_VIEW) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + val uri = FileProvider.getUriForFile(this, "${packageName}.fileprovider", file) + intent.setDataAndType(uri, "application/vnd.android.package-archive") + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } else { + intent.setDataAndType(Uri.fromFile(file), "application/vnd.android.package-archive") + } + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + startActivity(intent) + } +} diff --git a/frontend/admin-app/android/app/src/main/res/xml/file_paths.xml b/frontend/admin-app/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..618f68c --- /dev/null +++ b/frontend/admin-app/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/admin-app/lib/app/i18n/app_localizations.dart b/frontend/admin-app/lib/app/i18n/app_localizations.dart index ecdf82f..60c1593 100644 --- a/frontend/admin-app/lib/app/i18n/app_localizations.dart +++ b/frontend/admin-app/lib/app/i18n/app_localizations.dart @@ -550,6 +550,45 @@ class AppLocalizations { 'ai_suggestion_label': 'AI 建议', 'ai_suggestion_dismiss': '忽略', 'ai_suggestion_accept': '采纳', + + // ── Update ── + 'update.newVersion': '发现新版本', + 'update.importantUpdate': '发现重要更新', + 'update.latestVersion': '最新版本', + 'update.fileSize': '文件大小', + 'update.changelog': '更新内容', + 'update.marketHint': '检测到您的应用来自应用市场,建议前往应用市场更新以获得最佳体验。', + 'update.goMarket': '前往应用市场', + 'update.later': '稍后', + 'update.skipUpdate': '暂时不更新', + 'update.updateNow': '立即更新', + 'update.preparing': '准备下载...', + 'update.downloading': '正在下载...', + 'update.downloadComplete': '下载完成', + 'update.cancelled': '下载已取消', + 'update.failed': '下载失败,请稍后重试', + 'update.installing': '正在安装...', + 'update.installFailed': '安装失败,请手动安装', + 'update.installLater': '稍后安装', + 'update.installNow': '立即安装', + 'update.updating': '正在更新', + 'update.updateFailed': '更新失败', + 'update.cancel': '取消', + 'update.retry': '重试', + 'update.close': '关闭', + 'update.isLatest': '当前已是最新版本', + 'update.checkUpdate': '检查更新', + + // ── Notification ── + 'notification.system': '系统通知', + 'notification.activity': '活动通知', + 'notification.reward': '收益通知', + 'notification.upgrade': '升级通知', + 'notification.announcement': '公告', + 'notification.markAllRead': '全部已读', + 'notification.empty': '暂无通知', + 'notification.loadFailed': '加载失败', + 'notification.retry': '重试', }; // ================================================================ @@ -1070,6 +1109,45 @@ class AppLocalizations { 'ai_suggestion_label': 'AI Suggestion', 'ai_suggestion_dismiss': 'Dismiss', 'ai_suggestion_accept': 'Accept', + + // ── Update ── + 'update.newVersion': 'New Version Available', + 'update.importantUpdate': 'Important Update', + 'update.latestVersion': 'Latest version', + 'update.fileSize': 'File size', + 'update.changelog': "What's New", + 'update.marketHint': 'Your app was installed from an app store. We recommend updating through the store.', + 'update.goMarket': 'Go to App Store', + 'update.later': 'Later', + 'update.skipUpdate': 'Skip', + 'update.updateNow': 'Update Now', + 'update.preparing': 'Preparing...', + 'update.downloading': 'Downloading...', + 'update.downloadComplete': 'Download Complete', + 'update.cancelled': 'Download Cancelled', + 'update.failed': 'Download failed, please try again', + 'update.installing': 'Installing...', + 'update.installFailed': 'Installation failed', + 'update.installLater': 'Install Later', + 'update.installNow': 'Install Now', + 'update.updating': 'Updating', + 'update.updateFailed': 'Update Failed', + 'update.cancel': 'Cancel', + 'update.retry': 'Retry', + 'update.close': 'Close', + 'update.isLatest': 'Already up to date', + 'update.checkUpdate': 'Check for Updates', + + // ── Notification ── + 'notification.system': 'System', + 'notification.activity': 'Activity', + 'notification.reward': 'Reward', + 'notification.upgrade': 'Upgrade', + 'notification.announcement': 'Announcement', + 'notification.markAllRead': 'Mark All Read', + 'notification.empty': 'No notifications', + 'notification.loadFailed': 'Load failed', + 'notification.retry': 'Retry', }; // ================================================================ @@ -1590,6 +1668,45 @@ class AppLocalizations { 'ai_suggestion_label': 'AI 提案', 'ai_suggestion_dismiss': '無視', 'ai_suggestion_accept': '採用', + + // ── Update ── + 'update.newVersion': '新バージョン', + 'update.importantUpdate': '重要な更新', + 'update.latestVersion': '最新バージョン', + 'update.fileSize': 'ファイルサイズ', + 'update.changelog': '更新内容', + 'update.marketHint': 'アプリストアからインストールされました。ストアでの更新を推奨します。', + 'update.goMarket': 'ストアへ', + 'update.later': '後で', + 'update.skipUpdate': 'スキップ', + 'update.updateNow': '今すぐ更新', + 'update.preparing': '準備中...', + 'update.downloading': 'ダウンロード中...', + 'update.downloadComplete': 'ダウンロード完了', + 'update.cancelled': 'キャンセル済み', + 'update.failed': 'ダウンロード失敗', + 'update.installing': 'インストール中...', + 'update.installFailed': 'インストール失敗', + 'update.installLater': '後でインストール', + 'update.installNow': '今すぐインストール', + 'update.updating': '更新中', + 'update.updateFailed': '更新失敗', + 'update.cancel': 'キャンセル', + 'update.retry': 'リトライ', + 'update.close': '閉じる', + 'update.isLatest': '最新バージョンです', + 'update.checkUpdate': 'アップデート確認', + + // ── Notification ── + 'notification.system': 'システム', + 'notification.activity': 'アクティビティ', + 'notification.reward': '収益', + 'notification.upgrade': 'アップグレード', + 'notification.announcement': 'お知らせ', + 'notification.markAllRead': 'すべて既読', + 'notification.empty': '通知なし', + 'notification.loadFailed': '読み込み失敗', + 'notification.retry': 'リトライ', }; } diff --git a/frontend/admin-app/lib/app/issuer_main_shell.dart b/frontend/admin-app/lib/app/issuer_main_shell.dart index 54e895c..94ef218 100644 --- a/frontend/admin-app/lib/app/issuer_main_shell.dart +++ b/frontend/admin-app/lib/app/issuer_main_shell.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'theme/app_colors.dart'; import 'i18n/app_localizations.dart'; +import '../core/updater/update_service.dart'; import '../features/dashboard/presentation/pages/issuer_dashboard_page.dart'; import '../features/coupon_management/presentation/pages/coupon_list_page.dart'; import '../features/redemption/presentation/pages/redemption_page.dart'; @@ -19,6 +20,7 @@ class IssuerMainShell extends StatefulWidget { class _IssuerMainShellState extends State { int _currentIndex = 0; + bool _updateChecked = false; final _pages = const [ IssuerDashboardPage(), @@ -28,6 +30,21 @@ class _IssuerMainShellState extends State { SettingsPage(), ]; + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_updateChecked) { + _updateChecked = true; + _checkForUpdate(); + } + } + + Future _checkForUpdate() async { + await Future.delayed(const Duration(seconds: 3)); + if (!mounted) return; + await UpdateService().checkForUpdate(context); + } + @override Widget build(BuildContext context) { return Scaffold( diff --git a/frontend/admin-app/lib/core/network/api_client.dart b/frontend/admin-app/lib/core/network/api_client.dart new file mode 100644 index 0000000..d08bd9a --- /dev/null +++ b/frontend/admin-app/lib/core/network/api_client.dart @@ -0,0 +1,76 @@ +import 'package:dio/dio.dart'; + +/// Genex API 客户端 +/// 基于 Dio 的 HTTP 客户端单例 +class ApiClient { + static ApiClient? _instance; + late final Dio _dio; + + ApiClient._({required String baseUrl}) { + _dio = Dio(BaseOptions( + baseUrl: baseUrl, + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 30), + sendTimeout: const Duration(seconds: 30), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + )); + } + + static ApiClient get instance { + _instance ??= ApiClient._(baseUrl: 'https://api.gogenex.cn'); + return _instance!; + } + + static void initialize({required String baseUrl}) { + _instance = ApiClient._(baseUrl: baseUrl); + } + + Dio get dio => _dio; + + /// 设置 JWT Token + void setToken(String? token) { + if (token != null) { + _dio.options.headers['Authorization'] = 'Bearer $token'; + } else { + _dio.options.headers.remove('Authorization'); + } + } + + Future get( + String path, { + Map? queryParameters, + Options? options, + }) { + return _dio.get(path, queryParameters: queryParameters, options: options); + } + + Future post( + String path, { + dynamic data, + Map? queryParameters, + Options? options, + }) { + return _dio.post(path, data: data, queryParameters: queryParameters, options: options); + } + + Future put( + String path, { + dynamic data, + Map? queryParameters, + Options? options, + }) { + return _dio.put(path, data: data, queryParameters: queryParameters, options: options); + } + + Future delete( + String path, { + dynamic data, + Map? queryParameters, + Options? options, + }) { + return _dio.delete(path, data: data, queryParameters: queryParameters, options: options); + } +} diff --git a/frontend/admin-app/lib/core/providers/notification_badge_manager.dart b/frontend/admin-app/lib/core/providers/notification_badge_manager.dart new file mode 100644 index 0000000..6d816f5 --- /dev/null +++ b/frontend/admin-app/lib/core/providers/notification_badge_manager.dart @@ -0,0 +1,83 @@ +import 'dart:async'; +import 'package:flutter/widgets.dart'; +import '../services/notification_service.dart'; + +/// 未读通知徽章管理器 +/// 使用 ValueNotifier 管理未读数量,与 LocaleManager 风格一致 +class NotificationBadgeManager with WidgetsBindingObserver { + static final NotificationBadgeManager _instance = NotificationBadgeManager._(); + factory NotificationBadgeManager() => _instance; + NotificationBadgeManager._(); + + final ValueNotifier unreadCount = ValueNotifier(0); + + Timer? _refreshTimer; + NotificationService? _notificationService; + bool _initialized = false; + + static const _refreshIntervalSeconds = 30; + + /// 初始化 + void initialize({NotificationService? notificationService}) { + if (_initialized) return; + _notificationService = notificationService ?? NotificationService(); + WidgetsBinding.instance.addObserver(this); + _loadUnreadCount(); + _startAutoRefresh(); + _initialized = true; + } + + /// 释放资源 + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _refreshTimer?.cancel(); + _initialized = false; + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + _loadUnreadCount(); + } + } + + void _startAutoRefresh() { + _refreshTimer?.cancel(); + _refreshTimer = Timer.periodic( + const Duration(seconds: _refreshIntervalSeconds), + (_) => _loadUnreadCount(), + ); + } + + Future _loadUnreadCount() async { + try { + final notifCount = await _notificationService!.getUnreadCount(); + final announcementCount = await _notificationService!.getAnnouncementUnreadCount(); + unreadCount.value = notifCount + announcementCount; + } catch (e) { + debugPrint('[NotificationBadge] 加载未读数量失败: $e'); + } + } + + /// 手动刷新 + Future refresh() async { + await _loadUnreadCount(); + } + + /// 减少未读数(标记单条已读后调用) + void decrementCount() { + if (unreadCount.value > 0) { + unreadCount.value = unreadCount.value - 1; + } + } + + /// 清空未读数(全部标记已读后调用) + void clearCount() { + unreadCount.value = 0; + } + + /// 设置具体数值 + void updateCount(int count) { + unreadCount.value = count; + } +} diff --git a/frontend/admin-app/lib/core/push/push_service.dart b/frontend/admin-app/lib/core/push/push_service.dart new file mode 100644 index 0000000..3a9d19f --- /dev/null +++ b/frontend/admin-app/lib/core/push/push_service.dart @@ -0,0 +1,89 @@ +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import '../network/api_client.dart'; + +/// FCM 推送服务框架 +/// Firebase 配置文件后续再添加,当前用 try-catch 保护初始化 +class PushService { + static final PushService _instance = PushService._(); + factory PushService() => _instance; + PushService._(); + + bool _initialized = false; + String? _fcmToken; + + String? get fcmToken => _fcmToken; + bool get isInitialized => _initialized; + + /// 初始化推送服务 + /// 无 Firebase 配置文件时静默失败,不影响 app 启动 + Future initialize() async { + try { + // 动态导入 firebase,避免无配置文件时编译错误 + // Firebase 初始化需要 google-services.json / GoogleService-Info.plist + // 配置文件准备好后取消下方注释: + // + // await Firebase.initializeApp(); + // final messaging = FirebaseMessaging.instance; + // + // // 请求通知权限 (iOS + Android 13+) + // await messaging.requestPermission( + // alert: true, + // badge: true, + // sound: true, + // ); + // + // // 获取 FCM token + // _fcmToken = await messaging.getToken(); + // if (_fcmToken != null) { + // debugPrint('[PushService] FCM Token: $_fcmToken'); + // await _registerToken(_fcmToken!); + // } + // + // // 监听 token 刷新 + // messaging.onTokenRefresh.listen((token) { + // _fcmToken = token; + // _registerToken(token); + // }); + // + // // 前台消息处理 + // FirebaseMessaging.onMessage.listen(_handleForegroundMessage); + // + // // 后台消息点击处理 + // FirebaseMessaging.onMessageOpenedApp.listen(_handleMessageTap); + + _initialized = true; + debugPrint('[PushService] 推送服务框架已初始化(等待 Firebase 配置)'); + } catch (e) { + debugPrint('[PushService] 初始化失败(可能缺少 Firebase 配置文件): $e'); + } + } + + /// 注册设备 token 到后端 + Future _registerToken(String token) async { + try { + final platform = Platform.isIOS ? 'IOS' : 'ANDROID'; + await ApiClient.instance.post('/device-tokens', data: { + 'platform': platform, + 'channel': 'FCM', + 'token': token, + }); + debugPrint('[PushService] Token 注册成功'); + } catch (e) { + debugPrint('[PushService] Token 注册失败: $e'); + } + } + + /// 注销设备 token + Future unregisterToken() async { + if (_fcmToken == null) return; + try { + await ApiClient.instance.delete('/device-tokens', data: { + 'token': _fcmToken, + }); + debugPrint('[PushService] Token 已注销'); + } catch (e) { + debugPrint('[PushService] Token 注销失败: $e'); + } + } +} diff --git a/frontend/admin-app/lib/core/services/notification_service.dart b/frontend/admin-app/lib/core/services/notification_service.dart new file mode 100644 index 0000000..a0099df --- /dev/null +++ b/frontend/admin-app/lib/core/services/notification_service.dart @@ -0,0 +1,236 @@ +import 'package:flutter/foundation.dart'; +import '../network/api_client.dart'; + +/// 通知类型 +enum NotificationType { + system, + activity, + reward, + upgrade, + announcement, +} + +/// 通知优先级 +enum NotificationPriority { + low, + normal, + high, + urgent, +} + +/// 通知项 +class NotificationItem { + final String id; + final String title; + final String content; + final NotificationType type; + final NotificationPriority priority; + final String? imageUrl; + final String? linkUrl; + final DateTime? publishedAt; + final bool isRead; + final DateTime? readAt; + + NotificationItem({ + required this.id, + required this.title, + required this.content, + required this.type, + required this.priority, + this.imageUrl, + this.linkUrl, + this.publishedAt, + required this.isRead, + this.readAt, + }); + + factory NotificationItem.fromJson(Map json) { + return NotificationItem( + id: json['id']?.toString() ?? '', + title: json['title'] ?? '', + content: json['content'] ?? json['body'] ?? '', + type: _parseType(json['type']), + priority: _parsePriority(json['priority']), + imageUrl: json['imageUrl'], + linkUrl: json['linkUrl'], + publishedAt: json['publishedAt'] != null + ? DateTime.tryParse(json['publishedAt']) + : (json['createdAt'] != null ? DateTime.tryParse(json['createdAt']) : null), + isRead: json['isRead'] ?? (json['status'] == 'READ'), + readAt: json['readAt'] != null ? DateTime.tryParse(json['readAt']) : null, + ); + } + + static NotificationType _parseType(String? type) { + switch (type?.toUpperCase()) { + case 'SYSTEM': + return NotificationType.system; + case 'ACTIVITY': + return NotificationType.activity; + case 'REWARD': + return NotificationType.reward; + case 'UPGRADE': + return NotificationType.upgrade; + case 'ANNOUNCEMENT': + return NotificationType.announcement; + default: + return NotificationType.system; + } + } + + static NotificationPriority _parsePriority(String? priority) { + switch (priority?.toUpperCase()) { + case 'LOW': + return NotificationPriority.low; + case 'HIGH': + return NotificationPriority.high; + case 'URGENT': + return NotificationPriority.urgent; + default: + return NotificationPriority.normal; + } + } +} + +/// 通知列表响应 +class NotificationListResponse { + final List notifications; + final int total; + final int unreadCount; + + NotificationListResponse({ + required this.notifications, + required this.total, + required this.unreadCount, + }); + + factory NotificationListResponse.fromJson(Map json) { + final data = json['data'] ?? json; + final list = (data['notifications'] as List?) + ?.map((e) => NotificationItem.fromJson(e as Map)) + .toList() ?? + (data['items'] as List?) + ?.map((e) => NotificationItem.fromJson(e as Map)) + .toList() ?? + []; + return NotificationListResponse( + notifications: list, + total: data['total'] ?? list.length, + unreadCount: data['unreadCount'] ?? 0, + ); + } +} + +/// 通知服务 +class NotificationService { + final ApiClient _apiClient; + + NotificationService({ApiClient? apiClient}) + : _apiClient = apiClient ?? ApiClient.instance; + + /// 获取通知列表 + Future getNotifications({ + NotificationType? type, + int limit = 50, + int offset = 0, + }) async { + try { + final queryParams = { + 'limit': limit, + 'offset': offset, + }; + if (type != null) { + queryParams['type'] = type.name.toUpperCase(); + } + + final response = await _apiClient.get( + '/notifications', + queryParameters: queryParams, + ); + + return NotificationListResponse.fromJson( + response.data is Map ? response.data : {}, + ); + } catch (e) { + debugPrint('[NotificationService] 获取通知列表失败: $e'); + rethrow; + } + } + + /// 获取未读通知数量 + Future getUnreadCount() async { + try { + final response = await _apiClient.get('/notifications/unread-count'); + final data = response.data is Map ? response.data : {}; + return data['unreadCount'] ?? data['data']?['unreadCount'] ?? 0; + } catch (e) { + debugPrint('[NotificationService] 获取未读数量失败: $e'); + return 0; + } + } + + /// 标记通知为已读 + Future markAsRead(String notificationId) async { + try { + await _apiClient.put('/notifications/$notificationId/read'); + return true; + } catch (e) { + debugPrint('[NotificationService] 标记已读失败: $e'); + return false; + } + } + + /// 获取公告列表 + Future getAnnouncements({ + int limit = 50, + int offset = 0, + }) async { + try { + final response = await _apiClient.get( + '/announcements', + queryParameters: {'limit': limit, 'offset': offset}, + ); + + return NotificationListResponse.fromJson( + response.data is Map ? response.data : {}, + ); + } catch (e) { + debugPrint('[NotificationService] 获取公告列表失败: $e'); + rethrow; + } + } + + /// 获取公告未读数 + Future getAnnouncementUnreadCount() async { + try { + final response = await _apiClient.get('/announcements/unread-count'); + final data = response.data is Map ? response.data : {}; + return data['unreadCount'] ?? data['data']?['unreadCount'] ?? 0; + } catch (e) { + debugPrint('[NotificationService] 获取公告未读数失败: $e'); + return 0; + } + } + + /// 标记公告已读 + Future markAnnouncementAsRead(String announcementId) async { + try { + await _apiClient.put('/announcements/$announcementId/read'); + return true; + } catch (e) { + debugPrint('[NotificationService] 标记公告已读失败: $e'); + return false; + } + } + + /// 全部标记已读 + Future markAllAnnouncementsAsRead() async { + try { + await _apiClient.put('/announcements/read-all'); + return true; + } catch (e) { + debugPrint('[NotificationService] 全部标记已读失败: $e'); + return false; + } + } +} diff --git a/frontend/admin-app/lib/core/updater/apk_installer.dart b/frontend/admin-app/lib/core/updater/apk_installer.dart new file mode 100644 index 0000000..e1c51c6 --- /dev/null +++ b/frontend/admin-app/lib/core/updater/apk_installer.dart @@ -0,0 +1,53 @@ +import 'dart:io'; +import 'package:flutter/services.dart'; +import 'package:flutter/foundation.dart'; +import 'package:permission_handler/permission_handler.dart'; + +/// APK 安装器 +/// 负责调用原生代码安装 APK 文件 +class ApkInstaller { + static const MethodChannel _channel = + MethodChannel('cn.gogenex.issuer/apk_installer'); + + /// 安装 APK + static Future installApk(File apkFile) async { + try { + if (!await apkFile.exists()) { + debugPrint('APK file not found'); + return false; + } + + if (Platform.isAndroid) { + final hasPermission = await _requestInstallPermission(); + if (!hasPermission) { + debugPrint('Install permission denied'); + return false; + } + } + + final result = await _channel.invokeMethod('installApk', { + 'apkPath': apkFile.path, + }); + + return result == true; + } on PlatformException catch (e) { + debugPrint('Install failed: ${e.message}'); + return false; + } catch (e) { + debugPrint('Install failed: $e'); + return false; + } + } + + static Future _requestInstallPermission() async { + if (await Permission.requestInstallPackages.isGranted) { + return true; + } + final status = await Permission.requestInstallPackages.request(); + return status.isGranted; + } + + static Future hasInstallPermission() async { + return await Permission.requestInstallPackages.isGranted; + } +} diff --git a/frontend/admin-app/lib/core/updater/app_market_detector.dart b/frontend/admin-app/lib/core/updater/app_market_detector.dart new file mode 100644 index 0000000..c934a1f --- /dev/null +++ b/frontend/admin-app/lib/core/updater/app_market_detector.dart @@ -0,0 +1,82 @@ +import 'dart:io'; +import 'package:flutter/services.dart'; +import 'package:flutter/foundation.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// 应用市场检测器 +/// 检测应用安装来源,决定升级策略 +class AppMarketDetector { + static const MethodChannel _channel = + MethodChannel('cn.gogenex.issuer/app_market'); + + static const List _marketPackages = [ + 'com.android.vending', + 'com.huawei.appmarket', + 'com.xiaomi.market', + 'com.oppo.market', + 'com.bbk.appstore', + 'com.tencent.android.qqdownloader', + 'com.qihoo.appstore', + 'com.baidu.appsearch', + 'com.wandoujia.phoenix2', + 'com.dragon.android.pandaspace', + 'com.sec.android.app.samsungapps', + ]; + + static Future getInstallerPackageName() async { + if (!Platform.isAndroid) return null; + try { + return await _channel.invokeMethod('getInstallerPackageName'); + } catch (e) { + debugPrint('Get installer failed: $e'); + return null; + } + } + + static Future isFromAppMarket() async { + final installer = await getInstallerPackageName(); + if (installer == null || installer.isEmpty) return false; + return _marketPackages.contains(installer); + } + + static Future isFromGooglePlay() async { + final installer = await getInstallerPackageName(); + return installer == 'com.android.vending'; + } + + static Future getInstallerName() async { + final installer = await getInstallerPackageName(); + if (installer == null || installer.isEmpty) return '直接安装'; + switch (installer) { + case 'com.android.vending': + return 'Google Play'; + case 'com.huawei.appmarket': + return '华为应用市场'; + case 'com.xiaomi.market': + return '小米应用商店'; + case 'com.oppo.market': + return 'OPPO 软件商店'; + case 'com.bbk.appstore': + return 'vivo 应用商店'; + case 'com.tencent.android.qqdownloader': + return '应用宝'; + default: + return installer; + } + } + + static Future openAppMarketDetail(String packageName) async { + final marketUri = Uri.parse('market://details?id=$packageName'); + if (await canLaunchUrl(marketUri)) { + await launchUrl(marketUri, mode: LaunchMode.externalApplication); + return true; + } else { + final webUri = Uri.parse('https://play.google.com/store/apps/details?id=$packageName'); + if (await canLaunchUrl(webUri)) { + await launchUrl(webUri, mode: LaunchMode.externalApplication); + return true; + } + return false; + } + } +} diff --git a/frontend/admin-app/lib/core/updater/channels/google_play_updater.dart b/frontend/admin-app/lib/core/updater/channels/google_play_updater.dart new file mode 100644 index 0000000..a5af7ec --- /dev/null +++ b/frontend/admin-app/lib/core/updater/channels/google_play_updater.dart @@ -0,0 +1,44 @@ +import 'package:in_app_update/in_app_update.dart'; +import 'package:flutter/foundation.dart'; + +/// Google Play 应用内更新器 +class GooglePlayUpdater { + static Future checkForUpdate() async { + try { + final updateInfo = await InAppUpdate.checkForUpdate(); + return updateInfo.updateAvailability == UpdateAvailability.updateAvailable; + } catch (e) { + debugPrint('Check update failed: $e'); + return false; + } + } + + static Future performFlexibleUpdate() async { + try { + final updateInfo = await InAppUpdate.checkForUpdate(); + if (updateInfo.updateAvailability == UpdateAvailability.updateAvailable) { + if (updateInfo.flexibleUpdateAllowed) { + await InAppUpdate.startFlexibleUpdate(); + InAppUpdate.completeFlexibleUpdate().catchError((e) { + debugPrint('Update failed: $e'); + }); + } + } + } catch (e) { + debugPrint('Flexible update error: $e'); + } + } + + static Future performImmediateUpdate() async { + try { + final updateInfo = await InAppUpdate.checkForUpdate(); + if (updateInfo.updateAvailability == UpdateAvailability.updateAvailable) { + if (updateInfo.immediateUpdateAllowed) { + await InAppUpdate.performImmediateUpdate(); + } + } + } catch (e) { + debugPrint('Immediate update error: $e'); + } + } +} diff --git a/frontend/admin-app/lib/core/updater/channels/self_hosted_updater.dart b/frontend/admin-app/lib/core/updater/channels/self_hosted_updater.dart new file mode 100644 index 0000000..474a98d --- /dev/null +++ b/frontend/admin-app/lib/core/updater/channels/self_hosted_updater.dart @@ -0,0 +1,411 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import '../../../../app/i18n/app_localizations.dart'; +import '../../../../app/theme/app_colors.dart'; +import '../version_checker.dart'; +import '../download_manager.dart'; +import '../apk_installer.dart'; +import '../app_market_detector.dart'; +import '../models/version_info.dart'; + +/// 自建服务器更新器 +class SelfHostedUpdater { + final VersionChecker versionChecker; + final DownloadManager downloadManager; + + SelfHostedUpdater({required String apiBaseUrl}) + : versionChecker = VersionChecker(apiBaseUrl: apiBaseUrl), + downloadManager = DownloadManager(); + + Future checkAndPromptUpdate(BuildContext context) async { + final versionInfo = await versionChecker.checkForUpdate(); + if (versionInfo == null) return; + if (!context.mounted) return; + + final isFromMarket = await AppMarketDetector.isFromAppMarket(); + if (isFromMarket) { + _showMarketUpdateDialog(context, versionInfo); + } else { + _showSelfHostedUpdateDialog(context, versionInfo); + } + } + + Future silentCheckUpdate() async { + return await versionChecker.checkForUpdate(); + } + + void _showMarketUpdateDialog(BuildContext context, VersionInfo versionInfo) { + showDialog( + context: context, + barrierDismissible: !versionInfo.forceUpdate, + builder: (context) => PopScope( + canPop: !versionInfo.forceUpdate, + child: AlertDialog( + title: Text( + versionInfo.forceUpdate + ? context.t('update.importantUpdate') + : context.t('update.newVersion'), + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600), + ), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${context.t('update.latestVersion')}: ${versionInfo.version}', + style: TextStyle(fontSize: 14, color: Colors.grey[700]), + ), + if (versionInfo.updateLog != null) ...[ + const SizedBox(height: 16), + Text( + '${context.t('update.changelog')}:', + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), + Text( + versionInfo.updateLog!, + style: TextStyle(fontSize: 14, color: Colors.grey[700]), + ), + ], + const SizedBox(height: 16), + Text( + context.t('update.marketHint'), + style: TextStyle(fontSize: 12, color: Colors.grey[400]), + ), + ], + ), + ), + actions: [ + if (!versionInfo.forceUpdate) + TextButton( + onPressed: () => Navigator.pop(context), + child: Text( + context.t('update.later'), + style: TextStyle(fontSize: 14, color: Colors.grey[600]), + ), + ), + ElevatedButton( + onPressed: () async { + Navigator.pop(context); + final packageInfo = await versionChecker.getCurrentVersion(); + await AppMarketDetector.openAppMarketDetail(packageInfo.packageName); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + ), + child: Text(context.t('update.goMarket'), style: const TextStyle(fontSize: 14)), + ), + ], + ), + ), + ); + } + + void _showSelfHostedUpdateDialog(BuildContext context, VersionInfo versionInfo) { + showDialog( + context: context, + barrierDismissible: !versionInfo.forceUpdate, + builder: (context) => PopScope( + canPop: !versionInfo.forceUpdate, + child: AlertDialog( + title: Text( + versionInfo.forceUpdate + ? context.t('update.importantUpdate') + : context.t('update.newVersion'), + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600), + ), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${context.t('update.latestVersion')}: ${versionInfo.version}', + style: TextStyle(fontSize: 14, color: Colors.grey[700]), + ), + const SizedBox(height: 8), + Text( + '${context.t('update.fileSize')}: ${versionInfo.fileSizeFriendly}', + style: TextStyle(fontSize: 14, color: Colors.grey[700]), + ), + if (versionInfo.updateLog != null) ...[ + const SizedBox(height: 16), + Text( + '${context.t('update.changelog')}:', + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), + Text( + versionInfo.updateLog!, + style: TextStyle(fontSize: 14, color: Colors.grey[700]), + ), + ], + ], + ), + ), + actions: [ + if (!versionInfo.forceUpdate) + TextButton( + onPressed: () => Navigator.pop(context), + child: Text( + context.t('update.skipUpdate'), + style: TextStyle(fontSize: 14, color: Colors.grey[600]), + ), + ), + ElevatedButton( + onPressed: () { + Navigator.pop(context); + _startUpdate(context, versionInfo); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + ), + child: Text(context.t('update.updateNow'), style: const TextStyle(fontSize: 14)), + ), + ], + ), + ), + ); + } + + Future _startUpdate(BuildContext context, VersionInfo versionInfo) async { + if (!context.mounted) return; + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => _DownloadProgressDialog( + versionInfo: versionInfo, + downloadManager: downloadManager, + forceUpdate: versionInfo.forceUpdate, + ), + ); + } + + Future cleanup() async { + await downloadManager.cleanupDownloadedApk(); + } +} + +/// 下载进度对话框 +class _DownloadProgressDialog extends StatefulWidget { + final VersionInfo versionInfo; + final DownloadManager downloadManager; + final bool forceUpdate; + + const _DownloadProgressDialog({ + required this.versionInfo, + required this.downloadManager, + required this.forceUpdate, + }); + + @override + State<_DownloadProgressDialog> createState() => _DownloadProgressDialogState(); +} + +class _DownloadProgressDialogState extends State<_DownloadProgressDialog> { + double _progress = 0.0; + String _statusText = ''; + bool _isDownloading = true; + bool _hasError = false; + bool _downloadCompleted = false; + File? _downloadedApkFile; + + @override + void initState() { + super.initState(); + _startDownload(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_statusText.isEmpty) { + _statusText = context.t('update.preparing'); + } + } + + Future _startDownload() async { + if (mounted) { + setState(() { + _statusText = context.t('update.downloading'); + _isDownloading = true; + _hasError = false; + _downloadCompleted = false; + }); + } + + final apkFile = await widget.downloadManager.downloadApk( + url: widget.versionInfo.downloadUrl, + sha256Expected: widget.versionInfo.sha256, + onProgress: (received, total) { + if (mounted) { + setState(() { + _progress = received / total; + final receivedMB = (received / 1024 / 1024).toStringAsFixed(1); + final totalMB = (total / 1024 / 1024).toStringAsFixed(1); + _statusText = '${context.t('update.downloading')} $receivedMB MB / $totalMB MB'; + }); + } + }, + ); + + if (!mounted) return; + + if (apkFile == null) { + setState(() { + _statusText = widget.downloadManager.status == DownloadStatus.cancelled + ? context.t('update.cancelled') + : context.t('update.failed'); + _isDownloading = false; + _hasError = true; + }); + return; + } + + _downloadedApkFile = apkFile; + + if (widget.forceUpdate) { + setState(() => _statusText = context.t('update.installing')); + await Future.delayed(const Duration(milliseconds: 500)); + await _installApk(); + } else { + setState(() { + _statusText = context.t('update.downloadComplete'); + _isDownloading = false; + _downloadCompleted = true; + }); + } + } + + Future _installApk() async { + if (_downloadedApkFile == null) return; + + setState(() { + _statusText = context.t('update.installing'); + _isDownloading = true; + }); + + final installed = await ApkInstaller.installApk(_downloadedApkFile!); + + if (!mounted) return; + + if (installed) { + Navigator.pop(context); + } else { + setState(() { + _statusText = context.t('update.installFailed'); + _isDownloading = false; + _hasError = true; + }); + } + } + + void _cancelDownload() { + widget.downloadManager.cancelDownload(); + } + + void _retryDownload() { + widget.downloadManager.reset(); + _startDownload(); + } + + @override + Widget build(BuildContext context) { + String title; + if (_downloadCompleted && !_hasError) { + title = context.t('update.downloadComplete'); + } else if (_hasError) { + title = context.t('update.updateFailed'); + } else { + title = context.t('update.updating'); + } + + return PopScope( + canPop: !_isDownloading && !widget.forceUpdate, + child: AlertDialog( + title: Text( + title, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600), + ), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + LinearProgressIndicator( + value: _progress, + backgroundColor: Colors.grey[200], + valueColor: AlwaysStoppedAnimation( + _hasError ? Colors.red : AppColors.primary, + ), + ), + const SizedBox(height: 16), + Text( + _statusText, + style: TextStyle( + fontSize: 14, + color: _hasError ? Colors.red : Colors.grey[700], + ), + ), + if (_isDownloading) ...[ + const SizedBox(height: 8), + Text( + '${(_progress * 100).toStringAsFixed(0)}%', + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.w700, + color: AppColors.primary, + ), + ), + ], + ], + ), + actions: [ + if (_isDownloading) + TextButton( + onPressed: _cancelDownload, + child: Text( + context.t('update.cancel'), + style: TextStyle(fontSize: 14, color: Colors.grey[600]), + ), + ), + if (!_isDownloading && _hasError) ...[ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text( + context.t('update.close'), + style: TextStyle(fontSize: 14, color: Colors.grey[600]), + ), + ), + ElevatedButton( + onPressed: _retryDownload, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + ), + child: Text(context.t('update.retry'), style: const TextStyle(fontSize: 14)), + ), + ], + if (_downloadCompleted && !_hasError) ...[ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text( + context.t('update.installLater'), + style: TextStyle(fontSize: 14, color: Colors.grey[600]), + ), + ), + ElevatedButton( + onPressed: _installApk, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + ), + child: Text(context.t('update.installNow'), style: const TextStyle(fontSize: 14)), + ), + ], + ], + ), + ); + } +} diff --git a/frontend/admin-app/lib/core/updater/download_manager.dart b/frontend/admin-app/lib/core/updater/download_manager.dart new file mode 100644 index 0000000..320e6d5 --- /dev/null +++ b/frontend/admin-app/lib/core/updater/download_manager.dart @@ -0,0 +1,165 @@ +import 'dart:io'; +import 'package:dio/dio.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:crypto/crypto.dart'; +import 'package:flutter/foundation.dart'; + +/// 下载状态 +enum DownloadStatus { + idle, + downloading, + verifying, + completed, + failed, + cancelled, +} + +/// 下载进度回调 +typedef DownloadProgressCallback = void Function(int received, int total); + +/// 下载管理器 +/// 负责下载 APK 文件并验证完整性,支持断点续传 +class DownloadManager { + final Dio _dio = Dio(); + CancelToken? _cancelToken; + DownloadStatus _status = DownloadStatus.idle; + + DownloadStatus get status => _status; + + /// 下载 APK 文件(支持断点续传) + Future downloadApk({ + required String url, + required String sha256Expected, + DownloadProgressCallback? onProgress, + }) async { + try { + if (!url.startsWith('https://')) { + debugPrint('Download URL must use HTTPS'); + _status = DownloadStatus.failed; + return null; + } + + _status = DownloadStatus.downloading; + _cancelToken = CancelToken(); + + final dir = await getExternalStorageDirectory() ?? await getApplicationSupportDirectory(); + final savePath = '${dir.path}/app_update.apk'; + final tempPath = '${dir.path}/app_update.apk.tmp'; + final file = File(savePath); + final tempFile = File(tempPath); + + int downloadedBytes = 0; + if (await tempFile.exists()) { + downloadedBytes = await tempFile.length(); + } + + if (await file.exists()) { + await file.delete(); + } + + final response = await _dio.get( + url, + cancelToken: _cancelToken, + options: Options( + responseType: ResponseType.stream, + receiveTimeout: const Duration(minutes: 10), + sendTimeout: const Duration(minutes: 10), + headers: downloadedBytes > 0 + ? {'Range': 'bytes=$downloadedBytes-'} + : null, + ), + ); + + int totalBytes = 0; + final contentLength = response.headers.value('content-length'); + final contentRange = response.headers.value('content-range'); + + if (contentRange != null) { + final match = RegExp(r'bytes \d+-\d+/(\d+)').firstMatch(contentRange); + if (match != null) { + totalBytes = int.parse(match.group(1)!); + } + } else if (contentLength != null) { + totalBytes = int.parse(contentLength) + downloadedBytes; + } + + final sink = tempFile.openWrite(mode: FileMode.append); + int receivedBytes = downloadedBytes; + + try { + await for (final chunk in response.data!.stream) { + sink.add(chunk); + receivedBytes += chunk.length; + + if (totalBytes > 0) { + onProgress?.call(receivedBytes, totalBytes); + } + } + await sink.flush(); + } finally { + await sink.close(); + } + + await tempFile.rename(savePath); + + _status = DownloadStatus.verifying; + + final isValid = await _verifySha256(file, sha256Expected); + if (!isValid) { + debugPrint('SHA-256 verification failed'); + await file.delete(); + _status = DownloadStatus.failed; + return null; + } + + _status = DownloadStatus.completed; + return file; + } on DioException catch (e) { + if (e.type == DioExceptionType.cancel) { + _status = DownloadStatus.cancelled; + } else { + _status = DownloadStatus.failed; + } + return null; + } catch (e) { + debugPrint('Download failed: $e'); + _status = DownloadStatus.failed; + return null; + } + } + + void cancelDownload() { + _cancelToken?.cancel('User cancelled download'); + _status = DownloadStatus.cancelled; + } + + void reset() { + _cancelToken = null; + _status = DownloadStatus.idle; + } + + Future _verifySha256(File file, String expectedSha256) async { + try { + final bytes = await file.readAsBytes(); + final digest = sha256.convert(bytes); + final actualSha256 = digest.toString(); + return actualSha256.toLowerCase() == expectedSha256.toLowerCase(); + } catch (e) { + debugPrint('SHA-256 verification error: $e'); + return false; + } + } + + Future cleanupDownloadedApk() async { + try { + final dir = await getExternalStorageDirectory() ?? await getApplicationSupportDirectory(); + final file = File('${dir.path}/app_update.apk'); + final tempFile = File('${dir.path}/app_update.apk.tmp'); + + if (await file.exists()) await file.delete(); + if (await tempFile.exists()) await tempFile.delete(); + } catch (e) { + debugPrint('Cleanup failed: $e'); + } + } +} diff --git a/frontend/admin-app/lib/core/updater/models/update_config.dart b/frontend/admin-app/lib/core/updater/models/update_config.dart new file mode 100644 index 0000000..e645e3f --- /dev/null +++ b/frontend/admin-app/lib/core/updater/models/update_config.dart @@ -0,0 +1,56 @@ +/// 更新渠道类型 +enum UpdateChannel { + /// Google Play 应用内更新 + googlePlay, + + /// 自建服务器 APK 升级 + selfHosted, +} + +/// 更新配置 +class UpdateConfig { + /// 更新渠道 + final UpdateChannel channel; + + /// API 基础地址 (selfHosted 模式必需) + final String? apiBaseUrl; + + /// 是否启用更新检测 + final bool enabled; + + /// 检查更新间隔(秒) + final int checkIntervalSeconds; + + const UpdateConfig({ + required this.channel, + this.apiBaseUrl, + this.enabled = true, + this.checkIntervalSeconds = 86400, + }); + + /// 默认配置 - 自建服务器模式 + static UpdateConfig selfHosted({ + required String apiBaseUrl, + bool enabled = true, + int checkIntervalSeconds = 86400, + }) { + return UpdateConfig( + channel: UpdateChannel.selfHosted, + apiBaseUrl: apiBaseUrl, + enabled: enabled, + checkIntervalSeconds: checkIntervalSeconds, + ); + } + + /// 默认配置 - Google Play 模式 + static UpdateConfig googlePlay({ + bool enabled = true, + int checkIntervalSeconds = 86400, + }) { + return UpdateConfig( + channel: UpdateChannel.googlePlay, + enabled: enabled, + checkIntervalSeconds: checkIntervalSeconds, + ); + } +} diff --git a/frontend/admin-app/lib/core/updater/models/version_info.dart b/frontend/admin-app/lib/core/updater/models/version_info.dart new file mode 100644 index 0000000..10f7c93 --- /dev/null +++ b/frontend/admin-app/lib/core/updater/models/version_info.dart @@ -0,0 +1,80 @@ +/// 版本信息模型 +class VersionInfo { + /// 版本号: "1.2.0" + final String version; + + /// 版本代码: 120 + final int versionCode; + + /// APK 下载地址 + final String downloadUrl; + + /// 文件大小(字节) + final int fileSize; + + /// 友好显示: "25.6 MB" + final String fileSizeFriendly; + + /// SHA-256 校验 + final String sha256; + + /// 是否强制更新 + final bool forceUpdate; + + /// 更新日志 + final String? updateLog; + + /// 发布时间 + final DateTime releaseDate; + + const VersionInfo({ + required this.version, + required this.versionCode, + required this.downloadUrl, + required this.fileSize, + required this.fileSizeFriendly, + required this.sha256, + this.forceUpdate = false, + this.updateLog, + required this.releaseDate, + }); + + factory VersionInfo.fromJson(Map json) { + return VersionInfo( + version: json['version'] as String, + versionCode: json['versionCode'] as int, + downloadUrl: json['downloadUrl'] as String, + fileSize: json['fileSize'] as int, + fileSizeFriendly: json['fileSizeFriendly'] as String, + sha256: json['sha256'] as String, + forceUpdate: json['forceUpdate'] as bool? ?? false, + updateLog: json['updateLog'] as String?, + releaseDate: DateTime.parse(json['releaseDate'] as String), + ); + } + + Map toJson() { + return { + 'version': version, + 'versionCode': versionCode, + 'downloadUrl': downloadUrl, + 'fileSize': fileSize, + 'fileSizeFriendly': fileSizeFriendly, + 'sha256': sha256, + 'forceUpdate': forceUpdate, + 'updateLog': updateLog, + 'releaseDate': releaseDate.toIso8601String(), + }; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is VersionInfo && + runtimeType == other.runtimeType && + version == other.version && + versionCode == other.versionCode; + + @override + int get hashCode => version.hashCode ^ versionCode.hashCode; +} diff --git a/frontend/admin-app/lib/core/updater/update_service.dart b/frontend/admin-app/lib/core/updater/update_service.dart new file mode 100644 index 0000000..6439f73 --- /dev/null +++ b/frontend/admin-app/lib/core/updater/update_service.dart @@ -0,0 +1,142 @@ +import 'package:flutter/material.dart'; +import '../../app/i18n/app_localizations.dart'; +import '../../app/theme/app_colors.dart'; +import 'channels/google_play_updater.dart'; +import 'channels/self_hosted_updater.dart'; +import 'models/update_config.dart'; +import 'models/version_info.dart'; + +/// 统一升级服务 +/// 根据配置自动选择合适的升级渠道 +class UpdateService { + static UpdateService? _instance; + UpdateService._(); + + factory UpdateService() { + _instance ??= UpdateService._(); + return _instance!; + } + + late UpdateConfig _config; + SelfHostedUpdater? _selfHostedUpdater; + bool _isInitialized = false; + bool _isShowingUpdateDialog = false; + + bool get isInitialized => _isInitialized; + bool get isShowingUpdateDialog => _isShowingUpdateDialog; + UpdateConfig get config => _config; + + void initialize(UpdateConfig config) { + _config = config; + if (config.channel == UpdateChannel.selfHosted) { + if (config.apiBaseUrl == null) { + throw ArgumentError('apiBaseUrl is required for self-hosted channel'); + } + _selfHostedUpdater = SelfHostedUpdater(apiBaseUrl: config.apiBaseUrl!); + } + _isInitialized = true; + debugPrint('UpdateService initialized: ${_config.channel}'); + } + + Future checkForUpdate(BuildContext context) async { + if (!_isInitialized || !_config.enabled) return; + + _isShowingUpdateDialog = true; + try { + switch (_config.channel) { + case UpdateChannel.googlePlay: + await _checkGooglePlayUpdate(context); + break; + case UpdateChannel.selfHosted: + await _selfHostedUpdater!.checkAndPromptUpdate(context); + break; + } + } finally { + _isShowingUpdateDialog = false; + } + } + + Future silentCheck() async { + if (!_isInitialized || !_config.enabled) return null; + if (_config.channel == UpdateChannel.selfHosted) { + return await _selfHostedUpdater?.silentCheckUpdate(); + } + return null; + } + + Future _checkGooglePlayUpdate(BuildContext context) async { + final hasUpdate = await GooglePlayUpdater.checkForUpdate(); + if (!hasUpdate || !context.mounted) return; + + await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text( + context.t('update.newVersion'), + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600), + ), + content: Text( + context.t('update.updateNow'), + style: TextStyle(fontSize: 14, color: Colors.grey[700]), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text( + context.t('update.later'), + style: TextStyle(fontSize: 14, color: Colors.grey[600]), + ), + ), + ElevatedButton( + onPressed: () { + Navigator.pop(context); + GooglePlayUpdater.performFlexibleUpdate(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + ), + child: Text(context.t('update.updateNow'), style: const TextStyle(fontSize: 14)), + ), + ], + ), + ); + } + + Future manualCheckUpdate(BuildContext context) async { + if (!_isInitialized) return; + + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => const Center(child: CircularProgressIndicator(color: AppColors.primary)), + ); + + await Future.delayed(const Duration(seconds: 1)); + if (!context.mounted) return; + Navigator.pop(context); + + final versionInfo = await silentCheck(); + if (!context.mounted) return; + + if (versionInfo == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.t('update.isLatest')), + backgroundColor: AppColors.success, + duration: const Duration(seconds: 2), + ), + ); + } else { + await checkForUpdate(context); + } + } + + Future cleanup() async { + await _selfHostedUpdater?.cleanup(); + } + + static void reset() { + _instance = null; + } +} diff --git a/frontend/admin-app/lib/core/updater/version_checker.dart b/frontend/admin-app/lib/core/updater/version_checker.dart new file mode 100644 index 0000000..626a0e0 --- /dev/null +++ b/frontend/admin-app/lib/core/updater/version_checker.dart @@ -0,0 +1,99 @@ +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'models/version_info.dart'; + +/// 版本检测器 +/// 负责从服务器获取最新版本信息并与当前版本比较 +class VersionChecker { + final String apiBaseUrl; + final Dio _dio; + + VersionChecker({required this.apiBaseUrl}) + : _dio = Dio(BaseOptions( + baseUrl: apiBaseUrl, + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 10), + )); + + /// 获取当前版本信息 + Future getCurrentVersion() async { + return await PackageInfo.fromPlatform(); + } + + /// 从服务器获取最新版本信息 + Future fetchLatestVersion() async { + try { + final currentInfo = await getCurrentVersion(); + debugPrint('[VersionChecker] 当前版本: ${currentInfo.version}, buildNumber: ${currentInfo.buildNumber}'); + + final response = await _dio.get( + '/api/app/version/check', + queryParameters: { + 'platform': 'android', + 'current_version': currentInfo.version, + 'current_version_code': currentInfo.buildNumber, + }, + ); + + if (response.statusCode == 200 && response.data != null) { + final data = response.data is Map + ? response.data as Map + : (response.data['data'] as Map?) ?? response.data; + + final needUpdate = data['needUpdate'] as bool? ?? true; + if (!needUpdate) { + debugPrint('[VersionChecker] 无需更新'); + return null; + } + return VersionInfo.fromJson(data); + } + return null; + } catch (e) { + debugPrint('[VersionChecker] 获取版本失败: $e'); + return null; + } + } + + /// 检查是否有新版本 + Future checkForUpdate() async { + try { + final currentInfo = await getCurrentVersion(); + final latestInfo = await fetchLatestVersion(); + + if (latestInfo == null) return null; + + final currentCode = int.tryParse(currentInfo.buildNumber) ?? 0; + if (latestInfo.versionCode > currentCode) { + return latestInfo; + } + + return null; + } catch (e) { + debugPrint('Check update failed: $e'); + return null; + } + } + + /// 是否需要强制更新 + Future needForceUpdate() async { + final latestInfo = await checkForUpdate(); + return latestInfo?.forceUpdate ?? false; + } + + /// 获取版本差异 + Future getVersionDiff() async { + try { + final currentInfo = await getCurrentVersion(); + final latestInfo = await fetchLatestVersion(); + + if (latestInfo == null) return 0; + + final currentCode = int.tryParse(currentInfo.buildNumber) ?? 0; + return latestInfo.versionCode - currentCode; + } catch (e) { + debugPrint('Get version diff failed: $e'); + return 0; + } + } +} diff --git a/frontend/admin-app/lib/features/settings/presentation/pages/settings_page.dart b/frontend/admin-app/lib/features/settings/presentation/pages/settings_page.dart index 3ae78d9..1811f51 100644 --- a/frontend/admin-app/lib/features/settings/presentation/pages/settings_page.dart +++ b/frontend/admin-app/lib/features/settings/presentation/pages/settings_page.dart @@ -1,14 +1,36 @@ import 'package:flutter/material.dart'; +import 'package:package_info_plus/package_info_plus.dart'; import '../../../../app/theme/app_colors.dart'; import '../../../../app/router.dart'; import '../../../../app/i18n/app_localizations.dart'; +import '../../../../core/updater/update_service.dart'; /// 发行方设置页面(我的) /// /// 企业信息、门店管理、员工管理、专属客服、安全设置 -class SettingsPage extends StatelessWidget { +class SettingsPage extends StatefulWidget { const SettingsPage({super.key}); + @override + State createState() => _SettingsPageState(); +} + +class _SettingsPageState extends State { + String _appVersion = ''; + + @override + void initState() { + super.initState(); + _loadVersion(); + } + + Future _loadVersion() async { + final info = await PackageInfo.fromPlatform(); + if (mounted) { + setState(() => _appVersion = 'v${info.version}+${info.buildNumber}'); + } + } + @override Widget build(BuildContext context) { return Scaffold( @@ -62,9 +84,11 @@ class SettingsPage extends StatelessWidget { _MenuItem(context.t('settings_operation_log'), Icons.history_rounded, () { // TODO: Navigate to operation log page when available }), - _MenuItem(context.t('settings_about'), Icons.info_outline_rounded, () { - // TODO: Navigate to about page when available - }), + _MenuItem( + '${context.t('settings_about')}${_appVersion.isNotEmpty ? ' $_appVersion' : ''}', + Icons.info_outline_rounded, + () => UpdateService().manualCheckUpdate(context), + ), ]), // Logout diff --git a/frontend/admin-app/lib/main.dart b/frontend/admin-app/lib/main.dart index 41ae845..f7534fa 100644 --- a/frontend/admin-app/lib/main.dart +++ b/frontend/admin-app/lib/main.dart @@ -3,8 +3,26 @@ import 'package:flutter_localizations/flutter_localizations.dart'; import 'app/theme/app_theme.dart'; import 'app/router.dart'; import 'app/i18n/app_localizations.dart'; +import 'core/updater/update_service.dart'; +import 'core/updater/models/update_config.dart'; +import 'core/push/push_service.dart'; +import 'core/providers/notification_badge_manager.dart'; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + + // 初始化升级服务 + UpdateService().initialize(UpdateConfig.selfHosted( + apiBaseUrl: 'https://api.gogenex.cn', + enabled: true, + )); + + // 初始化推送服务(无 Firebase 配置时静默失败) + await PushService().initialize(); + + // 初始化通知徽章管理器 + NotificationBadgeManager().initialize(); -void main() { runApp(const GenexIssuerApp()); } diff --git a/frontend/admin-app/pubspec.yaml b/frontend/admin-app/pubspec.yaml index 2132781..b7087a1 100644 --- a/frontend/admin-app/pubspec.yaml +++ b/frontend/admin-app/pubspec.yaml @@ -11,6 +11,16 @@ dependencies: sdk: flutter flutter_localizations: sdk: flutter + dio: ^5.4.3+1 + package_info_plus: ^8.0.0 + path_provider: ^2.1.0 + crypto: ^3.0.3 + permission_handler: ^11.3.1 + url_launcher: ^6.2.6 + firebase_messaging: ^15.1.0 + firebase_core: ^3.4.0 + flutter_local_notifications: ^18.0.0 + in_app_update: ^4.2.2 dev_dependencies: flutter_test: diff --git a/frontend/genex-mobile/android/app/src/main/AndroidManifest.xml b/frontend/genex-mobile/android/app/src/main/AndroidManifest.xml index 304fac0..5f44d93 100644 --- a/frontend/genex-mobile/android/app/src/main/AndroidManifest.xml +++ b/frontend/genex-mobile/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ + - - + + + + - diff --git a/frontend/genex-mobile/android/app/src/main/kotlin/cn/gogenex/genex_consumer/MainActivity.kt b/frontend/genex-mobile/android/app/src/main/kotlin/cn/gogenex/genex_consumer/MainActivity.kt index 8e305e3..7c2330c 100644 --- a/frontend/genex-mobile/android/app/src/main/kotlin/cn/gogenex/genex_consumer/MainActivity.kt +++ b/frontend/genex-mobile/android/app/src/main/kotlin/cn/gogenex/genex_consumer/MainActivity.kt @@ -1,5 +1,86 @@ package cn.gogenex.genex_consumer +import android.content.Intent +import android.net.Uri +import android.os.Build +import androidx.core.content.FileProvider import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel +import java.io.File -class MainActivity : FlutterActivity() +class MainActivity : FlutterActivity() { + private val INSTALLER_CHANNEL = "cn.gogenex.consumer/apk_installer" + private val MARKET_CHANNEL = "cn.gogenex.consumer/app_market" + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + + // APK 安装器通道 + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, INSTALLER_CHANNEL) + .setMethodCallHandler { call, result -> + when (call.method) { + "installApk" -> { + val apkPath = call.argument("apkPath") + if (apkPath != null) { + try { + installApk(apkPath) + result.success(true) + } catch (e: Exception) { + result.error("INSTALL_FAILED", e.message, null) + } + } else { + result.error("INVALID_PATH", "APK path is null", null) + } + } + else -> result.notImplemented() + } + } + + // 应用市场检测通道 + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, MARKET_CHANNEL) + .setMethodCallHandler { call, result -> + when (call.method) { + "getInstallerPackageName" -> { + try { + val installer = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + packageManager.getInstallSourceInfo(packageName).installingPackageName + } else { + @Suppress("DEPRECATION") + packageManager.getInstallerPackageName(packageName) + } + result.success(installer) + } catch (e: Exception) { + result.success(null) + } + } + else -> result.notImplemented() + } + } + } + + private fun installApk(apkPath: String) { + val apkFile = File(apkPath) + if (!apkFile.exists()) { + throw Exception("APK file not found: $apkPath") + } + + val intent = Intent(Intent.ACTION_VIEW) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + + val apkUri: Uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + FileProvider.getUriForFile( + this, + "${applicationContext.packageName}.fileprovider", + apkFile + ) + } else { + Uri.fromFile(apkFile) + } + + intent.setDataAndType(apkUri, "application/vnd.android.package-archive") + startActivity(intent) + finishAffinity() + } +} diff --git a/frontend/genex-mobile/android/app/src/main/res/xml/file_paths.xml b/frontend/genex-mobile/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..08767a2 --- /dev/null +++ b/frontend/genex-mobile/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/genex-mobile/lib/app/i18n/strings/en.dart b/frontend/genex-mobile/lib/app/i18n/strings/en.dart index a07716c..8010c83 100644 --- a/frontend/genex-mobile/lib/app/i18n/strings/en.dart +++ b/frontend/genex-mobile/lib/app/i18n/strings/en.dart @@ -733,4 +733,43 @@ const Map en = { // ============ Issuer additions ============ 'issuer.unlisted': 'Unlisted', + + // ============ Update ============ + 'update.newVersion': 'New Version Available', + 'update.importantUpdate': 'Important Update', + 'update.latestVersion': 'Latest version', + 'update.fileSize': 'File size', + 'update.changelog': "What's New", + 'update.marketHint': 'Your app was installed from an app store. We recommend updating through the store.', + 'update.goMarket': 'Go to App Store', + 'update.later': 'Later', + 'update.skipUpdate': 'Skip', + 'update.updateNow': 'Update Now', + 'update.preparing': 'Preparing...', + 'update.downloading': 'Downloading...', + 'update.downloadComplete': 'Download Complete', + 'update.cancelled': 'Download Cancelled', + 'update.failed': 'Download failed, please try again', + 'update.installing': 'Installing...', + 'update.installFailed': 'Installation failed', + 'update.installLater': 'Install Later', + 'update.installNow': 'Install Now', + 'update.updating': 'Updating', + 'update.updateFailed': 'Update Failed', + 'update.cancel': 'Cancel', + 'update.retry': 'Retry', + 'update.close': 'Close', + 'update.isLatest': 'Already up to date', + 'update.checkUpdate': 'Check for Updates', + + // ============ Notification ============ + 'notification.system': 'System', + 'notification.activity': 'Activity', + 'notification.reward': 'Reward', + 'notification.upgrade': 'Upgrade', + 'notification.announcement': 'Announcement', + 'notification.markAllRead': 'Mark All Read', + 'notification.empty': 'No notifications', + 'notification.loadFailed': 'Load failed', + 'notification.retry': 'Retry', }; diff --git a/frontend/genex-mobile/lib/app/i18n/strings/ja.dart b/frontend/genex-mobile/lib/app/i18n/strings/ja.dart index b1ea4e8..d2ab3d3 100644 --- a/frontend/genex-mobile/lib/app/i18n/strings/ja.dart +++ b/frontend/genex-mobile/lib/app/i18n/strings/ja.dart @@ -733,4 +733,43 @@ const Map ja = { // ============ Issuer additions ============ 'issuer.unlisted': '非掲載', + + // ============ Update ============ + 'update.newVersion': '新バージョン', + 'update.importantUpdate': '重要な更新', + 'update.latestVersion': '最新バージョン', + 'update.fileSize': 'ファイルサイズ', + 'update.changelog': '更新内容', + 'update.marketHint': 'アプリストアからインストールされました。ストアでの更新を推奨します。', + 'update.goMarket': 'ストアへ', + 'update.later': '後で', + 'update.skipUpdate': 'スキップ', + 'update.updateNow': '今すぐ更新', + 'update.preparing': '準備中...', + 'update.downloading': 'ダウンロード中...', + 'update.downloadComplete': 'ダウンロード完了', + 'update.cancelled': 'キャンセル済み', + 'update.failed': 'ダウンロード失敗', + 'update.installing': 'インストール中...', + 'update.installFailed': 'インストール失敗', + 'update.installLater': '後でインストール', + 'update.installNow': '今すぐインストール', + 'update.updating': '更新中', + 'update.updateFailed': '更新失敗', + 'update.cancel': 'キャンセル', + 'update.retry': 'リトライ', + 'update.close': '閉じる', + 'update.isLatest': '最新バージョンです', + 'update.checkUpdate': 'アップデート確認', + + // ============ Notification ============ + 'notification.system': 'システム', + 'notification.activity': 'アクティビティ', + 'notification.reward': '収益', + 'notification.upgrade': 'アップグレード', + 'notification.announcement': 'お知らせ', + 'notification.markAllRead': 'すべて既読', + 'notification.empty': '通知なし', + 'notification.loadFailed': '読み込み失敗', + 'notification.retry': 'リトライ', }; diff --git a/frontend/genex-mobile/lib/app/i18n/strings/zh_cn.dart b/frontend/genex-mobile/lib/app/i18n/strings/zh_cn.dart index 4920ca2..8bacdbd 100644 --- a/frontend/genex-mobile/lib/app/i18n/strings/zh_cn.dart +++ b/frontend/genex-mobile/lib/app/i18n/strings/zh_cn.dart @@ -733,4 +733,43 @@ const Map zhCN = { // ============ Issuer additions ============ 'issuer.unlisted': '已下架', + + // ============ Update ============ + 'update.newVersion': '发现新版本', + 'update.importantUpdate': '发现重要更新', + 'update.latestVersion': '最新版本', + 'update.fileSize': '文件大小', + 'update.changelog': '更新内容', + 'update.marketHint': '检测到您的应用来自应用市场,建议前往应用市场更新以获得最佳体验。', + 'update.goMarket': '前往应用市场', + 'update.later': '稍后', + 'update.skipUpdate': '暂时不更新', + 'update.updateNow': '立即更新', + 'update.preparing': '准备下载...', + 'update.downloading': '正在下载...', + 'update.downloadComplete': '下载完成', + 'update.cancelled': '下载已取消', + 'update.failed': '下载失败,请稍后重试', + 'update.installing': '正在安装...', + 'update.installFailed': '安装失败,请手动安装', + 'update.installLater': '稍后安装', + 'update.installNow': '立即安装', + 'update.updating': '正在更新', + 'update.updateFailed': '更新失败', + 'update.cancel': '取消', + 'update.retry': '重试', + 'update.close': '关闭', + 'update.isLatest': '当前已是最新版本', + 'update.checkUpdate': '检查更新', + + // ============ Notification ============ + 'notification.system': '系统通知', + 'notification.activity': '活动通知', + 'notification.reward': '收益通知', + 'notification.upgrade': '升级通知', + 'notification.announcement': '公告', + 'notification.markAllRead': '全部已读', + 'notification.empty': '暂无通知', + 'notification.loadFailed': '加载失败', + 'notification.retry': '重试', }; diff --git a/frontend/genex-mobile/lib/app/i18n/strings/zh_tw.dart b/frontend/genex-mobile/lib/app/i18n/strings/zh_tw.dart index e060e60..87b7a4e 100644 --- a/frontend/genex-mobile/lib/app/i18n/strings/zh_tw.dart +++ b/frontend/genex-mobile/lib/app/i18n/strings/zh_tw.dart @@ -733,4 +733,43 @@ const Map zhTW = { // ============ Issuer additions ============ 'issuer.unlisted': '已下架', + + // ============ Update ============ + 'update.newVersion': '發現新版本', + 'update.importantUpdate': '發現重要更新', + 'update.latestVersion': '最新版本', + 'update.fileSize': '檔案大小', + 'update.changelog': '更新內容', + 'update.marketHint': '偵測到您的應用來自應用市場,建議前往應用市場更新。', + 'update.goMarket': '前往應用市場', + 'update.later': '稍後', + 'update.skipUpdate': '暫時不更新', + 'update.updateNow': '立即更新', + 'update.preparing': '準備下載...', + 'update.downloading': '正在下載...', + 'update.downloadComplete': '下載完成', + 'update.cancelled': '下載已取消', + 'update.failed': '下載失敗,請稍後重試', + 'update.installing': '正在安裝...', + 'update.installFailed': '安裝失敗,請手動安裝', + 'update.installLater': '稍後安裝', + 'update.installNow': '立即安裝', + 'update.updating': '正在更新', + 'update.updateFailed': '更新失敗', + 'update.cancel': '取消', + 'update.retry': '重試', + 'update.close': '關閉', + 'update.isLatest': '當前已是最新版本', + 'update.checkUpdate': '檢查更新', + + // ============ Notification ============ + 'notification.system': '系統通知', + 'notification.activity': '活動通知', + 'notification.reward': '收益通知', + 'notification.upgrade': '升級通知', + 'notification.announcement': '公告', + 'notification.markAllRead': '全部已讀', + 'notification.empty': '暫無通知', + 'notification.loadFailed': '載入失敗', + 'notification.retry': '重試', }; diff --git a/frontend/genex-mobile/lib/app/main_shell.dart b/frontend/genex-mobile/lib/app/main_shell.dart index 3ca5d60..100553b 100644 --- a/frontend/genex-mobile/lib/app/main_shell.dart +++ b/frontend/genex-mobile/lib/app/main_shell.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import '../app/theme/app_colors.dart'; import '../app/i18n/app_localizations.dart'; +import '../core/updater/update_service.dart'; +import '../core/providers/notification_badge_manager.dart'; import '../features/coupons/presentation/pages/home_page.dart'; import '../features/coupons/presentation/pages/market_page.dart'; import '../features/message/presentation/pages/message_page.dart'; @@ -18,6 +20,7 @@ class MainShell extends StatefulWidget { class _MainShellState extends State { int _currentIndex = 0; + bool _updateChecked = false; final _pages = const [ HomePage(), @@ -26,6 +29,21 @@ class _MainShellState extends State { ProfilePage(), ]; + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_updateChecked) { + _updateChecked = true; + _checkForUpdate(); + } + } + + Future _checkForUpdate() async { + await Future.delayed(const Duration(seconds: 3)); + if (!mounted) return; + await UpdateService().checkForUpdate(context); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -48,7 +66,6 @@ class _MainShellState extends State { Icons.notifications_rounded, Icons.notifications_outlined, context.t('nav.messages'), - 2, ), _buildDestination(Icons.person_rounded, Icons.person_outlined, context.t('nav.profile')), ], @@ -73,16 +90,27 @@ class _MainShellState extends State { IconData selected, IconData unselected, String label, - int count, ) { return NavigationDestination( - icon: Badge( - label: Text('$count', style: const TextStyle(fontSize: 10)), - child: Icon(unselected), + icon: ValueListenableBuilder( + valueListenable: NotificationBadgeManager().unreadCount, + builder: (context, count, _) { + if (count <= 0) return Icon(unselected); + return Badge( + label: Text('$count', style: const TextStyle(fontSize: 10)), + child: Icon(unselected), + ); + }, ), - selectedIcon: Badge( - label: Text('$count', style: const TextStyle(fontSize: 10)), - child: Icon(selected), + selectedIcon: ValueListenableBuilder( + valueListenable: NotificationBadgeManager().unreadCount, + builder: (context, count, _) { + if (count <= 0) return Icon(selected); + return Badge( + label: Text('$count', style: const TextStyle(fontSize: 10)), + child: Icon(selected), + ); + }, ), label: label, ); diff --git a/frontend/genex-mobile/lib/core/network/api_client.dart b/frontend/genex-mobile/lib/core/network/api_client.dart new file mode 100644 index 0000000..d08bd9a --- /dev/null +++ b/frontend/genex-mobile/lib/core/network/api_client.dart @@ -0,0 +1,76 @@ +import 'package:dio/dio.dart'; + +/// Genex API 客户端 +/// 基于 Dio 的 HTTP 客户端单例 +class ApiClient { + static ApiClient? _instance; + late final Dio _dio; + + ApiClient._({required String baseUrl}) { + _dio = Dio(BaseOptions( + baseUrl: baseUrl, + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 30), + sendTimeout: const Duration(seconds: 30), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + )); + } + + static ApiClient get instance { + _instance ??= ApiClient._(baseUrl: 'https://api.gogenex.cn'); + return _instance!; + } + + static void initialize({required String baseUrl}) { + _instance = ApiClient._(baseUrl: baseUrl); + } + + Dio get dio => _dio; + + /// 设置 JWT Token + void setToken(String? token) { + if (token != null) { + _dio.options.headers['Authorization'] = 'Bearer $token'; + } else { + _dio.options.headers.remove('Authorization'); + } + } + + Future get( + String path, { + Map? queryParameters, + Options? options, + }) { + return _dio.get(path, queryParameters: queryParameters, options: options); + } + + Future post( + String path, { + dynamic data, + Map? queryParameters, + Options? options, + }) { + return _dio.post(path, data: data, queryParameters: queryParameters, options: options); + } + + Future put( + String path, { + dynamic data, + Map? queryParameters, + Options? options, + }) { + return _dio.put(path, data: data, queryParameters: queryParameters, options: options); + } + + Future delete( + String path, { + dynamic data, + Map? queryParameters, + Options? options, + }) { + return _dio.delete(path, data: data, queryParameters: queryParameters, options: options); + } +} diff --git a/frontend/genex-mobile/lib/core/providers/notification_badge_manager.dart b/frontend/genex-mobile/lib/core/providers/notification_badge_manager.dart new file mode 100644 index 0000000..6d816f5 --- /dev/null +++ b/frontend/genex-mobile/lib/core/providers/notification_badge_manager.dart @@ -0,0 +1,83 @@ +import 'dart:async'; +import 'package:flutter/widgets.dart'; +import '../services/notification_service.dart'; + +/// 未读通知徽章管理器 +/// 使用 ValueNotifier 管理未读数量,与 LocaleManager 风格一致 +class NotificationBadgeManager with WidgetsBindingObserver { + static final NotificationBadgeManager _instance = NotificationBadgeManager._(); + factory NotificationBadgeManager() => _instance; + NotificationBadgeManager._(); + + final ValueNotifier unreadCount = ValueNotifier(0); + + Timer? _refreshTimer; + NotificationService? _notificationService; + bool _initialized = false; + + static const _refreshIntervalSeconds = 30; + + /// 初始化 + void initialize({NotificationService? notificationService}) { + if (_initialized) return; + _notificationService = notificationService ?? NotificationService(); + WidgetsBinding.instance.addObserver(this); + _loadUnreadCount(); + _startAutoRefresh(); + _initialized = true; + } + + /// 释放资源 + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _refreshTimer?.cancel(); + _initialized = false; + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + _loadUnreadCount(); + } + } + + void _startAutoRefresh() { + _refreshTimer?.cancel(); + _refreshTimer = Timer.periodic( + const Duration(seconds: _refreshIntervalSeconds), + (_) => _loadUnreadCount(), + ); + } + + Future _loadUnreadCount() async { + try { + final notifCount = await _notificationService!.getUnreadCount(); + final announcementCount = await _notificationService!.getAnnouncementUnreadCount(); + unreadCount.value = notifCount + announcementCount; + } catch (e) { + debugPrint('[NotificationBadge] 加载未读数量失败: $e'); + } + } + + /// 手动刷新 + Future refresh() async { + await _loadUnreadCount(); + } + + /// 减少未读数(标记单条已读后调用) + void decrementCount() { + if (unreadCount.value > 0) { + unreadCount.value = unreadCount.value - 1; + } + } + + /// 清空未读数(全部标记已读后调用) + void clearCount() { + unreadCount.value = 0; + } + + /// 设置具体数值 + void updateCount(int count) { + unreadCount.value = count; + } +} diff --git a/frontend/genex-mobile/lib/core/push/push_service.dart b/frontend/genex-mobile/lib/core/push/push_service.dart new file mode 100644 index 0000000..3a9d19f --- /dev/null +++ b/frontend/genex-mobile/lib/core/push/push_service.dart @@ -0,0 +1,89 @@ +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import '../network/api_client.dart'; + +/// FCM 推送服务框架 +/// Firebase 配置文件后续再添加,当前用 try-catch 保护初始化 +class PushService { + static final PushService _instance = PushService._(); + factory PushService() => _instance; + PushService._(); + + bool _initialized = false; + String? _fcmToken; + + String? get fcmToken => _fcmToken; + bool get isInitialized => _initialized; + + /// 初始化推送服务 + /// 无 Firebase 配置文件时静默失败,不影响 app 启动 + Future initialize() async { + try { + // 动态导入 firebase,避免无配置文件时编译错误 + // Firebase 初始化需要 google-services.json / GoogleService-Info.plist + // 配置文件准备好后取消下方注释: + // + // await Firebase.initializeApp(); + // final messaging = FirebaseMessaging.instance; + // + // // 请求通知权限 (iOS + Android 13+) + // await messaging.requestPermission( + // alert: true, + // badge: true, + // sound: true, + // ); + // + // // 获取 FCM token + // _fcmToken = await messaging.getToken(); + // if (_fcmToken != null) { + // debugPrint('[PushService] FCM Token: $_fcmToken'); + // await _registerToken(_fcmToken!); + // } + // + // // 监听 token 刷新 + // messaging.onTokenRefresh.listen((token) { + // _fcmToken = token; + // _registerToken(token); + // }); + // + // // 前台消息处理 + // FirebaseMessaging.onMessage.listen(_handleForegroundMessage); + // + // // 后台消息点击处理 + // FirebaseMessaging.onMessageOpenedApp.listen(_handleMessageTap); + + _initialized = true; + debugPrint('[PushService] 推送服务框架已初始化(等待 Firebase 配置)'); + } catch (e) { + debugPrint('[PushService] 初始化失败(可能缺少 Firebase 配置文件): $e'); + } + } + + /// 注册设备 token 到后端 + Future _registerToken(String token) async { + try { + final platform = Platform.isIOS ? 'IOS' : 'ANDROID'; + await ApiClient.instance.post('/device-tokens', data: { + 'platform': platform, + 'channel': 'FCM', + 'token': token, + }); + debugPrint('[PushService] Token 注册成功'); + } catch (e) { + debugPrint('[PushService] Token 注册失败: $e'); + } + } + + /// 注销设备 token + Future unregisterToken() async { + if (_fcmToken == null) return; + try { + await ApiClient.instance.delete('/device-tokens', data: { + 'token': _fcmToken, + }); + debugPrint('[PushService] Token 已注销'); + } catch (e) { + debugPrint('[PushService] Token 注销失败: $e'); + } + } +} diff --git a/frontend/genex-mobile/lib/core/services/notification_service.dart b/frontend/genex-mobile/lib/core/services/notification_service.dart new file mode 100644 index 0000000..a0099df --- /dev/null +++ b/frontend/genex-mobile/lib/core/services/notification_service.dart @@ -0,0 +1,236 @@ +import 'package:flutter/foundation.dart'; +import '../network/api_client.dart'; + +/// 通知类型 +enum NotificationType { + system, + activity, + reward, + upgrade, + announcement, +} + +/// 通知优先级 +enum NotificationPriority { + low, + normal, + high, + urgent, +} + +/// 通知项 +class NotificationItem { + final String id; + final String title; + final String content; + final NotificationType type; + final NotificationPriority priority; + final String? imageUrl; + final String? linkUrl; + final DateTime? publishedAt; + final bool isRead; + final DateTime? readAt; + + NotificationItem({ + required this.id, + required this.title, + required this.content, + required this.type, + required this.priority, + this.imageUrl, + this.linkUrl, + this.publishedAt, + required this.isRead, + this.readAt, + }); + + factory NotificationItem.fromJson(Map json) { + return NotificationItem( + id: json['id']?.toString() ?? '', + title: json['title'] ?? '', + content: json['content'] ?? json['body'] ?? '', + type: _parseType(json['type']), + priority: _parsePriority(json['priority']), + imageUrl: json['imageUrl'], + linkUrl: json['linkUrl'], + publishedAt: json['publishedAt'] != null + ? DateTime.tryParse(json['publishedAt']) + : (json['createdAt'] != null ? DateTime.tryParse(json['createdAt']) : null), + isRead: json['isRead'] ?? (json['status'] == 'READ'), + readAt: json['readAt'] != null ? DateTime.tryParse(json['readAt']) : null, + ); + } + + static NotificationType _parseType(String? type) { + switch (type?.toUpperCase()) { + case 'SYSTEM': + return NotificationType.system; + case 'ACTIVITY': + return NotificationType.activity; + case 'REWARD': + return NotificationType.reward; + case 'UPGRADE': + return NotificationType.upgrade; + case 'ANNOUNCEMENT': + return NotificationType.announcement; + default: + return NotificationType.system; + } + } + + static NotificationPriority _parsePriority(String? priority) { + switch (priority?.toUpperCase()) { + case 'LOW': + return NotificationPriority.low; + case 'HIGH': + return NotificationPriority.high; + case 'URGENT': + return NotificationPriority.urgent; + default: + return NotificationPriority.normal; + } + } +} + +/// 通知列表响应 +class NotificationListResponse { + final List notifications; + final int total; + final int unreadCount; + + NotificationListResponse({ + required this.notifications, + required this.total, + required this.unreadCount, + }); + + factory NotificationListResponse.fromJson(Map json) { + final data = json['data'] ?? json; + final list = (data['notifications'] as List?) + ?.map((e) => NotificationItem.fromJson(e as Map)) + .toList() ?? + (data['items'] as List?) + ?.map((e) => NotificationItem.fromJson(e as Map)) + .toList() ?? + []; + return NotificationListResponse( + notifications: list, + total: data['total'] ?? list.length, + unreadCount: data['unreadCount'] ?? 0, + ); + } +} + +/// 通知服务 +class NotificationService { + final ApiClient _apiClient; + + NotificationService({ApiClient? apiClient}) + : _apiClient = apiClient ?? ApiClient.instance; + + /// 获取通知列表 + Future getNotifications({ + NotificationType? type, + int limit = 50, + int offset = 0, + }) async { + try { + final queryParams = { + 'limit': limit, + 'offset': offset, + }; + if (type != null) { + queryParams['type'] = type.name.toUpperCase(); + } + + final response = await _apiClient.get( + '/notifications', + queryParameters: queryParams, + ); + + return NotificationListResponse.fromJson( + response.data is Map ? response.data : {}, + ); + } catch (e) { + debugPrint('[NotificationService] 获取通知列表失败: $e'); + rethrow; + } + } + + /// 获取未读通知数量 + Future getUnreadCount() async { + try { + final response = await _apiClient.get('/notifications/unread-count'); + final data = response.data is Map ? response.data : {}; + return data['unreadCount'] ?? data['data']?['unreadCount'] ?? 0; + } catch (e) { + debugPrint('[NotificationService] 获取未读数量失败: $e'); + return 0; + } + } + + /// 标记通知为已读 + Future markAsRead(String notificationId) async { + try { + await _apiClient.put('/notifications/$notificationId/read'); + return true; + } catch (e) { + debugPrint('[NotificationService] 标记已读失败: $e'); + return false; + } + } + + /// 获取公告列表 + Future getAnnouncements({ + int limit = 50, + int offset = 0, + }) async { + try { + final response = await _apiClient.get( + '/announcements', + queryParameters: {'limit': limit, 'offset': offset}, + ); + + return NotificationListResponse.fromJson( + response.data is Map ? response.data : {}, + ); + } catch (e) { + debugPrint('[NotificationService] 获取公告列表失败: $e'); + rethrow; + } + } + + /// 获取公告未读数 + Future getAnnouncementUnreadCount() async { + try { + final response = await _apiClient.get('/announcements/unread-count'); + final data = response.data is Map ? response.data : {}; + return data['unreadCount'] ?? data['data']?['unreadCount'] ?? 0; + } catch (e) { + debugPrint('[NotificationService] 获取公告未读数失败: $e'); + return 0; + } + } + + /// 标记公告已读 + Future markAnnouncementAsRead(String announcementId) async { + try { + await _apiClient.put('/announcements/$announcementId/read'); + return true; + } catch (e) { + debugPrint('[NotificationService] 标记公告已读失败: $e'); + return false; + } + } + + /// 全部标记已读 + Future markAllAnnouncementsAsRead() async { + try { + await _apiClient.put('/announcements/read-all'); + return true; + } catch (e) { + debugPrint('[NotificationService] 全部标记已读失败: $e'); + return false; + } + } +} diff --git a/frontend/genex-mobile/lib/core/updater/apk_installer.dart b/frontend/genex-mobile/lib/core/updater/apk_installer.dart new file mode 100644 index 0000000..febbb3a --- /dev/null +++ b/frontend/genex-mobile/lib/core/updater/apk_installer.dart @@ -0,0 +1,53 @@ +import 'dart:io'; +import 'package:flutter/services.dart'; +import 'package:flutter/foundation.dart'; +import 'package:permission_handler/permission_handler.dart'; + +/// APK 安装器 +/// 负责调用原生代码安装 APK 文件 +class ApkInstaller { + static const MethodChannel _channel = + MethodChannel('cn.gogenex.consumer/apk_installer'); + + /// 安装 APK + static Future installApk(File apkFile) async { + try { + if (!await apkFile.exists()) { + debugPrint('APK file not found'); + return false; + } + + if (Platform.isAndroid) { + final hasPermission = await _requestInstallPermission(); + if (!hasPermission) { + debugPrint('Install permission denied'); + return false; + } + } + + final result = await _channel.invokeMethod('installApk', { + 'apkPath': apkFile.path, + }); + + return result == true; + } on PlatformException catch (e) { + debugPrint('Install failed: ${e.message}'); + return false; + } catch (e) { + debugPrint('Install failed: $e'); + return false; + } + } + + static Future _requestInstallPermission() async { + if (await Permission.requestInstallPackages.isGranted) { + return true; + } + final status = await Permission.requestInstallPackages.request(); + return status.isGranted; + } + + static Future hasInstallPermission() async { + return await Permission.requestInstallPackages.isGranted; + } +} diff --git a/frontend/genex-mobile/lib/core/updater/app_market_detector.dart b/frontend/genex-mobile/lib/core/updater/app_market_detector.dart new file mode 100644 index 0000000..7165f19 --- /dev/null +++ b/frontend/genex-mobile/lib/core/updater/app_market_detector.dart @@ -0,0 +1,82 @@ +import 'dart:io'; +import 'package:flutter/services.dart'; +import 'package:flutter/foundation.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// 应用市场检测器 +/// 检测应用安装来源,决定升级策略 +class AppMarketDetector { + static const MethodChannel _channel = + MethodChannel('cn.gogenex.consumer/app_market'); + + static const List _marketPackages = [ + 'com.android.vending', + 'com.huawei.appmarket', + 'com.xiaomi.market', + 'com.oppo.market', + 'com.bbk.appstore', + 'com.tencent.android.qqdownloader', + 'com.qihoo.appstore', + 'com.baidu.appsearch', + 'com.wandoujia.phoenix2', + 'com.dragon.android.pandaspace', + 'com.sec.android.app.samsungapps', + ]; + + static Future getInstallerPackageName() async { + if (!Platform.isAndroid) return null; + try { + return await _channel.invokeMethod('getInstallerPackageName'); + } catch (e) { + debugPrint('Get installer failed: $e'); + return null; + } + } + + static Future isFromAppMarket() async { + final installer = await getInstallerPackageName(); + if (installer == null || installer.isEmpty) return false; + return _marketPackages.contains(installer); + } + + static Future isFromGooglePlay() async { + final installer = await getInstallerPackageName(); + return installer == 'com.android.vending'; + } + + static Future getInstallerName() async { + final installer = await getInstallerPackageName(); + if (installer == null || installer.isEmpty) return '直接安装'; + switch (installer) { + case 'com.android.vending': + return 'Google Play'; + case 'com.huawei.appmarket': + return '华为应用市场'; + case 'com.xiaomi.market': + return '小米应用商店'; + case 'com.oppo.market': + return 'OPPO 软件商店'; + case 'com.bbk.appstore': + return 'vivo 应用商店'; + case 'com.tencent.android.qqdownloader': + return '应用宝'; + default: + return installer; + } + } + + static Future openAppMarketDetail(String packageName) async { + final marketUri = Uri.parse('market://details?id=$packageName'); + if (await canLaunchUrl(marketUri)) { + await launchUrl(marketUri, mode: LaunchMode.externalApplication); + return true; + } else { + final webUri = Uri.parse('https://play.google.com/store/apps/details?id=$packageName'); + if (await canLaunchUrl(webUri)) { + await launchUrl(webUri, mode: LaunchMode.externalApplication); + return true; + } + return false; + } + } +} diff --git a/frontend/genex-mobile/lib/core/updater/channels/google_play_updater.dart b/frontend/genex-mobile/lib/core/updater/channels/google_play_updater.dart new file mode 100644 index 0000000..a5af7ec --- /dev/null +++ b/frontend/genex-mobile/lib/core/updater/channels/google_play_updater.dart @@ -0,0 +1,44 @@ +import 'package:in_app_update/in_app_update.dart'; +import 'package:flutter/foundation.dart'; + +/// Google Play 应用内更新器 +class GooglePlayUpdater { + static Future checkForUpdate() async { + try { + final updateInfo = await InAppUpdate.checkForUpdate(); + return updateInfo.updateAvailability == UpdateAvailability.updateAvailable; + } catch (e) { + debugPrint('Check update failed: $e'); + return false; + } + } + + static Future performFlexibleUpdate() async { + try { + final updateInfo = await InAppUpdate.checkForUpdate(); + if (updateInfo.updateAvailability == UpdateAvailability.updateAvailable) { + if (updateInfo.flexibleUpdateAllowed) { + await InAppUpdate.startFlexibleUpdate(); + InAppUpdate.completeFlexibleUpdate().catchError((e) { + debugPrint('Update failed: $e'); + }); + } + } + } catch (e) { + debugPrint('Flexible update error: $e'); + } + } + + static Future performImmediateUpdate() async { + try { + final updateInfo = await InAppUpdate.checkForUpdate(); + if (updateInfo.updateAvailability == UpdateAvailability.updateAvailable) { + if (updateInfo.immediateUpdateAllowed) { + await InAppUpdate.performImmediateUpdate(); + } + } + } catch (e) { + debugPrint('Immediate update error: $e'); + } + } +} diff --git a/frontend/genex-mobile/lib/core/updater/channels/self_hosted_updater.dart b/frontend/genex-mobile/lib/core/updater/channels/self_hosted_updater.dart new file mode 100644 index 0000000..474a98d --- /dev/null +++ b/frontend/genex-mobile/lib/core/updater/channels/self_hosted_updater.dart @@ -0,0 +1,411 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import '../../../../app/i18n/app_localizations.dart'; +import '../../../../app/theme/app_colors.dart'; +import '../version_checker.dart'; +import '../download_manager.dart'; +import '../apk_installer.dart'; +import '../app_market_detector.dart'; +import '../models/version_info.dart'; + +/// 自建服务器更新器 +class SelfHostedUpdater { + final VersionChecker versionChecker; + final DownloadManager downloadManager; + + SelfHostedUpdater({required String apiBaseUrl}) + : versionChecker = VersionChecker(apiBaseUrl: apiBaseUrl), + downloadManager = DownloadManager(); + + Future checkAndPromptUpdate(BuildContext context) async { + final versionInfo = await versionChecker.checkForUpdate(); + if (versionInfo == null) return; + if (!context.mounted) return; + + final isFromMarket = await AppMarketDetector.isFromAppMarket(); + if (isFromMarket) { + _showMarketUpdateDialog(context, versionInfo); + } else { + _showSelfHostedUpdateDialog(context, versionInfo); + } + } + + Future silentCheckUpdate() async { + return await versionChecker.checkForUpdate(); + } + + void _showMarketUpdateDialog(BuildContext context, VersionInfo versionInfo) { + showDialog( + context: context, + barrierDismissible: !versionInfo.forceUpdate, + builder: (context) => PopScope( + canPop: !versionInfo.forceUpdate, + child: AlertDialog( + title: Text( + versionInfo.forceUpdate + ? context.t('update.importantUpdate') + : context.t('update.newVersion'), + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600), + ), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${context.t('update.latestVersion')}: ${versionInfo.version}', + style: TextStyle(fontSize: 14, color: Colors.grey[700]), + ), + if (versionInfo.updateLog != null) ...[ + const SizedBox(height: 16), + Text( + '${context.t('update.changelog')}:', + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), + Text( + versionInfo.updateLog!, + style: TextStyle(fontSize: 14, color: Colors.grey[700]), + ), + ], + const SizedBox(height: 16), + Text( + context.t('update.marketHint'), + style: TextStyle(fontSize: 12, color: Colors.grey[400]), + ), + ], + ), + ), + actions: [ + if (!versionInfo.forceUpdate) + TextButton( + onPressed: () => Navigator.pop(context), + child: Text( + context.t('update.later'), + style: TextStyle(fontSize: 14, color: Colors.grey[600]), + ), + ), + ElevatedButton( + onPressed: () async { + Navigator.pop(context); + final packageInfo = await versionChecker.getCurrentVersion(); + await AppMarketDetector.openAppMarketDetail(packageInfo.packageName); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + ), + child: Text(context.t('update.goMarket'), style: const TextStyle(fontSize: 14)), + ), + ], + ), + ), + ); + } + + void _showSelfHostedUpdateDialog(BuildContext context, VersionInfo versionInfo) { + showDialog( + context: context, + barrierDismissible: !versionInfo.forceUpdate, + builder: (context) => PopScope( + canPop: !versionInfo.forceUpdate, + child: AlertDialog( + title: Text( + versionInfo.forceUpdate + ? context.t('update.importantUpdate') + : context.t('update.newVersion'), + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600), + ), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${context.t('update.latestVersion')}: ${versionInfo.version}', + style: TextStyle(fontSize: 14, color: Colors.grey[700]), + ), + const SizedBox(height: 8), + Text( + '${context.t('update.fileSize')}: ${versionInfo.fileSizeFriendly}', + style: TextStyle(fontSize: 14, color: Colors.grey[700]), + ), + if (versionInfo.updateLog != null) ...[ + const SizedBox(height: 16), + Text( + '${context.t('update.changelog')}:', + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), + Text( + versionInfo.updateLog!, + style: TextStyle(fontSize: 14, color: Colors.grey[700]), + ), + ], + ], + ), + ), + actions: [ + if (!versionInfo.forceUpdate) + TextButton( + onPressed: () => Navigator.pop(context), + child: Text( + context.t('update.skipUpdate'), + style: TextStyle(fontSize: 14, color: Colors.grey[600]), + ), + ), + ElevatedButton( + onPressed: () { + Navigator.pop(context); + _startUpdate(context, versionInfo); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + ), + child: Text(context.t('update.updateNow'), style: const TextStyle(fontSize: 14)), + ), + ], + ), + ), + ); + } + + Future _startUpdate(BuildContext context, VersionInfo versionInfo) async { + if (!context.mounted) return; + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => _DownloadProgressDialog( + versionInfo: versionInfo, + downloadManager: downloadManager, + forceUpdate: versionInfo.forceUpdate, + ), + ); + } + + Future cleanup() async { + await downloadManager.cleanupDownloadedApk(); + } +} + +/// 下载进度对话框 +class _DownloadProgressDialog extends StatefulWidget { + final VersionInfo versionInfo; + final DownloadManager downloadManager; + final bool forceUpdate; + + const _DownloadProgressDialog({ + required this.versionInfo, + required this.downloadManager, + required this.forceUpdate, + }); + + @override + State<_DownloadProgressDialog> createState() => _DownloadProgressDialogState(); +} + +class _DownloadProgressDialogState extends State<_DownloadProgressDialog> { + double _progress = 0.0; + String _statusText = ''; + bool _isDownloading = true; + bool _hasError = false; + bool _downloadCompleted = false; + File? _downloadedApkFile; + + @override + void initState() { + super.initState(); + _startDownload(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_statusText.isEmpty) { + _statusText = context.t('update.preparing'); + } + } + + Future _startDownload() async { + if (mounted) { + setState(() { + _statusText = context.t('update.downloading'); + _isDownloading = true; + _hasError = false; + _downloadCompleted = false; + }); + } + + final apkFile = await widget.downloadManager.downloadApk( + url: widget.versionInfo.downloadUrl, + sha256Expected: widget.versionInfo.sha256, + onProgress: (received, total) { + if (mounted) { + setState(() { + _progress = received / total; + final receivedMB = (received / 1024 / 1024).toStringAsFixed(1); + final totalMB = (total / 1024 / 1024).toStringAsFixed(1); + _statusText = '${context.t('update.downloading')} $receivedMB MB / $totalMB MB'; + }); + } + }, + ); + + if (!mounted) return; + + if (apkFile == null) { + setState(() { + _statusText = widget.downloadManager.status == DownloadStatus.cancelled + ? context.t('update.cancelled') + : context.t('update.failed'); + _isDownloading = false; + _hasError = true; + }); + return; + } + + _downloadedApkFile = apkFile; + + if (widget.forceUpdate) { + setState(() => _statusText = context.t('update.installing')); + await Future.delayed(const Duration(milliseconds: 500)); + await _installApk(); + } else { + setState(() { + _statusText = context.t('update.downloadComplete'); + _isDownloading = false; + _downloadCompleted = true; + }); + } + } + + Future _installApk() async { + if (_downloadedApkFile == null) return; + + setState(() { + _statusText = context.t('update.installing'); + _isDownloading = true; + }); + + final installed = await ApkInstaller.installApk(_downloadedApkFile!); + + if (!mounted) return; + + if (installed) { + Navigator.pop(context); + } else { + setState(() { + _statusText = context.t('update.installFailed'); + _isDownloading = false; + _hasError = true; + }); + } + } + + void _cancelDownload() { + widget.downloadManager.cancelDownload(); + } + + void _retryDownload() { + widget.downloadManager.reset(); + _startDownload(); + } + + @override + Widget build(BuildContext context) { + String title; + if (_downloadCompleted && !_hasError) { + title = context.t('update.downloadComplete'); + } else if (_hasError) { + title = context.t('update.updateFailed'); + } else { + title = context.t('update.updating'); + } + + return PopScope( + canPop: !_isDownloading && !widget.forceUpdate, + child: AlertDialog( + title: Text( + title, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600), + ), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + LinearProgressIndicator( + value: _progress, + backgroundColor: Colors.grey[200], + valueColor: AlwaysStoppedAnimation( + _hasError ? Colors.red : AppColors.primary, + ), + ), + const SizedBox(height: 16), + Text( + _statusText, + style: TextStyle( + fontSize: 14, + color: _hasError ? Colors.red : Colors.grey[700], + ), + ), + if (_isDownloading) ...[ + const SizedBox(height: 8), + Text( + '${(_progress * 100).toStringAsFixed(0)}%', + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.w700, + color: AppColors.primary, + ), + ), + ], + ], + ), + actions: [ + if (_isDownloading) + TextButton( + onPressed: _cancelDownload, + child: Text( + context.t('update.cancel'), + style: TextStyle(fontSize: 14, color: Colors.grey[600]), + ), + ), + if (!_isDownloading && _hasError) ...[ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text( + context.t('update.close'), + style: TextStyle(fontSize: 14, color: Colors.grey[600]), + ), + ), + ElevatedButton( + onPressed: _retryDownload, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + ), + child: Text(context.t('update.retry'), style: const TextStyle(fontSize: 14)), + ), + ], + if (_downloadCompleted && !_hasError) ...[ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text( + context.t('update.installLater'), + style: TextStyle(fontSize: 14, color: Colors.grey[600]), + ), + ), + ElevatedButton( + onPressed: _installApk, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + ), + child: Text(context.t('update.installNow'), style: const TextStyle(fontSize: 14)), + ), + ], + ], + ), + ); + } +} diff --git a/frontend/genex-mobile/lib/core/updater/download_manager.dart b/frontend/genex-mobile/lib/core/updater/download_manager.dart new file mode 100644 index 0000000..320e6d5 --- /dev/null +++ b/frontend/genex-mobile/lib/core/updater/download_manager.dart @@ -0,0 +1,165 @@ +import 'dart:io'; +import 'package:dio/dio.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:crypto/crypto.dart'; +import 'package:flutter/foundation.dart'; + +/// 下载状态 +enum DownloadStatus { + idle, + downloading, + verifying, + completed, + failed, + cancelled, +} + +/// 下载进度回调 +typedef DownloadProgressCallback = void Function(int received, int total); + +/// 下载管理器 +/// 负责下载 APK 文件并验证完整性,支持断点续传 +class DownloadManager { + final Dio _dio = Dio(); + CancelToken? _cancelToken; + DownloadStatus _status = DownloadStatus.idle; + + DownloadStatus get status => _status; + + /// 下载 APK 文件(支持断点续传) + Future downloadApk({ + required String url, + required String sha256Expected, + DownloadProgressCallback? onProgress, + }) async { + try { + if (!url.startsWith('https://')) { + debugPrint('Download URL must use HTTPS'); + _status = DownloadStatus.failed; + return null; + } + + _status = DownloadStatus.downloading; + _cancelToken = CancelToken(); + + final dir = await getExternalStorageDirectory() ?? await getApplicationSupportDirectory(); + final savePath = '${dir.path}/app_update.apk'; + final tempPath = '${dir.path}/app_update.apk.tmp'; + final file = File(savePath); + final tempFile = File(tempPath); + + int downloadedBytes = 0; + if (await tempFile.exists()) { + downloadedBytes = await tempFile.length(); + } + + if (await file.exists()) { + await file.delete(); + } + + final response = await _dio.get( + url, + cancelToken: _cancelToken, + options: Options( + responseType: ResponseType.stream, + receiveTimeout: const Duration(minutes: 10), + sendTimeout: const Duration(minutes: 10), + headers: downloadedBytes > 0 + ? {'Range': 'bytes=$downloadedBytes-'} + : null, + ), + ); + + int totalBytes = 0; + final contentLength = response.headers.value('content-length'); + final contentRange = response.headers.value('content-range'); + + if (contentRange != null) { + final match = RegExp(r'bytes \d+-\d+/(\d+)').firstMatch(contentRange); + if (match != null) { + totalBytes = int.parse(match.group(1)!); + } + } else if (contentLength != null) { + totalBytes = int.parse(contentLength) + downloadedBytes; + } + + final sink = tempFile.openWrite(mode: FileMode.append); + int receivedBytes = downloadedBytes; + + try { + await for (final chunk in response.data!.stream) { + sink.add(chunk); + receivedBytes += chunk.length; + + if (totalBytes > 0) { + onProgress?.call(receivedBytes, totalBytes); + } + } + await sink.flush(); + } finally { + await sink.close(); + } + + await tempFile.rename(savePath); + + _status = DownloadStatus.verifying; + + final isValid = await _verifySha256(file, sha256Expected); + if (!isValid) { + debugPrint('SHA-256 verification failed'); + await file.delete(); + _status = DownloadStatus.failed; + return null; + } + + _status = DownloadStatus.completed; + return file; + } on DioException catch (e) { + if (e.type == DioExceptionType.cancel) { + _status = DownloadStatus.cancelled; + } else { + _status = DownloadStatus.failed; + } + return null; + } catch (e) { + debugPrint('Download failed: $e'); + _status = DownloadStatus.failed; + return null; + } + } + + void cancelDownload() { + _cancelToken?.cancel('User cancelled download'); + _status = DownloadStatus.cancelled; + } + + void reset() { + _cancelToken = null; + _status = DownloadStatus.idle; + } + + Future _verifySha256(File file, String expectedSha256) async { + try { + final bytes = await file.readAsBytes(); + final digest = sha256.convert(bytes); + final actualSha256 = digest.toString(); + return actualSha256.toLowerCase() == expectedSha256.toLowerCase(); + } catch (e) { + debugPrint('SHA-256 verification error: $e'); + return false; + } + } + + Future cleanupDownloadedApk() async { + try { + final dir = await getExternalStorageDirectory() ?? await getApplicationSupportDirectory(); + final file = File('${dir.path}/app_update.apk'); + final tempFile = File('${dir.path}/app_update.apk.tmp'); + + if (await file.exists()) await file.delete(); + if (await tempFile.exists()) await tempFile.delete(); + } catch (e) { + debugPrint('Cleanup failed: $e'); + } + } +} diff --git a/frontend/genex-mobile/lib/core/updater/models/update_config.dart b/frontend/genex-mobile/lib/core/updater/models/update_config.dart new file mode 100644 index 0000000..e645e3f --- /dev/null +++ b/frontend/genex-mobile/lib/core/updater/models/update_config.dart @@ -0,0 +1,56 @@ +/// 更新渠道类型 +enum UpdateChannel { + /// Google Play 应用内更新 + googlePlay, + + /// 自建服务器 APK 升级 + selfHosted, +} + +/// 更新配置 +class UpdateConfig { + /// 更新渠道 + final UpdateChannel channel; + + /// API 基础地址 (selfHosted 模式必需) + final String? apiBaseUrl; + + /// 是否启用更新检测 + final bool enabled; + + /// 检查更新间隔(秒) + final int checkIntervalSeconds; + + const UpdateConfig({ + required this.channel, + this.apiBaseUrl, + this.enabled = true, + this.checkIntervalSeconds = 86400, + }); + + /// 默认配置 - 自建服务器模式 + static UpdateConfig selfHosted({ + required String apiBaseUrl, + bool enabled = true, + int checkIntervalSeconds = 86400, + }) { + return UpdateConfig( + channel: UpdateChannel.selfHosted, + apiBaseUrl: apiBaseUrl, + enabled: enabled, + checkIntervalSeconds: checkIntervalSeconds, + ); + } + + /// 默认配置 - Google Play 模式 + static UpdateConfig googlePlay({ + bool enabled = true, + int checkIntervalSeconds = 86400, + }) { + return UpdateConfig( + channel: UpdateChannel.googlePlay, + enabled: enabled, + checkIntervalSeconds: checkIntervalSeconds, + ); + } +} diff --git a/frontend/genex-mobile/lib/core/updater/models/version_info.dart b/frontend/genex-mobile/lib/core/updater/models/version_info.dart new file mode 100644 index 0000000..10f7c93 --- /dev/null +++ b/frontend/genex-mobile/lib/core/updater/models/version_info.dart @@ -0,0 +1,80 @@ +/// 版本信息模型 +class VersionInfo { + /// 版本号: "1.2.0" + final String version; + + /// 版本代码: 120 + final int versionCode; + + /// APK 下载地址 + final String downloadUrl; + + /// 文件大小(字节) + final int fileSize; + + /// 友好显示: "25.6 MB" + final String fileSizeFriendly; + + /// SHA-256 校验 + final String sha256; + + /// 是否强制更新 + final bool forceUpdate; + + /// 更新日志 + final String? updateLog; + + /// 发布时间 + final DateTime releaseDate; + + const VersionInfo({ + required this.version, + required this.versionCode, + required this.downloadUrl, + required this.fileSize, + required this.fileSizeFriendly, + required this.sha256, + this.forceUpdate = false, + this.updateLog, + required this.releaseDate, + }); + + factory VersionInfo.fromJson(Map json) { + return VersionInfo( + version: json['version'] as String, + versionCode: json['versionCode'] as int, + downloadUrl: json['downloadUrl'] as String, + fileSize: json['fileSize'] as int, + fileSizeFriendly: json['fileSizeFriendly'] as String, + sha256: json['sha256'] as String, + forceUpdate: json['forceUpdate'] as bool? ?? false, + updateLog: json['updateLog'] as String?, + releaseDate: DateTime.parse(json['releaseDate'] as String), + ); + } + + Map toJson() { + return { + 'version': version, + 'versionCode': versionCode, + 'downloadUrl': downloadUrl, + 'fileSize': fileSize, + 'fileSizeFriendly': fileSizeFriendly, + 'sha256': sha256, + 'forceUpdate': forceUpdate, + 'updateLog': updateLog, + 'releaseDate': releaseDate.toIso8601String(), + }; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is VersionInfo && + runtimeType == other.runtimeType && + version == other.version && + versionCode == other.versionCode; + + @override + int get hashCode => version.hashCode ^ versionCode.hashCode; +} diff --git a/frontend/genex-mobile/lib/core/updater/update_service.dart b/frontend/genex-mobile/lib/core/updater/update_service.dart new file mode 100644 index 0000000..6439f73 --- /dev/null +++ b/frontend/genex-mobile/lib/core/updater/update_service.dart @@ -0,0 +1,142 @@ +import 'package:flutter/material.dart'; +import '../../app/i18n/app_localizations.dart'; +import '../../app/theme/app_colors.dart'; +import 'channels/google_play_updater.dart'; +import 'channels/self_hosted_updater.dart'; +import 'models/update_config.dart'; +import 'models/version_info.dart'; + +/// 统一升级服务 +/// 根据配置自动选择合适的升级渠道 +class UpdateService { + static UpdateService? _instance; + UpdateService._(); + + factory UpdateService() { + _instance ??= UpdateService._(); + return _instance!; + } + + late UpdateConfig _config; + SelfHostedUpdater? _selfHostedUpdater; + bool _isInitialized = false; + bool _isShowingUpdateDialog = false; + + bool get isInitialized => _isInitialized; + bool get isShowingUpdateDialog => _isShowingUpdateDialog; + UpdateConfig get config => _config; + + void initialize(UpdateConfig config) { + _config = config; + if (config.channel == UpdateChannel.selfHosted) { + if (config.apiBaseUrl == null) { + throw ArgumentError('apiBaseUrl is required for self-hosted channel'); + } + _selfHostedUpdater = SelfHostedUpdater(apiBaseUrl: config.apiBaseUrl!); + } + _isInitialized = true; + debugPrint('UpdateService initialized: ${_config.channel}'); + } + + Future checkForUpdate(BuildContext context) async { + if (!_isInitialized || !_config.enabled) return; + + _isShowingUpdateDialog = true; + try { + switch (_config.channel) { + case UpdateChannel.googlePlay: + await _checkGooglePlayUpdate(context); + break; + case UpdateChannel.selfHosted: + await _selfHostedUpdater!.checkAndPromptUpdate(context); + break; + } + } finally { + _isShowingUpdateDialog = false; + } + } + + Future silentCheck() async { + if (!_isInitialized || !_config.enabled) return null; + if (_config.channel == UpdateChannel.selfHosted) { + return await _selfHostedUpdater?.silentCheckUpdate(); + } + return null; + } + + Future _checkGooglePlayUpdate(BuildContext context) async { + final hasUpdate = await GooglePlayUpdater.checkForUpdate(); + if (!hasUpdate || !context.mounted) return; + + await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text( + context.t('update.newVersion'), + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600), + ), + content: Text( + context.t('update.updateNow'), + style: TextStyle(fontSize: 14, color: Colors.grey[700]), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text( + context.t('update.later'), + style: TextStyle(fontSize: 14, color: Colors.grey[600]), + ), + ), + ElevatedButton( + onPressed: () { + Navigator.pop(context); + GooglePlayUpdater.performFlexibleUpdate(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + ), + child: Text(context.t('update.updateNow'), style: const TextStyle(fontSize: 14)), + ), + ], + ), + ); + } + + Future manualCheckUpdate(BuildContext context) async { + if (!_isInitialized) return; + + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => const Center(child: CircularProgressIndicator(color: AppColors.primary)), + ); + + await Future.delayed(const Duration(seconds: 1)); + if (!context.mounted) return; + Navigator.pop(context); + + final versionInfo = await silentCheck(); + if (!context.mounted) return; + + if (versionInfo == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.t('update.isLatest')), + backgroundColor: AppColors.success, + duration: const Duration(seconds: 2), + ), + ); + } else { + await checkForUpdate(context); + } + } + + Future cleanup() async { + await _selfHostedUpdater?.cleanup(); + } + + static void reset() { + _instance = null; + } +} diff --git a/frontend/genex-mobile/lib/core/updater/version_checker.dart b/frontend/genex-mobile/lib/core/updater/version_checker.dart new file mode 100644 index 0000000..626a0e0 --- /dev/null +++ b/frontend/genex-mobile/lib/core/updater/version_checker.dart @@ -0,0 +1,99 @@ +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'models/version_info.dart'; + +/// 版本检测器 +/// 负责从服务器获取最新版本信息并与当前版本比较 +class VersionChecker { + final String apiBaseUrl; + final Dio _dio; + + VersionChecker({required this.apiBaseUrl}) + : _dio = Dio(BaseOptions( + baseUrl: apiBaseUrl, + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 10), + )); + + /// 获取当前版本信息 + Future getCurrentVersion() async { + return await PackageInfo.fromPlatform(); + } + + /// 从服务器获取最新版本信息 + Future fetchLatestVersion() async { + try { + final currentInfo = await getCurrentVersion(); + debugPrint('[VersionChecker] 当前版本: ${currentInfo.version}, buildNumber: ${currentInfo.buildNumber}'); + + final response = await _dio.get( + '/api/app/version/check', + queryParameters: { + 'platform': 'android', + 'current_version': currentInfo.version, + 'current_version_code': currentInfo.buildNumber, + }, + ); + + if (response.statusCode == 200 && response.data != null) { + final data = response.data is Map + ? response.data as Map + : (response.data['data'] as Map?) ?? response.data; + + final needUpdate = data['needUpdate'] as bool? ?? true; + if (!needUpdate) { + debugPrint('[VersionChecker] 无需更新'); + return null; + } + return VersionInfo.fromJson(data); + } + return null; + } catch (e) { + debugPrint('[VersionChecker] 获取版本失败: $e'); + return null; + } + } + + /// 检查是否有新版本 + Future checkForUpdate() async { + try { + final currentInfo = await getCurrentVersion(); + final latestInfo = await fetchLatestVersion(); + + if (latestInfo == null) return null; + + final currentCode = int.tryParse(currentInfo.buildNumber) ?? 0; + if (latestInfo.versionCode > currentCode) { + return latestInfo; + } + + return null; + } catch (e) { + debugPrint('Check update failed: $e'); + return null; + } + } + + /// 是否需要强制更新 + Future needForceUpdate() async { + final latestInfo = await checkForUpdate(); + return latestInfo?.forceUpdate ?? false; + } + + /// 获取版本差异 + Future getVersionDiff() async { + try { + final currentInfo = await getCurrentVersion(); + final latestInfo = await fetchLatestVersion(); + + if (latestInfo == null) return 0; + + final currentCode = int.tryParse(currentInfo.buildNumber) ?? 0; + return latestInfo.versionCode - currentCode; + } catch (e) { + debugPrint('Get version diff failed: $e'); + return 0; + } + } +} diff --git a/frontend/genex-mobile/lib/features/message/presentation/pages/message_page.dart b/frontend/genex-mobile/lib/features/message/presentation/pages/message_page.dart index 822b625..d7474fc 100644 --- a/frontend/genex-mobile/lib/features/message/presentation/pages/message_page.dart +++ b/frontend/genex-mobile/lib/features/message/presentation/pages/message_page.dart @@ -1,14 +1,15 @@ import 'package:flutter/material.dart'; import '../../../../app/theme/app_colors.dart'; import '../../../../app/theme/app_typography.dart'; -import '../../../../app/theme/app_spacing.dart'; -import '../../../../shared/widgets/empty_state.dart'; import '../../../../app/i18n/app_localizations.dart'; +import '../../../../shared/widgets/empty_state.dart'; +import '../../../../core/services/notification_service.dart'; +import '../../../../core/providers/notification_badge_manager.dart'; /// A8. 消息模块 /// -/// 交易通知、系统公告、券到期提醒、价格提醒 -/// 分类Tab + 消息详情 +/// 通知 + 公告,分类Tab + 消息详情 +/// 接入后端真实 API class MessagePage extends StatefulWidget { const MessagePage({super.key}); @@ -19,11 +20,23 @@ class MessagePage extends StatefulWidget { class _MessagePageState extends State with SingleTickerProviderStateMixin { late TabController _tabController; + final NotificationService _notificationService = NotificationService(); + + List _notifications = []; + bool _isLoading = false; + String? _error; + NotificationType? _currentFilter; @override void initState() { super.initState(); _tabController = TabController(length: 4, vsync: this); + _tabController.addListener(() { + if (!_tabController.indexIsChanging) { + _onTabChanged(_tabController.index); + } + }); + _loadNotifications(); } @override @@ -32,6 +45,67 @@ class _MessagePageState extends State super.dispose(); } + void _onTabChanged(int index) { + switch (index) { + case 0: + _currentFilter = null; + break; + case 1: + _currentFilter = NotificationType.system; + break; + case 2: + _currentFilter = NotificationType.announcement; + break; + case 3: + _currentFilter = NotificationType.activity; + break; + } + _loadNotifications(); + } + + Future _loadNotifications() async { + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final response = await _notificationService.getNotifications( + type: _currentFilter, + ); + if (mounted) { + setState(() { + _notifications = response.notifications; + _isLoading = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _error = e.toString(); + _isLoading = false; + }); + } + } + } + + Future _markAllAsRead() async { + final success = await _notificationService.markAllAnnouncementsAsRead(); + if (success) { + NotificationBadgeManager().clearCount(); + _loadNotifications(); + } + } + + Future _markAsRead(NotificationItem item) async { + if (item.isRead) return; + final success = await _notificationService.markAsRead(item.id); + if (success) { + NotificationBadgeManager().decrementCount(); + _loadNotifications(); + } + } + @override Widget build(BuildContext context) { return Scaffold( @@ -39,86 +113,92 @@ class _MessagePageState extends State title: Text(context.t('message.title')), actions: [ TextButton( - onPressed: () {}, - child: Text(context.t('message.markAllRead'), style: AppTypography.labelSmall.copyWith( - color: AppColors.primary, - )), + onPressed: _markAllAsRead, + child: Text(context.t('notification.markAllRead'), + style: AppTypography.labelSmall.copyWith(color: AppColors.primary)), ), ], bottom: TabBar( controller: _tabController, tabs: [ Tab(text: context.t('common.all')), - Tab(text: context.t('message.tabTrade')), - Tab(text: context.t('message.tabExpiry')), - Tab(text: context.t('message.tabAnnouncement')), + Tab(text: context.t('notification.system')), + Tab(text: context.t('notification.announcement')), + Tab(text: context.t('notification.activity')), ], ), ), - body: TabBarView( - controller: _tabController, + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _error != null + ? _buildErrorView() + : _notifications.isEmpty + ? EmptyState.noMessages() + : RefreshIndicator( + onRefresh: _loadNotifications, + child: ListView.separated( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: _notifications.length, + separatorBuilder: (_, __) => const Divider(indent: 76), + itemBuilder: (context, index) { + return _buildMessageItem(_notifications[index]); + }, + ), + ), + ); + } + + Widget _buildErrorView() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, children: [ - _buildMessageList(all: true), - _buildMessageList(type: MessageType.transaction), - _buildMessageList(type: MessageType.expiry), - _buildMessageList(type: MessageType.announcement), + Text(context.t('notification.loadFailed'), + style: AppTypography.bodyMedium), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadNotifications, + child: Text(context.t('notification.retry')), + ), ], ), ); } - Widget _buildMessageList({bool all = false, MessageType? type}) { - if (type == MessageType.announcement) { - return EmptyState.noMessages(); - } - - final messages = _mockMessages - .where((m) => all || m.type == type) - .toList(); - - return ListView.separated( - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: messages.length, - separatorBuilder: (_, __) => const Divider(indent: 76), - itemBuilder: (context, index) { - final msg = messages[index]; - return _buildMessageItem(msg); - }, - ); - } - - Widget _buildMessageItem(_MockMessage msg) { + Widget _buildMessageItem(NotificationItem item) { return ListTile( contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4), leading: Container( width: 44, height: 44, decoration: BoxDecoration( - color: _iconBgColor(msg.type), + color: _iconColor(item.type).withValues(alpha: 0.1), shape: BoxShape.circle, ), - child: Icon(_iconData(msg.type), size: 22, color: _iconColor(msg.type)), + child: Icon(_iconData(item.type), size: 22, color: _iconColor(item.type)), ), title: Row( children: [ Expanded( - child: Text(msg.title, style: AppTypography.labelMedium.copyWith( - fontWeight: msg.isRead ? FontWeight.w400 : FontWeight.w600, - )), + child: Text(item.title, + style: AppTypography.labelMedium.copyWith( + fontWeight: item.isRead ? FontWeight.w400 : FontWeight.w600, + )), ), - Text(msg.time, style: AppTypography.caption), + if (item.publishedAt != null) + Text(_formatTime(item.publishedAt!), style: AppTypography.caption), ], ), subtitle: Padding( padding: const EdgeInsets.only(top: 4), child: Text( - msg.body, + item.content, style: AppTypography.bodySmall, maxLines: 2, overflow: TextOverflow.ellipsis, ), ), - trailing: !msg.isRead + trailing: !item.isRead ? Container( width: 8, height: 8, @@ -129,88 +209,49 @@ class _MessagePageState extends State ) : null, onTap: () { + _markAsRead(item); Navigator.pushNamed(context, '/message/detail'); }, ); } - IconData _iconData(MessageType type) { + String _formatTime(DateTime time) { + final now = DateTime.now(); + final diff = now.difference(time); + if (diff.inMinutes < 1) return '刚刚'; + if (diff.inMinutes < 60) return '${diff.inMinutes}分钟前'; + if (diff.inHours < 24) return '${diff.inHours}小时前'; + if (diff.inDays < 7) return '${diff.inDays}天前'; + return '${time.month}/${time.day}'; + } + + IconData _iconData(NotificationType type) { switch (type) { - case MessageType.transaction: + case NotificationType.system: + return Icons.settings_rounded; + case NotificationType.activity: return Icons.swap_horiz_rounded; - case MessageType.expiry: - return Icons.access_time_rounded; - case MessageType.price: - return Icons.trending_up_rounded; - case MessageType.announcement: + case NotificationType.reward: + return Icons.card_giftcard_rounded; + case NotificationType.upgrade: + return Icons.system_update_rounded; + case NotificationType.announcement: return Icons.campaign_rounded; } } - Color _iconColor(MessageType type) { + Color _iconColor(NotificationType type) { switch (type) { - case MessageType.transaction: + case NotificationType.system: return AppColors.primary; - case MessageType.expiry: - return AppColors.warning; - case MessageType.price: + case NotificationType.activity: return AppColors.success; - case MessageType.announcement: + case NotificationType.reward: + return AppColors.warning; + case NotificationType.upgrade: + return AppColors.info; + case NotificationType.announcement: return AppColors.info; } } - - Color _iconBgColor(MessageType type) { - return _iconColor(type).withValues(alpha: 0.1); - } } - -enum MessageType { transaction, expiry, price, announcement } - -class _MockMessage { - final String title; - final String body; - final String time; - final MessageType type; - final bool isRead; - - const _MockMessage(this.title, this.body, this.time, this.type, this.isRead); -} - -const _mockMessages = [ - _MockMessage( - '购买成功', - '您已成功购买 星巴克 \$25 礼品卡,共花费 \$21.25', - '14:32', - MessageType.transaction, - false, - ), - _MockMessage( - '券即将到期', - '您持有的 Target \$30 折扣券 将于3天后到期,请及时使用', - '10:15', - MessageType.expiry, - false, - ), - _MockMessage( - '价格提醒', - '您关注的 Amazon \$100 购物券 当前价格已降至 \$82,低于您设定的提醒价格', - '昨天', - MessageType.price, - true, - ), - _MockMessage( - '出售成交', - '您挂单出售的 Nike \$80 运动券 已成功售出,收入 \$68.00', - '02/07', - MessageType.transaction, - true, - ), - _MockMessage( - '核销成功', - 'Walmart \$50 生活券 已在门店核销成功', - '02/06', - MessageType.transaction, - true, - ), -]; diff --git a/frontend/genex-mobile/lib/features/profile/presentation/pages/settings_page.dart b/frontend/genex-mobile/lib/features/profile/presentation/pages/settings_page.dart index 0c28be8..08dbd38 100644 --- a/frontend/genex-mobile/lib/features/profile/presentation/pages/settings_page.dart +++ b/frontend/genex-mobile/lib/features/profile/presentation/pages/settings_page.dart @@ -1,9 +1,11 @@ import 'package:flutter/material.dart'; +import 'package:package_info_plus/package_info_plus.dart'; import '../../../../app/theme/app_colors.dart'; import '../../../../app/theme/app_typography.dart'; import '../../../../app/theme/app_spacing.dart'; import '../../../../app/i18n/app_localizations.dart'; import '../../../../app/i18n/locale_manager.dart'; +import '../../../../core/updater/update_service.dart'; /// 设置页面 /// @@ -18,12 +20,26 @@ class SettingsPage extends StatefulWidget { class _SettingsPageState extends State { _CurrencyOption _selectedCurrency = _currencyOptions[0]; + String _appVersion = ''; bool _notifyTrade = true; bool _notifyExpiry = true; bool _notifyMarket = false; bool _notifyMarketing = false; + @override + void initState() { + super.initState(); + _loadVersion(); + } + + Future _loadVersion() async { + final info = await PackageInfo.fromPlatform(); + if (mounted) { + setState(() => _appVersion = 'v${info.version}+${info.buildNumber}'); + } + } + String get _currentLanguageDisplay { final locale = LocaleManager.userLocale.value ?? Localizations.localeOf(context); @@ -89,7 +105,9 @@ class _SettingsPageState extends State { _buildSection(context.t('settings.about'), [ _buildTile(context.t('settings.version'), - subtitle: 'v1.0.0', icon: Icons.info_outline_rounded), + subtitle: _appVersion.isEmpty ? 'v1.0.0' : _appVersion, + icon: Icons.info_outline_rounded, + onTap: () => UpdateService().manualCheckUpdate(context)), _buildTile(context.t('settings.userAgreement'), icon: Icons.description_rounded), _buildTile(context.t('settings.privacyPolicy'), diff --git a/frontend/genex-mobile/lib/main.dart b/frontend/genex-mobile/lib/main.dart index 55179a5..10400b2 100644 --- a/frontend/genex-mobile/lib/main.dart +++ b/frontend/genex-mobile/lib/main.dart @@ -4,6 +4,10 @@ import 'app/theme/app_theme.dart'; import 'app/main_shell.dart'; import 'app/i18n/app_localizations.dart'; import 'app/i18n/locale_manager.dart'; +import 'core/updater/update_service.dart'; +import 'core/updater/models/update_config.dart'; +import 'core/push/push_service.dart'; +import 'core/providers/notification_badge_manager.dart'; import 'features/auth/presentation/pages/login_page.dart'; import 'features/auth/presentation/pages/welcome_page.dart'; import 'features/auth/presentation/pages/register_page.dart'; @@ -33,7 +37,21 @@ import 'features/merchant/presentation/pages/merchant_home_page.dart'; import 'features/trading/presentation/pages/trading_detail_page.dart'; import 'features/coupons/presentation/pages/wallet_coupons_page.dart'; -void main() { +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + + // 初始化升级服务 + UpdateService().initialize(UpdateConfig.selfHosted( + apiBaseUrl: 'https://api.gogenex.cn', + enabled: true, + )); + + // 初始化推送服务(无 Firebase 配置时静默失败) + await PushService().initialize(); + + // 初始化通知徽章管理器 + NotificationBadgeManager().initialize(); + runApp(const GenexConsumerApp()); } @@ -84,7 +102,6 @@ class _GenexConsumerAppState extends State { GlobalCupertinoLocalizations.delegate, ], localeResolutionCallback: (systemLocale, supportedLocales) { - // 用户未主动选择时,跟随系统语言 if (LocaleManager.userLocale.value == null) { return LocaleManager.resolve( systemLocale != null ? [systemLocale] : null, diff --git a/frontend/genex-mobile/pubspec.yaml b/frontend/genex-mobile/pubspec.yaml index f649b91..b08166b 100644 --- a/frontend/genex-mobile/pubspec.yaml +++ b/frontend/genex-mobile/pubspec.yaml @@ -12,6 +12,16 @@ dependencies: flutter_localizations: sdk: flutter intl: any + dio: ^5.4.3+1 + package_info_plus: ^8.0.0 + path_provider: ^2.1.0 + crypto: ^3.0.3 + permission_handler: ^11.3.1 + url_launcher: ^6.2.6 + firebase_messaging: ^15.1.0 + firebase_core: ^3.4.0 + flutter_local_notifications: ^18.0.0 + in_app_update: ^4.2.2 dev_dependencies: flutter_test: diff --git a/frontend/mobile/android/app/src/main/AndroidManifest.xml b/frontend/mobile/android/app/src/main/AndroidManifest.xml index 304fac0..b31d7ac 100644 --- a/frontend/mobile/android/app/src/main/AndroidManifest.xml +++ b/frontend/mobile/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ + + + + + when (call.method) { + "installApk" -> { + val filePath = call.argument("filePath") + if (filePath != null) { + try { + installApk(filePath) + result.success(true) + } catch (e: Exception) { + result.error("INSTALL_ERROR", e.message, null) + } + } else { + result.error("INVALID_PATH", "File path is null", null) + } + } + else -> result.notImplemented() + } + } + + // App Market Channel + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, APP_MARKET_CHANNEL) + .setMethodCallHandler { call, result -> + when (call.method) { + "getInstallerPackageName" -> { + val installer = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + packageManager.getInstallSourceInfo(packageName).installingPackageName + } else { + @Suppress("DEPRECATION") + packageManager.getInstallerPackageName(packageName) + } + result.success(installer) + } + "openAppStore" -> { + val packageName = call.argument("packageName") ?: this.packageName + try { + val intent = Intent(Intent.ACTION_VIEW).apply { + data = Uri.parse("market://details?id=$packageName") + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + startActivity(intent) + result.success(true) + } catch (e: Exception) { + try { + val intent = Intent(Intent.ACTION_VIEW).apply { + data = Uri.parse("https://play.google.com/store/apps/details?id=$packageName") + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + startActivity(intent) + result.success(true) + } catch (e2: Exception) { + result.error("MARKET_ERROR", e2.message, null) + } + } + } + else -> result.notImplemented() + } + } + } + + private fun installApk(filePath: String) { + val file = File(filePath) + if (!file.exists()) throw Exception("APK file not found: $filePath") + + val intent = Intent(Intent.ACTION_VIEW).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + + val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + FileProvider.getUriForFile( + this@MainActivity, + "${this@MainActivity.packageName}.fileprovider", + file + ) + } else { + Uri.fromFile(file) + } + setDataAndType(uri, "application/vnd.android.package-archive") + } + startActivity(intent) + } +} diff --git a/frontend/mobile/android/app/src/main/res/xml/file_paths.xml b/frontend/mobile/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..9a6af39 --- /dev/null +++ b/frontend/mobile/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/mobile/lib/app/i18n/strings/en.dart b/frontend/mobile/lib/app/i18n/strings/en.dart index 1dd4d59..d6d1f7f 100644 --- a/frontend/mobile/lib/app/i18n/strings/en.dart +++ b/frontend/mobile/lib/app/i18n/strings/en.dart @@ -683,4 +683,43 @@ const Map en = { 'receiveCoupon.id': 'Receive ID', 'receiveCoupon.idCopied': 'Receive ID copied to clipboard', 'receiveCoupon.note': 'Received coupons will be automatically added to your holdings.', + + // ============ Update ============ + 'update.newVersion': 'New Version Available', + 'update.importantUpdate': 'Important Update', + 'update.latestVersion': 'Latest version', + 'update.fileSize': 'File size', + 'update.changelog': "What's New", + 'update.marketHint': 'Your app was installed from an app store. We recommend updating through the store.', + 'update.goMarket': 'Go to App Store', + 'update.later': 'Later', + 'update.skipUpdate': 'Skip', + 'update.updateNow': 'Update Now', + 'update.preparing': 'Preparing...', + 'update.downloading': 'Downloading...', + 'update.downloadComplete': 'Download Complete', + 'update.cancelled': 'Download Cancelled', + 'update.failed': 'Download failed, please try again', + 'update.installing': 'Installing...', + 'update.installFailed': 'Installation failed', + 'update.installLater': 'Install Later', + 'update.installNow': 'Install Now', + 'update.updating': 'Updating', + 'update.updateFailed': 'Update Failed', + 'update.cancel': 'Cancel', + 'update.retry': 'Retry', + 'update.close': 'Close', + 'update.isLatest': 'Already up to date', + 'update.checkUpdate': 'Check for Updates', + + // ============ Notification ============ + 'notification.system': 'System', + 'notification.activity': 'Activity', + 'notification.reward': 'Reward', + 'notification.upgrade': 'Upgrade', + 'notification.announcement': 'Announcement', + 'notification.markAllRead': 'Mark All Read', + 'notification.empty': 'No notifications', + 'notification.loadFailed': 'Load failed', + 'notification.retry': 'Retry', }; diff --git a/frontend/mobile/lib/app/i18n/strings/ja.dart b/frontend/mobile/lib/app/i18n/strings/ja.dart index aff90e2..eea3899 100644 --- a/frontend/mobile/lib/app/i18n/strings/ja.dart +++ b/frontend/mobile/lib/app/i18n/strings/ja.dart @@ -683,4 +683,43 @@ const Map ja = { 'receiveCoupon.id': '受取ID', 'receiveCoupon.idCopied': '受取IDをクリップボードにコピーしました', 'receiveCoupon.note': '受取ったクーポンは自動的にウォレットに保存されます。ホーム画面のウォレットから確認・管理できます。', + + // ============ Update ============ + 'update.newVersion': '新バージョン', + 'update.importantUpdate': '重要な更新', + 'update.latestVersion': '最新バージョン', + 'update.fileSize': 'ファイルサイズ', + 'update.changelog': '更新内容', + 'update.marketHint': 'アプリストアからインストールされました。ストアでの更新を推奨します。', + 'update.goMarket': 'ストアへ', + 'update.later': '後で', + 'update.skipUpdate': 'スキップ', + 'update.updateNow': '今すぐ更新', + 'update.preparing': '準備中...', + 'update.downloading': 'ダウンロード中...', + 'update.downloadComplete': 'ダウンロード完了', + 'update.cancelled': 'キャンセル済み', + 'update.failed': 'ダウンロード失敗', + 'update.installing': 'インストール中...', + 'update.installFailed': 'インストール失敗', + 'update.installLater': '後でインストール', + 'update.installNow': '今すぐインストール', + 'update.updating': '更新中', + 'update.updateFailed': '更新失敗', + 'update.cancel': 'キャンセル', + 'update.retry': 'リトライ', + 'update.close': '閉じる', + 'update.isLatest': '最新バージョンです', + 'update.checkUpdate': 'アップデート確認', + + // ============ Notification ============ + 'notification.system': 'システム', + 'notification.activity': 'アクティビティ', + 'notification.reward': '収益', + 'notification.upgrade': 'アップグレード', + 'notification.announcement': 'お知らせ', + 'notification.markAllRead': 'すべて既読', + 'notification.empty': '通知なし', + 'notification.loadFailed': '読み込み失敗', + 'notification.retry': 'リトライ', }; diff --git a/frontend/mobile/lib/app/i18n/strings/zh_cn.dart b/frontend/mobile/lib/app/i18n/strings/zh_cn.dart index 629dd06..8d41335 100644 --- a/frontend/mobile/lib/app/i18n/strings/zh_cn.dart +++ b/frontend/mobile/lib/app/i18n/strings/zh_cn.dart @@ -683,4 +683,43 @@ const Map zhCN = { 'receiveCoupon.id': '接收ID', 'receiveCoupon.idCopied': '接收ID已复制到剪贴板', 'receiveCoupon.note': '接收的券将自动存入你的钱包,可在首页钱包中查看和管理。', + + // ============ Update ============ + 'update.newVersion': '发现新版本', + 'update.importantUpdate': '发现重要更新', + 'update.latestVersion': '最新版本', + 'update.fileSize': '文件大小', + 'update.changelog': '更新内容', + 'update.marketHint': '检测到您的应用来自应用市场,建议前往应用市场更新以获得最佳体验。', + 'update.goMarket': '前往应用市场', + 'update.later': '稍后', + 'update.skipUpdate': '暂时不更新', + 'update.updateNow': '立即更新', + 'update.preparing': '准备下载...', + 'update.downloading': '正在下载...', + 'update.downloadComplete': '下载完成', + 'update.cancelled': '下载已取消', + 'update.failed': '下载失败,请稍后重试', + 'update.installing': '正在安装...', + 'update.installFailed': '安装失败,请手动安装', + 'update.installLater': '稍后安装', + 'update.installNow': '立即安装', + 'update.updating': '正在更新', + 'update.updateFailed': '更新失败', + 'update.cancel': '取消', + 'update.retry': '重试', + 'update.close': '关闭', + 'update.isLatest': '当前已是最新版本', + 'update.checkUpdate': '检查更新', + + // ============ Notification ============ + 'notification.system': '系统通知', + 'notification.activity': '活动通知', + 'notification.reward': '收益通知', + 'notification.upgrade': '升级通知', + 'notification.announcement': '公告', + 'notification.markAllRead': '全部已读', + 'notification.empty': '暂无通知', + 'notification.loadFailed': '加载失败', + 'notification.retry': '重试', }; diff --git a/frontend/mobile/lib/app/i18n/strings/zh_tw.dart b/frontend/mobile/lib/app/i18n/strings/zh_tw.dart index 2cee6b6..8cb6628 100644 --- a/frontend/mobile/lib/app/i18n/strings/zh_tw.dart +++ b/frontend/mobile/lib/app/i18n/strings/zh_tw.dart @@ -683,4 +683,43 @@ const Map zhTW = { 'receiveCoupon.id': '接收ID', 'receiveCoupon.idCopied': '接收ID已複製到剪貼簿', 'receiveCoupon.note': '接收的券將自動存入你的錢包,可在首頁錢包中查看和管理。', + + // ============ Update ============ + 'update.newVersion': '發現新版本', + 'update.importantUpdate': '發現重要更新', + 'update.latestVersion': '最新版本', + 'update.fileSize': '檔案大小', + 'update.changelog': '更新內容', + 'update.marketHint': '偵測到您的應用來自應用市場,建議前往應用市場更新。', + 'update.goMarket': '前往應用市場', + 'update.later': '稍後', + 'update.skipUpdate': '暫時不更新', + 'update.updateNow': '立即更新', + 'update.preparing': '準備下載...', + 'update.downloading': '正在下載...', + 'update.downloadComplete': '下載完成', + 'update.cancelled': '下載已取消', + 'update.failed': '下載失敗,請稍後重試', + 'update.installing': '正在安裝...', + 'update.installFailed': '安裝失敗,請手動安裝', + 'update.installLater': '稍後安裝', + 'update.installNow': '立即安裝', + 'update.updating': '正在更新', + 'update.updateFailed': '更新失敗', + 'update.cancel': '取消', + 'update.retry': '重試', + 'update.close': '關閉', + 'update.isLatest': '當前已是最新版本', + 'update.checkUpdate': '檢查更新', + + // ============ Notification ============ + 'notification.system': '系統通知', + 'notification.activity': '活動通知', + 'notification.reward': '收益通知', + 'notification.upgrade': '升級通知', + 'notification.announcement': '公告', + 'notification.markAllRead': '全部已讀', + 'notification.empty': '暫無通知', + 'notification.loadFailed': '載入失敗', + 'notification.retry': '重試', }; diff --git a/frontend/mobile/lib/app/main_shell.dart b/frontend/mobile/lib/app/main_shell.dart index a110c96..84aa5c0 100644 --- a/frontend/mobile/lib/app/main_shell.dart +++ b/frontend/mobile/lib/app/main_shell.dart @@ -7,6 +7,8 @@ import '../features/coupons/presentation/pages/market_page.dart'; import '../features/coupons/presentation/pages/my_coupons_page.dart'; import '../features/message/presentation/pages/message_page.dart'; import '../features/profile/presentation/pages/profile_page.dart'; +import '../core/updater/update_service.dart'; +import '../core/providers/notification_badge_manager.dart'; /// 消费者App主Shell - Bottom Navigation /// @@ -20,6 +22,7 @@ class MainShell extends StatefulWidget { class _MainShellState extends State { int _currentIndex = 0; + bool _updateChecked = false; final _pages = const [ HomePage(), @@ -29,6 +32,19 @@ class _MainShellState extends State { ProfilePage(), ]; + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_updateChecked) { + _updateChecked = true; + Future.delayed(const Duration(seconds: 3), () { + if (mounted) { + UpdateService().checkForUpdate(context); + } + }); + } + } + @override Widget build(BuildContext context) { return Scaffold( @@ -56,7 +72,6 @@ class _MainShellState extends State { Icons.notifications_rounded, Icons.notifications_outlined, context.t('nav.messages'), - 2, ), _buildDestination(Icons.person_rounded, Icons.person_outlined, context.t('nav.profile')), ], @@ -81,16 +96,27 @@ class _MainShellState extends State { IconData selected, IconData unselected, String label, - int count, ) { return NavigationDestination( - icon: Badge( - label: Text('$count', style: const TextStyle(fontSize: 10)), - child: Icon(unselected), + icon: ValueListenableBuilder( + valueListenable: NotificationBadgeManager().unreadCount, + builder: (context, count, _) { + if (count <= 0) return Icon(unselected); + return Badge( + label: Text('$count', style: const TextStyle(fontSize: 10)), + child: Icon(unselected), + ); + }, ), - selectedIcon: Badge( - label: Text('$count', style: const TextStyle(fontSize: 10)), - child: Icon(selected), + selectedIcon: ValueListenableBuilder( + valueListenable: NotificationBadgeManager().unreadCount, + builder: (context, count, _) { + if (count <= 0) return Icon(selected); + return Badge( + label: Text('$count', style: const TextStyle(fontSize: 10)), + child: Icon(selected), + ); + }, ), label: label, ); diff --git a/frontend/mobile/lib/core/network/api_client.dart b/frontend/mobile/lib/core/network/api_client.dart new file mode 100644 index 0000000..d08bd9a --- /dev/null +++ b/frontend/mobile/lib/core/network/api_client.dart @@ -0,0 +1,76 @@ +import 'package:dio/dio.dart'; + +/// Genex API 客户端 +/// 基于 Dio 的 HTTP 客户端单例 +class ApiClient { + static ApiClient? _instance; + late final Dio _dio; + + ApiClient._({required String baseUrl}) { + _dio = Dio(BaseOptions( + baseUrl: baseUrl, + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 30), + sendTimeout: const Duration(seconds: 30), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + )); + } + + static ApiClient get instance { + _instance ??= ApiClient._(baseUrl: 'https://api.gogenex.cn'); + return _instance!; + } + + static void initialize({required String baseUrl}) { + _instance = ApiClient._(baseUrl: baseUrl); + } + + Dio get dio => _dio; + + /// 设置 JWT Token + void setToken(String? token) { + if (token != null) { + _dio.options.headers['Authorization'] = 'Bearer $token'; + } else { + _dio.options.headers.remove('Authorization'); + } + } + + Future get( + String path, { + Map? queryParameters, + Options? options, + }) { + return _dio.get(path, queryParameters: queryParameters, options: options); + } + + Future post( + String path, { + dynamic data, + Map? queryParameters, + Options? options, + }) { + return _dio.post(path, data: data, queryParameters: queryParameters, options: options); + } + + Future put( + String path, { + dynamic data, + Map? queryParameters, + Options? options, + }) { + return _dio.put(path, data: data, queryParameters: queryParameters, options: options); + } + + Future delete( + String path, { + dynamic data, + Map? queryParameters, + Options? options, + }) { + return _dio.delete(path, data: data, queryParameters: queryParameters, options: options); + } +} diff --git a/frontend/mobile/lib/core/providers/notification_badge_manager.dart b/frontend/mobile/lib/core/providers/notification_badge_manager.dart new file mode 100644 index 0000000..6d816f5 --- /dev/null +++ b/frontend/mobile/lib/core/providers/notification_badge_manager.dart @@ -0,0 +1,83 @@ +import 'dart:async'; +import 'package:flutter/widgets.dart'; +import '../services/notification_service.dart'; + +/// 未读通知徽章管理器 +/// 使用 ValueNotifier 管理未读数量,与 LocaleManager 风格一致 +class NotificationBadgeManager with WidgetsBindingObserver { + static final NotificationBadgeManager _instance = NotificationBadgeManager._(); + factory NotificationBadgeManager() => _instance; + NotificationBadgeManager._(); + + final ValueNotifier unreadCount = ValueNotifier(0); + + Timer? _refreshTimer; + NotificationService? _notificationService; + bool _initialized = false; + + static const _refreshIntervalSeconds = 30; + + /// 初始化 + void initialize({NotificationService? notificationService}) { + if (_initialized) return; + _notificationService = notificationService ?? NotificationService(); + WidgetsBinding.instance.addObserver(this); + _loadUnreadCount(); + _startAutoRefresh(); + _initialized = true; + } + + /// 释放资源 + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _refreshTimer?.cancel(); + _initialized = false; + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + _loadUnreadCount(); + } + } + + void _startAutoRefresh() { + _refreshTimer?.cancel(); + _refreshTimer = Timer.periodic( + const Duration(seconds: _refreshIntervalSeconds), + (_) => _loadUnreadCount(), + ); + } + + Future _loadUnreadCount() async { + try { + final notifCount = await _notificationService!.getUnreadCount(); + final announcementCount = await _notificationService!.getAnnouncementUnreadCount(); + unreadCount.value = notifCount + announcementCount; + } catch (e) { + debugPrint('[NotificationBadge] 加载未读数量失败: $e'); + } + } + + /// 手动刷新 + Future refresh() async { + await _loadUnreadCount(); + } + + /// 减少未读数(标记单条已读后调用) + void decrementCount() { + if (unreadCount.value > 0) { + unreadCount.value = unreadCount.value - 1; + } + } + + /// 清空未读数(全部标记已读后调用) + void clearCount() { + unreadCount.value = 0; + } + + /// 设置具体数值 + void updateCount(int count) { + unreadCount.value = count; + } +} diff --git a/frontend/mobile/lib/core/push/push_service.dart b/frontend/mobile/lib/core/push/push_service.dart new file mode 100644 index 0000000..3a9d19f --- /dev/null +++ b/frontend/mobile/lib/core/push/push_service.dart @@ -0,0 +1,89 @@ +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import '../network/api_client.dart'; + +/// FCM 推送服务框架 +/// Firebase 配置文件后续再添加,当前用 try-catch 保护初始化 +class PushService { + static final PushService _instance = PushService._(); + factory PushService() => _instance; + PushService._(); + + bool _initialized = false; + String? _fcmToken; + + String? get fcmToken => _fcmToken; + bool get isInitialized => _initialized; + + /// 初始化推送服务 + /// 无 Firebase 配置文件时静默失败,不影响 app 启动 + Future initialize() async { + try { + // 动态导入 firebase,避免无配置文件时编译错误 + // Firebase 初始化需要 google-services.json / GoogleService-Info.plist + // 配置文件准备好后取消下方注释: + // + // await Firebase.initializeApp(); + // final messaging = FirebaseMessaging.instance; + // + // // 请求通知权限 (iOS + Android 13+) + // await messaging.requestPermission( + // alert: true, + // badge: true, + // sound: true, + // ); + // + // // 获取 FCM token + // _fcmToken = await messaging.getToken(); + // if (_fcmToken != null) { + // debugPrint('[PushService] FCM Token: $_fcmToken'); + // await _registerToken(_fcmToken!); + // } + // + // // 监听 token 刷新 + // messaging.onTokenRefresh.listen((token) { + // _fcmToken = token; + // _registerToken(token); + // }); + // + // // 前台消息处理 + // FirebaseMessaging.onMessage.listen(_handleForegroundMessage); + // + // // 后台消息点击处理 + // FirebaseMessaging.onMessageOpenedApp.listen(_handleMessageTap); + + _initialized = true; + debugPrint('[PushService] 推送服务框架已初始化(等待 Firebase 配置)'); + } catch (e) { + debugPrint('[PushService] 初始化失败(可能缺少 Firebase 配置文件): $e'); + } + } + + /// 注册设备 token 到后端 + Future _registerToken(String token) async { + try { + final platform = Platform.isIOS ? 'IOS' : 'ANDROID'; + await ApiClient.instance.post('/device-tokens', data: { + 'platform': platform, + 'channel': 'FCM', + 'token': token, + }); + debugPrint('[PushService] Token 注册成功'); + } catch (e) { + debugPrint('[PushService] Token 注册失败: $e'); + } + } + + /// 注销设备 token + Future unregisterToken() async { + if (_fcmToken == null) return; + try { + await ApiClient.instance.delete('/device-tokens', data: { + 'token': _fcmToken, + }); + debugPrint('[PushService] Token 已注销'); + } catch (e) { + debugPrint('[PushService] Token 注销失败: $e'); + } + } +} diff --git a/frontend/mobile/lib/core/services/notification_service.dart b/frontend/mobile/lib/core/services/notification_service.dart new file mode 100644 index 0000000..a0099df --- /dev/null +++ b/frontend/mobile/lib/core/services/notification_service.dart @@ -0,0 +1,236 @@ +import 'package:flutter/foundation.dart'; +import '../network/api_client.dart'; + +/// 通知类型 +enum NotificationType { + system, + activity, + reward, + upgrade, + announcement, +} + +/// 通知优先级 +enum NotificationPriority { + low, + normal, + high, + urgent, +} + +/// 通知项 +class NotificationItem { + final String id; + final String title; + final String content; + final NotificationType type; + final NotificationPriority priority; + final String? imageUrl; + final String? linkUrl; + final DateTime? publishedAt; + final bool isRead; + final DateTime? readAt; + + NotificationItem({ + required this.id, + required this.title, + required this.content, + required this.type, + required this.priority, + this.imageUrl, + this.linkUrl, + this.publishedAt, + required this.isRead, + this.readAt, + }); + + factory NotificationItem.fromJson(Map json) { + return NotificationItem( + id: json['id']?.toString() ?? '', + title: json['title'] ?? '', + content: json['content'] ?? json['body'] ?? '', + type: _parseType(json['type']), + priority: _parsePriority(json['priority']), + imageUrl: json['imageUrl'], + linkUrl: json['linkUrl'], + publishedAt: json['publishedAt'] != null + ? DateTime.tryParse(json['publishedAt']) + : (json['createdAt'] != null ? DateTime.tryParse(json['createdAt']) : null), + isRead: json['isRead'] ?? (json['status'] == 'READ'), + readAt: json['readAt'] != null ? DateTime.tryParse(json['readAt']) : null, + ); + } + + static NotificationType _parseType(String? type) { + switch (type?.toUpperCase()) { + case 'SYSTEM': + return NotificationType.system; + case 'ACTIVITY': + return NotificationType.activity; + case 'REWARD': + return NotificationType.reward; + case 'UPGRADE': + return NotificationType.upgrade; + case 'ANNOUNCEMENT': + return NotificationType.announcement; + default: + return NotificationType.system; + } + } + + static NotificationPriority _parsePriority(String? priority) { + switch (priority?.toUpperCase()) { + case 'LOW': + return NotificationPriority.low; + case 'HIGH': + return NotificationPriority.high; + case 'URGENT': + return NotificationPriority.urgent; + default: + return NotificationPriority.normal; + } + } +} + +/// 通知列表响应 +class NotificationListResponse { + final List notifications; + final int total; + final int unreadCount; + + NotificationListResponse({ + required this.notifications, + required this.total, + required this.unreadCount, + }); + + factory NotificationListResponse.fromJson(Map json) { + final data = json['data'] ?? json; + final list = (data['notifications'] as List?) + ?.map((e) => NotificationItem.fromJson(e as Map)) + .toList() ?? + (data['items'] as List?) + ?.map((e) => NotificationItem.fromJson(e as Map)) + .toList() ?? + []; + return NotificationListResponse( + notifications: list, + total: data['total'] ?? list.length, + unreadCount: data['unreadCount'] ?? 0, + ); + } +} + +/// 通知服务 +class NotificationService { + final ApiClient _apiClient; + + NotificationService({ApiClient? apiClient}) + : _apiClient = apiClient ?? ApiClient.instance; + + /// 获取通知列表 + Future getNotifications({ + NotificationType? type, + int limit = 50, + int offset = 0, + }) async { + try { + final queryParams = { + 'limit': limit, + 'offset': offset, + }; + if (type != null) { + queryParams['type'] = type.name.toUpperCase(); + } + + final response = await _apiClient.get( + '/notifications', + queryParameters: queryParams, + ); + + return NotificationListResponse.fromJson( + response.data is Map ? response.data : {}, + ); + } catch (e) { + debugPrint('[NotificationService] 获取通知列表失败: $e'); + rethrow; + } + } + + /// 获取未读通知数量 + Future getUnreadCount() async { + try { + final response = await _apiClient.get('/notifications/unread-count'); + final data = response.data is Map ? response.data : {}; + return data['unreadCount'] ?? data['data']?['unreadCount'] ?? 0; + } catch (e) { + debugPrint('[NotificationService] 获取未读数量失败: $e'); + return 0; + } + } + + /// 标记通知为已读 + Future markAsRead(String notificationId) async { + try { + await _apiClient.put('/notifications/$notificationId/read'); + return true; + } catch (e) { + debugPrint('[NotificationService] 标记已读失败: $e'); + return false; + } + } + + /// 获取公告列表 + Future getAnnouncements({ + int limit = 50, + int offset = 0, + }) async { + try { + final response = await _apiClient.get( + '/announcements', + queryParameters: {'limit': limit, 'offset': offset}, + ); + + return NotificationListResponse.fromJson( + response.data is Map ? response.data : {}, + ); + } catch (e) { + debugPrint('[NotificationService] 获取公告列表失败: $e'); + rethrow; + } + } + + /// 获取公告未读数 + Future getAnnouncementUnreadCount() async { + try { + final response = await _apiClient.get('/announcements/unread-count'); + final data = response.data is Map ? response.data : {}; + return data['unreadCount'] ?? data['data']?['unreadCount'] ?? 0; + } catch (e) { + debugPrint('[NotificationService] 获取公告未读数失败: $e'); + return 0; + } + } + + /// 标记公告已读 + Future markAnnouncementAsRead(String announcementId) async { + try { + await _apiClient.put('/announcements/$announcementId/read'); + return true; + } catch (e) { + debugPrint('[NotificationService] 标记公告已读失败: $e'); + return false; + } + } + + /// 全部标记已读 + Future markAllAnnouncementsAsRead() async { + try { + await _apiClient.put('/announcements/read-all'); + return true; + } catch (e) { + debugPrint('[NotificationService] 全部标记已读失败: $e'); + return false; + } + } +} diff --git a/frontend/mobile/lib/core/updater/apk_installer.dart b/frontend/mobile/lib/core/updater/apk_installer.dart new file mode 100644 index 0000000..a34cbef --- /dev/null +++ b/frontend/mobile/lib/core/updater/apk_installer.dart @@ -0,0 +1,53 @@ +import 'dart:io'; +import 'package:flutter/services.dart'; +import 'package:flutter/foundation.dart'; +import 'package:permission_handler/permission_handler.dart'; + +/// APK 安装器 +/// 负责调用原生代码安装 APK 文件 +class ApkInstaller { + static const MethodChannel _channel = + MethodChannel('cn.gogenex.mobile/apk_installer'); + + /// 安装 APK + static Future installApk(File apkFile) async { + try { + if (!await apkFile.exists()) { + debugPrint('APK file not found'); + return false; + } + + if (Platform.isAndroid) { + final hasPermission = await _requestInstallPermission(); + if (!hasPermission) { + debugPrint('Install permission denied'); + return false; + } + } + + final result = await _channel.invokeMethod('installApk', { + 'apkPath': apkFile.path, + }); + + return result == true; + } on PlatformException catch (e) { + debugPrint('Install failed: ${e.message}'); + return false; + } catch (e) { + debugPrint('Install failed: $e'); + return false; + } + } + + static Future _requestInstallPermission() async { + if (await Permission.requestInstallPackages.isGranted) { + return true; + } + final status = await Permission.requestInstallPackages.request(); + return status.isGranted; + } + + static Future hasInstallPermission() async { + return await Permission.requestInstallPackages.isGranted; + } +} diff --git a/frontend/mobile/lib/core/updater/app_market_detector.dart b/frontend/mobile/lib/core/updater/app_market_detector.dart new file mode 100644 index 0000000..5cfa867 --- /dev/null +++ b/frontend/mobile/lib/core/updater/app_market_detector.dart @@ -0,0 +1,82 @@ +import 'dart:io'; +import 'package:flutter/services.dart'; +import 'package:flutter/foundation.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// 应用市场检测器 +/// 检测应用安装来源,决定升级策略 +class AppMarketDetector { + static const MethodChannel _channel = + MethodChannel('cn.gogenex.mobile/app_market'); + + static const List _marketPackages = [ + 'com.android.vending', + 'com.huawei.appmarket', + 'com.xiaomi.market', + 'com.oppo.market', + 'com.bbk.appstore', + 'com.tencent.android.qqdownloader', + 'com.qihoo.appstore', + 'com.baidu.appsearch', + 'com.wandoujia.phoenix2', + 'com.dragon.android.pandaspace', + 'com.sec.android.app.samsungapps', + ]; + + static Future getInstallerPackageName() async { + if (!Platform.isAndroid) return null; + try { + return await _channel.invokeMethod('getInstallerPackageName'); + } catch (e) { + debugPrint('Get installer failed: $e'); + return null; + } + } + + static Future isFromAppMarket() async { + final installer = await getInstallerPackageName(); + if (installer == null || installer.isEmpty) return false; + return _marketPackages.contains(installer); + } + + static Future isFromGooglePlay() async { + final installer = await getInstallerPackageName(); + return installer == 'com.android.vending'; + } + + static Future getInstallerName() async { + final installer = await getInstallerPackageName(); + if (installer == null || installer.isEmpty) return '直接安装'; + switch (installer) { + case 'com.android.vending': + return 'Google Play'; + case 'com.huawei.appmarket': + return '华为应用市场'; + case 'com.xiaomi.market': + return '小米应用商店'; + case 'com.oppo.market': + return 'OPPO 软件商店'; + case 'com.bbk.appstore': + return 'vivo 应用商店'; + case 'com.tencent.android.qqdownloader': + return '应用宝'; + default: + return installer; + } + } + + static Future openAppMarketDetail(String packageName) async { + final marketUri = Uri.parse('market://details?id=$packageName'); + if (await canLaunchUrl(marketUri)) { + await launchUrl(marketUri, mode: LaunchMode.externalApplication); + return true; + } else { + final webUri = Uri.parse('https://play.google.com/store/apps/details?id=$packageName'); + if (await canLaunchUrl(webUri)) { + await launchUrl(webUri, mode: LaunchMode.externalApplication); + return true; + } + return false; + } + } +} diff --git a/frontend/mobile/lib/core/updater/channels/google_play_updater.dart b/frontend/mobile/lib/core/updater/channels/google_play_updater.dart new file mode 100644 index 0000000..a5af7ec --- /dev/null +++ b/frontend/mobile/lib/core/updater/channels/google_play_updater.dart @@ -0,0 +1,44 @@ +import 'package:in_app_update/in_app_update.dart'; +import 'package:flutter/foundation.dart'; + +/// Google Play 应用内更新器 +class GooglePlayUpdater { + static Future checkForUpdate() async { + try { + final updateInfo = await InAppUpdate.checkForUpdate(); + return updateInfo.updateAvailability == UpdateAvailability.updateAvailable; + } catch (e) { + debugPrint('Check update failed: $e'); + return false; + } + } + + static Future performFlexibleUpdate() async { + try { + final updateInfo = await InAppUpdate.checkForUpdate(); + if (updateInfo.updateAvailability == UpdateAvailability.updateAvailable) { + if (updateInfo.flexibleUpdateAllowed) { + await InAppUpdate.startFlexibleUpdate(); + InAppUpdate.completeFlexibleUpdate().catchError((e) { + debugPrint('Update failed: $e'); + }); + } + } + } catch (e) { + debugPrint('Flexible update error: $e'); + } + } + + static Future performImmediateUpdate() async { + try { + final updateInfo = await InAppUpdate.checkForUpdate(); + if (updateInfo.updateAvailability == UpdateAvailability.updateAvailable) { + if (updateInfo.immediateUpdateAllowed) { + await InAppUpdate.performImmediateUpdate(); + } + } + } catch (e) { + debugPrint('Immediate update error: $e'); + } + } +} diff --git a/frontend/mobile/lib/core/updater/channels/self_hosted_updater.dart b/frontend/mobile/lib/core/updater/channels/self_hosted_updater.dart new file mode 100644 index 0000000..474a98d --- /dev/null +++ b/frontend/mobile/lib/core/updater/channels/self_hosted_updater.dart @@ -0,0 +1,411 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import '../../../../app/i18n/app_localizations.dart'; +import '../../../../app/theme/app_colors.dart'; +import '../version_checker.dart'; +import '../download_manager.dart'; +import '../apk_installer.dart'; +import '../app_market_detector.dart'; +import '../models/version_info.dart'; + +/// 自建服务器更新器 +class SelfHostedUpdater { + final VersionChecker versionChecker; + final DownloadManager downloadManager; + + SelfHostedUpdater({required String apiBaseUrl}) + : versionChecker = VersionChecker(apiBaseUrl: apiBaseUrl), + downloadManager = DownloadManager(); + + Future checkAndPromptUpdate(BuildContext context) async { + final versionInfo = await versionChecker.checkForUpdate(); + if (versionInfo == null) return; + if (!context.mounted) return; + + final isFromMarket = await AppMarketDetector.isFromAppMarket(); + if (isFromMarket) { + _showMarketUpdateDialog(context, versionInfo); + } else { + _showSelfHostedUpdateDialog(context, versionInfo); + } + } + + Future silentCheckUpdate() async { + return await versionChecker.checkForUpdate(); + } + + void _showMarketUpdateDialog(BuildContext context, VersionInfo versionInfo) { + showDialog( + context: context, + barrierDismissible: !versionInfo.forceUpdate, + builder: (context) => PopScope( + canPop: !versionInfo.forceUpdate, + child: AlertDialog( + title: Text( + versionInfo.forceUpdate + ? context.t('update.importantUpdate') + : context.t('update.newVersion'), + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600), + ), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${context.t('update.latestVersion')}: ${versionInfo.version}', + style: TextStyle(fontSize: 14, color: Colors.grey[700]), + ), + if (versionInfo.updateLog != null) ...[ + const SizedBox(height: 16), + Text( + '${context.t('update.changelog')}:', + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), + Text( + versionInfo.updateLog!, + style: TextStyle(fontSize: 14, color: Colors.grey[700]), + ), + ], + const SizedBox(height: 16), + Text( + context.t('update.marketHint'), + style: TextStyle(fontSize: 12, color: Colors.grey[400]), + ), + ], + ), + ), + actions: [ + if (!versionInfo.forceUpdate) + TextButton( + onPressed: () => Navigator.pop(context), + child: Text( + context.t('update.later'), + style: TextStyle(fontSize: 14, color: Colors.grey[600]), + ), + ), + ElevatedButton( + onPressed: () async { + Navigator.pop(context); + final packageInfo = await versionChecker.getCurrentVersion(); + await AppMarketDetector.openAppMarketDetail(packageInfo.packageName); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + ), + child: Text(context.t('update.goMarket'), style: const TextStyle(fontSize: 14)), + ), + ], + ), + ), + ); + } + + void _showSelfHostedUpdateDialog(BuildContext context, VersionInfo versionInfo) { + showDialog( + context: context, + barrierDismissible: !versionInfo.forceUpdate, + builder: (context) => PopScope( + canPop: !versionInfo.forceUpdate, + child: AlertDialog( + title: Text( + versionInfo.forceUpdate + ? context.t('update.importantUpdate') + : context.t('update.newVersion'), + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600), + ), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${context.t('update.latestVersion')}: ${versionInfo.version}', + style: TextStyle(fontSize: 14, color: Colors.grey[700]), + ), + const SizedBox(height: 8), + Text( + '${context.t('update.fileSize')}: ${versionInfo.fileSizeFriendly}', + style: TextStyle(fontSize: 14, color: Colors.grey[700]), + ), + if (versionInfo.updateLog != null) ...[ + const SizedBox(height: 16), + Text( + '${context.t('update.changelog')}:', + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), + Text( + versionInfo.updateLog!, + style: TextStyle(fontSize: 14, color: Colors.grey[700]), + ), + ], + ], + ), + ), + actions: [ + if (!versionInfo.forceUpdate) + TextButton( + onPressed: () => Navigator.pop(context), + child: Text( + context.t('update.skipUpdate'), + style: TextStyle(fontSize: 14, color: Colors.grey[600]), + ), + ), + ElevatedButton( + onPressed: () { + Navigator.pop(context); + _startUpdate(context, versionInfo); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + ), + child: Text(context.t('update.updateNow'), style: const TextStyle(fontSize: 14)), + ), + ], + ), + ), + ); + } + + Future _startUpdate(BuildContext context, VersionInfo versionInfo) async { + if (!context.mounted) return; + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => _DownloadProgressDialog( + versionInfo: versionInfo, + downloadManager: downloadManager, + forceUpdate: versionInfo.forceUpdate, + ), + ); + } + + Future cleanup() async { + await downloadManager.cleanupDownloadedApk(); + } +} + +/// 下载进度对话框 +class _DownloadProgressDialog extends StatefulWidget { + final VersionInfo versionInfo; + final DownloadManager downloadManager; + final bool forceUpdate; + + const _DownloadProgressDialog({ + required this.versionInfo, + required this.downloadManager, + required this.forceUpdate, + }); + + @override + State<_DownloadProgressDialog> createState() => _DownloadProgressDialogState(); +} + +class _DownloadProgressDialogState extends State<_DownloadProgressDialog> { + double _progress = 0.0; + String _statusText = ''; + bool _isDownloading = true; + bool _hasError = false; + bool _downloadCompleted = false; + File? _downloadedApkFile; + + @override + void initState() { + super.initState(); + _startDownload(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_statusText.isEmpty) { + _statusText = context.t('update.preparing'); + } + } + + Future _startDownload() async { + if (mounted) { + setState(() { + _statusText = context.t('update.downloading'); + _isDownloading = true; + _hasError = false; + _downloadCompleted = false; + }); + } + + final apkFile = await widget.downloadManager.downloadApk( + url: widget.versionInfo.downloadUrl, + sha256Expected: widget.versionInfo.sha256, + onProgress: (received, total) { + if (mounted) { + setState(() { + _progress = received / total; + final receivedMB = (received / 1024 / 1024).toStringAsFixed(1); + final totalMB = (total / 1024 / 1024).toStringAsFixed(1); + _statusText = '${context.t('update.downloading')} $receivedMB MB / $totalMB MB'; + }); + } + }, + ); + + if (!mounted) return; + + if (apkFile == null) { + setState(() { + _statusText = widget.downloadManager.status == DownloadStatus.cancelled + ? context.t('update.cancelled') + : context.t('update.failed'); + _isDownloading = false; + _hasError = true; + }); + return; + } + + _downloadedApkFile = apkFile; + + if (widget.forceUpdate) { + setState(() => _statusText = context.t('update.installing')); + await Future.delayed(const Duration(milliseconds: 500)); + await _installApk(); + } else { + setState(() { + _statusText = context.t('update.downloadComplete'); + _isDownloading = false; + _downloadCompleted = true; + }); + } + } + + Future _installApk() async { + if (_downloadedApkFile == null) return; + + setState(() { + _statusText = context.t('update.installing'); + _isDownloading = true; + }); + + final installed = await ApkInstaller.installApk(_downloadedApkFile!); + + if (!mounted) return; + + if (installed) { + Navigator.pop(context); + } else { + setState(() { + _statusText = context.t('update.installFailed'); + _isDownloading = false; + _hasError = true; + }); + } + } + + void _cancelDownload() { + widget.downloadManager.cancelDownload(); + } + + void _retryDownload() { + widget.downloadManager.reset(); + _startDownload(); + } + + @override + Widget build(BuildContext context) { + String title; + if (_downloadCompleted && !_hasError) { + title = context.t('update.downloadComplete'); + } else if (_hasError) { + title = context.t('update.updateFailed'); + } else { + title = context.t('update.updating'); + } + + return PopScope( + canPop: !_isDownloading && !widget.forceUpdate, + child: AlertDialog( + title: Text( + title, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600), + ), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + LinearProgressIndicator( + value: _progress, + backgroundColor: Colors.grey[200], + valueColor: AlwaysStoppedAnimation( + _hasError ? Colors.red : AppColors.primary, + ), + ), + const SizedBox(height: 16), + Text( + _statusText, + style: TextStyle( + fontSize: 14, + color: _hasError ? Colors.red : Colors.grey[700], + ), + ), + if (_isDownloading) ...[ + const SizedBox(height: 8), + Text( + '${(_progress * 100).toStringAsFixed(0)}%', + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.w700, + color: AppColors.primary, + ), + ), + ], + ], + ), + actions: [ + if (_isDownloading) + TextButton( + onPressed: _cancelDownload, + child: Text( + context.t('update.cancel'), + style: TextStyle(fontSize: 14, color: Colors.grey[600]), + ), + ), + if (!_isDownloading && _hasError) ...[ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text( + context.t('update.close'), + style: TextStyle(fontSize: 14, color: Colors.grey[600]), + ), + ), + ElevatedButton( + onPressed: _retryDownload, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + ), + child: Text(context.t('update.retry'), style: const TextStyle(fontSize: 14)), + ), + ], + if (_downloadCompleted && !_hasError) ...[ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text( + context.t('update.installLater'), + style: TextStyle(fontSize: 14, color: Colors.grey[600]), + ), + ), + ElevatedButton( + onPressed: _installApk, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + ), + child: Text(context.t('update.installNow'), style: const TextStyle(fontSize: 14)), + ), + ], + ], + ), + ); + } +} diff --git a/frontend/mobile/lib/core/updater/download_manager.dart b/frontend/mobile/lib/core/updater/download_manager.dart new file mode 100644 index 0000000..320e6d5 --- /dev/null +++ b/frontend/mobile/lib/core/updater/download_manager.dart @@ -0,0 +1,165 @@ +import 'dart:io'; +import 'package:dio/dio.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:crypto/crypto.dart'; +import 'package:flutter/foundation.dart'; + +/// 下载状态 +enum DownloadStatus { + idle, + downloading, + verifying, + completed, + failed, + cancelled, +} + +/// 下载进度回调 +typedef DownloadProgressCallback = void Function(int received, int total); + +/// 下载管理器 +/// 负责下载 APK 文件并验证完整性,支持断点续传 +class DownloadManager { + final Dio _dio = Dio(); + CancelToken? _cancelToken; + DownloadStatus _status = DownloadStatus.idle; + + DownloadStatus get status => _status; + + /// 下载 APK 文件(支持断点续传) + Future downloadApk({ + required String url, + required String sha256Expected, + DownloadProgressCallback? onProgress, + }) async { + try { + if (!url.startsWith('https://')) { + debugPrint('Download URL must use HTTPS'); + _status = DownloadStatus.failed; + return null; + } + + _status = DownloadStatus.downloading; + _cancelToken = CancelToken(); + + final dir = await getExternalStorageDirectory() ?? await getApplicationSupportDirectory(); + final savePath = '${dir.path}/app_update.apk'; + final tempPath = '${dir.path}/app_update.apk.tmp'; + final file = File(savePath); + final tempFile = File(tempPath); + + int downloadedBytes = 0; + if (await tempFile.exists()) { + downloadedBytes = await tempFile.length(); + } + + if (await file.exists()) { + await file.delete(); + } + + final response = await _dio.get( + url, + cancelToken: _cancelToken, + options: Options( + responseType: ResponseType.stream, + receiveTimeout: const Duration(minutes: 10), + sendTimeout: const Duration(minutes: 10), + headers: downloadedBytes > 0 + ? {'Range': 'bytes=$downloadedBytes-'} + : null, + ), + ); + + int totalBytes = 0; + final contentLength = response.headers.value('content-length'); + final contentRange = response.headers.value('content-range'); + + if (contentRange != null) { + final match = RegExp(r'bytes \d+-\d+/(\d+)').firstMatch(contentRange); + if (match != null) { + totalBytes = int.parse(match.group(1)!); + } + } else if (contentLength != null) { + totalBytes = int.parse(contentLength) + downloadedBytes; + } + + final sink = tempFile.openWrite(mode: FileMode.append); + int receivedBytes = downloadedBytes; + + try { + await for (final chunk in response.data!.stream) { + sink.add(chunk); + receivedBytes += chunk.length; + + if (totalBytes > 0) { + onProgress?.call(receivedBytes, totalBytes); + } + } + await sink.flush(); + } finally { + await sink.close(); + } + + await tempFile.rename(savePath); + + _status = DownloadStatus.verifying; + + final isValid = await _verifySha256(file, sha256Expected); + if (!isValid) { + debugPrint('SHA-256 verification failed'); + await file.delete(); + _status = DownloadStatus.failed; + return null; + } + + _status = DownloadStatus.completed; + return file; + } on DioException catch (e) { + if (e.type == DioExceptionType.cancel) { + _status = DownloadStatus.cancelled; + } else { + _status = DownloadStatus.failed; + } + return null; + } catch (e) { + debugPrint('Download failed: $e'); + _status = DownloadStatus.failed; + return null; + } + } + + void cancelDownload() { + _cancelToken?.cancel('User cancelled download'); + _status = DownloadStatus.cancelled; + } + + void reset() { + _cancelToken = null; + _status = DownloadStatus.idle; + } + + Future _verifySha256(File file, String expectedSha256) async { + try { + final bytes = await file.readAsBytes(); + final digest = sha256.convert(bytes); + final actualSha256 = digest.toString(); + return actualSha256.toLowerCase() == expectedSha256.toLowerCase(); + } catch (e) { + debugPrint('SHA-256 verification error: $e'); + return false; + } + } + + Future cleanupDownloadedApk() async { + try { + final dir = await getExternalStorageDirectory() ?? await getApplicationSupportDirectory(); + final file = File('${dir.path}/app_update.apk'); + final tempFile = File('${dir.path}/app_update.apk.tmp'); + + if (await file.exists()) await file.delete(); + if (await tempFile.exists()) await tempFile.delete(); + } catch (e) { + debugPrint('Cleanup failed: $e'); + } + } +} diff --git a/frontend/mobile/lib/core/updater/models/update_config.dart b/frontend/mobile/lib/core/updater/models/update_config.dart new file mode 100644 index 0000000..e645e3f --- /dev/null +++ b/frontend/mobile/lib/core/updater/models/update_config.dart @@ -0,0 +1,56 @@ +/// 更新渠道类型 +enum UpdateChannel { + /// Google Play 应用内更新 + googlePlay, + + /// 自建服务器 APK 升级 + selfHosted, +} + +/// 更新配置 +class UpdateConfig { + /// 更新渠道 + final UpdateChannel channel; + + /// API 基础地址 (selfHosted 模式必需) + final String? apiBaseUrl; + + /// 是否启用更新检测 + final bool enabled; + + /// 检查更新间隔(秒) + final int checkIntervalSeconds; + + const UpdateConfig({ + required this.channel, + this.apiBaseUrl, + this.enabled = true, + this.checkIntervalSeconds = 86400, + }); + + /// 默认配置 - 自建服务器模式 + static UpdateConfig selfHosted({ + required String apiBaseUrl, + bool enabled = true, + int checkIntervalSeconds = 86400, + }) { + return UpdateConfig( + channel: UpdateChannel.selfHosted, + apiBaseUrl: apiBaseUrl, + enabled: enabled, + checkIntervalSeconds: checkIntervalSeconds, + ); + } + + /// 默认配置 - Google Play 模式 + static UpdateConfig googlePlay({ + bool enabled = true, + int checkIntervalSeconds = 86400, + }) { + return UpdateConfig( + channel: UpdateChannel.googlePlay, + enabled: enabled, + checkIntervalSeconds: checkIntervalSeconds, + ); + } +} diff --git a/frontend/mobile/lib/core/updater/models/version_info.dart b/frontend/mobile/lib/core/updater/models/version_info.dart new file mode 100644 index 0000000..10f7c93 --- /dev/null +++ b/frontend/mobile/lib/core/updater/models/version_info.dart @@ -0,0 +1,80 @@ +/// 版本信息模型 +class VersionInfo { + /// 版本号: "1.2.0" + final String version; + + /// 版本代码: 120 + final int versionCode; + + /// APK 下载地址 + final String downloadUrl; + + /// 文件大小(字节) + final int fileSize; + + /// 友好显示: "25.6 MB" + final String fileSizeFriendly; + + /// SHA-256 校验 + final String sha256; + + /// 是否强制更新 + final bool forceUpdate; + + /// 更新日志 + final String? updateLog; + + /// 发布时间 + final DateTime releaseDate; + + const VersionInfo({ + required this.version, + required this.versionCode, + required this.downloadUrl, + required this.fileSize, + required this.fileSizeFriendly, + required this.sha256, + this.forceUpdate = false, + this.updateLog, + required this.releaseDate, + }); + + factory VersionInfo.fromJson(Map json) { + return VersionInfo( + version: json['version'] as String, + versionCode: json['versionCode'] as int, + downloadUrl: json['downloadUrl'] as String, + fileSize: json['fileSize'] as int, + fileSizeFriendly: json['fileSizeFriendly'] as String, + sha256: json['sha256'] as String, + forceUpdate: json['forceUpdate'] as bool? ?? false, + updateLog: json['updateLog'] as String?, + releaseDate: DateTime.parse(json['releaseDate'] as String), + ); + } + + Map toJson() { + return { + 'version': version, + 'versionCode': versionCode, + 'downloadUrl': downloadUrl, + 'fileSize': fileSize, + 'fileSizeFriendly': fileSizeFriendly, + 'sha256': sha256, + 'forceUpdate': forceUpdate, + 'updateLog': updateLog, + 'releaseDate': releaseDate.toIso8601String(), + }; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is VersionInfo && + runtimeType == other.runtimeType && + version == other.version && + versionCode == other.versionCode; + + @override + int get hashCode => version.hashCode ^ versionCode.hashCode; +} diff --git a/frontend/mobile/lib/core/updater/update_service.dart b/frontend/mobile/lib/core/updater/update_service.dart new file mode 100644 index 0000000..6439f73 --- /dev/null +++ b/frontend/mobile/lib/core/updater/update_service.dart @@ -0,0 +1,142 @@ +import 'package:flutter/material.dart'; +import '../../app/i18n/app_localizations.dart'; +import '../../app/theme/app_colors.dart'; +import 'channels/google_play_updater.dart'; +import 'channels/self_hosted_updater.dart'; +import 'models/update_config.dart'; +import 'models/version_info.dart'; + +/// 统一升级服务 +/// 根据配置自动选择合适的升级渠道 +class UpdateService { + static UpdateService? _instance; + UpdateService._(); + + factory UpdateService() { + _instance ??= UpdateService._(); + return _instance!; + } + + late UpdateConfig _config; + SelfHostedUpdater? _selfHostedUpdater; + bool _isInitialized = false; + bool _isShowingUpdateDialog = false; + + bool get isInitialized => _isInitialized; + bool get isShowingUpdateDialog => _isShowingUpdateDialog; + UpdateConfig get config => _config; + + void initialize(UpdateConfig config) { + _config = config; + if (config.channel == UpdateChannel.selfHosted) { + if (config.apiBaseUrl == null) { + throw ArgumentError('apiBaseUrl is required for self-hosted channel'); + } + _selfHostedUpdater = SelfHostedUpdater(apiBaseUrl: config.apiBaseUrl!); + } + _isInitialized = true; + debugPrint('UpdateService initialized: ${_config.channel}'); + } + + Future checkForUpdate(BuildContext context) async { + if (!_isInitialized || !_config.enabled) return; + + _isShowingUpdateDialog = true; + try { + switch (_config.channel) { + case UpdateChannel.googlePlay: + await _checkGooglePlayUpdate(context); + break; + case UpdateChannel.selfHosted: + await _selfHostedUpdater!.checkAndPromptUpdate(context); + break; + } + } finally { + _isShowingUpdateDialog = false; + } + } + + Future silentCheck() async { + if (!_isInitialized || !_config.enabled) return null; + if (_config.channel == UpdateChannel.selfHosted) { + return await _selfHostedUpdater?.silentCheckUpdate(); + } + return null; + } + + Future _checkGooglePlayUpdate(BuildContext context) async { + final hasUpdate = await GooglePlayUpdater.checkForUpdate(); + if (!hasUpdate || !context.mounted) return; + + await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text( + context.t('update.newVersion'), + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600), + ), + content: Text( + context.t('update.updateNow'), + style: TextStyle(fontSize: 14, color: Colors.grey[700]), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text( + context.t('update.later'), + style: TextStyle(fontSize: 14, color: Colors.grey[600]), + ), + ), + ElevatedButton( + onPressed: () { + Navigator.pop(context); + GooglePlayUpdater.performFlexibleUpdate(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + ), + child: Text(context.t('update.updateNow'), style: const TextStyle(fontSize: 14)), + ), + ], + ), + ); + } + + Future manualCheckUpdate(BuildContext context) async { + if (!_isInitialized) return; + + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => const Center(child: CircularProgressIndicator(color: AppColors.primary)), + ); + + await Future.delayed(const Duration(seconds: 1)); + if (!context.mounted) return; + Navigator.pop(context); + + final versionInfo = await silentCheck(); + if (!context.mounted) return; + + if (versionInfo == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.t('update.isLatest')), + backgroundColor: AppColors.success, + duration: const Duration(seconds: 2), + ), + ); + } else { + await checkForUpdate(context); + } + } + + Future cleanup() async { + await _selfHostedUpdater?.cleanup(); + } + + static void reset() { + _instance = null; + } +} diff --git a/frontend/mobile/lib/core/updater/version_checker.dart b/frontend/mobile/lib/core/updater/version_checker.dart new file mode 100644 index 0000000..626a0e0 --- /dev/null +++ b/frontend/mobile/lib/core/updater/version_checker.dart @@ -0,0 +1,99 @@ +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'models/version_info.dart'; + +/// 版本检测器 +/// 负责从服务器获取最新版本信息并与当前版本比较 +class VersionChecker { + final String apiBaseUrl; + final Dio _dio; + + VersionChecker({required this.apiBaseUrl}) + : _dio = Dio(BaseOptions( + baseUrl: apiBaseUrl, + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 10), + )); + + /// 获取当前版本信息 + Future getCurrentVersion() async { + return await PackageInfo.fromPlatform(); + } + + /// 从服务器获取最新版本信息 + Future fetchLatestVersion() async { + try { + final currentInfo = await getCurrentVersion(); + debugPrint('[VersionChecker] 当前版本: ${currentInfo.version}, buildNumber: ${currentInfo.buildNumber}'); + + final response = await _dio.get( + '/api/app/version/check', + queryParameters: { + 'platform': 'android', + 'current_version': currentInfo.version, + 'current_version_code': currentInfo.buildNumber, + }, + ); + + if (response.statusCode == 200 && response.data != null) { + final data = response.data is Map + ? response.data as Map + : (response.data['data'] as Map?) ?? response.data; + + final needUpdate = data['needUpdate'] as bool? ?? true; + if (!needUpdate) { + debugPrint('[VersionChecker] 无需更新'); + return null; + } + return VersionInfo.fromJson(data); + } + return null; + } catch (e) { + debugPrint('[VersionChecker] 获取版本失败: $e'); + return null; + } + } + + /// 检查是否有新版本 + Future checkForUpdate() async { + try { + final currentInfo = await getCurrentVersion(); + final latestInfo = await fetchLatestVersion(); + + if (latestInfo == null) return null; + + final currentCode = int.tryParse(currentInfo.buildNumber) ?? 0; + if (latestInfo.versionCode > currentCode) { + return latestInfo; + } + + return null; + } catch (e) { + debugPrint('Check update failed: $e'); + return null; + } + } + + /// 是否需要强制更新 + Future needForceUpdate() async { + final latestInfo = await checkForUpdate(); + return latestInfo?.forceUpdate ?? false; + } + + /// 获取版本差异 + Future getVersionDiff() async { + try { + final currentInfo = await getCurrentVersion(); + final latestInfo = await fetchLatestVersion(); + + if (latestInfo == null) return 0; + + final currentCode = int.tryParse(currentInfo.buildNumber) ?? 0; + return latestInfo.versionCode - currentCode; + } catch (e) { + debugPrint('Get version diff failed: $e'); + return 0; + } + } +} diff --git a/frontend/mobile/lib/features/message/presentation/pages/message_page.dart b/frontend/mobile/lib/features/message/presentation/pages/message_page.dart index 902f919..890a742 100644 --- a/frontend/mobile/lib/features/message/presentation/pages/message_page.dart +++ b/frontend/mobile/lib/features/message/presentation/pages/message_page.dart @@ -2,13 +2,14 @@ import 'package:flutter/material.dart'; import '../../../../app/i18n/app_localizations.dart'; import '../../../../app/theme/app_colors.dart'; import '../../../../app/theme/app_typography.dart'; -import '../../../../app/theme/app_spacing.dart'; import '../../../../shared/widgets/empty_state.dart'; +import '../../../../core/services/notification_service.dart'; +import '../../../../core/providers/notification_badge_manager.dart'; /// A8. 消息模块 /// -/// 交易通知、系统公告、券到期提醒、价格提醒 -/// 分类Tab + 消息详情 +/// 通知 + 公告,分类Tab + 消息详情 +/// 接入后端真实 API class MessagePage extends StatefulWidget { const MessagePage({super.key}); @@ -19,11 +20,23 @@ class MessagePage extends StatefulWidget { class _MessagePageState extends State with SingleTickerProviderStateMixin { late TabController _tabController; + final NotificationService _notificationService = NotificationService(); + + List _notifications = []; + bool _isLoading = false; + String? _error; + NotificationType? _currentFilter; @override void initState() { super.initState(); _tabController = TabController(length: 4, vsync: this); + _tabController.addListener(() { + if (!_tabController.indexIsChanging) { + _onTabChanged(_tabController.index); + } + }); + _loadNotifications(); } @override @@ -32,6 +45,67 @@ class _MessagePageState extends State super.dispose(); } + void _onTabChanged(int index) { + switch (index) { + case 0: + _currentFilter = null; + break; + case 1: + _currentFilter = NotificationType.system; + break; + case 2: + _currentFilter = NotificationType.announcement; + break; + case 3: + _currentFilter = NotificationType.activity; + break; + } + _loadNotifications(); + } + + Future _loadNotifications() async { + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final response = await _notificationService.getNotifications( + type: _currentFilter, + ); + if (mounted) { + setState(() { + _notifications = response.notifications; + _isLoading = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _error = e.toString(); + _isLoading = false; + }); + } + } + } + + Future _markAllAsRead() async { + final success = await _notificationService.markAllAnnouncementsAsRead(); + if (success) { + NotificationBadgeManager().clearCount(); + _loadNotifications(); + } + } + + Future _markAsRead(NotificationItem item) async { + if (item.isRead) return; + final success = await _notificationService.markAsRead(item.id); + if (success) { + NotificationBadgeManager().decrementCount(); + _loadNotifications(); + } + } + @override Widget build(BuildContext context) { return Scaffold( @@ -39,86 +113,92 @@ class _MessagePageState extends State title: Text(context.t('message.title')), actions: [ TextButton( - onPressed: () {}, - child: Text(context.t('message.markAllRead'), style: AppTypography.labelSmall.copyWith( - color: AppColors.primary, - )), + onPressed: _markAllAsRead, + child: Text(context.t('notification.markAllRead'), + style: AppTypography.labelSmall.copyWith(color: AppColors.primary)), ), ], bottom: TabBar( controller: _tabController, tabs: [ Tab(text: context.t('common.all')), - Tab(text: context.t('message.tabTrade')), - Tab(text: context.t('message.tabExpiry')), - Tab(text: context.t('message.tabAnnouncement')), + Tab(text: context.t('notification.system')), + Tab(text: context.t('notification.announcement')), + Tab(text: context.t('notification.activity')), ], ), ), - body: TabBarView( - controller: _tabController, + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _error != null + ? _buildErrorView() + : _notifications.isEmpty + ? EmptyState.noMessages(context: context) + : RefreshIndicator( + onRefresh: _loadNotifications, + child: ListView.separated( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: _notifications.length, + separatorBuilder: (_, __) => const Divider(indent: 76), + itemBuilder: (context, index) { + return _buildMessageItem(_notifications[index]); + }, + ), + ), + ); + } + + Widget _buildErrorView() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, children: [ - _buildMessageList(all: true), - _buildMessageList(type: MessageType.transaction), - _buildMessageList(type: MessageType.expiry), - _buildMessageList(type: MessageType.announcement), + Text(context.t('notification.loadFailed'), + style: AppTypography.bodyMedium), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadNotifications, + child: Text(context.t('notification.retry')), + ), ], ), ); } - Widget _buildMessageList({bool all = false, MessageType? type}) { - if (type == MessageType.announcement) { - return EmptyState.noMessages(context: context); - } - - final messages = _mockMessages - .where((m) => all || m.type == type) - .toList(); - - return ListView.separated( - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: messages.length, - separatorBuilder: (_, __) => const Divider(indent: 76), - itemBuilder: (context, index) { - final msg = messages[index]; - return _buildMessageItem(msg); - }, - ); - } - - Widget _buildMessageItem(_MockMessage msg) { + Widget _buildMessageItem(NotificationItem item) { return ListTile( contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4), leading: Container( width: 44, height: 44, decoration: BoxDecoration( - color: _iconBgColor(msg.type), + color: _iconColor(item.type).withValues(alpha: 0.1), shape: BoxShape.circle, ), - child: Icon(_iconData(msg.type), size: 22, color: _iconColor(msg.type)), + child: Icon(_iconData(item.type), size: 22, color: _iconColor(item.type)), ), title: Row( children: [ Expanded( - child: Text(msg.title, style: AppTypography.labelMedium.copyWith( - fontWeight: msg.isRead ? FontWeight.w400 : FontWeight.w600, - )), + child: Text(item.title, + style: AppTypography.labelMedium.copyWith( + fontWeight: item.isRead ? FontWeight.w400 : FontWeight.w600, + )), ), - Text(msg.time, style: AppTypography.caption), + if (item.publishedAt != null) + Text(_formatTime(item.publishedAt!), style: AppTypography.caption), ], ), subtitle: Padding( padding: const EdgeInsets.only(top: 4), child: Text( - msg.body, + item.content, style: AppTypography.bodySmall, maxLines: 2, overflow: TextOverflow.ellipsis, ), ), - trailing: !msg.isRead + trailing: !item.isRead ? Container( width: 8, height: 8, @@ -129,88 +209,49 @@ class _MessagePageState extends State ) : null, onTap: () { + _markAsRead(item); Navigator.pushNamed(context, '/message/detail'); }, ); } - IconData _iconData(MessageType type) { + String _formatTime(DateTime time) { + final now = DateTime.now(); + final diff = now.difference(time); + if (diff.inMinutes < 1) return '刚刚'; + if (diff.inMinutes < 60) return '${diff.inMinutes}分钟前'; + if (diff.inHours < 24) return '${diff.inHours}小时前'; + if (diff.inDays < 7) return '${diff.inDays}天前'; + return '${time.month}/${time.day}'; + } + + IconData _iconData(NotificationType type) { switch (type) { - case MessageType.transaction: + case NotificationType.system: + return Icons.settings_rounded; + case NotificationType.activity: return Icons.swap_horiz_rounded; - case MessageType.expiry: - return Icons.access_time_rounded; - case MessageType.price: - return Icons.trending_up_rounded; - case MessageType.announcement: + case NotificationType.reward: + return Icons.card_giftcard_rounded; + case NotificationType.upgrade: + return Icons.system_update_rounded; + case NotificationType.announcement: return Icons.campaign_rounded; } } - Color _iconColor(MessageType type) { + Color _iconColor(NotificationType type) { switch (type) { - case MessageType.transaction: + case NotificationType.system: return AppColors.primary; - case MessageType.expiry: - return AppColors.warning; - case MessageType.price: + case NotificationType.activity: return AppColors.success; - case MessageType.announcement: + case NotificationType.reward: + return AppColors.warning; + case NotificationType.upgrade: + return AppColors.info; + case NotificationType.announcement: return AppColors.info; } } - - Color _iconBgColor(MessageType type) { - return _iconColor(type).withValues(alpha: 0.1); - } } - -enum MessageType { transaction, expiry, price, announcement } - -class _MockMessage { - final String title; - final String body; - final String time; - final MessageType type; - final bool isRead; - - const _MockMessage(this.title, this.body, this.time, this.type, this.isRead); -} - -const _mockMessages = [ - _MockMessage( - 'Purchase Successful', - 'You have successfully purchased Starbucks \$25 Gift Card for \$21.25', - '14:32', - MessageType.transaction, - false, - ), - _MockMessage( - 'Coupon Expiring Soon', - 'Your Target \$30 Voucher will expire in 3 days', - '10:15', - MessageType.expiry, - false, - ), - _MockMessage( - 'Price Alert', - 'Amazon \$100 Voucher price dropped to \$82, below your alert price', - 'Yesterday', - MessageType.price, - true, - ), - _MockMessage( - 'Sale Completed', - 'Your listed Nike \$80 Voucher has been sold for \$68.00', - '02/07', - MessageType.transaction, - true, - ), - _MockMessage( - 'Redeem Successful', - 'Walmart \$50 Voucher redeemed at store', - '02/06', - MessageType.transaction, - true, - ), -]; diff --git a/frontend/mobile/lib/features/profile/presentation/pages/settings_page.dart b/frontend/mobile/lib/features/profile/presentation/pages/settings_page.dart index fd70914..c0b1b78 100644 --- a/frontend/mobile/lib/features/profile/presentation/pages/settings_page.dart +++ b/frontend/mobile/lib/features/profile/presentation/pages/settings_page.dart @@ -1,14 +1,42 @@ import 'package:flutter/material.dart'; +import 'package:package_info_plus/package_info_plus.dart'; import '../../../../app/i18n/app_localizations.dart'; import '../../../../app/theme/app_colors.dart'; import '../../../../app/theme/app_typography.dart'; +import '../../../../core/updater/update_service.dart'; /// 设置页面 /// /// 账号安全、通知、支付管理、语言、关于 -class SettingsPage extends StatelessWidget { +class SettingsPage extends StatefulWidget { const SettingsPage({super.key}); + @override + State createState() => _SettingsPageState(); +} + +class _SettingsPageState extends State { + String _appVersion = ''; + + @override + void initState() { + super.initState(); + _loadVersion(); + } + + Future _loadVersion() async { + try { + final info = await PackageInfo.fromPlatform(); + if (mounted) { + setState(() => _appVersion = 'v${info.version}'); + } + } catch (_) { + if (mounted) { + setState(() => _appVersion = 'v1.0.0'); + } + } + } + @override Widget build(BuildContext context) { return Scaffold( @@ -49,7 +77,9 @@ class SettingsPage extends StatelessWidget { // About _buildSection(context.t('settings.about'), [ - _buildTile(context.t('settings.version'), subtitle: 'v1.0.0', icon: Icons.info_outline_rounded), + _buildTile(context.t('settings.version'), subtitle: _appVersion, icon: Icons.info_outline_rounded, onTap: () { + UpdateService().manualCheckUpdate(context); + }), _buildTile(context.t('settings.userAgreement'), icon: Icons.description_rounded), _buildTile(context.t('settings.privacyPolicy'), icon: Icons.privacy_tip_rounded), _buildTile(context.t('settings.helpCenter'), icon: Icons.help_outline_rounded), diff --git a/frontend/mobile/lib/main.dart b/frontend/mobile/lib/main.dart index e119ec2..164c2b0 100644 --- a/frontend/mobile/lib/main.dart +++ b/frontend/mobile/lib/main.dart @@ -3,6 +3,10 @@ import 'package:flutter_localizations/flutter_localizations.dart'; import 'app/i18n/app_localizations.dart'; import 'app/theme/app_theme.dart'; import 'app/main_shell.dart'; +import 'core/updater/update_service.dart'; +import 'core/updater/models/update_config.dart'; +import 'core/push/push_service.dart'; +import 'core/providers/notification_badge_manager.dart'; import 'features/auth/presentation/pages/login_page.dart'; import 'features/auth/presentation/pages/welcome_page.dart'; import 'features/auth/presentation/pages/register_page.dart'; @@ -30,7 +34,17 @@ import 'features/message/presentation/pages/message_detail_page.dart'; import 'features/issuer/presentation/pages/issuer_main_page.dart'; import 'features/merchant/presentation/pages/merchant_home_page.dart'; -void main() { +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + // 初始化升级服务 + UpdateService().initialize(UpdateConfig.selfHosted( + apiBaseUrl: 'https://api.gogenex.cn', + enabled: true, + )); + // 初始化推送(静默失败) + await PushService().initialize(); + // 初始化通知徽章 + NotificationBadgeManager().initialize(); runApp(const GenexConsumerApp()); } diff --git a/frontend/mobile/pubspec.yaml b/frontend/mobile/pubspec.yaml index a7c2dd7..a1f098e 100644 --- a/frontend/mobile/pubspec.yaml +++ b/frontend/mobile/pubspec.yaml @@ -11,6 +11,16 @@ dependencies: sdk: flutter flutter_localizations: sdk: flutter + dio: ^5.4.3+1 + package_info_plus: ^8.0.0 + path_provider: ^2.1.0 + crypto: ^3.0.3 + permission_handler: ^11.3.1 + url_launcher: ^6.2.6 + firebase_messaging: ^15.1.0 + firebase_core: ^3.4.0 + flutter_local_notifications: ^18.0.0 + in_app_update: ^4.2.2 dev_dependencies: flutter_test: