feat: 三端集成 App升级 + 内部推送 + FCM外部推送框架 (genex-mobile/admin-app/mobile)
从 rwadurian/frontend/mobile-app 移植升级系统和通知系统到 Genex 三个 Flutter 客户端, 适配目标项目轻量架构(ValueNotifier 替代 Riverpod,Dio HTTP 客户端,移除 screenutil)。 ## 新增核心模块 (每个 app 13 个 Dart 文件) ### 升级系统 (core/updater/) - UpdateService: 统一升级服务单例,支持 Google Play + 自建服务器双渠道 - VersionChecker: 版本检测器,调用 GET /api/app/version/check - DownloadManager: APK 下载管理,支持断点续传 + SHA256 校验 - ApkInstaller: APK 安装器 (Platform Channel) - AppMarketDetector: 应用市场来源检测 - SelfHostedUpdater: 自建服务器渠道更新对话框 (i18n 化) - GooglePlayUpdater: Google Play 应用内更新 ### 通知系统 (core/services/ + core/providers/) - NotificationService: 通知 + 公告 API 服务 - GET /notifications, /notifications/unread-count - PUT /notifications/:id/read - GET /announcements, /announcements/unread-count - PUT /announcements/:id/read, /announcements/read-all - NotificationBadgeManager: 未读徽章管理器 - ValueNotifier<int> 驱动 UI - 30秒定时自动刷新 + 前后台切换刷新 (WidgetsBindingObserver) ### FCM 推送框架 (core/push/) - PushService: Firebase 推送服务框架 - Firebase 代码注释保护,无配置文件时静默跳过 - 设备 token 注册 POST /device-tokens - 待 Firebase 配置文件就绪后取消注释即可启用 ### HTTP 客户端 (core/network/) - ApiClient: Dio 封装单例,baseUrl = https://api.gogenex.cn ## Android 原生配置 (每个 app) - AndroidManifest.xml: 添加 REQUEST_INSTALL_PACKAGES 权限 + FileProvider - res/xml/file_paths.xml: FileProvider 路径配置 - MainActivity.kt: APK 安装器 + 应用市场检测 MethodChannel ## UI 层改造 (每个 app) - main.dart: 异步启动,初始化 UpdateService/PushService/NotificationBadgeManager - MainShell: 消息 Tab 徽章改为 ValueListenableBuilder 动态未读数,进入后 3 秒检查更新 - SettingsPage: StatefulWidget 化,动态版本号 (PackageInfo),点击版本号手动检查更新 - MessagePage: 移除 mock 数据,接入 NotificationService API,4 Tab 分类 + 下拉刷新 + 标记已读 ## i18n 新增 (~35 keys/语言) - update.*: 25 个升级相关 keys - notification.*: 9 个通知相关 keys - genex-mobile: 4 语言 (zh_CN/zh_TW/en/ja) 分文件 - admin-app: 3 语言 (zh_CN/en_US/ja_JP) 内联单文件 - mobile: 4 语言 (zh_CN/zh_TW/en/ja) 分文件 ## 三端差异化配置 | App | MethodChannel 前缀 | applicationId | |-----|-------------------|---------------| | genex-mobile | cn.gogenex.consumer | cn.gogenex.consumer | | admin-app | cn.gogenex.issuer | cn.gogenex.issuer | | mobile | cn.gogenex.mobile | cn.gogenex.mobile | Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
8adead23b6
commit
184a7d16db
|
|
@ -1,5 +1,6 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
||||
<application
|
||||
android:label="Genex发行方"
|
||||
android:name="${applicationName}"
|
||||
|
|
@ -26,6 +27,15 @@
|
|||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
|
|
|
|||
|
|
@ -1,5 +1,75 @@
|
|||
package cn.gogenex.genex_issuer
|
||||
|
||||
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() {
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
|
||||
// APK 安装器
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "cn.gogenex.issuer/apk_installer")
|
||||
.setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"installApk" -> {
|
||||
val filePath = call.argument<String>("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<String>("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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<files-path name="internal_files" path="." />
|
||||
<cache-path name="cache" path="." />
|
||||
<external-files-path name="external_files" path="." />
|
||||
<root-path name="app_flutter" path="." />
|
||||
</paths>
|
||||
|
|
@ -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': 'リトライ',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<IssuerMainShell> {
|
||||
int _currentIndex = 0;
|
||||
bool _updateChecked = false;
|
||||
|
||||
final _pages = const [
|
||||
IssuerDashboardPage(),
|
||||
|
|
@ -28,6 +30,21 @@ class _IssuerMainShellState extends State<IssuerMainShell> {
|
|||
SettingsPage(),
|
||||
];
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (!_updateChecked) {
|
||||
_updateChecked = true;
|
||||
_checkForUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _checkForUpdate() async {
|
||||
await Future.delayed(const Duration(seconds: 3));
|
||||
if (!mounted) return;
|
||||
await UpdateService().checkForUpdate(context);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
|
|
|
|||
|
|
@ -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<Response> get(
|
||||
String path, {
|
||||
Map<String, dynamic>? queryParameters,
|
||||
Options? options,
|
||||
}) {
|
||||
return _dio.get(path, queryParameters: queryParameters, options: options);
|
||||
}
|
||||
|
||||
Future<Response> post(
|
||||
String path, {
|
||||
dynamic data,
|
||||
Map<String, dynamic>? queryParameters,
|
||||
Options? options,
|
||||
}) {
|
||||
return _dio.post(path, data: data, queryParameters: queryParameters, options: options);
|
||||
}
|
||||
|
||||
Future<Response> put(
|
||||
String path, {
|
||||
dynamic data,
|
||||
Map<String, dynamic>? queryParameters,
|
||||
Options? options,
|
||||
}) {
|
||||
return _dio.put(path, data: data, queryParameters: queryParameters, options: options);
|
||||
}
|
||||
|
||||
Future<Response> delete(
|
||||
String path, {
|
||||
dynamic data,
|
||||
Map<String, dynamic>? queryParameters,
|
||||
Options? options,
|
||||
}) {
|
||||
return _dio.delete(path, data: data, queryParameters: queryParameters, options: options);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<int> unreadCount = ValueNotifier<int>(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<void> _loadUnreadCount() async {
|
||||
try {
|
||||
final notifCount = await _notificationService!.getUnreadCount();
|
||||
final announcementCount = await _notificationService!.getAnnouncementUnreadCount();
|
||||
unreadCount.value = notifCount + announcementCount;
|
||||
} catch (e) {
|
||||
debugPrint('[NotificationBadge] 加载未读数量失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 手动刷新
|
||||
Future<void> 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<void> 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<void> _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<void> 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String, dynamic> 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<NotificationItem> notifications;
|
||||
final int total;
|
||||
final int unreadCount;
|
||||
|
||||
NotificationListResponse({
|
||||
required this.notifications,
|
||||
required this.total,
|
||||
required this.unreadCount,
|
||||
});
|
||||
|
||||
factory NotificationListResponse.fromJson(Map<String, dynamic> json) {
|
||||
final data = json['data'] ?? json;
|
||||
final list = (data['notifications'] as List?)
|
||||
?.map((e) => NotificationItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
(data['items'] as List?)
|
||||
?.map((e) => NotificationItem.fromJson(e as Map<String, dynamic>))
|
||||
.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<NotificationListResponse> getNotifications({
|
||||
NotificationType? type,
|
||||
int limit = 50,
|
||||
int offset = 0,
|
||||
}) async {
|
||||
try {
|
||||
final queryParams = <String, dynamic>{
|
||||
'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<String, dynamic> ? response.data : {},
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[NotificationService] 获取通知列表失败: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取未读通知数量
|
||||
Future<int> getUnreadCount() async {
|
||||
try {
|
||||
final response = await _apiClient.get('/notifications/unread-count');
|
||||
final data = response.data is Map<String, dynamic> ? response.data : {};
|
||||
return data['unreadCount'] ?? data['data']?['unreadCount'] ?? 0;
|
||||
} catch (e) {
|
||||
debugPrint('[NotificationService] 获取未读数量失败: $e');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// 标记通知为已读
|
||||
Future<bool> markAsRead(String notificationId) async {
|
||||
try {
|
||||
await _apiClient.put('/notifications/$notificationId/read');
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('[NotificationService] 标记已读失败: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取公告列表
|
||||
Future<NotificationListResponse> 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<String, dynamic> ? response.data : {},
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[NotificationService] 获取公告列表失败: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取公告未读数
|
||||
Future<int> getAnnouncementUnreadCount() async {
|
||||
try {
|
||||
final response = await _apiClient.get('/announcements/unread-count');
|
||||
final data = response.data is Map<String, dynamic> ? response.data : {};
|
||||
return data['unreadCount'] ?? data['data']?['unreadCount'] ?? 0;
|
||||
} catch (e) {
|
||||
debugPrint('[NotificationService] 获取公告未读数失败: $e');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// 标记公告已读
|
||||
Future<bool> markAnnouncementAsRead(String announcementId) async {
|
||||
try {
|
||||
await _apiClient.put('/announcements/$announcementId/read');
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('[NotificationService] 标记公告已读失败: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 全部标记已读
|
||||
Future<bool> markAllAnnouncementsAsRead() async {
|
||||
try {
|
||||
await _apiClient.put('/announcements/read-all');
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('[NotificationService] 全部标记已读失败: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<bool> 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<bool> _requestInstallPermission() async {
|
||||
if (await Permission.requestInstallPackages.isGranted) {
|
||||
return true;
|
||||
}
|
||||
final status = await Permission.requestInstallPackages.request();
|
||||
return status.isGranted;
|
||||
}
|
||||
|
||||
static Future<bool> hasInstallPermission() async {
|
||||
return await Permission.requestInstallPackages.isGranted;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String> _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<String?> getInstallerPackageName() async {
|
||||
if (!Platform.isAndroid) return null;
|
||||
try {
|
||||
return await _channel.invokeMethod<String>('getInstallerPackageName');
|
||||
} catch (e) {
|
||||
debugPrint('Get installer failed: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<bool> isFromAppMarket() async {
|
||||
final installer = await getInstallerPackageName();
|
||||
if (installer == null || installer.isEmpty) return false;
|
||||
return _marketPackages.contains(installer);
|
||||
}
|
||||
|
||||
static Future<bool> isFromGooglePlay() async {
|
||||
final installer = await getInstallerPackageName();
|
||||
return installer == 'com.android.vending';
|
||||
}
|
||||
|
||||
static Future<String> 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<bool> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import 'package:in_app_update/in_app_update.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Google Play 应用内更新器
|
||||
class GooglePlayUpdater {
|
||||
static Future<bool> checkForUpdate() async {
|
||||
try {
|
||||
final updateInfo = await InAppUpdate.checkForUpdate();
|
||||
return updateInfo.updateAvailability == UpdateAvailability.updateAvailable;
|
||||
} catch (e) {
|
||||
debugPrint('Check update failed: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> 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<void> 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<void> 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<VersionInfo?> 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<void> _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<void> 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<void> _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<void> _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<Color>(
|
||||
_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)),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<File?> 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<ResponseBody>(
|
||||
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<bool> _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<void> 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String, dynamic> 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<String, dynamic> 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;
|
||||
}
|
||||
|
|
@ -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<void> 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<VersionInfo?> silentCheck() async {
|
||||
if (!_isInitialized || !_config.enabled) return null;
|
||||
if (_config.channel == UpdateChannel.selfHosted) {
|
||||
return await _selfHostedUpdater?.silentCheckUpdate();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _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<void> 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<void> cleanup() async {
|
||||
await _selfHostedUpdater?.cleanup();
|
||||
}
|
||||
|
||||
static void reset() {
|
||||
_instance = null;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<PackageInfo> getCurrentVersion() async {
|
||||
return await PackageInfo.fromPlatform();
|
||||
}
|
||||
|
||||
/// 从服务器获取最新版本信息
|
||||
Future<VersionInfo?> 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<String, dynamic>
|
||||
? response.data as Map<String, dynamic>
|
||||
: (response.data['data'] as Map<String, dynamic>?) ?? 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<VersionInfo?> 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<bool> needForceUpdate() async {
|
||||
final latestInfo = await checkForUpdate();
|
||||
return latestInfo?.forceUpdate ?? false;
|
||||
}
|
||||
|
||||
/// 获取版本差异
|
||||
Future<int> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<SettingsPage> createState() => _SettingsPageState();
|
||||
}
|
||||
|
||||
class _SettingsPageState extends State<SettingsPage> {
|
||||
String _appVersion = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadVersion();
|
||||
}
|
||||
|
||||
Future<void> _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
|
||||
|
|
|
|||
|
|
@ -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<void> 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());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<application
|
||||
android:label="Genex"
|
||||
android:name="${applicationName}"
|
||||
|
|
@ -13,10 +14,6 @@
|
|||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||
the Android process has started. This theme is visible to the user
|
||||
while the Flutter UI initializes. After that, this theme continues
|
||||
to determine the Window background behind the Flutter UI. -->
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"
|
||||
|
|
@ -26,17 +23,20 @@
|
|||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
<!-- FileProvider for APK installation (Android 7.0+) -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||
|
||||
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
|
|
|
|||
|
|
@ -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<String>("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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<files-path name="internal_files" path="." />
|
||||
<files-path name="app_flutter" path="app_flutter/" />
|
||||
<cache-path name="cache" path="." />
|
||||
<external-files-path name="external_files" path="." />
|
||||
</paths>
|
||||
|
|
@ -733,4 +733,43 @@ const Map<String, String> 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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -733,4 +733,43 @@ const Map<String, String> 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': 'リトライ',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -733,4 +733,43 @@ const Map<String, String> 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': '重试',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -733,4 +733,43 @@ const Map<String, String> 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': '重試',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<MainShell> {
|
||||
int _currentIndex = 0;
|
||||
bool _updateChecked = false;
|
||||
|
||||
final _pages = const [
|
||||
HomePage(),
|
||||
|
|
@ -26,6 +29,21 @@ class _MainShellState extends State<MainShell> {
|
|||
ProfilePage(),
|
||||
];
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (!_updateChecked) {
|
||||
_updateChecked = true;
|
||||
_checkForUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _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<MainShell> {
|
|||
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<MainShell> {
|
|||
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<int>(
|
||||
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<int>(
|
||||
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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<Response> get(
|
||||
String path, {
|
||||
Map<String, dynamic>? queryParameters,
|
||||
Options? options,
|
||||
}) {
|
||||
return _dio.get(path, queryParameters: queryParameters, options: options);
|
||||
}
|
||||
|
||||
Future<Response> post(
|
||||
String path, {
|
||||
dynamic data,
|
||||
Map<String, dynamic>? queryParameters,
|
||||
Options? options,
|
||||
}) {
|
||||
return _dio.post(path, data: data, queryParameters: queryParameters, options: options);
|
||||
}
|
||||
|
||||
Future<Response> put(
|
||||
String path, {
|
||||
dynamic data,
|
||||
Map<String, dynamic>? queryParameters,
|
||||
Options? options,
|
||||
}) {
|
||||
return _dio.put(path, data: data, queryParameters: queryParameters, options: options);
|
||||
}
|
||||
|
||||
Future<Response> delete(
|
||||
String path, {
|
||||
dynamic data,
|
||||
Map<String, dynamic>? queryParameters,
|
||||
Options? options,
|
||||
}) {
|
||||
return _dio.delete(path, data: data, queryParameters: queryParameters, options: options);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<int> unreadCount = ValueNotifier<int>(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<void> _loadUnreadCount() async {
|
||||
try {
|
||||
final notifCount = await _notificationService!.getUnreadCount();
|
||||
final announcementCount = await _notificationService!.getAnnouncementUnreadCount();
|
||||
unreadCount.value = notifCount + announcementCount;
|
||||
} catch (e) {
|
||||
debugPrint('[NotificationBadge] 加载未读数量失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 手动刷新
|
||||
Future<void> 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<void> 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<void> _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<void> 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String, dynamic> 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<NotificationItem> notifications;
|
||||
final int total;
|
||||
final int unreadCount;
|
||||
|
||||
NotificationListResponse({
|
||||
required this.notifications,
|
||||
required this.total,
|
||||
required this.unreadCount,
|
||||
});
|
||||
|
||||
factory NotificationListResponse.fromJson(Map<String, dynamic> json) {
|
||||
final data = json['data'] ?? json;
|
||||
final list = (data['notifications'] as List?)
|
||||
?.map((e) => NotificationItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
(data['items'] as List?)
|
||||
?.map((e) => NotificationItem.fromJson(e as Map<String, dynamic>))
|
||||
.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<NotificationListResponse> getNotifications({
|
||||
NotificationType? type,
|
||||
int limit = 50,
|
||||
int offset = 0,
|
||||
}) async {
|
||||
try {
|
||||
final queryParams = <String, dynamic>{
|
||||
'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<String, dynamic> ? response.data : {},
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[NotificationService] 获取通知列表失败: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取未读通知数量
|
||||
Future<int> getUnreadCount() async {
|
||||
try {
|
||||
final response = await _apiClient.get('/notifications/unread-count');
|
||||
final data = response.data is Map<String, dynamic> ? response.data : {};
|
||||
return data['unreadCount'] ?? data['data']?['unreadCount'] ?? 0;
|
||||
} catch (e) {
|
||||
debugPrint('[NotificationService] 获取未读数量失败: $e');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// 标记通知为已读
|
||||
Future<bool> markAsRead(String notificationId) async {
|
||||
try {
|
||||
await _apiClient.put('/notifications/$notificationId/read');
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('[NotificationService] 标记已读失败: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取公告列表
|
||||
Future<NotificationListResponse> 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<String, dynamic> ? response.data : {},
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[NotificationService] 获取公告列表失败: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取公告未读数
|
||||
Future<int> getAnnouncementUnreadCount() async {
|
||||
try {
|
||||
final response = await _apiClient.get('/announcements/unread-count');
|
||||
final data = response.data is Map<String, dynamic> ? response.data : {};
|
||||
return data['unreadCount'] ?? data['data']?['unreadCount'] ?? 0;
|
||||
} catch (e) {
|
||||
debugPrint('[NotificationService] 获取公告未读数失败: $e');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// 标记公告已读
|
||||
Future<bool> markAnnouncementAsRead(String announcementId) async {
|
||||
try {
|
||||
await _apiClient.put('/announcements/$announcementId/read');
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('[NotificationService] 标记公告已读失败: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 全部标记已读
|
||||
Future<bool> markAllAnnouncementsAsRead() async {
|
||||
try {
|
||||
await _apiClient.put('/announcements/read-all');
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('[NotificationService] 全部标记已读失败: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<bool> 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<bool> _requestInstallPermission() async {
|
||||
if (await Permission.requestInstallPackages.isGranted) {
|
||||
return true;
|
||||
}
|
||||
final status = await Permission.requestInstallPackages.request();
|
||||
return status.isGranted;
|
||||
}
|
||||
|
||||
static Future<bool> hasInstallPermission() async {
|
||||
return await Permission.requestInstallPackages.isGranted;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String> _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<String?> getInstallerPackageName() async {
|
||||
if (!Platform.isAndroid) return null;
|
||||
try {
|
||||
return await _channel.invokeMethod<String>('getInstallerPackageName');
|
||||
} catch (e) {
|
||||
debugPrint('Get installer failed: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<bool> isFromAppMarket() async {
|
||||
final installer = await getInstallerPackageName();
|
||||
if (installer == null || installer.isEmpty) return false;
|
||||
return _marketPackages.contains(installer);
|
||||
}
|
||||
|
||||
static Future<bool> isFromGooglePlay() async {
|
||||
final installer = await getInstallerPackageName();
|
||||
return installer == 'com.android.vending';
|
||||
}
|
||||
|
||||
static Future<String> 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<bool> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import 'package:in_app_update/in_app_update.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Google Play 应用内更新器
|
||||
class GooglePlayUpdater {
|
||||
static Future<bool> checkForUpdate() async {
|
||||
try {
|
||||
final updateInfo = await InAppUpdate.checkForUpdate();
|
||||
return updateInfo.updateAvailability == UpdateAvailability.updateAvailable;
|
||||
} catch (e) {
|
||||
debugPrint('Check update failed: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> 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<void> 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<void> 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<VersionInfo?> 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<void> _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<void> 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<void> _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<void> _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<Color>(
|
||||
_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)),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<File?> 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<ResponseBody>(
|
||||
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<bool> _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<void> 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String, dynamic> 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<String, dynamic> 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;
|
||||
}
|
||||
|
|
@ -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<void> 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<VersionInfo?> silentCheck() async {
|
||||
if (!_isInitialized || !_config.enabled) return null;
|
||||
if (_config.channel == UpdateChannel.selfHosted) {
|
||||
return await _selfHostedUpdater?.silentCheckUpdate();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _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<void> 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<void> cleanup() async {
|
||||
await _selfHostedUpdater?.cleanup();
|
||||
}
|
||||
|
||||
static void reset() {
|
||||
_instance = null;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<PackageInfo> getCurrentVersion() async {
|
||||
return await PackageInfo.fromPlatform();
|
||||
}
|
||||
|
||||
/// 从服务器获取最新版本信息
|
||||
Future<VersionInfo?> 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<String, dynamic>
|
||||
? response.data as Map<String, dynamic>
|
||||
: (response.data['data'] as Map<String, dynamic>?) ?? 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<VersionInfo?> 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<bool> needForceUpdate() async {
|
||||
final latestInfo = await checkForUpdate();
|
||||
return latestInfo?.forceUpdate ?? false;
|
||||
}
|
||||
|
||||
/// 获取版本差异
|
||||
Future<int> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<MessagePage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
final NotificationService _notificationService = NotificationService();
|
||||
|
||||
List<NotificationItem> _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<MessagePage>
|
|||
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<void> _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<void> _markAllAsRead() async {
|
||||
final success = await _notificationService.markAllAnnouncementsAsRead();
|
||||
if (success) {
|
||||
NotificationBadgeManager().clearCount();
|
||||
_loadNotifications();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _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<MessagePage>
|
|||
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<MessagePage>
|
|||
)
|
||||
: 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,
|
||||
),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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<SettingsPage> {
|
||||
_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<void> _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<SettingsPage> {
|
|||
|
||||
_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'),
|
||||
|
|
|
|||
|
|
@ -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<void> 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<GenexConsumerApp> {
|
|||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
localeResolutionCallback: (systemLocale, supportedLocales) {
|
||||
// 用户未主动选择时,跟随系统语言
|
||||
if (LocaleManager.userLocale.value == null) {
|
||||
return LocaleManager.resolve(
|
||||
systemLocale != null ? [systemLocale] : null,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
||||
<application
|
||||
android:label="Genex"
|
||||
android:name="${applicationName}"
|
||||
|
|
@ -26,6 +27,15 @@
|
|||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
|
|
|
|||
|
|
@ -1,5 +1,102 @@
|
|||
package cn.gogenex.genex_consumer
|
||||
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
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 APK_INSTALLER_CHANNEL = "cn.gogenex.mobile/apk_installer"
|
||||
private val APP_MARKET_CHANNEL = "cn.gogenex.mobile/app_market"
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
|
||||
// APK Installer Channel
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, APK_INSTALLER_CHANNEL)
|
||||
.setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"installApk" -> {
|
||||
val filePath = call.argument<String>("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<String>("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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<external-path name="external_files" path="." />
|
||||
<external-files-path name="external_app_files" path="." />
|
||||
<cache-path name="cache" path="." />
|
||||
<files-path name="files" path="." />
|
||||
</paths>
|
||||
|
|
@ -683,4 +683,43 @@ const Map<String, String> 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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -683,4 +683,43 @@ const Map<String, String> 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': 'リトライ',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -683,4 +683,43 @@ const Map<String, String> 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': '重试',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -683,4 +683,43 @@ const Map<String, String> 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': '重試',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<MainShell> {
|
||||
int _currentIndex = 0;
|
||||
bool _updateChecked = false;
|
||||
|
||||
final _pages = const [
|
||||
HomePage(),
|
||||
|
|
@ -29,6 +32,19 @@ class _MainShellState extends State<MainShell> {
|
|||
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<MainShell> {
|
|||
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<MainShell> {
|
|||
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<int>(
|
||||
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<int>(
|
||||
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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<Response> get(
|
||||
String path, {
|
||||
Map<String, dynamic>? queryParameters,
|
||||
Options? options,
|
||||
}) {
|
||||
return _dio.get(path, queryParameters: queryParameters, options: options);
|
||||
}
|
||||
|
||||
Future<Response> post(
|
||||
String path, {
|
||||
dynamic data,
|
||||
Map<String, dynamic>? queryParameters,
|
||||
Options? options,
|
||||
}) {
|
||||
return _dio.post(path, data: data, queryParameters: queryParameters, options: options);
|
||||
}
|
||||
|
||||
Future<Response> put(
|
||||
String path, {
|
||||
dynamic data,
|
||||
Map<String, dynamic>? queryParameters,
|
||||
Options? options,
|
||||
}) {
|
||||
return _dio.put(path, data: data, queryParameters: queryParameters, options: options);
|
||||
}
|
||||
|
||||
Future<Response> delete(
|
||||
String path, {
|
||||
dynamic data,
|
||||
Map<String, dynamic>? queryParameters,
|
||||
Options? options,
|
||||
}) {
|
||||
return _dio.delete(path, data: data, queryParameters: queryParameters, options: options);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<int> unreadCount = ValueNotifier<int>(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<void> _loadUnreadCount() async {
|
||||
try {
|
||||
final notifCount = await _notificationService!.getUnreadCount();
|
||||
final announcementCount = await _notificationService!.getAnnouncementUnreadCount();
|
||||
unreadCount.value = notifCount + announcementCount;
|
||||
} catch (e) {
|
||||
debugPrint('[NotificationBadge] 加载未读数量失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 手动刷新
|
||||
Future<void> 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<void> 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<void> _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<void> 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String, dynamic> 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<NotificationItem> notifications;
|
||||
final int total;
|
||||
final int unreadCount;
|
||||
|
||||
NotificationListResponse({
|
||||
required this.notifications,
|
||||
required this.total,
|
||||
required this.unreadCount,
|
||||
});
|
||||
|
||||
factory NotificationListResponse.fromJson(Map<String, dynamic> json) {
|
||||
final data = json['data'] ?? json;
|
||||
final list = (data['notifications'] as List?)
|
||||
?.map((e) => NotificationItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
(data['items'] as List?)
|
||||
?.map((e) => NotificationItem.fromJson(e as Map<String, dynamic>))
|
||||
.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<NotificationListResponse> getNotifications({
|
||||
NotificationType? type,
|
||||
int limit = 50,
|
||||
int offset = 0,
|
||||
}) async {
|
||||
try {
|
||||
final queryParams = <String, dynamic>{
|
||||
'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<String, dynamic> ? response.data : {},
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[NotificationService] 获取通知列表失败: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取未读通知数量
|
||||
Future<int> getUnreadCount() async {
|
||||
try {
|
||||
final response = await _apiClient.get('/notifications/unread-count');
|
||||
final data = response.data is Map<String, dynamic> ? response.data : {};
|
||||
return data['unreadCount'] ?? data['data']?['unreadCount'] ?? 0;
|
||||
} catch (e) {
|
||||
debugPrint('[NotificationService] 获取未读数量失败: $e');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// 标记通知为已读
|
||||
Future<bool> markAsRead(String notificationId) async {
|
||||
try {
|
||||
await _apiClient.put('/notifications/$notificationId/read');
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('[NotificationService] 标记已读失败: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取公告列表
|
||||
Future<NotificationListResponse> 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<String, dynamic> ? response.data : {},
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[NotificationService] 获取公告列表失败: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取公告未读数
|
||||
Future<int> getAnnouncementUnreadCount() async {
|
||||
try {
|
||||
final response = await _apiClient.get('/announcements/unread-count');
|
||||
final data = response.data is Map<String, dynamic> ? response.data : {};
|
||||
return data['unreadCount'] ?? data['data']?['unreadCount'] ?? 0;
|
||||
} catch (e) {
|
||||
debugPrint('[NotificationService] 获取公告未读数失败: $e');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// 标记公告已读
|
||||
Future<bool> markAnnouncementAsRead(String announcementId) async {
|
||||
try {
|
||||
await _apiClient.put('/announcements/$announcementId/read');
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('[NotificationService] 标记公告已读失败: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 全部标记已读
|
||||
Future<bool> markAllAnnouncementsAsRead() async {
|
||||
try {
|
||||
await _apiClient.put('/announcements/read-all');
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('[NotificationService] 全部标记已读失败: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<bool> 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<bool> _requestInstallPermission() async {
|
||||
if (await Permission.requestInstallPackages.isGranted) {
|
||||
return true;
|
||||
}
|
||||
final status = await Permission.requestInstallPackages.request();
|
||||
return status.isGranted;
|
||||
}
|
||||
|
||||
static Future<bool> hasInstallPermission() async {
|
||||
return await Permission.requestInstallPackages.isGranted;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String> _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<String?> getInstallerPackageName() async {
|
||||
if (!Platform.isAndroid) return null;
|
||||
try {
|
||||
return await _channel.invokeMethod<String>('getInstallerPackageName');
|
||||
} catch (e) {
|
||||
debugPrint('Get installer failed: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<bool> isFromAppMarket() async {
|
||||
final installer = await getInstallerPackageName();
|
||||
if (installer == null || installer.isEmpty) return false;
|
||||
return _marketPackages.contains(installer);
|
||||
}
|
||||
|
||||
static Future<bool> isFromGooglePlay() async {
|
||||
final installer = await getInstallerPackageName();
|
||||
return installer == 'com.android.vending';
|
||||
}
|
||||
|
||||
static Future<String> 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<bool> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import 'package:in_app_update/in_app_update.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Google Play 应用内更新器
|
||||
class GooglePlayUpdater {
|
||||
static Future<bool> checkForUpdate() async {
|
||||
try {
|
||||
final updateInfo = await InAppUpdate.checkForUpdate();
|
||||
return updateInfo.updateAvailability == UpdateAvailability.updateAvailable;
|
||||
} catch (e) {
|
||||
debugPrint('Check update failed: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> 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<void> 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<void> 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<VersionInfo?> 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<void> _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<void> 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<void> _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<void> _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<Color>(
|
||||
_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)),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<File?> 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<ResponseBody>(
|
||||
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<bool> _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<void> 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String, dynamic> 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<String, dynamic> 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;
|
||||
}
|
||||
|
|
@ -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<void> 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<VersionInfo?> silentCheck() async {
|
||||
if (!_isInitialized || !_config.enabled) return null;
|
||||
if (_config.channel == UpdateChannel.selfHosted) {
|
||||
return await _selfHostedUpdater?.silentCheckUpdate();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _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<void> 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<void> cleanup() async {
|
||||
await _selfHostedUpdater?.cleanup();
|
||||
}
|
||||
|
||||
static void reset() {
|
||||
_instance = null;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<PackageInfo> getCurrentVersion() async {
|
||||
return await PackageInfo.fromPlatform();
|
||||
}
|
||||
|
||||
/// 从服务器获取最新版本信息
|
||||
Future<VersionInfo?> 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<String, dynamic>
|
||||
? response.data as Map<String, dynamic>
|
||||
: (response.data['data'] as Map<String, dynamic>?) ?? 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<VersionInfo?> 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<bool> needForceUpdate() async {
|
||||
final latestInfo = await checkForUpdate();
|
||||
return latestInfo?.forceUpdate ?? false;
|
||||
}
|
||||
|
||||
/// 获取版本差异
|
||||
Future<int> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<MessagePage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
final NotificationService _notificationService = NotificationService();
|
||||
|
||||
List<NotificationItem> _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<MessagePage>
|
|||
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<void> _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<void> _markAllAsRead() async {
|
||||
final success = await _notificationService.markAllAnnouncementsAsRead();
|
||||
if (success) {
|
||||
NotificationBadgeManager().clearCount();
|
||||
_loadNotifications();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _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<MessagePage>
|
|||
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<MessagePage>
|
|||
)
|
||||
: 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,
|
||||
),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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<SettingsPage> createState() => _SettingsPageState();
|
||||
}
|
||||
|
||||
class _SettingsPageState extends State<SettingsPage> {
|
||||
String _appVersion = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadVersion();
|
||||
}
|
||||
|
||||
Future<void> _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),
|
||||
|
|
|
|||
|
|
@ -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<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
// 初始化升级服务
|
||||
UpdateService().initialize(UpdateConfig.selfHosted(
|
||||
apiBaseUrl: 'https://api.gogenex.cn',
|
||||
enabled: true,
|
||||
));
|
||||
// 初始化推送(静默失败)
|
||||
await PushService().initialize();
|
||||
// 初始化通知徽章
|
||||
NotificationBadgeManager().initialize();
|
||||
runApp(const GenexConsumerApp());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in New Issue