38 changed files with 2471 additions and 305 deletions
@ -0,0 +1,103 @@ |
|||||
|
import 'dart:convert'; |
||||
|
|
||||
|
import 'package:get_storage/get_storage.dart'; |
||||
|
|
||||
|
import '../../data/services/db/sqflite_api.dart'; |
||||
|
import '../../data/services/network/api.dart'; |
||||
|
|
||||
|
class Synchrodata { |
||||
|
static final GetStorage _storage = GetStorage(); |
||||
|
|
||||
|
static List _records = _storage.read("operation_records") ?? []; |
||||
|
static List _delids = _storage.read("operation_delids") ?? []; |
||||
|
static bool _isSynchro = false; |
||||
|
|
||||
|
// 保存操作记录 |
||||
|
static Future saveOperationRecord( |
||||
|
String method, |
||||
|
dynamic params, |
||||
|
) async { |
||||
|
String userId = ''; |
||||
|
Map? userInfo = _storage.read("user_info"); |
||||
|
if (userInfo != null) { |
||||
|
userId = userInfo['user']['uid']; |
||||
|
} |
||||
|
String id = '${userId}_${params['id']}'; |
||||
|
if (params['type'] != null) { |
||||
|
params['rtype'] = params['type']; |
||||
|
} |
||||
|
|
||||
|
switch (method) { |
||||
|
case 'insert': |
||||
|
_records.add({ |
||||
|
...params, |
||||
|
'id': id, |
||||
|
'number': params['id'], |
||||
|
'uid': userId, |
||||
|
}); |
||||
|
_storage.write("operation_records", _records); |
||||
|
break; |
||||
|
case 'update': |
||||
|
Map? foundElement = _records.firstWhere( |
||||
|
(element) => element['id'] == id, |
||||
|
orElse: () => null, |
||||
|
); |
||||
|
if (foundElement != null) { |
||||
|
foundElement.addAll({ |
||||
|
...params, |
||||
|
'id': id, |
||||
|
'number': params['id'], |
||||
|
}); |
||||
|
} else { |
||||
|
_records.add({ |
||||
|
...params, |
||||
|
'id': id, |
||||
|
'number': params['id'], |
||||
|
'uid': userId, |
||||
|
}); |
||||
|
} |
||||
|
_storage.write("operation_records", _records); |
||||
|
break; |
||||
|
case 'delete': |
||||
|
_delids.add(id); |
||||
|
_storage.write("operation_delids", _delids); |
||||
|
break; |
||||
|
default: |
||||
|
break; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 获取操作记录 |
||||
|
static Future startSynchro() async { |
||||
|
if (_isSynchro) return; |
||||
|
_isSynchro = true; |
||||
|
if (_records.isNotEmpty || _delids.isNotEmpty) { |
||||
|
// 获取需要修改的全部数据 |
||||
|
List putRecords = []; |
||||
|
for (var element in _records) { |
||||
|
final result = await SqfliteApi.getMeetingDetails(element['number']); |
||||
|
final speakerList = |
||||
|
await SqfliteApi.getMeetingSpeaker(element['number']); |
||||
|
putRecords.add({ |
||||
|
...result, |
||||
|
...result['details'], |
||||
|
'rtype': result['type'], |
||||
|
'original': jsonEncode(speakerList), |
||||
|
...element, |
||||
|
}); |
||||
|
} |
||||
|
var result = await Api.putOperationRecord({ |
||||
|
'delids': _delids, |
||||
|
'records': putRecords, |
||||
|
}); |
||||
|
if (result != null) { |
||||
|
_records = []; |
||||
|
_delids = []; |
||||
|
_storage.write("operation_records", _records); |
||||
|
_storage.write("operation_delids", _delids); |
||||
|
} |
||||
|
} |
||||
|
var result = await Api.getOperationRecord(); |
||||
|
_isSynchro = false; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,151 @@ |
|||||
|
import 'package:json_annotation/json_annotation.dart'; |
||||
|
|
||||
|
part 'channel_app_model.g.dart'; |
||||
|
|
||||
|
/// 渠道应用信息响应模型 |
||||
|
@JsonSerializable() |
||||
|
class ChannelAppResponse { |
||||
|
@JsonKey(name: 'code') |
||||
|
final int code; |
||||
|
|
||||
|
@JsonKey(name: 'data') |
||||
|
final ChannelAppData? data; |
||||
|
|
||||
|
@JsonKey(name: 'msg') |
||||
|
final String msg; |
||||
|
|
||||
|
const ChannelAppResponse({ |
||||
|
required this.code, |
||||
|
this.data, |
||||
|
required this.msg, |
||||
|
}); |
||||
|
|
||||
|
factory ChannelAppResponse.fromJson(Map<String, dynamic> json) => |
||||
|
_$ChannelAppResponseFromJson(json); |
||||
|
|
||||
|
Map<String, dynamic> toJson() => _$ChannelAppResponseToJson(this); |
||||
|
} |
||||
|
|
||||
|
/// 渠道应用数据模型 |
||||
|
@JsonSerializable() |
||||
|
class ChannelAppData { |
||||
|
@JsonKey(name: 'app') |
||||
|
final AppInfo app; |
||||
|
|
||||
|
const ChannelAppData({ |
||||
|
required this.app, |
||||
|
}); |
||||
|
|
||||
|
factory ChannelAppData.fromJson(Map<String, dynamic> json) => |
||||
|
_$ChannelAppDataFromJson(json); |
||||
|
|
||||
|
Map<String, dynamic> toJson() => _$ChannelAppDataToJson(this); |
||||
|
} |
||||
|
|
||||
|
/// 应用信息模型 |
||||
|
@JsonSerializable() |
||||
|
class AppInfo { |
||||
|
/// 下载地址 |
||||
|
@JsonKey(name: 'address') |
||||
|
final String address; |
||||
|
|
||||
|
/// 渠道标识 |
||||
|
@JsonKey(name: 'channel') |
||||
|
final String channel; |
||||
|
|
||||
|
/// 应用描述 |
||||
|
@JsonKey(name: 'description') |
||||
|
final String description; |
||||
|
|
||||
|
/// 最新版本号 |
||||
|
@JsonKey(name: 'version') |
||||
|
final String version; |
||||
|
|
||||
|
const AppInfo({ |
||||
|
required this.address, |
||||
|
required this.channel, |
||||
|
required this.description, |
||||
|
required this.version, |
||||
|
}); |
||||
|
|
||||
|
factory AppInfo.fromJson(Map<String, dynamic> json) => |
||||
|
_$AppInfoFromJson(json); |
||||
|
|
||||
|
Map<String, dynamic> toJson() => _$AppInfoToJson(this); |
||||
|
} |
||||
|
|
||||
|
/// 版本更新信息模型 |
||||
|
class VersionUpdateInfo { |
||||
|
/// 是否有新版本 |
||||
|
final bool hasUpdate; |
||||
|
|
||||
|
/// 当前版本 |
||||
|
final String currentVersion; |
||||
|
|
||||
|
/// 最新版本 |
||||
|
final String latestVersion; |
||||
|
|
||||
|
/// 应用信息 |
||||
|
final AppInfo? appInfo; |
||||
|
|
||||
|
/// 更新类型 |
||||
|
final UpdateType updateType; |
||||
|
|
||||
|
const VersionUpdateInfo({ |
||||
|
required this.hasUpdate, |
||||
|
required this.currentVersion, |
||||
|
required this.latestVersion, |
||||
|
this.appInfo, |
||||
|
required this.updateType, |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
/// 更新类型枚举 |
||||
|
enum UpdateType { |
||||
|
/// 无需更新 |
||||
|
none, |
||||
|
/// 可选更新 |
||||
|
optional, |
||||
|
/// 强制更新 |
||||
|
force, |
||||
|
} |
||||
|
|
||||
|
/// 应用商店类型枚举 |
||||
|
enum AppStoreType { |
||||
|
/// iOS App Store |
||||
|
appStore, |
||||
|
/// Google Play |
||||
|
googlePlay, |
||||
|
/// 华为应用市场 |
||||
|
huawei, |
||||
|
/// 小米应用商店 |
||||
|
xiaomi, |
||||
|
/// OPPO软件商店 |
||||
|
oppo, |
||||
|
/// vivo应用商店 |
||||
|
vivo, |
||||
|
/// 荣耀应用市场 |
||||
|
honor, |
||||
|
/// 官网下载 |
||||
|
official, |
||||
|
/// 未知 |
||||
|
unknown, |
||||
|
} |
||||
|
|
||||
|
/// 平台信息模型 |
||||
|
class PlatformInfo { |
||||
|
/// 平台类型 |
||||
|
final String platform; |
||||
|
|
||||
|
/// 应用商店类型 |
||||
|
final AppStoreType storeType; |
||||
|
|
||||
|
/// 渠道标识 |
||||
|
final String channel; |
||||
|
|
||||
|
const PlatformInfo({ |
||||
|
required this.platform, |
||||
|
required this.storeType, |
||||
|
required this.channel, |
||||
|
}); |
||||
|
} |
||||
@ -0,0 +1,47 @@ |
|||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND |
||||
|
|
||||
|
part of 'channel_app_model.dart'; |
||||
|
|
||||
|
// ************************************************************************** |
||||
|
// JsonSerializableGenerator |
||||
|
// ************************************************************************** |
||||
|
|
||||
|
ChannelAppResponse _$ChannelAppResponseFromJson(Map<String, dynamic> json) => |
||||
|
ChannelAppResponse( |
||||
|
code: (json['code'] as num).toInt(), |
||||
|
data: json['data'] == null |
||||
|
? null |
||||
|
: ChannelAppData.fromJson(json['data'] as Map<String, dynamic>), |
||||
|
msg: json['msg'] as String, |
||||
|
); |
||||
|
|
||||
|
Map<String, dynamic> _$ChannelAppResponseToJson(ChannelAppResponse instance) => |
||||
|
<String, dynamic>{ |
||||
|
'code': instance.code, |
||||
|
'data': instance.data, |
||||
|
'msg': instance.msg, |
||||
|
}; |
||||
|
|
||||
|
ChannelAppData _$ChannelAppDataFromJson(Map<String, dynamic> json) => |
||||
|
ChannelAppData( |
||||
|
app: AppInfo.fromJson(json['app'] as Map<String, dynamic>), |
||||
|
); |
||||
|
|
||||
|
Map<String, dynamic> _$ChannelAppDataToJson(ChannelAppData instance) => |
||||
|
<String, dynamic>{ |
||||
|
'app': instance.app, |
||||
|
}; |
||||
|
|
||||
|
AppInfo _$AppInfoFromJson(Map<String, dynamic> json) => AppInfo( |
||||
|
address: json['address'] as String, |
||||
|
channel: json['channel'] as String, |
||||
|
description: json['description'] as String, |
||||
|
version: json['version'] as String, |
||||
|
); |
||||
|
|
||||
|
Map<String, dynamic> _$AppInfoToJson(AppInfo instance) => <String, dynamic>{ |
||||
|
'address': instance.address, |
||||
|
'channel': instance.channel, |
||||
|
'description': instance.description, |
||||
|
'version': instance.version, |
||||
|
}; |
||||
@ -0,0 +1,315 @@ |
|||||
|
import 'dart:io'; |
||||
|
import 'package:flutter/foundation.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import 'package:package_info_plus/package_info_plus.dart'; |
||||
|
import 'package:url_launcher/url_launcher.dart'; |
||||
|
import 'package:logger/logger.dart'; |
||||
|
import 'package:device_info_plus/device_info_plus.dart'; |
||||
|
import '../models/channel_app_model.dart'; |
||||
|
import 'network/api.dart'; |
||||
|
|
||||
|
/// 版本更新服务类 |
||||
|
class VersionUpdateService extends GetxService { |
||||
|
static VersionUpdateService get to => Get.find(); |
||||
|
|
||||
|
final Logger _logger = Logger(); |
||||
|
|
||||
|
/// 获取当前应用版本信息 |
||||
|
Future<String> getCurrentVersion() async { |
||||
|
try { |
||||
|
final packageInfo = await PackageInfo.fromPlatform(); |
||||
|
return '${packageInfo.version}+${packageInfo.buildNumber}'; |
||||
|
} catch (e) { |
||||
|
_logger.e('获取当前版本信息失败: $e'); |
||||
|
return '1.0.0'; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 获取当前平台信息 |
||||
|
Future<PlatformInfo> getCurrentPlatformInfo() async { |
||||
|
if (kIsWeb) { |
||||
|
return const PlatformInfo( |
||||
|
platform: 'web', |
||||
|
storeType: AppStoreType.official, |
||||
|
channel: '官方网站', |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
if (Platform.isIOS) { |
||||
|
return const PlatformInfo( |
||||
|
platform: 'ios', |
||||
|
storeType: AppStoreType.appStore, |
||||
|
channel: 'App Store', |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
if (Platform.isAndroid) { |
||||
|
// 根据设备品牌判断应用商店类型 |
||||
|
return await getAndroidStoreInfoByBrand(); |
||||
|
} |
||||
|
|
||||
|
return const PlatformInfo( |
||||
|
platform: 'unknown', |
||||
|
storeType: AppStoreType.unknown, |
||||
|
channel: '未知', |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/// 根据设备品牌获取应用商店信息 |
||||
|
Future<PlatformInfo> getAndroidStoreInfoByBrand() async { |
||||
|
try { |
||||
|
final deviceInfo = DeviceInfoPlugin(); |
||||
|
final androidInfo = await deviceInfo.androidInfo; |
||||
|
final brand = androidInfo.brand.toLowerCase(); |
||||
|
|
||||
|
return const PlatformInfo( |
||||
|
platform: 'android', |
||||
|
storeType: AppStoreType.official, |
||||
|
channel: '官方内测', |
||||
|
); |
||||
|
|
||||
|
// switch (brand) { |
||||
|
// case 'huawei': |
||||
|
// case 'honor': |
||||
|
// return PlatformInfo( |
||||
|
// platform: 'android', |
||||
|
// storeType: |
||||
|
// brand == 'huawei' ? AppStoreType.huawei : AppStoreType.honor, |
||||
|
// channel: brand == 'huawei' ? '华为应用市场' : '荣耀应用市场', |
||||
|
// ); |
||||
|
// case 'xiaomi': |
||||
|
// case 'redmi': |
||||
|
// return const PlatformInfo( |
||||
|
// platform: 'android', |
||||
|
// storeType: AppStoreType.xiaomi, |
||||
|
// channel: '小米应用商店', |
||||
|
// ); |
||||
|
// case 'oppo': |
||||
|
// case 'oneplus': |
||||
|
// return const PlatformInfo( |
||||
|
// platform: 'android', |
||||
|
// storeType: AppStoreType.oppo, |
||||
|
// channel: 'OPPO软件商店', |
||||
|
// ); |
||||
|
// case 'vivo': |
||||
|
// case 'iqoo': |
||||
|
// return const PlatformInfo( |
||||
|
// platform: 'android', |
||||
|
// storeType: AppStoreType.vivo, |
||||
|
// channel: 'vivo应用商店', |
||||
|
// ); |
||||
|
// default: |
||||
|
// return const PlatformInfo( |
||||
|
// platform: 'android', |
||||
|
// storeType: AppStoreType.googlePlay, |
||||
|
// channel: 'Google Play', |
||||
|
// ); |
||||
|
// } |
||||
|
} catch (e) { |
||||
|
_logger.e('获取Android设备信息失败: $e'); |
||||
|
return const PlatformInfo( |
||||
|
platform: 'android', |
||||
|
storeType: AppStoreType.googlePlay, |
||||
|
channel: 'Google Play', |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 检查版本更新 |
||||
|
Future<VersionUpdateInfo> checkForUpdate() async { |
||||
|
try { |
||||
|
final currentVersion = await getCurrentVersion(); |
||||
|
final platformInfo = await getCurrentPlatformInfo(); |
||||
|
|
||||
|
// 调用后端接口获取最新版本信息 |
||||
|
final response = |
||||
|
await Api.getChannelApp({'channel': platformInfo.channel}); |
||||
|
|
||||
|
print('获取渠道包信息:$response'); |
||||
|
|
||||
|
if (response == null || !response.containsKey('app')) { |
||||
|
return VersionUpdateInfo( |
||||
|
hasUpdate: false, |
||||
|
currentVersion: currentVersion, |
||||
|
latestVersion: currentVersion, |
||||
|
updateType: UpdateType.none, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
//final channelAppResponse = ChannelAppResponse.fromJson(response); |
||||
|
// 直接从response中获取app信息 |
||||
|
final appInfo = AppInfo.fromJson(response['app']); |
||||
|
|
||||
|
// if (channelAppResponse.code != 0 || channelAppResponse.data == null) { |
||||
|
// _logger.w('获取版本信息失败: ${channelAppResponse.msg}'); |
||||
|
// return VersionUpdateInfo( |
||||
|
// hasUpdate: false, |
||||
|
// currentVersion: currentVersion, |
||||
|
// latestVersion: currentVersion, |
||||
|
// updateType: UpdateType.none, |
||||
|
// ); |
||||
|
// } |
||||
|
|
||||
|
// final appInfo = channelAppResponse.data!.app; |
||||
|
final hasUpdate = _compareVersions(currentVersion, appInfo.version); |
||||
|
|
||||
|
return VersionUpdateInfo( |
||||
|
hasUpdate: hasUpdate, |
||||
|
currentVersion: currentVersion, |
||||
|
latestVersion: appInfo.version, |
||||
|
appInfo: appInfo, |
||||
|
updateType: hasUpdate ? UpdateType.optional : UpdateType.none, |
||||
|
); |
||||
|
} catch (e) { |
||||
|
_logger.e('检查版本更新失败: $e'); |
||||
|
final currentVersion = await getCurrentVersion(); |
||||
|
return VersionUpdateInfo( |
||||
|
hasUpdate: false, |
||||
|
currentVersion: currentVersion, |
||||
|
latestVersion: currentVersion, |
||||
|
updateType: UpdateType.none, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 比较版本号 |
||||
|
/// 返回true表示需要更新(远程版本更高) |
||||
|
bool _compareVersions(String current, String latest) { |
||||
|
// 拆分版本号与构建号 |
||||
|
final currentParts = current.split('+'); |
||||
|
final latestParts = latest.split('+'); |
||||
|
|
||||
|
// 解析版本号 (major.minor.patch) |
||||
|
final currentVersions = currentParts[0].split('.').map(int.parse).toList(); |
||||
|
final latestVersions = latestParts[0].split('.').map(int.parse).toList(); |
||||
|
|
||||
|
// 逐级比较版本号 |
||||
|
for (var i = 0; i < currentVersions.length; i++) { |
||||
|
if (latestVersions[i] > currentVersions[i]) return true; |
||||
|
if (latestVersions[i] < currentVersions[i]) return false; |
||||
|
} |
||||
|
|
||||
|
// 版本号相同则比较构建号 |
||||
|
if (latestParts.length > 1 && currentParts.length > 1) { |
||||
|
return int.parse(latestParts[1]) > int.parse(currentParts[1]); |
||||
|
} |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
/// 解析版本号字符串为数字数组 |
||||
|
List<int> _parseVersion(String version) { |
||||
|
final parts = version.split('.'); |
||||
|
final result = <int>[]; |
||||
|
|
||||
|
for (int i = 0; i < 3; i++) { |
||||
|
if (i < parts.length) { |
||||
|
result.add(int.tryParse(parts[i]) ?? 0); |
||||
|
} else { |
||||
|
result.add(0); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return result; |
||||
|
} |
||||
|
|
||||
|
/// 执行更新操作 |
||||
|
Future<bool> performUpdate(VersionUpdateInfo updateInfo) async { |
||||
|
if (!updateInfo.hasUpdate || updateInfo.appInfo == null) { |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
final platformInfo = await getCurrentPlatformInfo(); |
||||
|
|
||||
|
try { |
||||
|
switch (platformInfo.storeType) { |
||||
|
case AppStoreType.appStore: |
||||
|
return await _openAppStore(); |
||||
|
case AppStoreType.googlePlay: |
||||
|
return await _openGooglePlay(); |
||||
|
case AppStoreType.huawei: |
||||
|
return await _openHuaweiStore(); |
||||
|
case AppStoreType.xiaomi: |
||||
|
return await _openXiaomiStore(); |
||||
|
case AppStoreType.oppo: |
||||
|
return await _openOppoStore(); |
||||
|
case AppStoreType.vivo: |
||||
|
return await _openVivoStore(); |
||||
|
case AppStoreType.honor: |
||||
|
return await _openHonorStore(); |
||||
|
case AppStoreType.official: |
||||
|
return await _openOfficialDownload(updateInfo.appInfo!.address); |
||||
|
default: |
||||
|
return await _openOfficialDownload(updateInfo.appInfo!.address); |
||||
|
} |
||||
|
} catch (e) { |
||||
|
_logger.e('执行更新失败: $e'); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 打开iOS App Store |
||||
|
Future<bool> _openAppStore() async { |
||||
|
// 这里需要替换为实际的App Store ID |
||||
|
const appStoreUrl = 'https://apps.apple.com/app/id你的应用ID'; |
||||
|
return await _launchUrl(appStoreUrl); |
||||
|
} |
||||
|
|
||||
|
/// 打开Google Play |
||||
|
Future<bool> _openGooglePlay() async { |
||||
|
// 这里需要替换为实际的包名 |
||||
|
const packageName = 'com.yourcompany.deepvoice'; |
||||
|
const playStoreUrl = |
||||
|
'https://play.google.com/store/apps/details?id=$packageName'; |
||||
|
return await _launchUrl(playStoreUrl); |
||||
|
} |
||||
|
|
||||
|
/// 打开华为应用市场 |
||||
|
Future<bool> _openHuaweiStore() async { |
||||
|
const huaweiStoreUrl = 'appmarket://details?id=com.yourcompany.deepvoice'; |
||||
|
return await _launchUrl(huaweiStoreUrl); |
||||
|
} |
||||
|
|
||||
|
/// 打开小米应用商店 |
||||
|
Future<bool> _openXiaomiStore() async { |
||||
|
const xiaomiStoreUrl = 'mimarket://details?id=com.yourcompany.deepvoice'; |
||||
|
return await _launchUrl(xiaomiStoreUrl); |
||||
|
} |
||||
|
|
||||
|
/// 打开OPPO软件商店 |
||||
|
Future<bool> _openOppoStore() async { |
||||
|
const oppoStoreUrl = |
||||
|
'oppomarket://details?packagename=com.yourcompany.deepvoice'; |
||||
|
return await _launchUrl(oppoStoreUrl); |
||||
|
} |
||||
|
|
||||
|
/// 打开vivo应用商店 |
||||
|
Future<bool> _openVivoStore() async { |
||||
|
const vivoStoreUrl = 'vivomarket://details?id=com.yourcompany.deepvoice'; |
||||
|
return await _launchUrl(vivoStoreUrl); |
||||
|
} |
||||
|
|
||||
|
/// 打开荣耀应用市场 |
||||
|
Future<bool> _openHonorStore() async { |
||||
|
const honorStoreUrl = 'honormarket://details?id=com.yourcompany.deepvoice'; |
||||
|
return await _launchUrl(honorStoreUrl); |
||||
|
} |
||||
|
|
||||
|
/// 打开官网下载 |
||||
|
Future<bool> _openOfficialDownload(String downloadUrl) async { |
||||
|
return await _launchUrl(downloadUrl); |
||||
|
} |
||||
|
|
||||
|
/// 启动URL |
||||
|
Future<bool> _launchUrl(String url) async { |
||||
|
try { |
||||
|
final uri = Uri.parse(url); |
||||
|
if (await canLaunchUrl(uri)) { |
||||
|
return await launchUrl(uri, mode: LaunchMode.externalApplication); |
||||
|
} |
||||
|
return false; |
||||
|
} catch (e) { |
||||
|
_logger.e('启动URL失败: $e'); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,160 @@ |
|||||
|
# Realtime 实时语音聊天模块 |
||||
|
|
||||
|
## ⚠️ 当前状态 |
||||
|
|
||||
|
**此模块为功能预览版本,提供完整的实时语音对话功能。** |
||||
|
|
||||
|
通过原生插件实现语音识别、AI对话和语音合成的完整语音交互体验。 |
||||
|
|
||||
|
## 概述 |
||||
|
|
||||
|
Realtime模块实现了类似ChatGPT app的实时语音对话功能,当前版本提供: |
||||
|
|
||||
|
- ✅ **完整语音交互** - 实时语音识别、AI对话和语音合成 |
||||
|
- ✅ **状态管理** - 聆听、思考、回答状态的可视化 |
||||
|
- ✅ **动画效果** - 基于Shader的流动云效果,支持音频振幅驱动 |
||||
|
- ✅ **字幕显示** - 可选择开启/关闭的字幕模式 |
||||
|
- ✅ **智能UI** - 根据连接状态自动调整按钮可用性 |
||||
|
|
||||
|
## 界面设计 |
||||
|
|
||||
|
### 布局结构 |
||||
|
- **顶部状态区域** - 显示当前状态和字幕模式切换按钮 |
||||
|
- **中间可视化区域** - 圆形Shader动画,根据音频振幅呈现呼吸效果 |
||||
|
- **底部控制区域** - 主控制按钮和状态指示器 |
||||
|
|
||||
|
### 状态指示 |
||||
|
- 🔵 **聆听状态** - 蓝色动画,接收用户语音输入 |
||||
|
- 🟠 **思考状态** - 橙色动画,AI处理中 |
||||
|
- 🟢 **回答状态** - 绿色动画,AI语音输出 |
||||
|
- ⚪ **空闲状态** - 灰色静态,等待交互 |
||||
|
|
||||
|
### 字幕模式 |
||||
|
- **默认关闭** - 初始状态下不显示任何字幕内容 |
||||
|
- **手动切换** - 通过右上角按钮手动开启/关闭字幕 |
||||
|
- **完整隐藏** - 关闭时字幕区域完全不显示,不占用界面空间 |
||||
|
- **智能布局** - 开启时圆形动画自动上移,字幕区域占据下半屏 |
||||
|
|
||||
|
### 交互控制 |
||||
|
- **录音按钮** - 仅在成功连接AI服务器且开始录音后可用 |
||||
|
- **连接状态** - 未连接时录音按钮显示不可用状态 |
||||
|
- **状态反馈** - 按钮颜色和可点击性根据连接状态动态调整 |
||||
|
|
||||
|
## 使用方法 |
||||
|
|
||||
|
### 路由导航 |
||||
|
|
||||
|
```dart |
||||
|
// 跳转到实时语音对话页面 |
||||
|
Get.toNamed(Routes.realtime); |
||||
|
``` |
||||
|
|
||||
|
### 交互流程 |
||||
|
|
||||
|
1. **自动连接** - 页面加载后自动尝试连接AI服务器 |
||||
|
2. **等待连接** - 录音按钮在连接成功前保持不可用状态 |
||||
|
3. **开始录音** - 连接成功后自动开始录音,录音按钮变为可用 |
||||
|
4. **语音对话** - 用户说话 → AI处理 → 语音回复 |
||||
|
5. **字幕控制** - 可随时通过右上角按钮切换字幕显示 |
||||
|
|
||||
|
## 技术架构 |
||||
|
|
||||
|
### 控制器 (RealtimeController) |
||||
|
- **连接管理**: WebSocket连接到AI服务器 |
||||
|
- **状态同步**: `isConnected`, `isListening`, `isSpeaking` |
||||
|
- **字幕控制**: `isSubtitleMode` 默认为false,用户手动控制 |
||||
|
- **消息管理**: 智能去重,避免流式响应重复显示 |
||||
|
- **音频处理**: 原生层计算RMS值,Flutter层接收并控制动画 |
||||
|
|
||||
|
### 视图组件 |
||||
|
- **RealtimeView** - 主视图容器,响应式布局 |
||||
|
- **ShaderMicFlow** - 基于GLSL Shader的流动云动画组件 |
||||
|
- **AutoScrollList** - 自动滚动的字幕列表 |
||||
|
|
||||
|
### 原生集成 |
||||
|
- **RealtimeService** - 原生音频处理和WebSocket通信 |
||||
|
- **音频格式** - 16kHz, 1声道, 16位PCM |
||||
|
- **实时传输** - 低延迟音频流处理 |
||||
|
- **RMS计算** - 原生层实时计算音频振幅 |
||||
|
|
||||
|
## 默认行为 |
||||
|
|
||||
|
### 字幕模式 |
||||
|
- **初始状态**: 关闭 (`isSubtitleMode.value = false`) |
||||
|
- **显示逻辑**: 仅在用户手动开启时显示字幕内容 |
||||
|
- **空间占用**: 关闭时字幕区域高度为0,不影响其他UI元素 |
||||
|
- **切换效果**: 平滑动画过渡,圆形动画位置和大小同步调整 |
||||
|
|
||||
|
### 按钮状态 |
||||
|
- **录音按钮**: 默认不可用,连接成功且开始录音后才可点击 |
||||
|
- **状态指示**: 通过颜色和图标变化反映当前可用性 |
||||
|
- **交互反馈**: 不可用时点击无效果,避免误操作 |
||||
|
|
||||
|
## 开发计划 |
||||
|
|
||||
|
### 已完成功能 ✅ |
||||
|
- [x] 完整语音交互流程 |
||||
|
- [x] 实时WebSocket通信 |
||||
|
- [x] Shader流动云动画 |
||||
|
- [x] RMS音频振幅计算 |
||||
|
- [x] 智能UI状态管理 |
||||
|
- [x] 字幕模式控制 |
||||
|
- [x] 消息去重优化 |
||||
|
|
||||
|
### 计划增强 ⏳ |
||||
|
- [ ] 多语言语音识别 |
||||
|
- [ ] 语音中断检测 |
||||
|
- [ ] 音频质量自适应 |
||||
|
- [ ] 离线降级处理 |
||||
|
- [ ] 语音情感识别 |
||||
|
|
||||
|
## 注意事项 |
||||
|
|
||||
|
1. **网络依赖** - 需要连接到AI服务器 (ws://192.168.1.11:8000/ws) |
||||
|
2. **麦克风权限** - 需要获取麦克风使用权限 |
||||
|
3. **音频会话** - 自动管理音频会话,支持蓝牙设备 |
||||
|
4. **资源管理** - 页面关闭时自动清理音频资源和网络连接 |
||||
|
|
||||
|
## 自定义配置 |
||||
|
|
||||
|
### 修改服务器地址 |
||||
|
|
||||
|
```dart |
||||
|
// 在RealtimeController中修改连接URL |
||||
|
await _realtimeService.initialize( |
||||
|
serverUrl: 'ws://your-server:port/ws', |
||||
|
// ... 其他参数 |
||||
|
); |
||||
|
``` |
||||
|
|
||||
|
### 调整UI布局 |
||||
|
|
||||
|
```dart |
||||
|
// 修改字幕模式下圆形动画位置 |
||||
|
alignment: controller.isSubtitleMode.value |
||||
|
? const Alignment(0, -0.9) // 调整Y轴位置 |
||||
|
: const Alignment(0, -0.25), |
||||
|
``` |
||||
|
|
||||
|
### 音频参数调优 |
||||
|
|
||||
|
```dart |
||||
|
// 在初始化时调整音频参数 |
||||
|
sampleRate: 16000, // 采样率 |
||||
|
channels: 1, // 声道数 |
||||
|
bitsPerSample: 16, // 位深度 |
||||
|
``` |
||||
|
|
||||
|
## 技术栈 |
||||
|
|
||||
|
- **Flutter SDK**: >= 3.0.0 |
||||
|
- **GetX**: >= 4.6.5 (状态管理) |
||||
|
- **Flutter Shaders**: >= 0.0.6 (Shader动画) |
||||
|
- **原生插件**: Realtime服务 (音频处理) |
||||
|
- **WebSocket**: 实时通信协议 |
||||
|
|
||||
|
## 版本信息 |
||||
|
|
||||
|
- **当前版本**: v1.0.0 (Production) |
||||
|
- **最后更新**: 2024年 |
||||
|
- **状态**: 完整功能实现 |
||||
@ -0,0 +1,10 @@ |
|||||
|
import 'package:get/get.dart'; |
||||
|
import '../controllers/FTFTranslation_controller.dart'; |
||||
|
|
||||
|
class FTFTranslationBinding extends Bindings { |
||||
|
@override |
||||
|
void dependencies() { |
||||
|
// 注册实时语音聊天控制器 |
||||
|
Get.lazyPut<FTFTranslationController>(() => FTFTranslationController()); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,241 @@ |
|||||
|
import 'dart:async'; |
||||
|
import 'dart:typed_data'; |
||||
|
|
||||
|
import 'package:get/get.dart'; |
||||
|
import '../../../core/utils/logger.dart'; |
||||
|
import '../../../data/services/asr_service.dart'; |
||||
|
import '../../../data/services/speech_impl/xunfei_asr_service.dart'; |
||||
|
import '../../../data/services/tts_service.dart'; |
||||
|
import '../../../data/services/language_manager.dart'; |
||||
|
import '../../../data/services/volcano_translation_service.dart'; |
||||
|
|
||||
|
class FTFTranslationController extends GetxController |
||||
|
with GetSingleTickerProviderStateMixin { |
||||
|
final messages = <FTFTranslationMessage>[].obs; |
||||
|
final isChineseInput = true.obs; |
||||
|
final leftLanguage = '中文'.obs; |
||||
|
final rightLanguage = 'English'.obs; |
||||
|
final isLeftInput = true.obs; |
||||
|
|
||||
|
final supportedLanguages = const [ |
||||
|
'中文', |
||||
|
'English', |
||||
|
'日本語', |
||||
|
'한국어', |
||||
|
'Español', |
||||
|
]; |
||||
|
|
||||
|
late final AsrService _asrService; |
||||
|
|
||||
|
StreamSubscription? _recognitionSubscription; |
||||
|
|
||||
|
RxString intermediateContent = ''.obs; |
||||
|
RxString finalContent = ''.obs; |
||||
|
BytesBuilder _originaBytes = BytesBuilder(); //原始音频 |
||||
|
|
||||
|
final VolcanoTranslationService _translationService = |
||||
|
Get.find<VolcanoTranslationService>(); |
||||
|
final LanguageManager _languageManager = Get.find<LanguageManager>(); |
||||
|
// 当前使用的TTS服务 |
||||
|
late TtsService _ttsService; |
||||
|
final isTranslating = false.obs; |
||||
|
final sourceLanguageCode = 'zh-CN'.obs; |
||||
|
final targetLanguageCode = 'en-US'.obs; |
||||
|
final isTtsEnabled = true.obs; |
||||
|
|
||||
|
@override |
||||
|
void onInit() { |
||||
|
super.onInit(); |
||||
|
Get.put(XunfeiAsrService()); |
||||
|
final TtsService _ttsService = Get.find<TtsService>(); |
||||
|
_asrService = Get.find<XunfeiAsrService>(); |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
void onClose() { |
||||
|
_asrService.dispose(); |
||||
|
_recognitionSubscription?.cancel(); |
||||
|
super.onClose(); |
||||
|
} |
||||
|
|
||||
|
//开始录音 |
||||
|
Future<void> _startRecorder() async { |
||||
|
final recognitionStream = |
||||
|
await _asrService.startContinuousRecognition(false); |
||||
|
_recognitionSubscription = recognitionStream.listen( |
||||
|
_handleRecognitionEvent, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
//结束录音 |
||||
|
Future<void> _stopRecorder() async { |
||||
|
await _asrService.stopContinuousRecognition(); |
||||
|
} |
||||
|
|
||||
|
// 处理语音识别事件 |
||||
|
void _handleRecognitionEvent(RecognitionEvent event) { |
||||
|
switch (event.type) { |
||||
|
case RecognitionEventType.intermediateResult: |
||||
|
intermediateContent.value = event.text; |
||||
|
handleFinalResult(event.text); |
||||
|
break; |
||||
|
case RecognitionEventType.finalResult: |
||||
|
finalContent.value += _spliceText(event); |
||||
|
intermediateContent.value = ''; |
||||
|
_originaBytes.add(event.audio!); |
||||
|
handleIntermediateResult(event.text); |
||||
|
break; |
||||
|
default: |
||||
|
// 不做任何处理 |
||||
|
break; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 翻译文本 |
||||
|
Future<String?> translateText(String sourceText, |
||||
|
{bool isFinal = false}) async { |
||||
|
if (sourceText.isEmpty) return null; |
||||
|
|
||||
|
isTranslating.value = true; |
||||
|
String? translationResult; |
||||
|
|
||||
|
try { |
||||
|
// 根据检测到的语言确定源语言和目标语言 |
||||
|
final bool shouldSwap = |
||||
|
intermediateContent.value == sourceLanguageCode.value; |
||||
|
final detectedSourceLanguageCode = |
||||
|
shouldSwap ? sourceLanguageCode.value : targetLanguageCode.value; |
||||
|
final detectedTargetLanguageCode = |
||||
|
shouldSwap ? targetLanguageCode.value : sourceLanguageCode.value; |
||||
|
|
||||
|
// 调用翻译服务 |
||||
|
translationResult = await _translationService.translateText( |
||||
|
text: sourceText, |
||||
|
sourceLanguageCode: detectedSourceLanguageCode, |
||||
|
targetLanguageCode: detectedTargetLanguageCode, |
||||
|
); |
||||
|
|
||||
|
if (translationResult != null) { |
||||
|
// 查找匹配的项目 - 优先使用timestamp查找 |
||||
|
int index = -1; |
||||
|
|
||||
|
// 播放TTS - 只在最终结果时播放 |
||||
|
if (isTtsEnabled.value && isFinal) { |
||||
|
// Logger.info('播放翻译文本: ${translationResult}'); |
||||
|
await playTranslatedText(translationResult); |
||||
|
} |
||||
|
} |
||||
|
} catch (e) { |
||||
|
Logger.error('翻译失败: ${e.toString()}'); |
||||
|
} finally { |
||||
|
isTranslating.value = false; |
||||
|
} |
||||
|
|
||||
|
return translationResult; |
||||
|
} |
||||
|
|
||||
|
// 处理中间识别结果 |
||||
|
void handleIntermediateResult(String text) { |
||||
|
if (text.isEmpty) return; |
||||
|
|
||||
|
translateText(text, isFinal: false); |
||||
|
} |
||||
|
|
||||
|
// 处理最终识别结果 |
||||
|
Future<void> handleFinalResult(String text) async { |
||||
|
if (text.isEmpty) return; |
||||
|
|
||||
|
// 使用项目的时间戳进行翻译 |
||||
|
translateText(text, isFinal: true); |
||||
|
} |
||||
|
|
||||
|
// 播放翻译文本 |
||||
|
Future<void> playTranslatedText(String text) async { |
||||
|
if (text.isEmpty) return; |
||||
|
|
||||
|
try { |
||||
|
final bool shouldSwap = |
||||
|
intermediateContent.value == targetLanguageCode.value; |
||||
|
final detectedTargetLanguageCode = |
||||
|
shouldSwap ? sourceLanguageCode.value : targetLanguageCode.value; |
||||
|
final voiceName = _languageManager |
||||
|
.getTtsVoiceNameByAsrCode(detectedTargetLanguageCode) ?? |
||||
|
'en-US-AriaNeural'; |
||||
|
|
||||
|
await _ttsService.setVoice(voiceName); |
||||
|
await _ttsService.speakOnce(text); |
||||
|
} catch (e) { |
||||
|
Logger.error('播放翻译失败: ${e.toString()}'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
String _spliceText(RecognitionEvent event) { |
||||
|
String eventText = event.text; |
||||
|
String text = ''; |
||||
|
if (finalContent.value.isNotEmpty) { |
||||
|
if (event.role != '') { |
||||
|
final punctuations = { |
||||
|
',', |
||||
|
'。', |
||||
|
'!', |
||||
|
'?', |
||||
|
';', |
||||
|
':', |
||||
|
',', |
||||
|
'.', |
||||
|
':', |
||||
|
';', |
||||
|
'!', |
||||
|
'?' |
||||
|
}; |
||||
|
final firstChar = eventText[0]; |
||||
|
if (punctuations.contains(firstChar)) { |
||||
|
text += '$firstChar\n'; |
||||
|
eventText = eventText.substring(1); |
||||
|
} else { |
||||
|
text += '\n'; |
||||
|
} |
||||
|
text += '说话人${event.role}:'; |
||||
|
} |
||||
|
} else { |
||||
|
text += '说话人${event.role}:'; |
||||
|
} |
||||
|
text += eventText; |
||||
|
return text; |
||||
|
} |
||||
|
|
||||
|
void addMessage(String text) { |
||||
|
final fromLang = |
||||
|
isLeftInput.value ? leftLanguage.value : rightLanguage.value; |
||||
|
final toLang = isLeftInput.value ? rightLanguage.value : leftLanguage.value; |
||||
|
|
||||
|
messages.add( |
||||
|
FTFTranslationMessage( |
||||
|
original: text, |
||||
|
translated: '[$fromLang → $toLang] $text (翻译模拟)', |
||||
|
isChineseSpeaker: isLeftInput.value, |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
void swapLanguages() { |
||||
|
final temp = leftLanguage.value; |
||||
|
leftLanguage.value = rightLanguage.value; |
||||
|
rightLanguage.value = temp; |
||||
|
isLeftInput.value = !isLeftInput.value; |
||||
|
} |
||||
|
|
||||
|
void toggleInputSide(bool isLeft) => isLeftInput.value = isLeft; |
||||
|
} |
||||
|
|
||||
|
class FTFTranslationMessage { |
||||
|
final String original; |
||||
|
final String translated; |
||||
|
final bool isChineseSpeaker; |
||||
|
|
||||
|
FTFTranslationMessage({ |
||||
|
required this.original, |
||||
|
required this.translated, |
||||
|
required this.isChineseSpeaker, |
||||
|
}); |
||||
|
} |
||||
@ -0,0 +1,212 @@ |
|||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import '../controllers/FTFTranslation_controller.dart'; |
||||
|
|
||||
|
class FTFTranslationView extends GetView<FTFTranslationController> { |
||||
|
const FTFTranslationView({Key? key}) : super(key: key); |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
final TextEditingController inputController = TextEditingController(); |
||||
|
|
||||
|
return Scaffold( |
||||
|
backgroundColor: const Color(0xFF0E0F12), |
||||
|
appBar: AppBar( |
||||
|
backgroundColor: Colors.black, |
||||
|
title: const Text('面对面翻译'), |
||||
|
centerTitle: true, |
||||
|
), |
||||
|
body: Column( |
||||
|
children: [ |
||||
|
Expanded( |
||||
|
child: Obx(() => ListView.builder( |
||||
|
padding: const EdgeInsets.all(16), |
||||
|
itemCount: controller.messages.length, |
||||
|
itemBuilder: (context, index) { |
||||
|
final msg = controller.messages[index]; |
||||
|
final isChinese = msg.isChineseSpeaker; |
||||
|
return Align( |
||||
|
alignment: isChinese |
||||
|
? Alignment.centerLeft |
||||
|
: Alignment.centerRight, |
||||
|
child: Container( |
||||
|
margin: const EdgeInsets.symmetric(vertical: 6), |
||||
|
padding: const EdgeInsets.all(12), |
||||
|
decoration: BoxDecoration( |
||||
|
color: isChinese ? Colors.blue : Colors.grey[800], |
||||
|
borderRadius: BorderRadius.circular(12), |
||||
|
), |
||||
|
constraints: const BoxConstraints(maxWidth: 300), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Text( |
||||
|
msg.original, |
||||
|
style: const TextStyle( |
||||
|
color: Colors.white, |
||||
|
fontWeight: FontWeight.bold, |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(height: 4), |
||||
|
Text( |
||||
|
msg.translated, |
||||
|
style: TextStyle( |
||||
|
color: Colors.white.withOpacity(0.9), |
||||
|
fontSize: 14, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
}, |
||||
|
)), |
||||
|
), |
||||
|
_buildBottomInput(inputController), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _buildBottomInput(TextEditingController inputController) { |
||||
|
return Obx(() { |
||||
|
final isLeftInput = controller.isLeftInput.value; |
||||
|
final inputLang = isLeftInput |
||||
|
? controller.leftLanguage.value |
||||
|
: controller.rightLanguage.value; |
||||
|
|
||||
|
return Container( |
||||
|
color: Colors.black, |
||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), |
||||
|
child: Column( |
||||
|
children: [ |
||||
|
Row( |
||||
|
children: [ |
||||
|
Expanded( |
||||
|
child: TextField( |
||||
|
controller: inputController, |
||||
|
style: const TextStyle(color: Colors.white), |
||||
|
decoration: InputDecoration( |
||||
|
filled: true, |
||||
|
fillColor: Colors.grey[900], |
||||
|
hintText: '请输入 $inputLang 内容', |
||||
|
hintStyle: const TextStyle(color: Colors.grey), |
||||
|
border: OutlineInputBorder( |
||||
|
borderRadius: BorderRadius.circular(8), |
||||
|
borderSide: BorderSide.none, |
||||
|
), |
||||
|
contentPadding: |
||||
|
const EdgeInsets.symmetric(horizontal: 12), |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
IconButton( |
||||
|
onPressed: () { |
||||
|
final text = inputController.text.trim(); |
||||
|
if (text.isNotEmpty) { |
||||
|
controller.addMessage(text); |
||||
|
inputController.clear(); |
||||
|
} |
||||
|
}, |
||||
|
icon: const Icon(Icons.send, color: Colors.white), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
const SizedBox(height: 8), |
||||
|
Row( |
||||
|
children: [ |
||||
|
Expanded( |
||||
|
child: _buildLanguageDropdown( |
||||
|
selected: controller.leftLanguage.value, |
||||
|
items: controller.supportedLanguages, |
||||
|
onChanged: (value) { |
||||
|
if (value != null) controller.leftLanguage.value = value; |
||||
|
}, |
||||
|
icon: Icons.headphones, |
||||
|
isSelected: isLeftInput, |
||||
|
label: '左方语言', |
||||
|
onTapInputSide: () => controller.toggleInputSide(true), |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(width: 12), |
||||
|
GestureDetector( |
||||
|
onTap: controller.swapLanguages, |
||||
|
child: Container( |
||||
|
padding: const EdgeInsets.all(8), |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.white10, |
||||
|
shape: BoxShape.circle, |
||||
|
), |
||||
|
child: |
||||
|
const Icon(Icons.compare_arrows, color: Colors.white), |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(width: 12), |
||||
|
Expanded( |
||||
|
child: _buildLanguageDropdown( |
||||
|
selected: controller.rightLanguage.value, |
||||
|
items: controller.supportedLanguages, |
||||
|
onChanged: (value) { |
||||
|
if (value != null) controller.rightLanguage.value = value; |
||||
|
}, |
||||
|
icon: Icons.smartphone, |
||||
|
isSelected: !isLeftInput, |
||||
|
label: '右方语言', |
||||
|
onTapInputSide: () => controller.toggleInputSide(false), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
Widget _buildLanguageDropdown({ |
||||
|
required String selected, |
||||
|
required List<String> items, |
||||
|
required void Function(String?) onChanged, |
||||
|
required IconData icon, |
||||
|
required bool isSelected, |
||||
|
required String label, |
||||
|
required VoidCallback onTapInputSide, |
||||
|
}) { |
||||
|
return GestureDetector( |
||||
|
onTap: onTapInputSide, |
||||
|
child: Container( |
||||
|
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), |
||||
|
decoration: BoxDecoration( |
||||
|
border: Border.all( |
||||
|
color: isSelected ? Colors.white : Colors.grey, |
||||
|
), |
||||
|
borderRadius: BorderRadius.circular(24), |
||||
|
color: isSelected ? Colors.white10 : Colors.transparent, |
||||
|
), |
||||
|
child: Row( |
||||
|
children: [ |
||||
|
Icon(icon, color: Colors.white), |
||||
|
const SizedBox(width: 8), |
||||
|
Expanded( |
||||
|
child: DropdownButton<String>( |
||||
|
dropdownColor: Colors.grey[900], |
||||
|
value: selected, |
||||
|
isExpanded: true, |
||||
|
underline: const SizedBox(), |
||||
|
iconEnabledColor: Colors.white, |
||||
|
style: const TextStyle(color: Colors.white), |
||||
|
items: items.map((lang) { |
||||
|
return DropdownMenuItem( |
||||
|
value: lang, |
||||
|
child: Text(lang), |
||||
|
); |
||||
|
}).toList(), |
||||
|
onChanged: onChanged, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,248 @@ |
|||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import '../../../data/models/channel_app_model.dart'; |
||||
|
import '../../../data/services/version_update_service.dart'; |
||||
|
|
||||
|
/// 版本更新提示对话框 - 禁止外部关闭 |
||||
|
class VersionUpdateDialog extends StatelessWidget { |
||||
|
final VersionUpdateInfo updateInfo; |
||||
|
final VoidCallback? onCancel; |
||||
|
final VoidCallback? onUpdate; |
||||
|
|
||||
|
const VersionUpdateDialog({ |
||||
|
super.key, |
||||
|
required this.updateInfo, |
||||
|
this.onCancel, |
||||
|
this.onUpdate, |
||||
|
}); |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
final isDarkMode = Theme.of(context).brightness == Brightness.dark; |
||||
|
|
||||
|
// 在强制更新时添加拦截层 |
||||
|
Widget dialogContent = _buildDialogContent(isDarkMode); |
||||
|
//if (updateInfo.updateType == UpdateType.force) { |
||||
|
return PopScope( |
||||
|
canPop: false, // 完全禁用返回键关闭 |
||||
|
child: dialogContent, |
||||
|
); |
||||
|
//} |
||||
|
|
||||
|
//return dialogContent; |
||||
|
} |
||||
|
|
||||
|
/// 构建对话框内容 |
||||
|
Widget _buildDialogContent(bool isDarkMode) { |
||||
|
return Dialog( |
||||
|
// 关键修改:禁用外部点击关闭 |
||||
|
//barrierDismissible: false, |
||||
|
shape: RoundedRectangleBorder( |
||||
|
borderRadius: BorderRadius.circular(16.r), |
||||
|
), |
||||
|
child: Container( |
||||
|
width: 320.w, |
||||
|
padding: EdgeInsets.all(24.w), |
||||
|
decoration: BoxDecoration( |
||||
|
color: isDarkMode ? Colors.grey[900] : Colors.white, |
||||
|
borderRadius: BorderRadius.circular(16.r), |
||||
|
), |
||||
|
child: Column( |
||||
|
mainAxisSize: MainAxisSize.min, |
||||
|
children: [ |
||||
|
// 标题图标 |
||||
|
Container( |
||||
|
width: 64.w, |
||||
|
height: 64.w, |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.blue.withOpacity(0.1), |
||||
|
borderRadius: BorderRadius.circular(32.r), |
||||
|
), |
||||
|
child: Icon( |
||||
|
Icons.system_update, |
||||
|
size: 32.sp, |
||||
|
color: Colors.blue, |
||||
|
), |
||||
|
), |
||||
|
|
||||
|
SizedBox(height: 16.h), |
||||
|
|
||||
|
// 标题 |
||||
|
Text( |
||||
|
'发现新版本', |
||||
|
style: TextStyle( |
||||
|
fontSize: 20.sp, |
||||
|
fontWeight: FontWeight.bold, |
||||
|
color: isDarkMode ? Colors.white : Colors.black87, |
||||
|
), |
||||
|
), |
||||
|
|
||||
|
SizedBox(height: 8.h), |
||||
|
|
||||
|
// 版本信息 |
||||
|
Text( |
||||
|
'${updateInfo.currentVersion} → ${updateInfo.latestVersion}', |
||||
|
style: TextStyle( |
||||
|
fontSize: 14.sp, |
||||
|
color: isDarkMode ? Colors.grey[400] : Colors.grey[600], |
||||
|
), |
||||
|
), |
||||
|
|
||||
|
SizedBox(height: 16.h), |
||||
|
|
||||
|
// 更新描述 |
||||
|
if (updateInfo.appInfo?.description.isNotEmpty == true) ...[ |
||||
|
Container( |
||||
|
width: double.infinity, |
||||
|
padding: EdgeInsets.all(12.w), |
||||
|
decoration: BoxDecoration( |
||||
|
color: isDarkMode ? Colors.grey[800] : Colors.grey[100], |
||||
|
borderRadius: BorderRadius.circular(8.r), |
||||
|
), |
||||
|
child: Text( |
||||
|
updateInfo.appInfo!.description, |
||||
|
style: TextStyle( |
||||
|
fontSize: 14.sp, |
||||
|
color: isDarkMode ? Colors.grey[300] : Colors.grey[700], |
||||
|
height: 1.4, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
SizedBox(height: 20.h), |
||||
|
], |
||||
|
|
||||
|
// 按钮区域 |
||||
|
Row( |
||||
|
children: [ |
||||
|
// 取消按钮(仅在非强制更新时显示) |
||||
|
if (updateInfo.updateType != UpdateType.force) ...[ |
||||
|
Expanded( |
||||
|
child: TextButton( |
||||
|
onPressed: () { |
||||
|
Get.back(); |
||||
|
onCancel?.call(); |
||||
|
}, |
||||
|
style: TextButton.styleFrom( |
||||
|
padding: EdgeInsets.symmetric(vertical: 12.h), |
||||
|
shape: RoundedRectangleBorder( |
||||
|
borderRadius: BorderRadius.circular(8.r), |
||||
|
side: BorderSide( |
||||
|
color: isDarkMode |
||||
|
? Colors.grey[600]! |
||||
|
: Colors.grey[300]!, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
child: Text( |
||||
|
'稍后提醒', |
||||
|
style: TextStyle( |
||||
|
fontSize: 16.sp, |
||||
|
color: |
||||
|
isDarkMode ? Colors.grey[400] : Colors.grey[600], |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
SizedBox(width: 12.w), |
||||
|
], |
||||
|
|
||||
|
// 更新按钮 |
||||
|
Expanded( |
||||
|
child: ElevatedButton( |
||||
|
onPressed: () async { |
||||
|
Get.back(); |
||||
|
await _handleUpdate(); |
||||
|
onUpdate?.call(); |
||||
|
}, |
||||
|
style: ElevatedButton.styleFrom( |
||||
|
backgroundColor: Colors.blue, |
||||
|
foregroundColor: Colors.white, |
||||
|
padding: EdgeInsets.symmetric(vertical: 12.h), |
||||
|
shape: RoundedRectangleBorder( |
||||
|
borderRadius: BorderRadius.circular(8.r), |
||||
|
), |
||||
|
elevation: 0, |
||||
|
), |
||||
|
child: Text( |
||||
|
_getUpdateButtonText(), |
||||
|
style: TextStyle( |
||||
|
fontSize: 16.sp, |
||||
|
fontWeight: FontWeight.w600, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/// 获取更新按钮文本 |
||||
|
String _getUpdateButtonText() { |
||||
|
switch (updateInfo.updateType) { |
||||
|
case UpdateType.force: |
||||
|
return '立即更新'; |
||||
|
case UpdateType.optional: |
||||
|
return '立即更新'; |
||||
|
default: |
||||
|
return '检查更新'; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 处理更新操作 |
||||
|
Future<void> _handleUpdate() async { |
||||
|
try { |
||||
|
// 显示加载提示 |
||||
|
Get.dialog( |
||||
|
const Center( |
||||
|
child: CircularProgressIndicator(), |
||||
|
), |
||||
|
barrierDismissible: false, |
||||
|
); |
||||
|
|
||||
|
final versionService = VersionUpdateService.to; |
||||
|
final success = await versionService.performUpdate(updateInfo); |
||||
|
|
||||
|
// 关闭加载提示 |
||||
|
Get.back(); |
||||
|
|
||||
|
if (!success) { |
||||
|
Get.snackbar( |
||||
|
'更新失败', |
||||
|
'无法打开应用商店,请手动前往应用商店更新', |
||||
|
snackPosition: SnackPosition.TOP, |
||||
|
backgroundColor: Colors.red.withOpacity(0.8), |
||||
|
colorText: Colors.white, |
||||
|
); |
||||
|
} |
||||
|
} catch (e) { |
||||
|
// 关闭加载提示 |
||||
|
if (Get.isDialogOpen == true) { |
||||
|
Get.back(); |
||||
|
} |
||||
|
|
||||
|
Get.snackbar( |
||||
|
'更新失败', |
||||
|
'更新过程中发生错误,请稍后重试', |
||||
|
snackPosition: SnackPosition.TOP, |
||||
|
backgroundColor: Colors.red.withOpacity(0.8), |
||||
|
colorText: Colors.white, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 显示版本更新对话框 |
||||
|
static Future<void> show(VersionUpdateInfo updateInfo) async { |
||||
|
await Get.dialog( |
||||
|
VersionUpdateDialog(updateInfo: updateInfo), |
||||
|
// 关键修改:禁用外部点击关闭 |
||||
|
barrierDismissible: false, |
||||
|
// 防止返回键关闭(在强制更新模式下) |
||||
|
//useRootNavigator: true, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue