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:
hailin 2026-02-13 07:02:14 -08:00
parent 8adead23b6
commit 184a7d16db
71 changed files with 6136 additions and 266 deletions

View File

@ -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

View File

@ -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)
}
}

View File

@ -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>

View File

@ -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': 'リトライ',
};
}

View File

@ -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(

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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');
}
}
}

View File

@ -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;
}
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}
}

View File

@ -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');
}
}
}

View File

@ -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)),
),
],
],
),
);
}
}

View File

@ -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');
}
}
}

View File

@ -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,
);
}
}

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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;
}
}
}

View File

@ -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

View File

@ -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());
}

View File

@ -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:

View File

@ -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"/>

View File

@ -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()
}
}

View File

@ -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>

View File

@ -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',
};

View File

@ -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': 'リトライ',
};

View File

@ -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': '重试',
};

View File

@ -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': '重試',
};

View File

@ -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,
);

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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');
}
}
}

View File

@ -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;
}
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}
}

View File

@ -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');
}
}
}

View File

@ -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)),
),
],
],
),
);
}
}

View File

@ -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');
}
}
}

View File

@ -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,
);
}
}

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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;
}
}
}

View File

@ -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,
),
];

View File

@ -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'),

View File

@ -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,

View File

@ -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:

View File

@ -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

View File

@ -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)
}
}

View File

@ -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>

View File

@ -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',
};

View File

@ -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': 'リトライ',
};

View File

@ -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': '重试',
};

View File

@ -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': '重試',
};

View File

@ -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,
);

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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');
}
}
}

View File

@ -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;
}
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}
}

View File

@ -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');
}
}
}

View File

@ -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)),
),
],
],
),
);
}
}

View File

@ -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');
}
}
}

View File

@ -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,
);
}
}

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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;
}
}
}

View File

@ -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,
),
];

View File

@ -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),

View File

@ -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());
}

View File

@ -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: