99 changed files with 8887 additions and 2599 deletions
@ -0,0 +1 @@ |
|||
{"projectName":"trae_deep_voice_k3cu"} |
|||
Binary file not shown.
|
After Width: | Height: | Size: 774 KiB |
|
After Width: | Height: | Size: 904 KiB |
|
After Width: | Height: | Size: 155 KiB |
@ -1,201 +0,0 @@ |
|||
PODS: |
|||
- amap_service (0.0.1): |
|||
- AMapNavi (~> 10.0.900) |
|||
- Flutter |
|||
- AMapFoundation (1.8.2) |
|||
- AMapNavi (10.0.900): |
|||
- AMapFoundation (>= 1.8.2) |
|||
- audio_waveforms (0.0.1): |
|||
- Flutter |
|||
- classic_bluetooth (0.0.1): |
|||
- Flutter |
|||
- FBAEMKit (18.0.0): |
|||
- FBSDKCoreKit_Basics (= 18.0.0) |
|||
- FBSDKCoreKit (18.0.0): |
|||
- FBAEMKit (= 18.0.0) |
|||
- FBSDKCoreKit_Basics (= 18.0.0) |
|||
- FBSDKCoreKit_Basics (18.0.0) |
|||
- FBSDKLoginKit (18.0.0): |
|||
- FBSDKCoreKit (= 18.0.0) |
|||
- Flutter (1.0.0) |
|||
- flutter_facebook_auth (7.1.2): |
|||
- FBSDKLoginKit (~> 18.0.0) |
|||
- Flutter |
|||
- flutter_image_compress_common (1.0.0): |
|||
- Flutter |
|||
- Mantle |
|||
- SDWebImage |
|||
- SDWebImageWebPCoder |
|||
- flutter_local_notifications (0.0.1): |
|||
- Flutter |
|||
- flutter_secure_storage (6.0.0): |
|||
- Flutter |
|||
- flutter_tts (0.0.1): |
|||
- Flutter |
|||
- fluwx (0.0.1): |
|||
- Flutter |
|||
- fluwx/pay (= 0.0.1) |
|||
- fluwx/pay (0.0.1): |
|||
- Flutter |
|||
- WechatOpenSDK-XCFramework (~> 2.0.4) |
|||
- haptic_feedback (0.5.1): |
|||
- Flutter |
|||
- keyboard_height_plugin (0.0.1): |
|||
- Flutter |
|||
- libwebp (1.5.0): |
|||
- libwebp/demux (= 1.5.0) |
|||
- libwebp/mux (= 1.5.0) |
|||
- libwebp/sharpyuv (= 1.5.0) |
|||
- libwebp/webp (= 1.5.0) |
|||
- libwebp/demux (1.5.0): |
|||
- libwebp/webp |
|||
- libwebp/mux (1.5.0): |
|||
- libwebp/demux |
|||
- libwebp/sharpyuv (1.5.0) |
|||
- libwebp/webp (1.5.0): |
|||
- libwebp/sharpyuv |
|||
- Mantle (2.2.0): |
|||
- Mantle/extobjc (= 2.2.0) |
|||
- Mantle/extobjc (2.2.0) |
|||
- ota (0.0.1): |
|||
- Flutter |
|||
- permission_handler_apple (9.3.0): |
|||
- Flutter |
|||
- QCloudCore (6.4.7): |
|||
- QCloudCore/Default (= 6.4.7) |
|||
- QCloudCore/Default (6.4.7): |
|||
- QCloudTrack/Beacon (= 6.4.7) |
|||
- QCloudCOSXML (6.4.7): |
|||
- QCloudCOSXML/Default (= 6.4.7) |
|||
- QCloudCOSXML/Default (6.4.7): |
|||
- QCloudCore (= 6.4.7) |
|||
- QCloudTrack/Beacon (6.4.7) |
|||
- record_darwin (1.0.0): |
|||
- Flutter |
|||
- SDWebImage (5.21.1): |
|||
- SDWebImage/Core (= 5.21.1) |
|||
- SDWebImage/Core (5.21.1) |
|||
- SDWebImageWebPCoder (0.14.6): |
|||
- libwebp (~> 1.0) |
|||
- SDWebImage/Core (~> 5.17) |
|||
- spotify_sdk (0.0.1): |
|||
- Flutter |
|||
- tencentcloud_cos_sdk_plugin (1.2.3): |
|||
- Flutter |
|||
- QCloudCOSXML (= 6.4.7) |
|||
- WechatOpenSDK-XCFramework (2.0.4) |
|||
- wifi_iot (0.0.1): |
|||
- Flutter |
|||
|
|||
DEPENDENCIES: |
|||
- amap_service (from `.symlinks/plugins/amap_service/ios`) |
|||
- audio_waveforms (from `.symlinks/plugins/audio_waveforms/ios`) |
|||
- classic_bluetooth (from `.symlinks/plugins/classic_bluetooth/ios`) |
|||
- Flutter (from `Flutter`) |
|||
- flutter_facebook_auth (from `.symlinks/plugins/flutter_facebook_auth/ios`) |
|||
- flutter_image_compress_common (from `.symlinks/plugins/flutter_image_compress_common/ios`) |
|||
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) |
|||
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) |
|||
- flutter_tts (from `.symlinks/plugins/flutter_tts/ios`) |
|||
- fluwx (from `.symlinks/plugins/fluwx/ios`) |
|||
- haptic_feedback (from `.symlinks/plugins/haptic_feedback/ios`) |
|||
- keyboard_height_plugin (from `.symlinks/plugins/keyboard_height_plugin/ios`) |
|||
- ota (from `.symlinks/plugins/ota/ios`) |
|||
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) |
|||
- record_darwin (from `.symlinks/plugins/record_darwin/ios`) |
|||
- spotify_sdk (from `.symlinks/plugins/spotify_sdk/ios`) |
|||
- tencentcloud_cos_sdk_plugin (from `.symlinks/plugins/tencentcloud_cos_sdk_plugin/ios`) |
|||
- wifi_iot (from `.symlinks/plugins/wifi_iot/ios`) |
|||
|
|||
SPEC REPOS: |
|||
trunk: |
|||
- AMapFoundation |
|||
- AMapNavi |
|||
- FBAEMKit |
|||
- FBSDKCoreKit |
|||
- FBSDKCoreKit_Basics |
|||
- FBSDKLoginKit |
|||
- libwebp |
|||
- Mantle |
|||
- QCloudCore |
|||
- QCloudCOSXML |
|||
- QCloudTrack |
|||
- SDWebImage |
|||
- SDWebImageWebPCoder |
|||
- WechatOpenSDK-XCFramework |
|||
|
|||
EXTERNAL SOURCES: |
|||
amap_service: |
|||
:path: ".symlinks/plugins/amap_service/ios" |
|||
audio_waveforms: |
|||
:path: ".symlinks/plugins/audio_waveforms/ios" |
|||
classic_bluetooth: |
|||
:path: ".symlinks/plugins/classic_bluetooth/ios" |
|||
Flutter: |
|||
:path: Flutter |
|||
flutter_facebook_auth: |
|||
:path: ".symlinks/plugins/flutter_facebook_auth/ios" |
|||
flutter_image_compress_common: |
|||
:path: ".symlinks/plugins/flutter_image_compress_common/ios" |
|||
flutter_local_notifications: |
|||
:path: ".symlinks/plugins/flutter_local_notifications/ios" |
|||
flutter_secure_storage: |
|||
:path: ".symlinks/plugins/flutter_secure_storage/ios" |
|||
flutter_tts: |
|||
:path: ".symlinks/plugins/flutter_tts/ios" |
|||
fluwx: |
|||
:path: ".symlinks/plugins/fluwx/ios" |
|||
haptic_feedback: |
|||
:path: ".symlinks/plugins/haptic_feedback/ios" |
|||
keyboard_height_plugin: |
|||
:path: ".symlinks/plugins/keyboard_height_plugin/ios" |
|||
ota: |
|||
:path: ".symlinks/plugins/ota/ios" |
|||
permission_handler_apple: |
|||
:path: ".symlinks/plugins/permission_handler_apple/ios" |
|||
record_darwin: |
|||
:path: ".symlinks/plugins/record_darwin/ios" |
|||
spotify_sdk: |
|||
:path: ".symlinks/plugins/spotify_sdk/ios" |
|||
tencentcloud_cos_sdk_plugin: |
|||
:path: ".symlinks/plugins/tencentcloud_cos_sdk_plugin/ios" |
|||
wifi_iot: |
|||
:path: ".symlinks/plugins/wifi_iot/ios" |
|||
|
|||
SPEC CHECKSUMS: |
|||
amap_service: 5d40a69bd91031df3da423894b75a0236e26da96 |
|||
AMapFoundation: 9885c48fc3a78fdfb84a0299a2293e56ea3c9fec |
|||
AMapNavi: fceb4715f9a3b5a25f330facebee2aa2bdba42dd |
|||
audio_waveforms: a6dde7fe7c0ea05f06ffbdb0f7c1b2b2ba6cedcf |
|||
classic_bluetooth: 29f392f1ab7c52862a9ef036c94f2713b02e0c50 |
|||
FBAEMKit: e34530df538b8eb8aeb53c35867715ba6c63ef0c |
|||
FBSDKCoreKit: d3f479a69127acebb1c6aad91c1a33907bcf6c2f |
|||
FBSDKCoreKit_Basics: 017b6dc2a1862024815a8229e75661e627ac1e29 |
|||
FBSDKLoginKit: 5875762d1fe09ddcb05d03365d4f5dc34413843d |
|||
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 |
|||
flutter_facebook_auth: d97dc09216aebd99d7ac52360186a630f67bdf18 |
|||
flutter_image_compress_common: 1697a328fd72bfb335507c6bca1a65fa5ad87df1 |
|||
flutter_local_notifications: 395056b3175ba4f08480a7c5de30cd36d69827e4 |
|||
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 |
|||
flutter_tts: b88dbc8655d3dc961bc4a796e4e16a4cc1795833 |
|||
fluwx: 6bf9c5a3a99ad31b0de137dd92370a0d10a60f4b |
|||
haptic_feedback: fd1d8509833f58f164fc12122c0357fa9b2750e0 |
|||
keyboard_height_plugin: ef70a8181b29f27670e9e2450814ca6b6dc05b05 |
|||
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 |
|||
Mantle: c5aa8794a29a022dfbbfc9799af95f477a69b62d |
|||
ota: 2256716620ec8ecfc2aea220679557f94b13915f |
|||
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d |
|||
QCloudCore: 49e7571984a8e5fd8f0fd58c2f3b806255796223 |
|||
QCloudCOSXML: 7205e76aa9cf613468222615483c9d7c074e4298 |
|||
QCloudTrack: 3b53a7fc4fe3920e407f2aa73f2452992a61f7f3 |
|||
record_darwin: fb1f375f1d9603714f55b8708a903bbb91ffdb0a |
|||
SDWebImage: f29024626962457f3470184232766516dee8dfea |
|||
SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380 |
|||
spotify_sdk: 60c0a0c8d154f2c67f5598f2d652c3877e2b570a |
|||
tencentcloud_cos_sdk_plugin: c3d78b2c3bae42b5a20009dc047f3f1621e6272b |
|||
WechatOpenSDK-XCFramework: 36fb2bea0754266c17184adf4963d7e6ff98b69f |
|||
wifi_iot: f645260a2be8608517b2a9bf4c39b98e97003acc |
|||
|
|||
PODFILE CHECKSUM: e69e629768e642fde97676ea149350c61d57751e |
|||
|
|||
COCOAPODS: 1.16.2 |
|||
@ -0,0 +1,450 @@ |
|||
|
|||
|
|||
/// 翻译模式统计数据 |
|||
class TranslationModeStats { |
|||
final int apiCalls; // API调用次数 |
|||
final int sourceCharacters; // 源文本字符数 |
|||
final int effectiveAudioDuration; // 有效音频时长 |
|||
final int ttsCharacters; // TTS字符数 |
|||
final double cost; // 成本 |
|||
|
|||
TranslationModeStats({ |
|||
this.apiCalls = 0, |
|||
this.sourceCharacters = 0, |
|||
this.effectiveAudioDuration = 0, |
|||
this.ttsCharacters = 0, |
|||
this.cost = 0.0, |
|||
}); |
|||
|
|||
factory TranslationModeStats.fromJson(Map<String, dynamic> json) { |
|||
return TranslationModeStats( |
|||
apiCalls: json['apiCalls'] ?? 0, |
|||
sourceCharacters: json['sourceCharacters'] ?? 0, |
|||
effectiveAudioDuration: json['effectiveAudioDuration'] ?? 0, |
|||
ttsCharacters: json['ttsCharacters'] ?? 0, |
|||
cost: (json['cost'] ?? 0.0).toDouble(), |
|||
); |
|||
} |
|||
|
|||
Map<String, dynamic> toJson() { |
|||
return { |
|||
'apiCalls': apiCalls, |
|||
'sourceCharacters': sourceCharacters, |
|||
'effectiveAudioDuration': effectiveAudioDuration, |
|||
'ttsCharacters': ttsCharacters, |
|||
'cost': cost, |
|||
}; |
|||
} |
|||
|
|||
TranslationModeStats copyWith({ |
|||
int? apiCalls, |
|||
int? sourceCharacters, |
|||
int? effectiveAudioDuration, |
|||
int? ttsCharacters, |
|||
double? cost, |
|||
}) { |
|||
return TranslationModeStats( |
|||
apiCalls: apiCalls ?? this.apiCalls, |
|||
sourceCharacters: sourceCharacters ?? this.sourceCharacters, |
|||
effectiveAudioDuration: effectiveAudioDuration ?? this.effectiveAudioDuration, |
|||
ttsCharacters: ttsCharacters ?? this.ttsCharacters, |
|||
cost: cost ?? this.cost, |
|||
); |
|||
} |
|||
} |
|||
|
|||
/// 语言对统计数据 |
|||
class LanguagePairStats { |
|||
final String languagePair; // 语言对,如"zh-CN→en-US" |
|||
final int translationCount; // 翻译次数 |
|||
final int sourceCharacters; // 源文本字符数 |
|||
final double cost; // 成本 |
|||
|
|||
LanguagePairStats({ |
|||
required this.languagePair, |
|||
this.translationCount = 0, |
|||
this.sourceCharacters = 0, |
|||
this.cost = 0.0, |
|||
}); |
|||
|
|||
factory LanguagePairStats.fromJson(Map<String, dynamic> json) { |
|||
return LanguagePairStats( |
|||
languagePair: json['languagePair'] ?? '', |
|||
translationCount: json['translationCount'] ?? 0, |
|||
sourceCharacters: json['sourceCharacters'] ?? 0, |
|||
cost: (json['cost'] ?? 0.0).toDouble(), |
|||
); |
|||
} |
|||
|
|||
Map<String, dynamic> toJson() { |
|||
return { |
|||
'languagePair': languagePair, |
|||
'translationCount': translationCount, |
|||
'sourceCharacters': sourceCharacters, |
|||
'cost': cost, |
|||
}; |
|||
} |
|||
} |
|||
|
|||
/// 小时统计数据 |
|||
class HourlyStats { |
|||
final int hour; // 小时(0-23) |
|||
final int apiCalls; // API调用次数 |
|||
final int sourceCharacters; // 源文本字符数 |
|||
final int effectiveAudioDuration; // 有效音频时长 |
|||
final double cost; // 成本 |
|||
|
|||
HourlyStats({ |
|||
required this.hour, |
|||
this.apiCalls = 0, |
|||
this.sourceCharacters = 0, |
|||
this.effectiveAudioDuration = 0, |
|||
this.cost = 0.0, |
|||
}); |
|||
|
|||
factory HourlyStats.fromJson(Map<String, dynamic> json) { |
|||
return HourlyStats( |
|||
hour: json['hour'] ?? 0, |
|||
apiCalls: json['apiCalls'] ?? 0, |
|||
sourceCharacters: json['sourceCharacters'] ?? 0, |
|||
effectiveAudioDuration: json['effectiveAudioDuration'] ?? 0, |
|||
cost: (json['cost'] ?? 0.0).toDouble(), |
|||
); |
|||
} |
|||
|
|||
Map<String, dynamic> toJson() { |
|||
return { |
|||
'hour': hour, |
|||
'apiCalls': apiCalls, |
|||
'sourceCharacters': sourceCharacters, |
|||
'effectiveAudioDuration': effectiveAudioDuration, |
|||
'cost': cost, |
|||
}; |
|||
} |
|||
} |
|||
|
|||
/// 翻译使用统计数据模型 - 优化版 |
|||
class TranslationUsageStats { |
|||
// 基础信息 |
|||
final String id; // 统计记录ID |
|||
final DateTime date; // 统计日期 |
|||
final String userId; // 用户ID |
|||
final String timeGranularity; // 时间粒度:realtime/hour/day/week/month |
|||
|
|||
// API调用统计 - 精确化 |
|||
final int translationApiCalls; // 翻译API调用次数 |
|||
final int asrApiCalls; // ASR语音识别调用次数 |
|||
final int ttsApiCalls; // TTS语音合成调用次数 |
|||
final int aiSummaryApiCalls; // AI摘要生成调用次数(已废弃但保留兼容性) |
|||
|
|||
// 精确计费参数统计 |
|||
final int sourceTextCharacters; // 源文本字符数(翻译API计费基础) |
|||
final int effectiveAudioDuration; // ASR有效语音时长(秒,ASR计费基础) |
|||
final int ttsTextCharacters; // TTS文本字符数(TTS计费基础) |
|||
final int totalSessionDuration; // 总会话时长(秒,用于分析) |
|||
|
|||
// 网络流量统计 |
|||
final int asrNetworkBytes; // ASR网络流量(字节) |
|||
final int ttsNetworkBytes; // TTS网络流量(字节) |
|||
final int translationNetworkBytes; // 翻译API网络流量(字节) |
|||
|
|||
// 多维度分类统计 |
|||
final int sessionCount; // 翻译会话数量 |
|||
final Map<String, TranslationModeStats> modeStats; // 按翻译模式分类统计 |
|||
final Map<String, LanguagePairStats> languagePairStats; // 按语言对分类统计 |
|||
final Map<String, HourlyStats> hourlyStats; // 按小时分类统计(仅日统计包含) |
|||
|
|||
// 成本相关 |
|||
final double estimatedCost; // 预估成本 |
|||
final String currency; // 货币单位 |
|||
final Map<String, double> costBreakdown; // 成本分解(ASR/TTS/Translation) |
|||
|
|||
TranslationUsageStats({ |
|||
required this.id, |
|||
required this.date, |
|||
required this.userId, |
|||
this.timeGranularity = 'day', |
|||
this.translationApiCalls = 0, |
|||
this.asrApiCalls = 0, |
|||
this.ttsApiCalls = 0, |
|||
this.aiSummaryApiCalls = 0, |
|||
this.sourceTextCharacters = 0, |
|||
this.effectiveAudioDuration = 0, |
|||
this.ttsTextCharacters = 0, |
|||
this.totalSessionDuration = 0, |
|||
this.asrNetworkBytes = 0, |
|||
this.ttsNetworkBytes = 0, |
|||
this.translationNetworkBytes = 0, |
|||
this.sessionCount = 0, |
|||
this.modeStats = const {}, |
|||
this.languagePairStats = const {}, |
|||
this.hourlyStats = const {}, |
|||
this.estimatedCost = 0.0, |
|||
this.currency = 'CNY', |
|||
this.costBreakdown = const {}, |
|||
}); |
|||
|
|||
factory TranslationUsageStats.fromJson(Map<String, dynamic> json) { |
|||
// 处理多维度统计数据的反序列化 |
|||
final modeStatsMap = <String, TranslationModeStats>{}; |
|||
if (json['modeStats'] != null) { |
|||
final modeStatsJson = json['modeStats'] as Map<String, dynamic>; |
|||
modeStatsJson.forEach((key, value) { |
|||
modeStatsMap[key] = TranslationModeStats.fromJson(value); |
|||
}); |
|||
} |
|||
|
|||
final languagePairStatsMap = <String, LanguagePairStats>{}; |
|||
if (json['languagePairStats'] != null) { |
|||
final languagePairStatsJson = json['languagePairStats'] as Map<String, dynamic>; |
|||
languagePairStatsJson.forEach((key, value) { |
|||
languagePairStatsMap[key] = LanguagePairStats.fromJson(value); |
|||
}); |
|||
} |
|||
|
|||
final hourlyStatsMap = <String, HourlyStats>{}; |
|||
if (json['hourlyStats'] != null) { |
|||
final hourlyStatsJson = json['hourlyStats'] as Map<String, dynamic>; |
|||
hourlyStatsJson.forEach((key, value) { |
|||
hourlyStatsMap[key] = HourlyStats.fromJson(value); |
|||
}); |
|||
} |
|||
|
|||
return TranslationUsageStats( |
|||
id: json['id'] ?? '', |
|||
date: DateTime.parse(json['date']), |
|||
userId: json['userId'] ?? '', |
|||
timeGranularity: json['timeGranularity'] ?? 'day', |
|||
translationApiCalls: json['translationApiCalls'] ?? 0, |
|||
asrApiCalls: json['asrApiCalls'] ?? 0, |
|||
ttsApiCalls: json['ttsApiCalls'] ?? 0, |
|||
aiSummaryApiCalls: json['aiSummaryApiCalls'] ?? 0, |
|||
sourceTextCharacters: json['sourceTextCharacters'] ?? json['sourceCharacterCount'] ?? 0, // 兼容旧字段 |
|||
effectiveAudioDuration: json['effectiveAudioDuration'] ?? json['audioRecordingDuration'] ?? 0, // 兼容旧字段 |
|||
ttsTextCharacters: json['ttsTextCharacters'] ?? json['targetCharacterCount'] ?? 0, // 兼容旧字段 |
|||
totalSessionDuration: json['totalSessionDuration'] ?? 0, |
|||
asrNetworkBytes: json['asrNetworkBytes'] ?? 0, |
|||
ttsNetworkBytes: json['ttsNetworkBytes'] ?? 0, |
|||
translationNetworkBytes: json['translationNetworkBytes'] ?? 0, |
|||
sessionCount: json['sessionCount'] ?? 0, |
|||
modeStats: modeStatsMap, |
|||
languagePairStats: languagePairStatsMap, |
|||
hourlyStats: hourlyStatsMap, |
|||
estimatedCost: (json['estimatedCost'] ?? 0.0).toDouble(), |
|||
currency: json['currency'] ?? 'CNY', |
|||
costBreakdown: Map<String, double>.from(json['costBreakdown'] ?? {}), |
|||
); |
|||
} |
|||
|
|||
Map<String, dynamic> toJson() { |
|||
// 序列化多维度统计数据 |
|||
final modeStatsJson = <String, dynamic>{}; |
|||
modeStats.forEach((key, value) { |
|||
modeStatsJson[key] = value.toJson(); |
|||
}); |
|||
|
|||
final languagePairStatsJson = <String, dynamic>{}; |
|||
languagePairStats.forEach((key, value) { |
|||
languagePairStatsJson[key] = value.toJson(); |
|||
}); |
|||
|
|||
final hourlyStatsJson = <String, dynamic>{}; |
|||
hourlyStats.forEach((key, value) { |
|||
hourlyStatsJson[key] = value.toJson(); |
|||
}); |
|||
|
|||
return { |
|||
'id': id, |
|||
'date': date.toIso8601String(), |
|||
'userId': userId, |
|||
'timeGranularity': timeGranularity, |
|||
'translationApiCalls': translationApiCalls, |
|||
'asrApiCalls': asrApiCalls, |
|||
'ttsApiCalls': ttsApiCalls, |
|||
'aiSummaryApiCalls': aiSummaryApiCalls, |
|||
'sourceTextCharacters': sourceTextCharacters, |
|||
'effectiveAudioDuration': effectiveAudioDuration, |
|||
'ttsTextCharacters': ttsTextCharacters, |
|||
'totalSessionDuration': totalSessionDuration, |
|||
'asrNetworkBytes': asrNetworkBytes, |
|||
'ttsNetworkBytes': ttsNetworkBytes, |
|||
'translationNetworkBytes': translationNetworkBytes, |
|||
'sessionCount': sessionCount, |
|||
'modeStats': modeStatsJson, |
|||
'languagePairStats': languagePairStatsJson, |
|||
'hourlyStats': hourlyStatsJson, |
|||
'estimatedCost': estimatedCost, |
|||
'currency': currency, |
|||
'costBreakdown': costBreakdown, |
|||
// 保持向后兼容性 |
|||
'sourceCharacterCount': sourceTextCharacters, |
|||
'targetCharacterCount': ttsTextCharacters, |
|||
'audioRecordingDuration': effectiveAudioDuration, |
|||
'ttsPlaybackDuration': totalSessionDuration, |
|||
}; |
|||
} |
|||
|
|||
/// 创建空的统计记录 |
|||
factory TranslationUsageStats.empty(String userId) { |
|||
return TranslationUsageStats( |
|||
id: DateTime.now().millisecondsSinceEpoch.toString(), |
|||
date: DateTime.now(), |
|||
userId: userId, |
|||
); |
|||
} |
|||
|
|||
/// 复制并更新统计数据 |
|||
TranslationUsageStats copyWith({ |
|||
String? id, |
|||
DateTime? date, |
|||
String? userId, |
|||
String? timeGranularity, |
|||
int? translationApiCalls, |
|||
int? asrApiCalls, |
|||
int? ttsApiCalls, |
|||
int? aiSummaryApiCalls, |
|||
int? sourceTextCharacters, |
|||
int? effectiveAudioDuration, |
|||
int? ttsTextCharacters, |
|||
int? totalSessionDuration, |
|||
int? asrNetworkBytes, |
|||
int? ttsNetworkBytes, |
|||
int? translationNetworkBytes, |
|||
int? sessionCount, |
|||
Map<String, TranslationModeStats>? modeStats, |
|||
Map<String, LanguagePairStats>? languagePairStats, |
|||
Map<String, HourlyStats>? hourlyStats, |
|||
double? estimatedCost, |
|||
String? currency, |
|||
Map<String, double>? costBreakdown, |
|||
}) { |
|||
return TranslationUsageStats( |
|||
id: id ?? this.id, |
|||
date: date ?? this.date, |
|||
userId: userId ?? this.userId, |
|||
timeGranularity: timeGranularity ?? this.timeGranularity, |
|||
translationApiCalls: translationApiCalls ?? this.translationApiCalls, |
|||
asrApiCalls: asrApiCalls ?? this.asrApiCalls, |
|||
ttsApiCalls: ttsApiCalls ?? this.ttsApiCalls, |
|||
aiSummaryApiCalls: aiSummaryApiCalls ?? this.aiSummaryApiCalls, |
|||
sourceTextCharacters: sourceTextCharacters ?? this.sourceTextCharacters, |
|||
effectiveAudioDuration: effectiveAudioDuration ?? this.effectiveAudioDuration, |
|||
ttsTextCharacters: ttsTextCharacters ?? this.ttsTextCharacters, |
|||
totalSessionDuration: totalSessionDuration ?? this.totalSessionDuration, |
|||
asrNetworkBytes: asrNetworkBytes ?? this.asrNetworkBytes, |
|||
ttsNetworkBytes: ttsNetworkBytes ?? this.ttsNetworkBytes, |
|||
translationNetworkBytes: translationNetworkBytes ?? this.translationNetworkBytes, |
|||
sessionCount: sessionCount ?? this.sessionCount, |
|||
modeStats: modeStats ?? this.modeStats, |
|||
languagePairStats: languagePairStats ?? this.languagePairStats, |
|||
hourlyStats: hourlyStats ?? this.hourlyStats, |
|||
estimatedCost: estimatedCost ?? this.estimatedCost, |
|||
currency: currency ?? this.currency, |
|||
costBreakdown: costBreakdown ?? this.costBreakdown, |
|||
); |
|||
} |
|||
|
|||
/// 获取总API调用次数 |
|||
int get totalApiCalls => translationApiCalls + asrApiCalls + ttsApiCalls + aiSummaryApiCalls; |
|||
|
|||
/// 获取总字符数(精确计费基础) |
|||
int get totalCharacterCount => sourceTextCharacters + ttsTextCharacters; |
|||
|
|||
/// 获取总音频时长(包含有效时长和总会话时长) |
|||
int get totalAudioDuration => effectiveAudioDuration + totalSessionDuration; |
|||
|
|||
/// 获取总网络流量 |
|||
int get totalNetworkBytes => asrNetworkBytes + ttsNetworkBytes + translationNetworkBytes; |
|||
|
|||
/// 获取最常用的语言对 |
|||
String get mostUsedLanguagePair { |
|||
if (languagePairStats.isEmpty) return '无数据'; |
|||
return languagePairStats.entries |
|||
.reduce((a, b) => a.value.translationCount > b.value.translationCount ? a : b) |
|||
.key; |
|||
} |
|||
|
|||
/// 获取最常用的翻译模式 |
|||
String get mostUsedMode { |
|||
if (modeStats.isEmpty) return '无数据'; |
|||
return modeStats.entries |
|||
.reduce((a, b) => a.value.apiCalls > b.value.apiCalls ? a : b) |
|||
.key; |
|||
} |
|||
|
|||
// 向后兼容性getter |
|||
/// @deprecated 使用 sourceTextCharacters 替代 |
|||
int get sourceCharacterCount => sourceTextCharacters; |
|||
|
|||
/// @deprecated 使用 ttsTextCharacters 替代 |
|||
int get targetCharacterCount => ttsTextCharacters; |
|||
|
|||
/// @deprecated 使用 effectiveAudioDuration 替代 |
|||
int get audioRecordingDuration => effectiveAudioDuration; |
|||
|
|||
/// @deprecated 使用 totalSessionDuration 替代 |
|||
int get ttsPlaybackDuration => totalSessionDuration; |
|||
|
|||
/// @deprecated 使用 languagePairStats 替代 |
|||
Map<String, int> get languagePairUsage { |
|||
final result = <String, int>{}; |
|||
languagePairStats.forEach((key, value) { |
|||
result[key] = value.translationCount; |
|||
}); |
|||
return result; |
|||
} |
|||
|
|||
/// @deprecated 使用 modeStats 替代 |
|||
Map<String, int> get modeUsage { |
|||
final result = <String, int>{}; |
|||
modeStats.forEach((key, value) { |
|||
result[key] = value.apiCalls; |
|||
}); |
|||
return result; |
|||
} |
|||
} |
|||
|
|||
/// 实时统计数据模型(用于当前会话)- 优化版 |
|||
class RealtimeUsageStats { |
|||
// 精确计费参数 |
|||
int translationCount = 0; // 当前会话翻译次数 |
|||
int sourceTextCharacters = 0; // 当前会话源文本字符数(翻译计费基础) |
|||
int ttsTextCharacters = 0; // 当前会话TTS文本字符数(TTS计费基础) |
|||
int effectiveAudioDuration = 0; // 当前会话有效语音时长(ASR计费基础) |
|||
int totalSessionDuration = 0; // 当前会话总时长 |
|||
|
|||
// 会话信息 |
|||
String currentLanguagePair = ''; // 当前语言对 |
|||
String currentMode = ''; // 当前翻译模式 |
|||
DateTime sessionStartTime = DateTime.now(); // 会话开始时间 |
|||
|
|||
/// 重置统计数据 |
|||
void reset() { |
|||
translationCount = 0; |
|||
sourceTextCharacters = 0; |
|||
ttsTextCharacters = 0; |
|||
effectiveAudioDuration = 0; |
|||
totalSessionDuration = 0; |
|||
currentLanguagePair = ''; |
|||
currentMode = ''; |
|||
sessionStartTime = DateTime.now(); |
|||
} |
|||
|
|||
/// 获取会话持续时间(分钟) |
|||
int get sessionDurationMinutes { |
|||
return DateTime.now().difference(sessionStartTime).inMinutes; |
|||
} |
|||
|
|||
// 向后兼容性getter |
|||
/// @deprecated 使用 sourceTextCharacters 替代 |
|||
int get sourceCharacters => sourceTextCharacters; |
|||
|
|||
/// @deprecated 使用 ttsTextCharacters 替代 |
|||
int get targetCharacters => ttsTextCharacters; |
|||
|
|||
/// @deprecated 使用 effectiveAudioDuration 替代 |
|||
int get recordingDuration => effectiveAudioDuration; |
|||
|
|||
/// @deprecated 使用 totalSessionDuration 替代 |
|||
int get ttsPlayDuration => totalSessionDuration; |
|||
} |
|||
@ -0,0 +1,519 @@ |
|||
import 'dart:convert'; |
|||
import 'package:get/get.dart'; |
|||
import 'package:get_storage/get_storage.dart'; |
|||
import '../models/translation_usage_stats.dart'; |
|||
import '../../core/utils/logger.dart'; |
|||
|
|||
/// 翻译使用量统计服务 |
|||
class TranslationUsageService extends GetxService { |
|||
static const String _storageKey = 'translation_usage_stats'; |
|||
static const String _dailyStatsKey = 'daily_translation_stats'; |
|||
static const String _costConfigKey = 'translation_cost_config'; |
|||
|
|||
final GetStorage _storage = GetStorage(); |
|||
|
|||
// 实时统计数据 |
|||
final Rx<RealtimeUsageStats> realtimeStats = RealtimeUsageStats().obs; |
|||
|
|||
// 当日统计数据 |
|||
final Rx<TranslationUsageStats> todayStats = TranslationUsageStats.empty('').obs; |
|||
|
|||
// 成本配置 |
|||
final RxMap<String, double> costConfig = <String, double>{}.obs; |
|||
|
|||
// 预警阈值 |
|||
final RxDouble dailyCostThreshold = 10.0.obs; // 日成本预警阈值 |
|||
final RxDouble monthlyCostThreshold = 300.0.obs; // 月成本预警阈值 |
|||
|
|||
@override |
|||
void onInit() { |
|||
super.onInit(); |
|||
_initializeService(); |
|||
} |
|||
|
|||
/// 初始化服务 |
|||
void _initializeService() { |
|||
_loadCostConfig(); |
|||
_loadTodayStats(); |
|||
Logger.info('翻译使用量统计服务初始化完成'); |
|||
} |
|||
|
|||
/// 加载成本配置 |
|||
void _loadCostConfig() { |
|||
try { |
|||
final configData = _storage.read(_costConfigKey); |
|||
if (configData != null) { |
|||
costConfig.value = Map<String, double>.from(configData); |
|||
} else { |
|||
// 设置默认成本配置(每千字符的成本,单位:元) |
|||
costConfig.value = { |
|||
'translation_per_1k_chars': 0.05, // 翻译:每千字符0.05元 |
|||
'asr_per_minute': 0.02, // ASR:每分钟0.02元 |
|||
'tts_per_1k_chars': 0.03, // TTS:每千字符0.03元 |
|||
'ai_summary_per_request': 0.01, // AI摘要:每次请求0.01元 |
|||
}; |
|||
_saveCostConfig(); |
|||
} |
|||
} catch (e) { |
|||
Logger.error('加载成本配置失败: $e'); |
|||
} |
|||
} |
|||
|
|||
/// 保存成本配置 |
|||
void _saveCostConfig() { |
|||
try { |
|||
_storage.write(_costConfigKey, costConfig.value); |
|||
} catch (e) { |
|||
Logger.error('保存成本配置失败: $e'); |
|||
} |
|||
} |
|||
|
|||
/// 加载当日统计数据 |
|||
void _loadTodayStats() { |
|||
try { |
|||
final today = DateTime.now(); |
|||
final todayKey = '${today.year}-${today.month.toString().padLeft(2, '0')}-${today.day.toString().padLeft(2, '0')}'; |
|||
|
|||
final statsData = _storage.read('${_dailyStatsKey}_$todayKey'); |
|||
if (statsData != null) { |
|||
todayStats.value = TranslationUsageStats.fromJson(Map<String, dynamic>.from(statsData)); |
|||
} else { |
|||
todayStats.value = TranslationUsageStats.empty(getCurrentUserId()); |
|||
} |
|||
} catch (e) { |
|||
Logger.error('加载当日统计数据失败: $e'); |
|||
todayStats.value = TranslationUsageStats.empty(getCurrentUserId()); |
|||
} |
|||
} |
|||
|
|||
/// 获取当前用户ID |
|||
String getCurrentUserId() { |
|||
try { |
|||
final userInfo = _storage.read('user_info'); |
|||
if (userInfo != null && userInfo['user'] != null) { |
|||
return userInfo['user']['uid'] ?? 'anonymous'; |
|||
} |
|||
} catch (e) { |
|||
Logger.warning('获取用户ID失败: $e'); |
|||
} |
|||
return 'anonymous'; |
|||
} |
|||
|
|||
/// 记录翻译API调用 |
|||
void recordTranslationApiCall({ |
|||
required String sourceText, |
|||
required String targetText, |
|||
required String sourceLanguage, |
|||
required String targetLanguage, |
|||
required String mode, |
|||
}) { |
|||
try { |
|||
// 更新实时统计 |
|||
realtimeStats.value.translationCount++; |
|||
realtimeStats.value.sourceTextCharacters += sourceText.length; |
|||
realtimeStats.value.ttsTextCharacters += targetText.length; |
|||
realtimeStats.value.currentLanguagePair = '$sourceLanguage-$targetLanguage'; |
|||
realtimeStats.value.currentMode = mode; |
|||
realtimeStats.refresh(); |
|||
|
|||
// 更新当日统计 - 使用多维度统计 |
|||
final languagePair = '$sourceLanguage→$targetLanguage'; |
|||
|
|||
// 更新语言对统计 |
|||
final newLanguagePairStats = Map<String, LanguagePairStats>.from(todayStats.value.languagePairStats); |
|||
if (newLanguagePairStats.containsKey(languagePair)) { |
|||
final existing = newLanguagePairStats[languagePair]!; |
|||
newLanguagePairStats[languagePair] = LanguagePairStats( |
|||
languagePair: languagePair, |
|||
translationCount: existing.translationCount + 1, |
|||
sourceCharacters: existing.sourceCharacters + sourceText.length, |
|||
cost: existing.cost + _calculateTranslationCost(sourceText.length), |
|||
); |
|||
} else { |
|||
newLanguagePairStats[languagePair] = LanguagePairStats( |
|||
languagePair: languagePair, |
|||
translationCount: 1, |
|||
sourceCharacters: sourceText.length, |
|||
cost: _calculateTranslationCost(sourceText.length), |
|||
); |
|||
} |
|||
|
|||
// 更新模式统计 |
|||
final newModeStats = Map<String, TranslationModeStats>.from(todayStats.value.modeStats); |
|||
if (newModeStats.containsKey(mode)) { |
|||
final existing = newModeStats[mode]!; |
|||
newModeStats[mode] = existing.copyWith( |
|||
apiCalls: existing.apiCalls + 1, |
|||
sourceCharacters: existing.sourceCharacters + sourceText.length, |
|||
ttsCharacters: existing.ttsCharacters + targetText.length, |
|||
cost: existing.cost + _calculateTranslationCost(sourceText.length), |
|||
); |
|||
} else { |
|||
newModeStats[mode] = TranslationModeStats( |
|||
apiCalls: 1, |
|||
sourceCharacters: sourceText.length, |
|||
ttsCharacters: targetText.length, |
|||
cost: _calculateTranslationCost(sourceText.length), |
|||
); |
|||
} |
|||
|
|||
todayStats.value = todayStats.value.copyWith( |
|||
translationApiCalls: todayStats.value.translationApiCalls + 1, |
|||
sourceTextCharacters: todayStats.value.sourceTextCharacters + sourceText.length, |
|||
ttsTextCharacters: todayStats.value.ttsTextCharacters + targetText.length, |
|||
languagePairStats: newLanguagePairStats, |
|||
modeStats: newModeStats, |
|||
estimatedCost: _calculateEstimatedCost(todayStats.value.copyWith( |
|||
translationApiCalls: todayStats.value.translationApiCalls + 1, |
|||
sourceTextCharacters: todayStats.value.sourceTextCharacters + sourceText.length, |
|||
ttsTextCharacters: todayStats.value.ttsTextCharacters + targetText.length, |
|||
)), |
|||
); |
|||
|
|||
_saveTodayStats(); |
|||
_checkCostThreshold(); |
|||
|
|||
Logger.info('记录翻译API调用: 源文本${sourceText.length}字符, 目标文本${targetText.length}字符'); |
|||
} catch (e) { |
|||
Logger.error('记录翻译API调用失败: $e'); |
|||
} |
|||
} |
|||
|
|||
/// 记录ASR API调用 - 优化版(精确计费参数) |
|||
void recordAsrApiCall({ |
|||
required int effectiveDurationSeconds, // 有效语音时长(VAD检测的实际语音) |
|||
required int totalDurationSeconds, // 总会话时长(包含静音) |
|||
required String language, |
|||
required String mode, // 翻译模式 |
|||
String? audioFormat, // 音频格式(可选) |
|||
int? sampleRate, // 采样率(可选) |
|||
}) { |
|||
try { |
|||
// 更新实时统计(使用有效语音时长作为主要计费依据) |
|||
realtimeStats.value.effectiveAudioDuration += effectiveDurationSeconds; |
|||
realtimeStats.value.totalSessionDuration += totalDurationSeconds; |
|||
realtimeStats.refresh(); |
|||
|
|||
// 更新模式统计 |
|||
final newModeStats = Map<String, TranslationModeStats>.from(todayStats.value.modeStats); |
|||
if (newModeStats.containsKey(mode)) { |
|||
final existing = newModeStats[mode]!; |
|||
newModeStats[mode] = existing.copyWith( |
|||
effectiveAudioDuration: existing.effectiveAudioDuration + effectiveDurationSeconds, |
|||
cost: existing.cost + _calculateAsrCost(effectiveDurationSeconds), |
|||
); |
|||
} else { |
|||
newModeStats[mode] = TranslationModeStats( |
|||
effectiveAudioDuration: effectiveDurationSeconds, |
|||
cost: _calculateAsrCost(effectiveDurationSeconds), |
|||
); |
|||
} |
|||
|
|||
// 更新当日统计 |
|||
todayStats.value = todayStats.value.copyWith( |
|||
asrApiCalls: todayStats.value.asrApiCalls + 1, |
|||
effectiveAudioDuration: todayStats.value.effectiveAudioDuration + effectiveDurationSeconds, |
|||
totalSessionDuration: todayStats.value.totalSessionDuration + totalDurationSeconds, |
|||
modeStats: newModeStats, |
|||
estimatedCost: _calculateEstimatedCost(todayStats.value.copyWith( |
|||
asrApiCalls: todayStats.value.asrApiCalls + 1, |
|||
effectiveAudioDuration: todayStats.value.effectiveAudioDuration + effectiveDurationSeconds, |
|||
)), |
|||
); |
|||
|
|||
_saveTodayStats(); |
|||
|
|||
Logger.info('ASR统计: 有效语音${effectiveDurationSeconds}秒, 总时长${totalDurationSeconds}秒, 语言$language, 模式$mode'); |
|||
} catch (e) { |
|||
Logger.error('记录ASR API调用失败: $e'); |
|||
} |
|||
} |
|||
|
|||
/// 记录TTS API调用 |
|||
void recordTtsApiCall({ |
|||
required String text, |
|||
required int durationSeconds, |
|||
required String language, |
|||
}) { |
|||
try { |
|||
// 更新实时统计 |
|||
realtimeStats.value.totalSessionDuration += durationSeconds; |
|||
realtimeStats.refresh(); |
|||
|
|||
// 更新当日统计(TTS按字符数计费,不是按时长) |
|||
todayStats.value = todayStats.value.copyWith( |
|||
ttsApiCalls: todayStats.value.ttsApiCalls + 1, |
|||
ttsTextCharacters: todayStats.value.ttsTextCharacters + text.length, |
|||
totalSessionDuration: todayStats.value.totalSessionDuration + durationSeconds, |
|||
estimatedCost: _calculateEstimatedCost(todayStats.value.copyWith( |
|||
ttsApiCalls: todayStats.value.ttsApiCalls + 1, |
|||
ttsTextCharacters: todayStats.value.ttsTextCharacters + text.length, |
|||
)), |
|||
); |
|||
|
|||
_saveTodayStats(); |
|||
|
|||
Logger.info('记录TTS API调用: 文本${text.length}字符, 时长${durationSeconds}秒'); |
|||
} catch (e) { |
|||
Logger.error('记录TTS API调用失败: $e'); |
|||
} |
|||
} |
|||
|
|||
/// 记录AI摘要API调用 |
|||
void recordAiSummaryApiCall() { |
|||
try { |
|||
// 更新当日统计 |
|||
todayStats.value = todayStats.value.copyWith( |
|||
aiSummaryApiCalls: todayStats.value.aiSummaryApiCalls + 1, |
|||
estimatedCost: _calculateEstimatedCost(todayStats.value.copyWith( |
|||
aiSummaryApiCalls: todayStats.value.aiSummaryApiCalls + 1, |
|||
)), |
|||
); |
|||
|
|||
_saveTodayStats(); |
|||
|
|||
Logger.info('记录AI摘要API调用'); |
|||
} catch (e) { |
|||
Logger.error('记录AI摘要API调用失败: $e'); |
|||
} |
|||
} |
|||
|
|||
/// 开始新会话 |
|||
void startNewSession() { |
|||
realtimeStats.value.reset(); |
|||
|
|||
// 更新当日会话数 |
|||
todayStats.value = todayStats.value.copyWith( |
|||
sessionCount: todayStats.value.sessionCount + 1, |
|||
); |
|||
|
|||
_saveTodayStats(); |
|||
Logger.info('开始新的翻译会话'); |
|||
} |
|||
|
|||
/// 计算预估成本 - 优化版(基于精确计费参数) |
|||
double _calculateEstimatedCost(TranslationUsageStats stats) { |
|||
double cost = 0.0; |
|||
|
|||
// 翻译成本:基于源文本字符数 |
|||
cost += (stats.sourceTextCharacters / 1000) * (costConfig['translation_per_1k_chars'] ?? 0.05); |
|||
|
|||
// ASR成本:基于有效音频时长 |
|||
cost += (stats.effectiveAudioDuration / 60) * (costConfig['asr_per_minute'] ?? 0.02); |
|||
|
|||
// TTS成本:基于TTS文本字符数 |
|||
cost += (stats.ttsTextCharacters / 1000) * (costConfig['tts_per_1k_chars'] ?? 0.03); |
|||
|
|||
// AI摘要成本(已废弃但保留兼容性) |
|||
cost += stats.aiSummaryApiCalls * (costConfig['ai_summary_per_request'] ?? 0.01); |
|||
|
|||
return cost; |
|||
} |
|||
|
|||
/// 计算翻译成本(单次调用) |
|||
double _calculateTranslationCost(int sourceCharacters) { |
|||
return (sourceCharacters / 1000) * (costConfig['translation_per_1k_chars'] ?? 0.05); |
|||
} |
|||
|
|||
/// 计算ASR成本(单次调用) |
|||
double _calculateAsrCost(int durationSeconds) { |
|||
return (durationSeconds / 60) * (costConfig['asr_per_minute'] ?? 0.02); |
|||
} |
|||
|
|||
/// 计算TTS成本(单次调用) |
|||
double _calculateTtsCost(int textCharacters) { |
|||
return (textCharacters / 1000) * (costConfig['tts_per_1k_chars'] ?? 0.03); |
|||
} |
|||
|
|||
/// 保存当日统计数据 |
|||
void _saveTodayStats() { |
|||
try { |
|||
final today = DateTime.now(); |
|||
final todayKey = '${today.year}-${today.month.toString().padLeft(2, '0')}-${today.day.toString().padLeft(2, '0')}'; |
|||
_storage.write('${_dailyStatsKey}_$todayKey', todayStats.value.toJson()); |
|||
} catch (e) { |
|||
Logger.error('保存当日统计数据失败: $e'); |
|||
} |
|||
} |
|||
|
|||
/// 检查成本阈值 |
|||
void _checkCostThreshold() { |
|||
final todayCost = todayStats.value.estimatedCost; |
|||
|
|||
if (todayCost >= dailyCostThreshold.value) { |
|||
_showCostWarning('今日翻译成本已达到预警阈值:¥${todayCost.toStringAsFixed(2)}'); |
|||
} |
|||
} |
|||
|
|||
/// 显示成本预警 |
|||
void _showCostWarning(String message) { |
|||
Get.snackbar( |
|||
'成本预警', |
|||
message, |
|||
snackPosition: SnackPosition.TOP, |
|||
backgroundColor: Get.theme.colorScheme.error.withOpacity(0.1), |
|||
colorText: Get.theme.colorScheme.error, |
|||
duration: const Duration(seconds: 5), |
|||
); |
|||
} |
|||
|
|||
/// 获取历史统计数据 |
|||
Future<List<TranslationUsageStats>> getHistoryStats({ |
|||
required DateTime startDate, |
|||
required DateTime endDate, |
|||
}) async { |
|||
try { |
|||
final List<TranslationUsageStats> historyStats = []; |
|||
|
|||
for (DateTime date = startDate; |
|||
date.isBefore(endDate) || date.isAtSameMomentAs(endDate); |
|||
date = date.add(const Duration(days: 1))) { |
|||
|
|||
final dateKey = '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; |
|||
final statsData = _storage.read('${_dailyStatsKey}_$dateKey'); |
|||
|
|||
if (statsData != null) { |
|||
historyStats.add(TranslationUsageStats.fromJson(Map<String, dynamic>.from(statsData))); |
|||
} |
|||
} |
|||
|
|||
return historyStats; |
|||
} catch (e) { |
|||
Logger.error('获取历史统计数据失败: $e'); |
|||
return []; |
|||
} |
|||
} |
|||
|
|||
/// 清理过期数据(保留最近90天) |
|||
void cleanupOldData() { |
|||
try { |
|||
final cutoffDate = DateTime.now().subtract(const Duration(days: 90)); |
|||
final allKeys = _storage.getKeys(); |
|||
|
|||
for (final key in allKeys) { |
|||
if (key.startsWith(_dailyStatsKey)) { |
|||
final dateStr = key.replaceFirst('${_dailyStatsKey}_', ''); |
|||
try { |
|||
final date = DateTime.parse(dateStr); |
|||
if (date.isBefore(cutoffDate)) { |
|||
_storage.remove(key); |
|||
Logger.info('清理过期统计数据: $key'); |
|||
} |
|||
} catch (e) { |
|||
// 忽略解析错误的键 |
|||
} |
|||
} |
|||
} |
|||
} catch (e) { |
|||
Logger.error('清理过期数据失败: $e'); |
|||
} |
|||
} |
|||
|
|||
/// 更新成本配置 |
|||
void updateCostConfig(Map<String, double> newConfig) { |
|||
costConfig.value = newConfig; |
|||
_saveCostConfig(); |
|||
|
|||
// 重新计算当日成本 |
|||
todayStats.value = todayStats.value.copyWith( |
|||
estimatedCost: _calculateEstimatedCost(todayStats.value), |
|||
); |
|||
_saveTodayStats(); |
|||
|
|||
Logger.info('更新成本配置完成'); |
|||
} |
|||
|
|||
/// 更新预警阈值 |
|||
void updateThresholds({ |
|||
double? dailyThreshold, |
|||
double? monthlyThreshold, |
|||
}) { |
|||
if (dailyThreshold != null) { |
|||
dailyCostThreshold.value = dailyThreshold; |
|||
} |
|||
if (monthlyThreshold != null) { |
|||
monthlyCostThreshold.value = monthlyThreshold; |
|||
} |
|||
|
|||
// 保存阈值配置 |
|||
_storage.write('daily_cost_threshold', dailyCostThreshold.value); |
|||
_storage.write('monthly_cost_threshold', monthlyCostThreshold.value); |
|||
|
|||
Logger.info('更新预警阈值完成'); |
|||
} |
|||
|
|||
/// 获取月度统计汇总 |
|||
Future<TranslationUsageStats> getMonthlyStats(int year, int month) async { |
|||
try { |
|||
final startDate = DateTime(year, month, 1); |
|||
final endDate = DateTime(year, month + 1, 0); // 月末 |
|||
|
|||
final dailyStats = await getHistoryStats( |
|||
startDate: startDate, |
|||
endDate: endDate, |
|||
); |
|||
|
|||
if (dailyStats.isEmpty) { |
|||
return TranslationUsageStats.empty(getCurrentUserId()); |
|||
} |
|||
|
|||
// 汇总月度数据 |
|||
int totalTranslationCalls = 0; |
|||
int totalAsrCalls = 0; |
|||
int totalTtsCalls = 0; |
|||
int totalAiSummaryCalls = 0; |
|||
int totalSourceChars = 0; |
|||
int totalTargetChars = 0; |
|||
int totalRecordingDuration = 0; |
|||
int totalTtsPlayDuration = 0; |
|||
int totalSessions = 0; |
|||
double totalCost = 0.0; |
|||
|
|||
final Map<String, int> combinedLanguagePairUsage = {}; |
|||
final Map<String, int> combinedModeUsage = {}; |
|||
|
|||
for (final stats in dailyStats) { |
|||
totalTranslationCalls += stats.translationApiCalls; |
|||
totalAsrCalls += stats.asrApiCalls; |
|||
totalTtsCalls += stats.ttsApiCalls; |
|||
totalAiSummaryCalls += stats.aiSummaryApiCalls; |
|||
totalSourceChars += stats.sourceCharacterCount; |
|||
totalTargetChars += stats.targetCharacterCount; |
|||
totalRecordingDuration += stats.audioRecordingDuration; |
|||
totalTtsPlayDuration += stats.ttsPlaybackDuration; |
|||
totalSessions += stats.sessionCount; |
|||
totalCost += stats.estimatedCost; |
|||
|
|||
// 合并语言对使用统计 |
|||
stats.languagePairUsage.forEach((key, value) { |
|||
combinedLanguagePairUsage[key] = (combinedLanguagePairUsage[key] ?? 0) + value; |
|||
}); |
|||
|
|||
// 合并模式使用统计 |
|||
stats.modeUsage.forEach((key, value) { |
|||
combinedModeUsage[key] = (combinedModeUsage[key] ?? 0) + value; |
|||
}); |
|||
} |
|||
|
|||
return TranslationUsageStats( |
|||
id: 'monthly_${year}_$month', |
|||
date: startDate, |
|||
userId: getCurrentUserId(), |
|||
timeGranularity: 'month', |
|||
translationApiCalls: totalTranslationCalls, |
|||
asrApiCalls: totalAsrCalls, |
|||
ttsApiCalls: totalTtsCalls, |
|||
aiSummaryApiCalls: totalAiSummaryCalls, |
|||
sourceTextCharacters: totalSourceChars, |
|||
effectiveAudioDuration: totalRecordingDuration, |
|||
ttsTextCharacters: totalTargetChars, |
|||
totalSessionDuration: totalTtsPlayDuration, |
|||
sessionCount: totalSessions, |
|||
estimatedCost: totalCost, |
|||
); |
|||
} catch (e) { |
|||
Logger.error('获取月度统计失败: $e'); |
|||
return TranslationUsageStats.empty(getCurrentUserId()); |
|||
} |
|||
} |
|||
} |
|||
@ -1,10 +1,16 @@ |
|||
import 'package:get/get.dart'; |
|||
import '../controllers/meeting_controller.dart'; |
|||
import '../controllers/meeting_home_controller.dart'; |
|||
import '../controllers/meeting_mine_controller.dart'; |
|||
import '../../profile/controllers/profile_controller.dart'; |
|||
|
|||
class MeetingBinding extends Bindings { |
|||
@override |
|||
void dependencies() { |
|||
// 注册控制器 |
|||
Get.lazyPut<MeetingController>(() => MeetingController()); |
|||
Get.lazyPut<MeetingHomeController>(() => MeetingHomeController()); |
|||
Get.lazyPut<MeetingMineController>(() => MeetingMineController()); |
|||
Get.lazyPut<ProfileController>(() => ProfileController()); |
|||
} |
|||
} |
|||
|
|||
@ -1,402 +1,24 @@ |
|||
import 'dart:io'; |
|||
|
|||
import 'package:file_picker/file_picker.dart'; |
|||
import 'package:flutter/material.dart'; |
|||
import 'package:flutter_easyloading/flutter_easyloading.dart'; |
|||
import 'package:get/get.dart'; |
|||
import 'package:just_audio/just_audio.dart'; |
|||
import 'package:path/path.dart' as path; |
|||
import 'package:path_provider/path_provider.dart'; |
|||
import 'package:permission_handler/permission_handler.dart'; |
|||
|
|||
import '../../../core/utils/logger.dart'; |
|||
import '../../../core/utils/synchrodata.dart'; |
|||
import '../../../data/services/ble_manager.dart'; |
|||
import '../../../data/services/db/sqflite_api.dart'; |
|||
import '../../../core/utils/permission_util.dart'; |
|||
|
|||
import 'package:share_plus/share_plus.dart'; |
|||
|
|||
import '../../../data/services/meeting/meeting_task_service.dart'; |
|||
import '../../../data/services/meeting/meeting_upload_service.dart'; |
|||
import '../../../routes/app_routes.dart'; |
|||
import '../views/meeting_home_view.dart'; |
|||
import '../views/meeting_mine_view.dart'; |
|||
|
|||
class MeetingController extends GetxController { |
|||
final meetingUploadService = Get.find<MeetingUploadService>(); |
|||
final meetingTaskService = Get.find<MeetingTaskService>(); |
|||
|
|||
final ScrollController listController = ScrollController(); |
|||
final AudioPlayer _audioPlayer = AudioPlayer(); |
|||
final GlobalKey<ScaffoldState> scaffoldKey = GlobalKey<ScaffoldState>(); |
|||
|
|||
// 是否还有更多数据 |
|||
bool isGetMore = true; |
|||
bool isLoading = false; |
|||
int page = 1; |
|||
int addQuantity = 0; |
|||
List<String> selected = []; |
|||
late PageController pageController; |
|||
|
|||
final appDocDir = getApplicationDocumentsDirectory(); |
|||
List<FileSystemEntity> files = []; |
|||
TextEditingController titleController = TextEditingController(); // 录音标题 |
|||
String titleCSuffix = ""; // 录音标题后缀 |
|||
RxList dataList = [].obs; |
|||
final List<Widget> pageList = [ |
|||
const MeetingHomeView(), |
|||
const MeetingMineView(), |
|||
]; |
|||
|
|||
// 蓝牙服务 |
|||
final bleManager = Get.find<BleManager>(); |
|||
RxInt pageIndex = 0.obs; |
|||
|
|||
@override |
|||
void onInit() { |
|||
super.onInit(); |
|||
listController.addListener(_onScroll); |
|||
_requestStoragePermission(); |
|||
_initGetDataList(); |
|||
addMeetingAudio(); |
|||
final Map<String, dynamic> args = Get.arguments ?? {}; |
|||
// 检查参数是否存在且包含有效路径 |
|||
if (args.isNotEmpty && |
|||
args.containsKey('filePath') && |
|||
args['filePath'] is String && |
|||
args['filePath'].isNotEmpty) { |
|||
final String path = args['filePath']; |
|||
addAudio(path); |
|||
} |
|||
} |
|||
|
|||
@override |
|||
void onClose() { |
|||
listController.dispose(); |
|||
titleController.dispose(); |
|||
_audioPlayer.dispose(); |
|||
super.onClose(); |
|||
} |
|||
|
|||
void onPopInvokedWithResult(didPop, result) { |
|||
Synchrodata.uploadOperationRecords(isSynchro: true); |
|||
} |
|||
|
|||
void _onScroll() { |
|||
if (isGetMore && |
|||
!isLoading && |
|||
listController.position.pixels >= |
|||
listController.position.maxScrollExtent - 100) { |
|||
isLoading = true; |
|||
page++; |
|||
_getDataList(); |
|||
} |
|||
} |
|||
|
|||
void _initGetDataList() async { |
|||
await Synchrodata.uploadOperationRecords(isSynchro: true); |
|||
_getDataList(); |
|||
} |
|||
|
|||
// 请求本地数据 |
|||
void _getDataList() async { |
|||
List list = await SqfliteApi.getMeetingList((page - 1) * 10 + addQuantity); |
|||
dataList.addAll(list); |
|||
addQuantity = 0; |
|||
if (list.length < 10) { |
|||
isGetMore = false; |
|||
} else { |
|||
isGetMore = true; |
|||
} |
|||
isLoading = false; |
|||
} |
|||
|
|||
// 请求存储权限 |
|||
void _requestStoragePermission() async { |
|||
PermissionUtil.instance |
|||
.requestPermission( |
|||
permissionType: Permission.storage, |
|||
permissionName: "存储", |
|||
explanationText: "我们需要访问存储权限,以便您可以选择并上传音频文件。", |
|||
permanentDenialText: "检测到您已永久拒绝存储权限。如需继续使用此功能,请前往应用设置中手动开启存储权限。", |
|||
) |
|||
.then((granted) { |
|||
if (granted) { |
|||
Logger.d("Permission", "用户授予了存储权限"); |
|||
} else { |
|||
Logger.d("Permission", "用户未授予存储权限"); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
void loadTranslatopus() async { |
|||
final dir = |
|||
Directory("/data/user/0/com.yunqiinnovation.deepsound/files/OpusAudio"); |
|||
|
|||
files = dir |
|||
.listSync() |
|||
.where((f) => f is File && (f.path.endsWith('.opus'))) |
|||
.toList(); |
|||
} |
|||
|
|||
void loadTranslatAudio() async { |
|||
try { |
|||
final appDir = await appDocDir; // 先 await 获取目录 |
|||
final dir = Directory("${appDir.path}/TranslatAudio"); |
|||
|
|||
if (!await dir.exists()) { |
|||
await dir.create(recursive: true); |
|||
files = []; |
|||
return; |
|||
} |
|||
|
|||
files = dir |
|||
.listSync() |
|||
.where((f) => |
|||
f is File && |
|||
(f.path.endsWith('.mp3') || |
|||
f.path.endsWith('.wav') || |
|||
f.path.endsWith('.aac') || |
|||
f.path.endsWith('.m4a') || |
|||
f.path.endsWith('.mp4') || |
|||
f.path.endsWith('.mov'))) |
|||
.toList(); |
|||
} catch (e) { |
|||
files = []; |
|||
} |
|||
} |
|||
|
|||
Future<void> shareAllTranslatAudio() async { |
|||
final filePath = selected[0]; |
|||
try { |
|||
await Share.shareXFiles( |
|||
[XFile(filePath)], |
|||
text: '分享会议音频', |
|||
); |
|||
|
|||
EasyLoading.showToast('分享完成!'); |
|||
} catch (e) { |
|||
EasyLoading.showToast('分享失败: ${e.toString()}'); |
|||
} |
|||
} |
|||
|
|||
void addMeetingAudio() async { |
|||
EasyLoading.show(status: '正在导入本地录音...'); |
|||
try { |
|||
int addUp = 0; |
|||
final appDir = await appDocDir; |
|||
|
|||
final sourceDirs = [ |
|||
Directory("${appDir.path}/MeetingAudio/"), |
|||
Directory("${appDir.path}/DestAudio/"), |
|||
]; |
|||
|
|||
final destDir = Directory("${appDir.path}/LocalAudio/"); |
|||
if (!await destDir.exists()) { |
|||
await destDir.create(recursive: true); |
|||
} |
|||
|
|||
List<FileSystemEntity> allFiles = []; |
|||
|
|||
for (final dir in sourceDirs) { |
|||
if (await dir.exists()) { |
|||
final files = |
|||
await dir.list().where((entity) => entity is File).toList(); |
|||
allFiles.addAll(files); |
|||
} |
|||
} |
|||
|
|||
for (final fileEntity in allFiles) { |
|||
try { |
|||
final file = File(fileEntity.path); |
|||
final filePath = file.path; |
|||
|
|||
// 设置音频源获取时长 |
|||
|
|||
await _audioPlayer.setAudioSource(AudioSource.file(filePath)); |
|||
int seconds = _audioPlayer.duration?.inSeconds ?? 0; |
|||
|
|||
// 获取文件修改时间 |
|||
final fileStat = await file.stat(); |
|||
final creationTime = fileStat.changed.millisecondsSinceEpoch; |
|||
|
|||
// 准备新路径 |
|||
final fileName = filePath.split('/').last; |
|||
final newPath = "${destDir.path}/$fileName"; |
|||
|
|||
// 构建数据 |
|||
Map<String, Object?> data = { |
|||
'title': fileName, |
|||
'type': 'LOCAL', |
|||
'seconds': seconds, |
|||
'filepath': newPath, // 存储新路径 |
|||
'audiourl': '', |
|||
'tasktype': 0, |
|||
'creationtime': creationTime, |
|||
}; |
|||
|
|||
// 插入数据库 |
|||
final meetingId = await SqfliteApi.insertMeeting(data); |
|||
if (meetingId != null) { |
|||
// 上传OSS |
|||
meetingUploadService.addUpload(meetingId, newPath, 'LocalAudio'); |
|||
|
|||
// 更新UI数据 |
|||
dataList.insert(0, { |
|||
'id': meetingId, |
|||
'taskid': '', |
|||
...data, |
|||
}); |
|||
|
|||
// 移动文件 |
|||
await file.copy(newPath); |
|||
await file.delete(); |
|||
|
|||
addQuantity++; |
|||
addUp++; |
|||
} |
|||
} catch (e) { |
|||
print("文件处理失败: ${fileEntity.path}, 错误: $e"); |
|||
} |
|||
} |
|||
if (addUp > 0) EasyLoading.showSuccess('成功导入本地 $addUp 条录音'); |
|||
} catch (e) { |
|||
EasyLoading.showError('导入失败: $e'); |
|||
} finally { |
|||
EasyLoading.dismiss(); |
|||
} |
|||
} |
|||
|
|||
void addTranslatAudio() async { |
|||
EasyLoading.show(status: '正在批量导入...'); |
|||
for (final filePath in selected) { |
|||
try { |
|||
await _audioPlayer.setAudioSource(AudioSource.file(filePath)); |
|||
final fileStat = await File(filePath).stat(); |
|||
final creationTime = fileStat.changed.millisecondsSinceEpoch; // 时间戳 |
|||
int seconds = _audioPlayer.duration?.inSeconds ?? 0; |
|||
final fileName = filePath.split('/').last; |
|||
Map<String, Object?> data = { |
|||
'title': fileName, |
|||
'type': 'TRANSLAT', |
|||
'seconds': seconds, |
|||
'filepath': filePath, |
|||
'audiourl': '', |
|||
'tasktype': 0, |
|||
'creationtime': creationTime, |
|||
}; |
|||
final meetingId = await SqfliteApi.insertMeeting(data); |
|||
if (meetingId != null) { |
|||
meetingUploadService.addUpload(meetingId, filePath, 'TranslatAudio'); |
|||
|
|||
dataList.insert(0, { |
|||
'id': meetingId, |
|||
'taskid': '', |
|||
...data, |
|||
}); |
|||
addQuantity++; |
|||
} |
|||
} catch (e) { |
|||
Logger.error('导入文件失败: $filePath, error: $e'); |
|||
} |
|||
} |
|||
EasyLoading.dismiss(); |
|||
} |
|||
|
|||
void addAudio([String? filePath]) async { |
|||
EasyLoading.show(status: '正在导入...'); |
|||
String fileName = ""; |
|||
bool isParamProvided = filePath != null; // 更清晰的变量名 |
|||
if (filePath != null) { |
|||
// 正确截取文件名(跨平台安全方式) |
|||
fileName = path.basename(filePath); |
|||
} else { |
|||
FilePickerResult? result = await FilePicker.platform.pickFiles( |
|||
withData: true, |
|||
); |
|||
if (result != null) { |
|||
PlatformFile file = result.files.first; |
|||
filePath = await _saveFile(file); |
|||
fileName = file.name; |
|||
} else { |
|||
EasyLoading.dismiss(); |
|||
return; |
|||
} |
|||
} |
|||
|
|||
final meetingId = await insertMeetingAudio(fileName, filePath); |
|||
|
|||
if (isParamProvided) { |
|||
Get.toNamed(Routes.meetingDetails, arguments: { |
|||
'id': meetingId, |
|||
}); |
|||
} |
|||
EasyLoading.dismiss(); |
|||
} |
|||
|
|||
Future insertMeetingAudio(String fileName, String filePath) async { |
|||
await _audioPlayer.setAudioSource(AudioSource.file(filePath)); |
|||
int seconds = _audioPlayer.duration?.inSeconds ?? 0; |
|||
int creationTime = DateTime.now().millisecondsSinceEpoch; |
|||
Map<String, Object?> data = { |
|||
'title': fileName, |
|||
'type': 'IMPORT', |
|||
'seconds': seconds, |
|||
'filepath': filePath, |
|||
'audiourl': '', |
|||
'tasktype': 0, |
|||
'creationtime': creationTime, |
|||
}; |
|||
final meetingId = await SqfliteApi.insertMeeting(data); |
|||
if (meetingId != null) { |
|||
meetingUploadService.addUpload(meetingId, filePath, 'ExternalAudio'); |
|||
|
|||
dataList.insert(0, { |
|||
'id': meetingId, |
|||
'taskid': '', |
|||
...data, |
|||
}); |
|||
addQuantity++; |
|||
} |
|||
return meetingId; |
|||
} |
|||
|
|||
Future<String> _saveFile(PlatformFile file) async { |
|||
final directory = await getApplicationDocumentsDirectory(); |
|||
final directoryDir = Directory('${directory.path}/ExternalAudio'); |
|||
if (!await directoryDir.exists()) { |
|||
await directoryDir.create(recursive: true); |
|||
} |
|||
final String filePath = '${directory.path}/ExternalAudio/${file.name}'; |
|||
final File localFile = File(filePath); |
|||
await localFile.writeAsBytes(file.bytes!); |
|||
return filePath; |
|||
} |
|||
|
|||
// 修改标题 |
|||
void editMeetingTitle(int id) async { |
|||
if (titleController.text.isNotEmpty) { |
|||
String title = titleController.text + titleCSuffix; |
|||
SqfliteApi.editMeetingTitle(id, {'title': title}); |
|||
int index = dataList.indexWhere((item) => item['id'] == id); |
|||
if (index != -1) { |
|||
final dataMap = Map<String, dynamic>.from(dataList[index]); |
|||
dataMap['title'] = title; |
|||
dataList[index] = dataMap; |
|||
} |
|||
} |
|||
Get.back(); |
|||
} |
|||
|
|||
// 删除数据 |
|||
void deleteMeeting(int id, String filepath) async { |
|||
dataList.removeWhere((item) => item['id'] == id); |
|||
// 删除数据库中的记录 |
|||
SqfliteApi.deleteMeeting(id); |
|||
_deleteFile(filepath); |
|||
meetingUploadService.removeUpload(id, isDeleteTask: true); |
|||
meetingTaskService.removeTask(id, isDeleteTask: true); |
|||
addQuantity--; |
|||
} |
|||
|
|||
// 删除文件 |
|||
Future<void> _deleteFile(String filepath) async { |
|||
File file = File(filepath); |
|||
bool fileExists = await file.exists(); |
|||
if (fileExists) { |
|||
await file.delete(); |
|||
} |
|||
pageController = PageController(initialPage: 0); |
|||
} |
|||
} |
|||
|
|||
@ -0,0 +1,420 @@ |
|||
import 'dart:io'; |
|||
|
|||
import 'package:file_picker/file_picker.dart'; |
|||
import 'package:flutter/material.dart'; |
|||
import 'package:flutter_easyloading/flutter_easyloading.dart'; |
|||
import 'package:get/get.dart'; |
|||
import 'package:just_audio/just_audio.dart'; |
|||
import 'package:path/path.dart' as path; |
|||
import 'package:path_provider/path_provider.dart'; |
|||
import 'package:permission_handler/permission_handler.dart'; |
|||
|
|||
import '../../../core/utils/logger.dart'; |
|||
import '../../../core/utils/synchrodata.dart'; |
|||
import '../../../data/services/ble_manager.dart'; |
|||
import '../../../data/services/db/sqflite_api.dart'; |
|||
import '../../../core/utils/permission_util.dart'; |
|||
|
|||
import 'package:share_plus/share_plus.dart'; |
|||
|
|||
import '../../../data/services/meeting/meeting_task_service.dart'; |
|||
import '../../../data/services/meeting/meeting_upload_service.dart'; |
|||
import '../../../routes/app_routes.dart'; |
|||
import 'meeting_controller.dart'; |
|||
|
|||
class MeetingHomeController extends GetxController { |
|||
final meetingController = Get.find<MeetingController>(); |
|||
final meetingUploadService = Get.find<MeetingUploadService>(); |
|||
final meetingTaskService = Get.find<MeetingTaskService>(); |
|||
|
|||
final AudioPlayer _audioPlayer = AudioPlayer(); |
|||
|
|||
List<String> selected = []; |
|||
|
|||
final appDocDir = getApplicationDocumentsDirectory(); |
|||
List<FileSystemEntity> files = []; |
|||
TextEditingController titleController = TextEditingController(); // 录音标题 |
|||
String titleCSuffix = ""; // 录音标题后缀 |
|||
|
|||
RxList originalDataList = [].obs; |
|||
RxList dataList = [].obs; |
|||
RxString filtrateType = ''.obs; |
|||
bool isAsc = false; |
|||
TextEditingController filtrateController = TextEditingController(); |
|||
|
|||
// 蓝牙服务 |
|||
final bleManager = Get.find<BleManager>(); |
|||
|
|||
@override |
|||
void onInit() { |
|||
super.onInit(); |
|||
_requestStoragePermission(); |
|||
_initGetDataList(); |
|||
addMeetingAudio(); |
|||
final Map<String, dynamic> args = Get.arguments ?? {}; |
|||
// 检查参数是否存在且包含有效路径 |
|||
if (args.isNotEmpty && |
|||
args.containsKey('filePath') && |
|||
args['filePath'] is String && |
|||
args['filePath'].isNotEmpty) { |
|||
final String path = args['filePath']; |
|||
addAudio(path); |
|||
} |
|||
} |
|||
|
|||
@override |
|||
void onClose() { |
|||
titleController.dispose(); |
|||
filtrateController.dispose(); |
|||
_audioPlayer.dispose(); |
|||
super.onClose(); |
|||
} |
|||
|
|||
void onPopInvokedWithResult(didPop, result) { |
|||
Synchrodata.uploadOperationRecords(isSynchro: true); |
|||
} |
|||
|
|||
void _initGetDataList() async { |
|||
await Synchrodata.uploadOperationRecords(isSynchro: true); |
|||
_getDataList(); |
|||
} |
|||
|
|||
// 请求本地数据 |
|||
void _getDataList() async { |
|||
List list = await SqfliteApi.getMeetingList(); |
|||
originalDataList.value = List.from(list); |
|||
dataList.value = List.from(list); |
|||
} |
|||
|
|||
// 请求存储权限 |
|||
void _requestStoragePermission() async { |
|||
PermissionUtil.instance |
|||
.requestPermission( |
|||
permissionType: Permission.storage, |
|||
permissionName: "存储", |
|||
explanationText: "我们需要访问存储权限,以便您可以选择并上传音频文件。", |
|||
permanentDenialText: "检测到您已永久拒绝存储权限。如需继续使用此功能,请前往应用设置中手动开启存储权限。", |
|||
) |
|||
.then((granted) { |
|||
if (granted) { |
|||
Logger.d("Permission", "用户授予了存储权限"); |
|||
} else { |
|||
Logger.d("Permission", "用户未授予存储权限"); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
void loadTranslatopus() async { |
|||
final dir = |
|||
Directory("/data/user/0/com.yunqiinnovation.deepsound/files/OpusAudio"); |
|||
|
|||
files = dir |
|||
.listSync() |
|||
.where((f) => f is File && (f.path.endsWith('.opus'))) |
|||
.toList(); |
|||
} |
|||
|
|||
void loadTranslatAudio() async { |
|||
try { |
|||
final appDir = await appDocDir; // 先 await 获取目录 |
|||
final dir = Directory("${appDir.path}/TranslatAudio"); |
|||
|
|||
if (!await dir.exists()) { |
|||
await dir.create(recursive: true); |
|||
files = []; |
|||
return; |
|||
} |
|||
|
|||
files = dir |
|||
.listSync() |
|||
.where((f) => |
|||
f is File && |
|||
(f.path.endsWith('.mp3') || |
|||
f.path.endsWith('.wav') || |
|||
f.path.endsWith('.aac') || |
|||
f.path.endsWith('.m4a') || |
|||
f.path.endsWith('.mp4') || |
|||
f.path.endsWith('.mov'))) |
|||
.toList(); |
|||
} catch (e) { |
|||
files = []; |
|||
} |
|||
} |
|||
|
|||
Future<void> shareAllTranslatAudio() async { |
|||
final filePath = selected[0]; |
|||
try { |
|||
await Share.shareXFiles( |
|||
[XFile(filePath)], |
|||
text: '分享会议音频', |
|||
); |
|||
|
|||
EasyLoading.showToast('分享完成!'); |
|||
} catch (e) { |
|||
EasyLoading.showToast('分享失败: ${e.toString()}'); |
|||
} |
|||
} |
|||
|
|||
void addMeetingAudio() async { |
|||
EasyLoading.show(status: '正在导入本地录音...'); |
|||
try { |
|||
int addUp = 0; |
|||
final appDir = await appDocDir; |
|||
|
|||
final sourceDirs = [ |
|||
Directory("${appDir.path}/MeetingAudio/"), |
|||
Directory("${appDir.path}/DestAudio/"), |
|||
]; |
|||
|
|||
final destDir = Directory("${appDir.path}/LocalAudio/"); |
|||
if (!await destDir.exists()) { |
|||
await destDir.create(recursive: true); |
|||
} |
|||
|
|||
List<FileSystemEntity> allFiles = []; |
|||
|
|||
for (final dir in sourceDirs) { |
|||
if (await dir.exists()) { |
|||
final files = |
|||
await dir.list().where((entity) => entity is File).toList(); |
|||
allFiles.addAll(files); |
|||
} |
|||
} |
|||
|
|||
for (final fileEntity in allFiles) { |
|||
try { |
|||
final file = File(fileEntity.path); |
|||
final filePath = file.path; |
|||
|
|||
// 设置音频源获取时长 |
|||
|
|||
await _audioPlayer.setAudioSource(AudioSource.file(filePath)); |
|||
int seconds = _audioPlayer.duration?.inSeconds ?? 0; |
|||
|
|||
// 获取文件修改时间 |
|||
final fileStat = await file.stat(); |
|||
final creationTime = fileStat.changed.millisecondsSinceEpoch; |
|||
|
|||
// 准备新路径 |
|||
final fileName = filePath.split('/').last; |
|||
final newPath = "${destDir.path}/$fileName"; |
|||
|
|||
// 构建数据 |
|||
Map<String, Object?> data = { |
|||
'title': fileName, |
|||
'type': 'LOCAL', |
|||
'seconds': seconds, |
|||
'filepath': newPath, // 存储新路径 |
|||
'audiourl': '', |
|||
'tasktype': 0, |
|||
'creationtime': creationTime, |
|||
}; |
|||
|
|||
// 插入数据库 |
|||
final meetingId = await SqfliteApi.insertMeeting(data); |
|||
if (meetingId != null) { |
|||
// 上传OSS |
|||
meetingUploadService.addUpload(meetingId, newPath, 'LocalAudio'); |
|||
|
|||
originalDataList.insert(0, { |
|||
'id': meetingId, |
|||
...data, |
|||
}); |
|||
// 更新UI数据 |
|||
if (isAsc) { |
|||
dataList.add({ |
|||
'id': meetingId, |
|||
...data, |
|||
}); |
|||
} else { |
|||
dataList.insert(0, { |
|||
'id': meetingId, |
|||
...data, |
|||
}); |
|||
} |
|||
|
|||
// 移动文件 |
|||
await file.copy(newPath); |
|||
await file.delete(); |
|||
|
|||
addUp++; |
|||
} |
|||
} catch (e) { |
|||
print("文件处理失败: ${fileEntity.path}, 错误: $e"); |
|||
} |
|||
} |
|||
if (addUp > 0) EasyLoading.showSuccess('成功导入本地 $addUp 条录音'); |
|||
} catch (e) { |
|||
EasyLoading.showError('导入失败: $e'); |
|||
} finally { |
|||
EasyLoading.dismiss(); |
|||
} |
|||
} |
|||
|
|||
void addTranslatAudio() async { |
|||
EasyLoading.show(status: '正在批量导入...'); |
|||
for (final filePath in selected) { |
|||
try { |
|||
await _audioPlayer.setAudioSource(AudioSource.file(filePath)); |
|||
final fileStat = await File(filePath).stat(); |
|||
final creationTime = fileStat.changed.millisecondsSinceEpoch; // 时间戳 |
|||
int seconds = _audioPlayer.duration?.inSeconds ?? 0; |
|||
final fileName = filePath.split('/').last; |
|||
Map<String, Object?> data = { |
|||
'title': fileName, |
|||
'type': 'TRANSLAT', |
|||
'seconds': seconds, |
|||
'filepath': filePath, |
|||
'audiourl': '', |
|||
'tasktype': 0, |
|||
'creationtime': creationTime, |
|||
}; |
|||
final meetingId = await SqfliteApi.insertMeeting(data); |
|||
if (meetingId != null) { |
|||
meetingUploadService.addUpload(meetingId, filePath, 'TranslatAudio'); |
|||
|
|||
originalDataList.insert(0, { |
|||
'id': meetingId, |
|||
...data, |
|||
}); |
|||
if (isAsc) { |
|||
dataList.add({ |
|||
'id': meetingId, |
|||
...data, |
|||
}); |
|||
} else { |
|||
dataList.insert(0, { |
|||
'id': meetingId, |
|||
...data, |
|||
}); |
|||
} |
|||
} |
|||
} catch (e) { |
|||
Logger.error('导入文件失败: $filePath, error: $e'); |
|||
} |
|||
} |
|||
EasyLoading.dismiss(); |
|||
} |
|||
|
|||
void addAudio([String? filePath]) async { |
|||
EasyLoading.show(status: '正在导入...'); |
|||
String fileName = ""; |
|||
bool isParamProvided = filePath != null; // 更清晰的变量名 |
|||
if (filePath != null) { |
|||
// 正确截取文件名(跨平台安全方式) |
|||
fileName = path.basename(filePath); |
|||
} else { |
|||
FilePickerResult? result = await FilePicker.platform.pickFiles( |
|||
withData: true, |
|||
type: FileType.audio, |
|||
); |
|||
if (result != null) { |
|||
PlatformFile file = result.files.first; |
|||
filePath = await _saveFile(file); |
|||
fileName = file.name; |
|||
} else { |
|||
EasyLoading.dismiss(); |
|||
return; |
|||
} |
|||
} |
|||
|
|||
final meetingId = await insertMeetingAudio(fileName, filePath); |
|||
|
|||
if (isParamProvided) { |
|||
Get.toNamed(Routes.meetingDetails, arguments: { |
|||
'id': meetingId, |
|||
}); |
|||
} |
|||
EasyLoading.dismiss(); |
|||
} |
|||
|
|||
Future insertMeetingAudio(String fileName, String filePath) async { |
|||
await _audioPlayer.setAudioSource(AudioSource.file(filePath)); |
|||
int seconds = _audioPlayer.duration?.inSeconds ?? 0; |
|||
int creationTime = DateTime.now().millisecondsSinceEpoch; |
|||
Map<String, Object?> data = { |
|||
'title': fileName, |
|||
'type': 'IMPORT', |
|||
'seconds': seconds, |
|||
'filepath': filePath, |
|||
'audiourl': '', |
|||
'tasktype': 0, |
|||
'creationtime': creationTime, |
|||
}; |
|||
final meetingId = await SqfliteApi.insertMeeting(data); |
|||
if (meetingId != null) { |
|||
meetingUploadService.addUpload(meetingId, filePath, 'ExternalAudio'); |
|||
originalDataList.insert(0, { |
|||
'id': meetingId, |
|||
...data, |
|||
}); |
|||
if (isAsc) { |
|||
dataList.add({ |
|||
'id': meetingId, |
|||
...data, |
|||
}); |
|||
} else { |
|||
dataList.insert(0, { |
|||
'id': meetingId, |
|||
...data, |
|||
}); |
|||
} |
|||
} |
|||
return meetingId; |
|||
} |
|||
|
|||
Future<String> _saveFile(PlatformFile file) async { |
|||
final directory = await getApplicationDocumentsDirectory(); |
|||
final directoryDir = Directory('${directory.path}/ExternalAudio'); |
|||
if (!await directoryDir.exists()) { |
|||
await directoryDir.create(recursive: true); |
|||
} |
|||
final String filePath = '${directory.path}/ExternalAudio/${file.name}'; |
|||
final File localFile = File(filePath); |
|||
await localFile.writeAsBytes(file.bytes!); |
|||
return filePath; |
|||
} |
|||
|
|||
// 修改标题 |
|||
void editMeetingTitle(int id) async { |
|||
if (titleController.text.isNotEmpty) { |
|||
String title = titleController.text + titleCSuffix; |
|||
SqfliteApi.editMeetingTitle(id, {'title': title}); |
|||
int originalIndex = |
|||
originalDataList.indexWhere((item) => item['id'] == id); |
|||
int index = dataList.indexWhere((item) => item['id'] == id); |
|||
if (originalIndex != -1) { |
|||
final dataMap = |
|||
Map<String, dynamic>.from(originalDataList[originalIndex]); |
|||
dataMap['title'] = title; |
|||
originalDataList[originalIndex] = dataMap; |
|||
} |
|||
if (index != -1) { |
|||
final dataMap = Map<String, dynamic>.from(dataList[index]); |
|||
dataMap['title'] = title; |
|||
dataList[index] = dataMap; |
|||
} |
|||
} |
|||
Get.back(); |
|||
} |
|||
|
|||
// 删除数据 |
|||
void deleteMeeting(int id, String filepath) async { |
|||
originalDataList.removeWhere((item) => item['id'] == id); |
|||
dataList.removeWhere((item) => item['id'] == id); |
|||
// 删除数据库中的记录 |
|||
SqfliteApi.deleteMeeting(id); |
|||
_deleteFile(filepath); |
|||
meetingUploadService.removeUpload(id, isDeleteTask: true); |
|||
meetingTaskService.removeTask(id, isDeleteTask: true); |
|||
} |
|||
|
|||
// 删除文件 |
|||
Future<void> _deleteFile(String filepath) async { |
|||
File file = File(filepath); |
|||
bool fileExists = await file.exists(); |
|||
if (fileExists) { |
|||
await file.delete(); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,121 @@ |
|||
import 'package:common_utils/common_utils.dart'; |
|||
import 'package:flutter/material.dart'; |
|||
import 'package:get/get.dart'; |
|||
|
|||
import '../../../data/services/db/sqflite_api.dart'; |
|||
import '../../profile/controllers/profile_controller.dart'; |
|||
import 'meeting_controller.dart'; |
|||
import 'meeting_home_controller.dart'; |
|||
|
|||
class MeetingMineController extends GetxController { |
|||
final _meetingController = Get.find<MeetingController>(); |
|||
final _homeController = Get.find<MeetingHomeController>(); |
|||
final profileCtrl = Get.find<ProfileController>(); |
|||
|
|||
final ScrollController scrollController = ScrollController(); |
|||
|
|||
late final Worker _pageIndexListener; |
|||
late final Worker _originalDataListener; |
|||
|
|||
bool isStatistics = true; |
|||
Map statistics = {}; |
|||
|
|||
RxInt days = 0.obs; |
|||
RxInt numberFiles = 0.obs; |
|||
RxDouble hourage = 0.0.obs; |
|||
RxInt continuousDays = 0.obs; |
|||
RxInt maxNumberFiles = 0.obs; |
|||
RxDouble maxHourage = 0.0.obs; |
|||
|
|||
int averageNumberFiles = 0; |
|||
|
|||
@override |
|||
void onInit() { |
|||
super.onInit(); |
|||
WidgetsBinding.instance.addPostFrameCallback((_) { |
|||
if (scrollController.hasClients) { |
|||
final now = DateTime.now(); |
|||
if (now.month > 6) { |
|||
scrollController.jumpTo(scrollController.position.maxScrollExtent); |
|||
} |
|||
} |
|||
}); |
|||
_getStatistics(); |
|||
_pageIndexListener = ever(_meetingController.pageIndex, (value) { |
|||
if (isStatistics && value == 2) { |
|||
_getStatistics(); |
|||
} |
|||
}); |
|||
_originalDataListener = ever(_homeController.originalDataList, (value) { |
|||
isStatistics = true; |
|||
}); |
|||
} |
|||
|
|||
@override |
|||
void onClose() { |
|||
_pageIndexListener.dispose(); |
|||
_originalDataListener.dispose(); |
|||
scrollController.dispose(); |
|||
super.onClose(); |
|||
} |
|||
|
|||
void _getStatistics() async { |
|||
if (!isStatistics) return; |
|||
List dataList = await SqfliteApi.getMeetingList(); |
|||
numberFiles.value = dataList.length; |
|||
statistics = {}; |
|||
|
|||
for (var element in dataList) { |
|||
DateTime creationtime = |
|||
DateTime.fromMillisecondsSinceEpoch(element['creationtime']); |
|||
int dayOfYear = DateUtil.getDayOfYear(creationtime); |
|||
|
|||
if (!statistics.containsKey(dayOfYear)) { |
|||
statistics[dayOfYear] = { |
|||
'creationtime': creationtime, |
|||
'count': 0, |
|||
'seconds': 0, |
|||
}; |
|||
} |
|||
statistics[dayOfYear]['count']++; |
|||
statistics[dayOfYear]['seconds'] += element['seconds']; |
|||
} |
|||
|
|||
int currentD = 1; |
|||
int maxD = 1; |
|||
int previous = 0; |
|||
int h = 0; |
|||
int maxH = 0; |
|||
int maxN = 0; |
|||
int minN = 0; |
|||
statistics.forEach((key, value) { |
|||
h += value['seconds'] as int; |
|||
if (key + 1 == previous) { |
|||
currentD++; |
|||
if (currentD > maxD) { |
|||
maxD = currentD; |
|||
} |
|||
} else { |
|||
currentD = 1; |
|||
} |
|||
previous = key; |
|||
if (value['count'] > maxN) { |
|||
maxN = value['count']; |
|||
} |
|||
if (value['count'] < minN || minN == 0) { |
|||
minN = value['count']; |
|||
} |
|||
if (value['seconds'] > maxH) { |
|||
maxH = value['seconds']; |
|||
} |
|||
}); |
|||
|
|||
days.value = statistics.length; |
|||
hourage.value = double.parse((h / 60 / 60).toStringAsFixed(2)); |
|||
continuousDays.value = maxD; |
|||
maxNumberFiles.value = maxN; |
|||
maxHourage.value = double.parse((maxH / 60 / 60).toStringAsFixed(2)); |
|||
averageNumberFiles = (maxN + minN) ~/ 2; |
|||
isStatistics = false; |
|||
} |
|||
} |
|||
@ -0,0 +1,63 @@ |
|||
import 'package:flutter/material.dart'; |
|||
|
|||
/// 会议模块UI工具类 |
|||
/// 提供统一的颜色和样式方法,确保UI风格一致性 |
|||
class MeetingUIUtils { |
|||
/// 获取背景颜色 |
|||
static Color getBackgroundColor(bool isDarkMode) { |
|||
return isDarkMode ? Colors.black : Colors.grey[50]!; |
|||
} |
|||
|
|||
/// 获取卡片背景颜色 |
|||
static Color getCardColor(bool isDarkMode) { |
|||
return isDarkMode ? const Color(0xFF1A1A1A) : Colors.white; |
|||
} |
|||
|
|||
/// 获取半透明白色 |
|||
static Color getTranslucentWhite(double alpha) { |
|||
return Colors.white.withValues(alpha: alpha); |
|||
} |
|||
|
|||
/// 获取半透明黑色 |
|||
static Color getTranslucentBlack(double alpha) { |
|||
return Colors.black.withValues(alpha: alpha); |
|||
} |
|||
|
|||
/// 获取主文本颜色 |
|||
static Color getTextColor(bool isDarkMode) { |
|||
return isDarkMode ? Colors.white : Colors.grey[800]!; |
|||
} |
|||
|
|||
/// 获取次要文本颜色 |
|||
static Color getSecondaryTextColor(bool isDarkMode) { |
|||
return isDarkMode ? Colors.grey[400]! : Colors.grey[600]!; |
|||
} |
|||
|
|||
/// 获取卡片阴影 |
|||
static List<BoxShadow> getCardShadow(bool isDarkMode) { |
|||
return [ |
|||
BoxShadow( |
|||
color: isDarkMode |
|||
? Colors.black.withValues(alpha: 0.2) |
|||
: Colors.black.withValues(alpha: 0.05), |
|||
blurRadius: 10, |
|||
offset: const Offset(0, 4), |
|||
), |
|||
]; |
|||
} |
|||
|
|||
/// 获取按钮颜色 |
|||
static Color getButtonColor(bool isDarkMode) { |
|||
return isDarkMode ? const Color(0xFF0066CC) : Colors.blue; |
|||
} |
|||
|
|||
/// 获取带透明度的颜色 |
|||
static Color getColorWithAlpha(Color color, double alpha) { |
|||
return color.withValues(alpha: alpha); |
|||
} |
|||
|
|||
/// 获取禁用状态的颜色 |
|||
static Color getDisabledColor(Color color, bool isEnabled) { |
|||
return isEnabled ? color : color.withValues(alpha: 0.5); |
|||
} |
|||
} |
|||
@ -1,74 +1,240 @@ |
|||
import 'package:flutter/material.dart'; |
|||
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|||
import 'package:get/get.dart'; |
|||
|
|||
class FiltrateDrawer extends StatelessWidget { |
|||
import '../../controllers/meeting_controller.dart'; |
|||
import '../../controllers/meeting_home_controller.dart'; |
|||
|
|||
class FiltrateDrawer extends StatefulWidget { |
|||
const FiltrateDrawer({super.key}); |
|||
|
|||
@override |
|||
State<FiltrateDrawer> createState() => _FiltrateDrawerState(); |
|||
} |
|||
|
|||
class _FiltrateDrawerState extends State<FiltrateDrawer> { |
|||
final _controller = Get.find<MeetingController>(); |
|||
final _homeController = Get.find<MeetingHomeController>(); |
|||
|
|||
@override |
|||
Widget build(BuildContext context) { |
|||
return SafeArea( |
|||
child: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Padding( |
|||
padding: EdgeInsets.all(20.w), |
|||
child: TextField( |
|||
decoration: InputDecoration( |
|||
hintText: '在所有文件中搜索', |
|||
prefixIcon: Icon(Icons.search), |
|||
border: OutlineInputBorder( |
|||
borderRadius: BorderRadius.circular(12.w), |
|||
borderSide: BorderSide.none, |
|||
child: Padding( |
|||
padding: EdgeInsets.symmetric(horizontal: 12.w), |
|||
child: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Container( |
|||
padding: EdgeInsets.symmetric(horizontal: 10.w), |
|||
margin: EdgeInsets.symmetric(vertical: 15.w), |
|||
alignment: Alignment.center, |
|||
decoration: BoxDecoration( |
|||
color: Colors.grey[200], |
|||
borderRadius: BorderRadius.circular(12.w), |
|||
), |
|||
child: Row( |
|||
children: [ |
|||
Icon( |
|||
Icons.search, |
|||
color: Colors.black87, |
|||
size: 20.w, |
|||
), |
|||
Expanded( |
|||
child: TextField( |
|||
controller: _homeController.filtrateController, |
|||
style: TextStyle( |
|||
color: Colors.black87, |
|||
fontSize: 14.sp, |
|||
), |
|||
decoration: InputDecoration( |
|||
hintText: '在所有文件中搜索', |
|||
hintStyle: TextStyle( |
|||
color: Colors.black54, |
|||
fontSize: 14.sp, |
|||
), |
|||
isDense: true, |
|||
fillColor: Colors.transparent, |
|||
border: InputBorder.none, |
|||
contentPadding: EdgeInsets.symmetric(vertical: 10.w), |
|||
), |
|||
onSubmitted: (String value) { |
|||
_controller.scaffoldKey.currentState?.closeDrawer(); |
|||
_homeController.dataList.value = |
|||
_homeController.originalDataList.where((i) { |
|||
final titleLower = i['title'].trim().toLowerCase(); |
|||
final queryLower = value.trim().toLowerCase(); |
|||
bool isType = true; |
|||
if (_homeController.filtrateType.value.isNotEmpty) { |
|||
isType = |
|||
_homeController.filtrateType.value == i['type']; |
|||
} |
|||
return isType && titleLower.contains(queryLower); |
|||
}).toList(); |
|||
if (_homeController.isAsc) { |
|||
_homeController.dataList.value = |
|||
_homeController.dataList.reversed.toList(); |
|||
} |
|||
}, |
|||
), |
|||
), |
|||
], |
|||
), |
|||
), |
|||
GestureDetector( |
|||
onTap: () { |
|||
_filtrateDataList(''); |
|||
}, |
|||
behavior: HitTestBehavior.translucent, |
|||
child: Container( |
|||
padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 10.w), |
|||
decoration: _homeController.filtrateType.value == '' |
|||
? BoxDecoration( |
|||
color: Colors.white, |
|||
borderRadius: BorderRadius.circular(12.w), |
|||
boxShadow: [ |
|||
BoxShadow( |
|||
color: Colors.black.withValues(alpha: 0.06), |
|||
blurRadius: 10, |
|||
offset: const Offset(0, 2), |
|||
), |
|||
], |
|||
) |
|||
: null, |
|||
child: Row( |
|||
children: [ |
|||
Icon( |
|||
Icons.folder, |
|||
color: Colors.red, |
|||
size: 20.w, |
|||
), |
|||
6.horizontalSpace, |
|||
Text( |
|||
'全部文件(${_homeController.originalDataList.length})', |
|||
style: TextStyle( |
|||
color: Colors.black87, |
|||
fontSize: 14.sp, |
|||
), |
|||
), |
|||
], |
|||
), |
|||
), |
|||
), |
|||
Padding( |
|||
padding: EdgeInsetsGeometry.symmetric(vertical: 15.w), |
|||
child: Divider( |
|||
height: 1, |
|||
color: Colors.grey[300], |
|||
), |
|||
), |
|||
Padding( |
|||
padding: EdgeInsets.only(bottom: 10.w), |
|||
child: Text( |
|||
'来自', |
|||
style: TextStyle( |
|||
color: Colors.grey, |
|||
fontSize: 14.sp, |
|||
fontWeight: FontWeight.bold, |
|||
), |
|||
), |
|||
), |
|||
...['IMPORT', 'TRANSLAT', 'LOCAL'].map( |
|||
(name) => GestureDetector( |
|||
onTap: () { |
|||
_filtrateDataList(name); |
|||
}, |
|||
behavior: HitTestBehavior.translucent, |
|||
child: Container( |
|||
width: double.infinity, |
|||
padding: |
|||
EdgeInsets.symmetric(horizontal: 10.w, vertical: 10.w), |
|||
margin: EdgeInsets.symmetric(vertical: 2.w), |
|||
decoration: _homeController.filtrateType.value == name |
|||
? BoxDecoration( |
|||
color: Colors.white, |
|||
borderRadius: BorderRadius.circular(12.w), |
|||
boxShadow: [ |
|||
BoxShadow( |
|||
color: Colors.black.withValues(alpha: 0.06), |
|||
blurRadius: 10, |
|||
offset: const Offset(0, 2), |
|||
), |
|||
], |
|||
) |
|||
: null, |
|||
child: Text( |
|||
name, |
|||
style: TextStyle( |
|||
color: Colors.black87, |
|||
fontSize: 14.sp, |
|||
), |
|||
), |
|||
), |
|||
), |
|||
), |
|||
Padding( |
|||
padding: EdgeInsets.symmetric(vertical: 10.w), |
|||
child: Text( |
|||
'排序方式', |
|||
style: TextStyle( |
|||
color: Colors.grey, |
|||
fontSize: 14.sp, |
|||
fontWeight: FontWeight.bold, |
|||
), |
|||
), |
|||
), |
|||
GestureDetector( |
|||
onTap: () { |
|||
_controller.scaffoldKey.currentState?.closeDrawer(); |
|||
_homeController.isAsc = !_homeController.isAsc; |
|||
_homeController.dataList.value = |
|||
_homeController.dataList.reversed.toList(); |
|||
}, |
|||
behavior: HitTestBehavior.translucent, |
|||
child: Container( |
|||
padding: EdgeInsets.symmetric(vertical: 10.w), |
|||
margin: EdgeInsets.symmetric(vertical: 2.w), |
|||
child: Row( |
|||
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
|||
children: [ |
|||
Text( |
|||
'创建时间', |
|||
style: TextStyle( |
|||
color: Colors.black87, |
|||
fontSize: 14.sp, |
|||
), |
|||
), |
|||
Icon( |
|||
_homeController.isAsc |
|||
? Icons.keyboard_double_arrow_up |
|||
: Icons.keyboard_double_arrow_down, |
|||
color: Colors.grey, |
|||
size: 20.w, |
|||
), |
|||
], |
|||
), |
|||
filled: true, |
|||
fillColor: Colors.grey[100], |
|||
contentPadding: EdgeInsets.zero, |
|||
), |
|||
), |
|||
), |
|||
ListTile( |
|||
leading: Icon(Icons.folder, color: Colors.red), |
|||
title: |
|||
Text('全部文件(4)', style: TextStyle(fontWeight: FontWeight.bold)), |
|||
onTap: () {}, |
|||
), |
|||
ListTile( |
|||
leading: Icon(Icons.category, color: Colors.blue), |
|||
title: Text('未分类(4)'), |
|||
onTap: () {}, |
|||
), |
|||
ListTile( |
|||
leading: Icon(Icons.delete, color: Colors.teal), |
|||
title: Text('回收站'), |
|||
onTap: () {}, |
|||
), |
|||
Divider(), |
|||
Padding( |
|||
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.w), |
|||
child: Text('文件夹', style: TextStyle(color: Colors.grey)), |
|||
), |
|||
...['NOTE', 'CALL', 'NotePin', 'Import', 'Desktop'] |
|||
.map((name) => ListTile( |
|||
title: Text(name), |
|||
onTap: () {}, |
|||
)), |
|||
Divider(), |
|||
Padding( |
|||
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.w), |
|||
child: Text('排序方式', style: TextStyle(color: Colors.grey)), |
|||
), |
|||
ListTile( |
|||
title: Text('创建时间'), |
|||
trailing: Icon(Icons.arrow_upward), |
|||
onTap: () {}, |
|||
), |
|||
ListTile( |
|||
title: Text('修改时间'), |
|||
trailing: Icon(Icons.arrow_upward), |
|||
onTap: () {}, |
|||
), |
|||
], |
|||
], |
|||
), |
|||
), |
|||
); |
|||
} |
|||
|
|||
void _filtrateDataList(String type) { |
|||
_controller.scaffoldKey.currentState?.closeDrawer(); |
|||
_homeController.filtrateType.value = type; |
|||
_homeController.filtrateController.text = ''; |
|||
if (type.isNotEmpty) { |
|||
_homeController.dataList.value = _homeController.originalDataList |
|||
.where((i) => i['type'] == type) |
|||
.toList(); |
|||
} else { |
|||
_homeController.dataList.value = |
|||
List.from(_homeController.originalDataList); |
|||
} |
|||
if (_homeController.isAsc) { |
|||
_homeController.dataList.value = |
|||
_homeController.dataList.reversed.toList(); |
|||
} |
|||
} |
|||
} |
|||
|
|||
@ -0,0 +1,114 @@ |
|||
import 'package:flutter/material.dart'; |
|||
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|||
import 'package:get/get_navigation/src/extension_navigation.dart'; |
|||
import 'package:get/state_manager.dart'; |
|||
|
|||
import '../../controllers/meeting_home_controller.dart'; |
|||
import '../../utils/meeting_ui_utils.dart'; |
|||
|
|||
class NavigationBarBottomSheet extends GetView<MeetingHomeController> { |
|||
const NavigationBarBottomSheet({super.key}); |
|||
|
|||
@override |
|||
Widget build(BuildContext context) { |
|||
return SafeArea( |
|||
child: Column( |
|||
mainAxisSize: MainAxisSize.min, |
|||
children: [ |
|||
_buildItem( |
|||
context, // 传递 context 参数 |
|||
'开始录音', |
|||
Icons.mic_rounded, |
|||
Colors.red, |
|||
), |
|||
SizedBox(height: 15.w), |
|||
_buildItem( |
|||
context, // 传递 context 参数 |
|||
'导入音频', |
|||
Icons.file_upload_outlined, |
|||
Colors.deepPurple, |
|||
onTap: () { |
|||
Get.back(); |
|||
controller.addAudio(); |
|||
}, |
|||
), |
|||
SizedBox(height: 24.w), |
|||
GestureDetector( |
|||
onTap: () => Get.back(), |
|||
child: Container( |
|||
width: 45.w, |
|||
height: 45.w, |
|||
decoration: BoxDecoration( |
|||
color: Colors.black.withValues(alpha: 0.8), |
|||
borderRadius: BorderRadius.circular(50.w), |
|||
), |
|||
child: const Icon(Icons.close, color: Colors.white), |
|||
), |
|||
), |
|||
SizedBox(height: 24.w), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
Widget _buildItem( |
|||
BuildContext context, // 添加 context 参数 |
|||
String title, |
|||
IconData icon, |
|||
Color color, { |
|||
Function()? onTap, |
|||
}) { |
|||
final isDarkMode = |
|||
Theme.of(context).brightness == Brightness.dark; // 在方法内部获取 isDarkMode |
|||
return GestureDetector( |
|||
onTap: onTap, |
|||
child: Container( |
|||
width: 220.w, |
|||
height: 60.w, |
|||
padding: EdgeInsets.all(5.w), |
|||
decoration: BoxDecoration( |
|||
color: isDarkMode |
|||
? Colors.grey[800]!.withValues(alpha: onTap != null ? 1 : 0.5) |
|||
: Colors.white.withValues(alpha: onTap != null ? 1 : 0.5), |
|||
borderRadius: BorderRadius.circular(60.w), |
|||
), |
|||
child: Row( |
|||
children: [ |
|||
Container( |
|||
width: 46.w, |
|||
height: 46.w, |
|||
margin: EdgeInsets.only(right: 5.w), |
|||
decoration: BoxDecoration( |
|||
color: color.withValues(alpha: onTap != null ? 0.3 : 0.2), |
|||
borderRadius: BorderRadius.circular(60.w), |
|||
), |
|||
child: Icon( |
|||
icon, |
|||
color: color.withValues(alpha: onTap != null ? 1 : 0.5), |
|||
), |
|||
), |
|||
Expanded( |
|||
child: Text( |
|||
title, |
|||
style: TextStyle( |
|||
fontSize: 15.sp, |
|||
color: isDarkMode |
|||
? Colors.white.withValues(alpha: onTap != null ? 1 : 0.5) |
|||
: Colors.black87 |
|||
.withValues(alpha: onTap != null ? 1 : 0.5), |
|||
fontWeight: FontWeight.bold, |
|||
), |
|||
), |
|||
), |
|||
Icon( |
|||
Icons.arrow_forward_ios, |
|||
size: 16.w, |
|||
color: Colors.grey.withOpacity(onTap != null ? 1 : 0.5), |
|||
), |
|||
5.horizontalSpace, |
|||
], |
|||
), |
|||
), |
|||
); |
|||
} |
|||
} |
|||
@ -0,0 +1,164 @@ |
|||
import 'package:flutter/material.dart'; |
|||
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|||
import 'package:get/get.dart'; |
|||
|
|||
class SecondLevelBottomSheet extends StatefulWidget { |
|||
final String title; |
|||
final String subtitle; |
|||
final Function(int index) onTap; |
|||
final List children; |
|||
|
|||
const SecondLevelBottomSheet( |
|||
this.title, this.subtitle, this.onTap, this.children, |
|||
{super.key}); |
|||
|
|||
@override |
|||
State<SecondLevelBottomSheet> createState() => _SecondLevelBottomSheetState(); |
|||
} |
|||
|
|||
class _SecondLevelBottomSheetState extends State<SecondLevelBottomSheet> { |
|||
int _selectedIndex = 0; |
|||
|
|||
@override |
|||
Widget build(BuildContext context) { |
|||
return Padding( |
|||
padding: EdgeInsets.symmetric(horizontal: 12.w), |
|||
child: Column( |
|||
mainAxisSize: MainAxisSize.min, |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Container( |
|||
padding: EdgeInsets.symmetric(vertical: 10.w), |
|||
child: Row( |
|||
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
|||
children: [ |
|||
Text( |
|||
widget.title, |
|||
style: TextStyle( |
|||
fontSize: 16.sp, |
|||
color: Colors.black87, |
|||
fontWeight: FontWeight.bold, |
|||
), |
|||
), |
|||
GestureDetector( |
|||
onTap: () => Get.back(), |
|||
child: Container( |
|||
width: 26.w, |
|||
height: 26.w, |
|||
decoration: BoxDecoration( |
|||
color: Colors.white, |
|||
borderRadius: BorderRadius.circular(26.r), |
|||
), |
|||
child: Icon( |
|||
Icons.close, |
|||
size: 18.w, |
|||
color: Colors.grey[400], |
|||
), |
|||
), |
|||
) |
|||
], |
|||
), |
|||
), |
|||
Divider( |
|||
height: 1, |
|||
color: Colors.grey[300], |
|||
), |
|||
Padding( |
|||
padding: EdgeInsetsGeometry.only( |
|||
top: 20.w, |
|||
bottom: 10.w, |
|||
left: 12.w, |
|||
), |
|||
child: Text( |
|||
widget.subtitle, |
|||
style: TextStyle( |
|||
fontSize: 14.sp, |
|||
color: Colors.grey[600], |
|||
), |
|||
), |
|||
), |
|||
Container( |
|||
clipBehavior: Clip.hardEdge, |
|||
decoration: BoxDecoration( |
|||
color: Colors.white, |
|||
borderRadius: BorderRadius.circular(12.r), |
|||
), |
|||
child: Column( |
|||
children: List.generate(widget.children.length, (int index) { |
|||
Map item = widget.children[index]; |
|||
return InkWell( |
|||
onTap: () { |
|||
setState(() { |
|||
_selectedIndex = index; |
|||
}); |
|||
}, |
|||
child: Container( |
|||
padding: |
|||
EdgeInsets.symmetric(horizontal: 12.w, vertical: 14.w), |
|||
decoration: BoxDecoration( |
|||
border: item['isBorder'] |
|||
? Border( |
|||
bottom: BorderSide( |
|||
width: 0.5, |
|||
color: Colors.grey[300]!, |
|||
), |
|||
) |
|||
: null, |
|||
), |
|||
child: Row( |
|||
children: [ |
|||
Icon( |
|||
item['icon'], |
|||
color: Colors.black87, |
|||
), |
|||
10.horizontalSpace, |
|||
Expanded( |
|||
child: Text( |
|||
item['title'], |
|||
style: TextStyle( |
|||
fontSize: 14.sp, |
|||
color: Colors.black87, |
|||
), |
|||
), |
|||
), |
|||
if (_selectedIndex == index) |
|||
const Icon( |
|||
Icons.check, |
|||
color: Colors.black87, |
|||
), |
|||
], |
|||
), |
|||
), |
|||
); |
|||
}), |
|||
), |
|||
), |
|||
Padding( |
|||
padding: EdgeInsets.symmetric(vertical: 20.w), |
|||
child: GestureDetector( |
|||
onTap: () { |
|||
Get.back(); |
|||
widget.onTap(_selectedIndex); |
|||
}, |
|||
child: Container( |
|||
height: 48.w, |
|||
alignment: Alignment.center, |
|||
decoration: BoxDecoration( |
|||
color: Colors.black, |
|||
borderRadius: BorderRadius.circular(12.r), |
|||
), |
|||
child: Text( |
|||
'导出', |
|||
style: TextStyle( |
|||
fontSize: 16.sp, |
|||
color: Colors.white, |
|||
), |
|||
), |
|||
), |
|||
), |
|||
), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
} |
|||
@ -0,0 +1,383 @@ |
|||
import 'dart:io'; |
|||
import 'dart:typed_data'; |
|||
import 'dart:ui' as ui; |
|||
|
|||
import 'package:flutter/material.dart'; |
|||
import 'package:flutter/rendering.dart'; |
|||
import 'package:flutter_easyloading/flutter_easyloading.dart'; |
|||
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|||
import 'package:get/get.dart'; |
|||
import 'package:image_gallery_saver_plus/image_gallery_saver_plus.dart'; |
|||
import 'package:path_provider/path_provider.dart'; |
|||
import 'package:share_plus/share_plus.dart'; |
|||
|
|||
import '../../../profile/controllers/profile_controller.dart'; |
|||
import '../../controllers/meeting_mine_controller.dart'; |
|||
|
|||
class StatisticsBottomSheet extends StatefulWidget { |
|||
const StatisticsBottomSheet({super.key}); |
|||
|
|||
@override |
|||
State<StatisticsBottomSheet> createState() => _StatisticsBottomSheetState(); |
|||
} |
|||
|
|||
class _StatisticsBottomSheetState extends State<StatisticsBottomSheet> { |
|||
final _mineController = Get.find<MeetingMineController>(); |
|||
final _profileCtrl = Get.find<ProfileController>(); |
|||
|
|||
final GlobalKey _posterKey = GlobalKey(); |
|||
|
|||
@override |
|||
Widget build(BuildContext context) { |
|||
return Stack( |
|||
children: [ |
|||
GestureDetector( |
|||
onTap: () => Get.back(), |
|||
behavior: HitTestBehavior.translucent, |
|||
child: SizedBox( |
|||
width: 1.sw, |
|||
height: 1.sh, |
|||
), |
|||
), |
|||
Padding( |
|||
padding: EdgeInsets.symmetric(horizontal: 10.w), |
|||
child: Column( |
|||
mainAxisAlignment: MainAxisAlignment.center, |
|||
children: [ |
|||
RepaintBoundary( |
|||
key: _posterKey, |
|||
child: Container( |
|||
padding: |
|||
EdgeInsets.symmetric(horizontal: 15.w, vertical: 20.w), |
|||
decoration: BoxDecoration( |
|||
color: Colors.white, |
|||
borderRadius: BorderRadius.circular(16.w), |
|||
image: const DecorationImage( |
|||
alignment: Alignment.topCenter, |
|||
image: AssetImage('assets/images/mine-background.png'), |
|||
fit: BoxFit.fitWidth, |
|||
), |
|||
), |
|||
child: Column( |
|||
children: [ |
|||
Container( |
|||
width: 50.w, |
|||
height: 50.w, |
|||
margin: EdgeInsets.only(bottom: 10.w), |
|||
decoration: BoxDecoration( |
|||
borderRadius: BorderRadius.circular(50.w), |
|||
), |
|||
child: CircleAvatar( |
|||
radius: 25.w, |
|||
backgroundImage: _profileCtrl |
|||
.localAvatar.value.isNotEmpty |
|||
? FileImage(File(_profileCtrl.localAvatar.value)) |
|||
: (_profileCtrl.userInfo.value.avatar.isNotEmpty |
|||
? NetworkImage( |
|||
_profileCtrl.userInfo.value.avatar) |
|||
: null) as ImageProvider<Object>?, |
|||
backgroundColor: Colors.grey[200], |
|||
child: (_profileCtrl.localAvatar.value.isEmpty && |
|||
_profileCtrl.userInfo.value.avatar.isEmpty) |
|||
? Icon( |
|||
Icons.person, |
|||
size: 25.r, |
|||
color: Colors.grey[600], |
|||
) |
|||
: null, |
|||
), |
|||
), |
|||
Text( |
|||
_profileCtrl.userInfo.value.name, |
|||
style: TextStyle( |
|||
color: Colors.black87, |
|||
fontSize: 20.sp, |
|||
fontWeight: FontWeight.bold, |
|||
), |
|||
), |
|||
Padding( |
|||
padding: EdgeInsets.symmetric(vertical: 20.w), |
|||
child: Wrap( |
|||
spacing: 5.w, |
|||
runSpacing: 5.w, |
|||
children: [ |
|||
_item( |
|||
'天', |
|||
'${_mineController.days.value}', |
|||
Colors.amber, |
|||
), |
|||
_item( |
|||
'文件', |
|||
'${_mineController.numberFiles.value}', |
|||
Colors.blueGrey, |
|||
), |
|||
_item( |
|||
'小时', |
|||
'${_mineController.hourage.value}', |
|||
Colors.cyan, |
|||
), |
|||
_item( |
|||
'连续录音天数', |
|||
'${_mineController.continuousDays.value}', |
|||
Colors.deepOrangeAccent, |
|||
), |
|||
_item( |
|||
'单日最多录音', |
|||
'${_mineController.maxNumberFiles.value}', |
|||
Colors.teal, |
|||
), |
|||
_item( |
|||
'单日最长录音', |
|||
'${_mineController.maxHourage.value}', |
|||
Colors.pink, |
|||
), |
|||
], |
|||
), |
|||
), |
|||
Row( |
|||
children: [ |
|||
Text( |
|||
'过去12个月的热力图', |
|||
style: TextStyle( |
|||
color: Colors.grey, |
|||
fontSize: 12.sp, |
|||
), |
|||
), |
|||
const Spacer(), |
|||
Text( |
|||
'较冷', |
|||
style: TextStyle( |
|||
color: Colors.grey, |
|||
fontSize: 12.sp, |
|||
), |
|||
), |
|||
5.horizontalSpace, |
|||
Container( |
|||
width: 10.w, |
|||
height: 10.w, |
|||
margin: EdgeInsets.symmetric(horizontal: 1.w), |
|||
decoration: BoxDecoration( |
|||
color: Colors.grey[200], |
|||
borderRadius: BorderRadius.circular(2.w), |
|||
), |
|||
), |
|||
Container( |
|||
width: 10.w, |
|||
height: 10.w, |
|||
margin: EdgeInsets.symmetric(horizontal: 1.w), |
|||
decoration: BoxDecoration( |
|||
color: Colors.red[100], |
|||
borderRadius: BorderRadius.circular(2.w), |
|||
), |
|||
), |
|||
Container( |
|||
width: 10.w, |
|||
height: 10.w, |
|||
margin: EdgeInsets.symmetric(horizontal: 1.w), |
|||
decoration: BoxDecoration( |
|||
color: Colors.red, |
|||
borderRadius: BorderRadius.circular(2.w), |
|||
), |
|||
), |
|||
5.horizontalSpace, |
|||
Text( |
|||
'较热', |
|||
style: TextStyle( |
|||
color: Colors.grey, |
|||
fontSize: 12.sp, |
|||
), |
|||
), |
|||
], |
|||
), |
|||
Container( |
|||
height: 50.w, |
|||
alignment: Alignment.center, |
|||
child: GridView.builder( |
|||
shrinkWrap: true, |
|||
physics: const NeverScrollableScrollPhysics(), |
|||
gridDelegate: |
|||
SliverGridDelegateWithFixedCrossAxisCount( |
|||
crossAxisCount: 52, |
|||
mainAxisSpacing: 1.w, |
|||
crossAxisSpacing: 1.w, |
|||
), |
|||
itemCount: 364, |
|||
itemBuilder: (BuildContext context, int index) { |
|||
final col = index % 52; |
|||
final row = index ~/ 52; |
|||
final vIndex = row + col * 7; |
|||
|
|||
int type = 0; |
|||
if (_mineController.statistics |
|||
.containsKey(vIndex + 1)) { |
|||
if (_mineController.statistics[vIndex + 1] |
|||
['count'] <= |
|||
_mineController.averageNumberFiles) { |
|||
type = 1; |
|||
} else { |
|||
type = 2; |
|||
} |
|||
} |
|||
return Container( |
|||
color: type == 0 |
|||
? Colors.grey[200] |
|||
: type == 1 |
|||
? Colors.red[100] |
|||
: Colors.red, |
|||
); |
|||
}, |
|||
), |
|||
), |
|||
DefaultTextStyle( |
|||
style: TextStyle( |
|||
color: Colors.grey, |
|||
fontSize: 10.sp, |
|||
), |
|||
child: const Row( |
|||
mainAxisAlignment: MainAxisAlignment.spaceAround, |
|||
children: [ |
|||
Text('Jan'), |
|||
Text('Feb'), |
|||
Text('Mar'), |
|||
Text('Apr'), |
|||
Text('May'), |
|||
Text('Jun'), |
|||
Text('Jul'), |
|||
Text('Aug'), |
|||
Text('Sep'), |
|||
Text('Oct'), |
|||
Text('Nov'), |
|||
Text('Dec'), |
|||
], |
|||
), |
|||
), |
|||
], |
|||
), |
|||
), |
|||
), |
|||
SizedBox(height: 10.w), |
|||
Row( |
|||
children: [ |
|||
_button(Icons.share, '分享图片', Colors.deepOrange, () async { |
|||
EasyLoading.show(); |
|||
Uint8List pngBytes = await _generate(); |
|||
final tempDir = await getTemporaryDirectory(); |
|||
final String fileName = |
|||
'share_${DateTime.now().millisecondsSinceEpoch}.png'; |
|||
final file = File('${tempDir.path}/$fileName'); |
|||
await file.writeAsBytes(pngBytes); |
|||
final result = await SharePlus.instance.share(ShareParams( |
|||
text: '分享 DEAPSOUND', |
|||
files: [XFile(file.path)], |
|||
)); |
|||
if (result.status == ShareResultStatus.success) { |
|||
Get.back(); |
|||
EasyLoading.showToast('分享完成!'); |
|||
} |
|||
EasyLoading.dismiss(); |
|||
}), |
|||
10.horizontalSpace, |
|||
_button(Icons.download, '保存图片', Colors.cyan, () async { |
|||
EasyLoading.show(); |
|||
Uint8List pngBytes = await _generate(); |
|||
final result = await ImageGallerySaverPlus.saveImage( |
|||
pngBytes, |
|||
quality: 100, |
|||
); |
|||
if (result['isSuccess']) { |
|||
Get.back(); |
|||
EasyLoading.showToast('保存成功'); |
|||
} else { |
|||
EasyLoading.showToast('保存失败'); |
|||
} |
|||
EasyLoading.dismiss(); |
|||
}), |
|||
], |
|||
), |
|||
], |
|||
), |
|||
), |
|||
], |
|||
); |
|||
} |
|||
|
|||
Widget _item(String unit, String amount, Color color) { |
|||
return Container( |
|||
width: 105.w, |
|||
height: 70.w, |
|||
padding: EdgeInsets.all(10.w), |
|||
decoration: BoxDecoration( |
|||
color: color.withValues(alpha: 0.2), |
|||
borderRadius: BorderRadius.circular(8.w), |
|||
), |
|||
child: Column( |
|||
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Text( |
|||
unit, |
|||
style: TextStyle( |
|||
color: Colors.grey, |
|||
fontSize: 12.sp, |
|||
), |
|||
), |
|||
Text( |
|||
amount, |
|||
style: TextStyle( |
|||
color: Colors.black87, |
|||
fontSize: 18.sp, |
|||
fontWeight: FontWeight.bold, |
|||
), |
|||
), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
Widget _button(IconData icon, String title, Color color, Function() onTap) { |
|||
return Expanded( |
|||
child: GestureDetector( |
|||
onTap: onTap, |
|||
child: Container( |
|||
decoration: BoxDecoration( |
|||
color: Colors.white, |
|||
borderRadius: BorderRadius.circular(16.w), |
|||
), |
|||
child: Container( |
|||
height: 60.w, |
|||
decoration: BoxDecoration( |
|||
color: color.withValues(alpha: 0.1), |
|||
borderRadius: BorderRadius.circular(16.w), |
|||
), |
|||
child: Row( |
|||
mainAxisAlignment: MainAxisAlignment.center, |
|||
children: [ |
|||
Icon( |
|||
icon, |
|||
size: 25.w, |
|||
), |
|||
5.horizontalSpace, |
|||
Text( |
|||
title, |
|||
style: TextStyle( |
|||
color: Colors.black87, |
|||
fontSize: 16.sp, |
|||
), |
|||
), |
|||
], |
|||
), |
|||
), |
|||
), |
|||
), |
|||
); |
|||
} |
|||
|
|||
Future<Uint8List> _generate() async { |
|||
RenderRepaintBoundary boundary = |
|||
_posterKey.currentContext!.findRenderObject()! as RenderRepaintBoundary; |
|||
ui.Image image = await boundary.toImage(pixelRatio: 6.0); |
|||
ByteData? byteData = await image.toByteData(format: ui.ImageByteFormat.png); |
|||
Uint8List pngBytes = byteData!.buffer.asUint8List(); |
|||
return pngBytes; |
|||
} |
|||
} |
|||
@ -0,0 +1,474 @@ |
|||
import 'package:common_utils/common_utils.dart'; |
|||
import 'package:flutter/material.dart'; |
|||
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|||
import 'package:get/get.dart'; |
|||
import 'package:haptic_feedback/haptic_feedback.dart'; |
|||
|
|||
import '../../../core/theme/app_colors.dart'; |
|||
import '../../../routes/app_routes.dart'; |
|||
import '../controllers/meeting_home_controller.dart'; |
|||
import '../model/meeting_model.dart'; |
|||
import '../utils/meeting_ui_utils.dart'; |
|||
import 'bottomSheet/edit_title_bottom_sheet.dart'; |
|||
import 'bottomSheet/import_audio_bottom_sheet.dart'; |
|||
|
|||
class MeetingHomeView extends StatefulWidget { |
|||
const MeetingHomeView({super.key}); |
|||
|
|||
@override |
|||
State<MeetingHomeView> createState() => _MeetingHomeViewState(); |
|||
} |
|||
|
|||
class _MeetingHomeViewState extends State<MeetingHomeView> |
|||
with AutomaticKeepAliveClientMixin { |
|||
final _controller = Get.put(MeetingHomeController()); |
|||
|
|||
@override |
|||
bool get wantKeepAlive => true; |
|||
|
|||
@override |
|||
Widget build(BuildContext context) { |
|||
super.build(context); |
|||
final isDarkMode = Theme.of(context).brightness == Brightness.dark; |
|||
|
|||
return PopScope( |
|||
onPopInvokedWithResult: _controller.onPopInvokedWithResult, |
|||
child: Scaffold( |
|||
backgroundColor: MeetingUIUtils.getBackgroundColor(isDarkMode), |
|||
appBar: _buildAppBar(isDarkMode), |
|||
body: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Row( |
|||
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
|||
children: [ |
|||
_buildFiltrate(isDarkMode), |
|||
_buildConnect(isDarkMode), |
|||
], |
|||
), |
|||
Expanded( |
|||
child: _buildBody(context, isDarkMode), |
|||
), |
|||
], |
|||
), |
|||
floatingActionButton: FloatingActionButton( |
|||
heroTag: "addAudio", |
|||
onPressed: () { |
|||
Get.bottomSheet( |
|||
const ImportAudioBottomSheet(), |
|||
isScrollControlled: true, |
|||
backgroundColor: isDarkMode ? Colors.grey[800] : Colors.grey[100], |
|||
shape: RoundedRectangleBorder( |
|||
borderRadius: BorderRadius.vertical( |
|||
top: Radius.circular(12.r), |
|||
), |
|||
), |
|||
); |
|||
}, |
|||
child: const Icon(Icons.add), |
|||
), |
|||
), |
|||
); |
|||
} |
|||
|
|||
Widget _buildFiltrate(bool isDarkMode) { |
|||
return GestureDetector( |
|||
onTap: () { |
|||
_controller.meetingController.scaffoldKey.currentState?.openDrawer(); |
|||
}, |
|||
behavior: HitTestBehavior.translucent, |
|||
child: Row( |
|||
children: [ |
|||
12.horizontalSpace, |
|||
Container( |
|||
width: 32.w, |
|||
height: 32.w, |
|||
margin: EdgeInsets.symmetric( |
|||
vertical: 10.w, |
|||
), |
|||
decoration: BoxDecoration( |
|||
color: isDarkMode |
|||
? MeetingUIUtils.getTranslucentWhite(0.05) |
|||
: MeetingUIUtils.getCardColor(isDarkMode), |
|||
borderRadius: BorderRadius.circular(12.w), |
|||
boxShadow: MeetingUIUtils.getCardShadow(isDarkMode), |
|||
), |
|||
child: Icon( |
|||
Icons.format_list_bulleted, |
|||
color: isDarkMode ? Colors.white : Colors.black87, |
|||
size: 20.sp, |
|||
), |
|||
), |
|||
5.horizontalSpace, |
|||
Obx( |
|||
() => Text( |
|||
'${_controller.filtrateType.value.isNotEmpty ? _controller.filtrateType : '全部文件'}(${_controller.dataList.length})', |
|||
style: TextStyle( |
|||
color: isDarkMode ? Colors.white : Colors.black87, |
|||
fontSize: 16.sp, |
|||
fontWeight: FontWeight.bold, |
|||
), |
|||
), |
|||
), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
Widget _buildConnect(bool isDarkMode) { |
|||
return GestureDetector( |
|||
onTap: () { |
|||
Get.toNamed(Routes.meetingConnect); |
|||
}, |
|||
child: Container( |
|||
padding: EdgeInsets.all(5.w), |
|||
margin: EdgeInsets.symmetric( |
|||
horizontal: 12.w, |
|||
vertical: 10.w, |
|||
), |
|||
decoration: BoxDecoration( |
|||
color: isDarkMode |
|||
? MeetingUIUtils.getTranslucentWhite(0.05) |
|||
: MeetingUIUtils.getCardColor(isDarkMode), |
|||
borderRadius: BorderRadius.circular(50.w), |
|||
boxShadow: MeetingUIUtils.getCardShadow(isDarkMode), |
|||
), |
|||
child: Row( |
|||
mainAxisSize: MainAxisSize.min, |
|||
children: [ |
|||
Container( |
|||
width: 32.w, |
|||
height: 32.w, |
|||
decoration: BoxDecoration( |
|||
color: MeetingUIUtils.getButtonColor(isDarkMode), |
|||
borderRadius: BorderRadius.circular(50.w), |
|||
), |
|||
child: Icon( |
|||
Icons.add, |
|||
color: Colors.white, |
|||
size: 20.sp, |
|||
), |
|||
), |
|||
Padding( |
|||
padding: EdgeInsets.symmetric(horizontal: 5.w), |
|||
child: Text( |
|||
'连接', |
|||
style: TextStyle( |
|||
color: isDarkMode ? Colors.white : Colors.black87, |
|||
fontSize: 14.sp, |
|||
), |
|||
), |
|||
) |
|||
], |
|||
), |
|||
), |
|||
); |
|||
} |
|||
|
|||
// 构建应用栏 |
|||
PreferredSizeWidget _buildAppBar(bool isDarkMode) { |
|||
return AppBar( |
|||
backgroundColor: isDarkMode ? Colors.black : Colors.white, |
|||
surfaceTintColor: Colors.transparent, |
|||
elevation: 0.5, |
|||
leading: IconButton( |
|||
icon: Icon( |
|||
Icons.arrow_back_ios, |
|||
color: isDarkMode ? Colors.white : Colors.black, |
|||
size: 20.sp, |
|||
), |
|||
onPressed: () => Get.back(), |
|||
), |
|||
title: Text( |
|||
'conferenceAssistant'.tr, // 商务会议助手 |
|||
style: TextStyle( |
|||
fontSize: 17.sp, |
|||
fontWeight: FontWeight.w600, |
|||
color: isDarkMode ? Colors.white : Colors.black, |
|||
), |
|||
), |
|||
centerTitle: true, |
|||
); |
|||
} |
|||
|
|||
// 构建主体内容 |
|||
Widget _buildBody(BuildContext context, bool isDarkMode) { |
|||
return Obx( |
|||
() => ListView.builder( |
|||
itemCount: _controller.dataList.length, |
|||
padding: EdgeInsets.fromLTRB(12.w, 5.w, 12.w, 90.w), |
|||
itemBuilder: (BuildContext context, int index) { |
|||
MeetingModel data = |
|||
MeetingModel.fromJson(_controller.dataList[index]); |
|||
return _item(context, isDarkMode, data); |
|||
}, |
|||
), |
|||
); |
|||
} |
|||
|
|||
Widget _item(BuildContext context, bool isDarkMode, MeetingModel data) { |
|||
return GestureDetector( |
|||
onTap: () { |
|||
Get.toNamed(Routes.meetingDetails, arguments: { |
|||
'id': data.id, |
|||
}); |
|||
}, |
|||
onLongPressStart: (LongPressStartDetails details) { |
|||
_onLongPressItem(context, isDarkMode, details, data); |
|||
}, |
|||
child: Container( |
|||
padding: EdgeInsets.symmetric(horizontal: 15.w, vertical: 10.w), |
|||
margin: EdgeInsets.symmetric(vertical: 5.w), |
|||
decoration: BoxDecoration( |
|||
color: isDarkMode |
|||
? MeetingUIUtils.getTranslucentWhite(0.05) |
|||
: MeetingUIUtils.getCardColor(isDarkMode), |
|||
borderRadius: BorderRadius.circular(12.w), |
|||
boxShadow: MeetingUIUtils.getCardShadow(isDarkMode), |
|||
), |
|||
child: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Text( |
|||
data.title, |
|||
style: TextStyle( |
|||
color: MeetingUIUtils.getTextColor(isDarkMode), |
|||
fontSize: 18.sp, |
|||
), |
|||
overflow: TextOverflow.ellipsis, |
|||
), |
|||
Padding( |
|||
padding: EdgeInsets.symmetric(vertical: 5.w), |
|||
child: Row( |
|||
children: [ |
|||
Icon( |
|||
Icons.calendar_month, |
|||
size: 14.w, |
|||
color: isDarkMode ? Colors.grey[400] : Colors.grey[500], |
|||
), |
|||
2.horizontalSpace, |
|||
Text( |
|||
DateUtil.formatDateMs( |
|||
data.creationtime, |
|||
format: DateFormats.y_mo_d_h_m, |
|||
), |
|||
style: TextStyle( |
|||
color: isDarkMode ? Colors.grey[400] : Colors.grey[500], |
|||
fontSize: 12.sp, |
|||
), |
|||
), |
|||
20.horizontalSpace, |
|||
Icon( |
|||
Icons.access_time, |
|||
size: 14.w, |
|||
color: isDarkMode ? Colors.grey[400] : Colors.grey[500], |
|||
), |
|||
2.horizontalSpace, |
|||
Text( |
|||
_formatDateSeconds(data.seconds), |
|||
style: TextStyle( |
|||
color: isDarkMode ? Colors.grey[400] : Colors.grey[500], |
|||
fontSize: 12.sp, |
|||
), |
|||
), |
|||
], |
|||
), |
|||
), |
|||
Row( |
|||
children: [ |
|||
Container( |
|||
padding: EdgeInsets.symmetric(horizontal: 5.w, vertical: 2.w), |
|||
margin: EdgeInsets.only(top: 5.w), |
|||
decoration: BoxDecoration( |
|||
color: isDarkMode ? Colors.grey[900] : Colors.grey[100], |
|||
borderRadius: BorderRadius.circular(4.w), |
|||
), |
|||
child: Text( |
|||
data.type, |
|||
style: TextStyle( |
|||
color: isDarkMode ? Colors.grey[400] : Colors.grey[600], |
|||
fontSize: 10.sp, |
|||
), |
|||
), |
|||
), |
|||
const Spacer(), |
|||
Obx(() { |
|||
Map uploadData = |
|||
_controller.meetingUploadService.uploadList.firstWhere( |
|||
(element) => element['id'] == data.id, |
|||
orElse: () => {}, |
|||
); |
|||
return uploadData.isNotEmpty |
|||
? Stack( |
|||
alignment: Alignment.center, |
|||
children: [ |
|||
if (uploadData['id'] == |
|||
_controller |
|||
.meetingUploadService.meetingId.value) |
|||
Obx( |
|||
() => SizedBox( |
|||
width: 20.w, |
|||
height: 20.w, |
|||
child: CircularProgressIndicator( |
|||
value: _controller.meetingUploadService |
|||
.uploadProgress.value, |
|||
valueColor: |
|||
const AlwaysStoppedAnimation<Color>( |
|||
Colors.red), |
|||
strokeWidth: 1.w, |
|||
), |
|||
), |
|||
), |
|||
SizedBox( |
|||
width: 20.w, |
|||
height: 20.w, |
|||
child: Icon( |
|||
uploadData['audiourl'].isNotEmpty |
|||
? Icons.cloud_done |
|||
: Icons.cloud_upload, |
|||
color: Colors.blue, |
|||
size: 16.w, |
|||
), |
|||
), |
|||
], |
|||
) |
|||
: const SizedBox(); |
|||
}), |
|||
Obx(() { |
|||
Map taskData = |
|||
_controller.meetingTaskService.taskFinishList.firstWhere( |
|||
(element) => element['id'] == data.id, |
|||
orElse: () => {}, |
|||
); |
|||
return taskData.isNotEmpty |
|||
? Padding( |
|||
padding: EdgeInsets.only(top: 5.w), |
|||
child: Text( |
|||
taskData['tasktype'] == 1 |
|||
? '正在转写...' |
|||
: taskData['tasktype'] == 2 |
|||
? '正在总结...' |
|||
: '总结完成', |
|||
style: TextStyle( |
|||
color: Colors.blue, |
|||
fontSize: 10.sp, |
|||
), |
|||
), |
|||
) |
|||
: const SizedBox(); |
|||
}), |
|||
], |
|||
), |
|||
], |
|||
), |
|||
), |
|||
); |
|||
} |
|||
|
|||
void _onLongPressItem(BuildContext context, bool isDarkMode, |
|||
LongPressStartDetails details, MeetingModel data) { |
|||
Haptics.vibrate(HapticsType.success); |
|||
RenderBox? renderBox = |
|||
Overlay.of(context).context.findRenderObject() as RenderBox; |
|||
final RelativeRect position = RelativeRect.fromRect( |
|||
Rect.fromLTRB( |
|||
details.globalPosition.dx, |
|||
details.globalPosition.dy, |
|||
details.globalPosition.dx + 110, // 菜单显示位置X轴坐标 |
|||
details.globalPosition.dy - 40, // 菜单显示位置Y轴坐标 |
|||
), |
|||
Offset.zero & renderBox.size, |
|||
); |
|||
|
|||
showMenu( |
|||
context: context, |
|||
position: position, |
|||
color: isDarkMode ? AppColors.cardBackground : Colors.white, |
|||
shape: RoundedRectangleBorder( |
|||
borderRadius: BorderRadius.circular(12.r), |
|||
), |
|||
items: [ |
|||
_popupMenuEntry( |
|||
Icon( |
|||
Icons.edit_calendar, |
|||
size: 20.w, |
|||
color: isDarkMode ? Colors.white : Colors.black87, |
|||
), |
|||
'edit'.tr, // 编辑 |
|||
isDarkMode ? Colors.white : Colors.black87, |
|||
() { |
|||
final dotIndex = data.title.lastIndexOf('.'); |
|||
_controller.titleController.text = |
|||
dotIndex != -1 ? data.title.substring(0, dotIndex) : data.title; |
|||
_controller.titleCSuffix = |
|||
data.title.substring(dotIndex, data.title.length); |
|||
Get.bottomSheet( |
|||
EditTitleBottomSheet(data.id), |
|||
isScrollControlled: true, |
|||
backgroundColor: isDarkMode ? Colors.grey[800] : Colors.grey[100], |
|||
shape: RoundedRectangleBorder( |
|||
borderRadius: BorderRadius.vertical( |
|||
top: Radius.circular(12.r), |
|||
), |
|||
), |
|||
); |
|||
}, |
|||
), |
|||
_popupMenuEntry( |
|||
Icon( |
|||
Icons.delete_outline, |
|||
size: 20.w, |
|||
color: isDarkMode ? Colors.red[700] : Colors.red, |
|||
), |
|||
'delete'.tr, // 删除 |
|||
isDarkMode ? Colors.red[700]! : Colors.red, |
|||
() { |
|||
_controller.deleteMeeting(data.id, data.filepath); |
|||
}, |
|||
), |
|||
], |
|||
); |
|||
} |
|||
|
|||
PopupMenuEntry _popupMenuEntry( |
|||
Icon icon, |
|||
String title, |
|||
Color color, |
|||
Function() onTap, |
|||
) { |
|||
return PopupMenuItem( |
|||
onTap: onTap, |
|||
child: SizedBox( |
|||
width: 140.w, |
|||
child: Row( |
|||
children: [ |
|||
icon, |
|||
8.horizontalSpace, |
|||
Text( |
|||
title, |
|||
style: TextStyle( |
|||
color: color, |
|||
fontSize: 14.sp, |
|||
), |
|||
), |
|||
], |
|||
), |
|||
), |
|||
); |
|||
} |
|||
|
|||
String _formatDateSeconds(int seconds) { |
|||
if (seconds < 60) { |
|||
return '${seconds}s'; |
|||
} else if (seconds < 3600) { |
|||
int minutes = seconds ~/ 60; |
|||
int remainingSeconds = seconds % 60; |
|||
return '${minutes}m ${remainingSeconds}s'; |
|||
} else { |
|||
int hours = seconds ~/ 3600; |
|||
int remainingMinutes = (seconds % 3600) ~/ 60; |
|||
int remainingSeconds = seconds % 60; |
|||
return '${hours}h ${remainingMinutes}m ${remainingSeconds}s'; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,439 @@ |
|||
import 'package:flutter/material.dart'; |
|||
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|||
import 'package:get/get.dart'; |
|||
import '../../../routes/app_routes.dart'; |
|||
import '../../profile/views/unified_avatar.dart'; |
|||
|
|||
import '../controllers/meeting_mine_controller.dart'; |
|||
import '../utils/meeting_ui_utils.dart'; |
|||
import 'bottomSheet/statistics_bottom_sheet.dart'; |
|||
|
|||
class MeetingMineView extends StatefulWidget { |
|||
const MeetingMineView({super.key}); |
|||
|
|||
@override |
|||
State<MeetingMineView> createState() => _MeetingMineViewState(); |
|||
} |
|||
|
|||
class _MeetingMineViewState extends State<MeetingMineView> |
|||
with AutomaticKeepAliveClientMixin { |
|||
final _controller = Get.put(MeetingMineController()); |
|||
|
|||
@override |
|||
bool get wantKeepAlive => true; |
|||
|
|||
// 添加 isDarkMode 作为类的 getter |
|||
bool get isDarkMode => Theme.of(context).brightness == Brightness.dark; |
|||
|
|||
@override |
|||
Widget build(BuildContext context) { |
|||
super.build(context); |
|||
return Scaffold( |
|||
backgroundColor: MeetingUIUtils.getBackgroundColor(isDarkMode), |
|||
body: Container( |
|||
decoration: const BoxDecoration( |
|||
image: DecorationImage( |
|||
alignment: Alignment.topCenter, |
|||
image: AssetImage('assets/images/mine-background.png'), |
|||
fit: BoxFit.fitWidth, |
|||
), |
|||
), |
|||
child: SafeArea( |
|||
child: ListView( |
|||
padding: EdgeInsets.symmetric(horizontal: 12.w), |
|||
children: [ |
|||
_head(), |
|||
_statistics(), |
|||
_member(), |
|||
_box([ |
|||
_item( |
|||
Icons.article_outlined, |
|||
'转写记录', |
|||
onTap: () {}, |
|||
), |
|||
]), |
|||
_box([ |
|||
_item( |
|||
Icons.settings_outlined, |
|||
'设置', |
|||
onTap: () {}, |
|||
), |
|||
_item( |
|||
Icons.layers_outlined, |
|||
'模版社区', |
|||
onTap: () {}, |
|||
), |
|||
_item( |
|||
Icons.sync_alt_outlined, |
|||
'集成', |
|||
onTap: () {}, |
|||
), |
|||
]), |
|||
_box([ |
|||
_item( |
|||
Icons.cloud_upload_outlined, |
|||
'DEAPSOUND PRIVATE CLOUD', |
|||
description: '开启', |
|||
onTap: () {}, |
|||
), |
|||
]), |
|||
_box([ |
|||
_item( |
|||
Icons.language_outlined, |
|||
'WEB', |
|||
description: 'web.deapsound.com', |
|||
onTap: () {}, |
|||
), |
|||
_item( |
|||
Icons.settings_remote_outlined, |
|||
'我的DEAPSOUND设备', |
|||
onTap: () {}, |
|||
), |
|||
]), |
|||
_box([ |
|||
_item( |
|||
Icons.help_outline_rounded, |
|||
'帮助与反馈', |
|||
onTap: () {}, |
|||
), |
|||
_item( |
|||
Icons.info_outline_rounded, |
|||
'关于DEAPSOUND', |
|||
description: 'v1.0.0', |
|||
onTap: () {}, |
|||
), |
|||
]), |
|||
], |
|||
), |
|||
), |
|||
), |
|||
); |
|||
} |
|||
|
|||
Widget _head() { |
|||
return Padding( |
|||
padding: EdgeInsets.symmetric(vertical: 20.w), |
|||
child: GestureDetector( |
|||
onTap: () => Get.toNamed(Routes.profileMeeting), // 跳转到个人信息页面 |
|||
child: Row( |
|||
children: [ |
|||
// 使用统一头像组件,避免闪烁问题 |
|||
Container( |
|||
margin: EdgeInsets.only(right: 10.w), |
|||
child: UnifiedAvatar( |
|||
radius: 25.w, |
|||
backgroundColor: Colors.grey[200], |
|||
iconColor: Colors.grey[600], |
|||
iconSize: 25.r, |
|||
isDarkMode: isDarkMode, |
|||
), |
|||
), |
|||
Expanded( |
|||
child: Obx( |
|||
() => Text( |
|||
_controller.profileCtrl.userInfo.value.name, |
|||
style: TextStyle( |
|||
color: isDarkMode ? Colors.white : Colors.black87, |
|||
fontSize: 20.sp, |
|||
fontWeight: FontWeight.bold, |
|||
), |
|||
), |
|||
), |
|||
), |
|||
Icon( |
|||
Icons.arrow_forward_ios, |
|||
size: 16.w, |
|||
color: Colors.grey[700], |
|||
), |
|||
], |
|||
), |
|||
), |
|||
); |
|||
} |
|||
|
|||
Widget _statistics() { |
|||
return GestureDetector( |
|||
onTap: () { |
|||
Get.bottomSheet( |
|||
const StatisticsBottomSheet(), |
|||
isScrollControlled: true, |
|||
); |
|||
}, |
|||
child: Container( |
|||
padding: EdgeInsets.all(8.w), |
|||
margin: EdgeInsets.only(bottom: 15.w), |
|||
decoration: BoxDecoration( |
|||
color: MeetingUIUtils.getCardColor(isDarkMode), |
|||
borderRadius: BorderRadius.circular(16.w), |
|||
boxShadow: MeetingUIUtils.getCardShadow(isDarkMode), |
|||
), |
|||
child: Obx( |
|||
() => Column( |
|||
children: [ |
|||
Row( |
|||
children: [ |
|||
_statisticsItem( |
|||
'${_controller.days.value}', |
|||
'天', |
|||
Colors.lime, |
|||
), |
|||
_statisticsItem( |
|||
'${_controller.numberFiles.value}', |
|||
'文件', |
|||
Colors.lightGreen, |
|||
), |
|||
_statisticsItem( |
|||
'${_controller.hourage.value}', |
|||
'小时', |
|||
Colors.blueGrey, |
|||
), |
|||
], |
|||
), |
|||
Container( |
|||
height: 80.w, |
|||
padding: EdgeInsets.symmetric(horizontal: 2.w), |
|||
margin: EdgeInsets.only(top: 8.w, bottom: 2.w), |
|||
child: GridView.builder( |
|||
controller: _controller.scrollController, |
|||
scrollDirection: Axis.horizontal, |
|||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( |
|||
crossAxisCount: 7, |
|||
mainAxisSpacing: 2.w, |
|||
crossAxisSpacing: 2.w, |
|||
), |
|||
itemCount: 364, |
|||
itemBuilder: (BuildContext context, int index) { |
|||
int type = 0; |
|||
if (_controller.statistics.containsKey(index + 1)) { |
|||
if (_controller.statistics[index + 1]['count'] <= |
|||
_controller.averageNumberFiles) { |
|||
type = 1; |
|||
} else { |
|||
type = 2; |
|||
} |
|||
} |
|||
return Container( |
|||
decoration: BoxDecoration( |
|||
color: type == 0 |
|||
? Colors.grey[200] |
|||
: type == 1 |
|||
? Colors.red[100] |
|||
: Colors.red, |
|||
borderRadius: BorderRadius.circular(2.w), |
|||
), |
|||
); |
|||
}, |
|||
), |
|||
), |
|||
], |
|||
), |
|||
), |
|||
), |
|||
); |
|||
} |
|||
|
|||
Widget _statisticsItem(String amount, String unit, Color color) { |
|||
return Expanded( |
|||
child: Container( |
|||
height: 30.w, |
|||
margin: EdgeInsets.symmetric(horizontal: 2.w), |
|||
alignment: Alignment.center, |
|||
decoration: BoxDecoration( |
|||
color: color.withValues(alpha: 0.2), |
|||
borderRadius: BorderRadius.circular(8.w), |
|||
), |
|||
child: Row( |
|||
mainAxisAlignment: MainAxisAlignment.center, |
|||
crossAxisAlignment: CrossAxisAlignment.end, |
|||
children: [ |
|||
Text( |
|||
amount, |
|||
style: TextStyle( |
|||
height: 0.1, |
|||
color: isDarkMode ? Colors.white : Colors.black87, |
|||
fontSize: 16.sp, |
|||
fontWeight: FontWeight.bold, |
|||
), |
|||
), |
|||
2.horizontalSpace, |
|||
Text( |
|||
unit, |
|||
style: TextStyle( |
|||
height: 0.1, |
|||
color: Colors.grey, |
|||
fontSize: 10.sp, |
|||
), |
|||
), |
|||
], |
|||
), |
|||
), |
|||
); |
|||
} |
|||
|
|||
Widget _member() { |
|||
return Container( |
|||
margin: EdgeInsets.only(bottom: 15.w), |
|||
decoration: BoxDecoration( |
|||
color: MeetingUIUtils.getCardColor(isDarkMode), |
|||
borderRadius: BorderRadius.circular(16.w), |
|||
), |
|||
child: Column( |
|||
children: [ |
|||
Padding( |
|||
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 10.w), |
|||
child: Row( |
|||
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
|||
children: [ |
|||
Text( |
|||
'DEAPSOUND AI会员', |
|||
style: TextStyle( |
|||
color: MeetingUIUtils.getTextColor(isDarkMode), |
|||
fontSize: 16.sp, |
|||
), |
|||
), |
|||
Icon( |
|||
Icons.arrow_forward_ios, |
|||
size: 16.w, |
|||
color: Colors.grey, |
|||
), |
|||
], |
|||
), |
|||
), |
|||
Container( |
|||
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 15.w), |
|||
decoration: BoxDecoration( |
|||
color: Colors.black87, |
|||
borderRadius: BorderRadius.circular(16.w), |
|||
), |
|||
child: Column( |
|||
children: [ |
|||
Row( |
|||
children: [ |
|||
Icon( |
|||
Icons.auto_awesome, |
|||
color: Colors.amberAccent, |
|||
size: 25.sp, |
|||
), |
|||
5.horizontalSpace, |
|||
Text( |
|||
'解锁 Starter 会员', |
|||
style: TextStyle( |
|||
color: Colors.white, |
|||
fontSize: 18.sp, |
|||
fontWeight: FontWeight.bold, |
|||
), |
|||
), |
|||
], |
|||
), |
|||
Padding( |
|||
padding: EdgeInsets.only(top: 15.w), |
|||
child: Row( |
|||
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
|||
children: [ |
|||
Text( |
|||
'绑定 DEAPSOUND 设备可激活 Starter 会员', |
|||
style: TextStyle( |
|||
color: Colors.white, |
|||
fontSize: 12.sp, |
|||
), |
|||
), |
|||
GestureDetector( |
|||
onTap: () {}, |
|||
child: Container( |
|||
padding: EdgeInsets.symmetric( |
|||
horizontal: 10.w, |
|||
vertical: 6.w, |
|||
), |
|||
decoration: BoxDecoration( |
|||
borderRadius: BorderRadius.circular(50.w), |
|||
gradient: const LinearGradient( |
|||
colors: [Colors.blue, Colors.pink], |
|||
begin: Alignment.centerLeft, |
|||
end: Alignment.centerRight, |
|||
), |
|||
), |
|||
child: Text( |
|||
'立即解锁', |
|||
style: TextStyle( |
|||
color: Colors.white, |
|||
fontSize: 14.sp, |
|||
fontWeight: FontWeight.bold, |
|||
), |
|||
), |
|||
), |
|||
), |
|||
], |
|||
), |
|||
), |
|||
], |
|||
), |
|||
), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
Widget _box(List<Widget> children) { |
|||
return Container( |
|||
padding: EdgeInsets.symmetric(vertical: 5.w), |
|||
margin: EdgeInsets.only(bottom: 15.w), |
|||
decoration: BoxDecoration( |
|||
color: MeetingUIUtils.getCardColor(isDarkMode), |
|||
borderRadius: BorderRadius.circular(16.w), |
|||
boxShadow: MeetingUIUtils.getCardShadow(isDarkMode), |
|||
), |
|||
child: Column( |
|||
children: children, |
|||
), |
|||
); |
|||
} |
|||
|
|||
Widget _item( |
|||
IconData icon, |
|||
String title, { |
|||
String? description, |
|||
Function()? onTap, |
|||
}) { |
|||
return GestureDetector( |
|||
onTap: onTap, |
|||
child: Padding( |
|||
padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 12.w), |
|||
child: Row( |
|||
children: [ |
|||
Icon( |
|||
icon, |
|||
size: 24.w, |
|||
color: isDarkMode ? Colors.grey[400] : Colors.grey, |
|||
), |
|||
8.horizontalSpace, |
|||
Expanded( |
|||
child: Text( |
|||
title, |
|||
style: TextStyle( |
|||
color: isDarkMode ? Colors.white : Colors.black87, |
|||
fontSize: 16.sp, |
|||
), |
|||
), |
|||
), |
|||
if (description != null) |
|||
Padding( |
|||
padding: EdgeInsets.only(right: 5.w), |
|||
child: Text( |
|||
description, |
|||
style: TextStyle( |
|||
color: Colors.grey, |
|||
fontSize: 16.sp, |
|||
), |
|||
), |
|||
), |
|||
Icon( |
|||
Icons.arrow_forward_ios, |
|||
size: 16.w, |
|||
color: isDarkMode ? Colors.grey[400] : Colors.grey, |
|||
), |
|||
], |
|||
), |
|||
), |
|||
); |
|||
} |
|||
} |
|||
@ -1,444 +1,120 @@ |
|||
import 'package:common_utils/common_utils.dart'; |
|||
import 'dart:ui'; |
|||
|
|||
import 'package:flutter/material.dart'; |
|||
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|||
import 'package:get/get.dart'; |
|||
import 'package:haptic_feedback/haptic_feedback.dart'; |
|||
|
|||
import '../../../core/theme/app_colors.dart'; |
|||
import '../../../routes/app_routes.dart'; |
|||
import '../controllers/meeting_controller.dart'; |
|||
import '../model/meeting_model.dart'; |
|||
import 'bottomSheet/edit_title_bottom_sheet.dart'; |
|||
import '../utils/meeting_ui_utils.dart'; |
|||
import 'bottomSheet/filtrate_drawer.dart'; |
|||
import 'bottomSheet/import_audio_bottom_sheet.dart'; |
|||
import 'bottomSheet/navigation_bar_bottom_sheet.dart'; |
|||
|
|||
class MeetingView extends GetView<MeetingController> { |
|||
const MeetingView({super.key}); |
|||
|
|||
@override |
|||
Widget build(BuildContext context) { |
|||
final GlobalKey<ScaffoldState> scaffoldKey = GlobalKey<ScaffoldState>(); |
|||
final isDarkMode = Theme.of(context).brightness == Brightness.dark; |
|||
|
|||
return PopScope( |
|||
onPopInvokedWithResult: controller.onPopInvokedWithResult, |
|||
child: Scaffold( |
|||
key: scaffoldKey, |
|||
backgroundColor: isDarkMode ? Colors.black : Colors.grey[50], |
|||
appBar: _buildAppBar(isDarkMode), |
|||
drawer: Drawer( |
|||
width: 260.w, |
|||
child: const FiltrateDrawer(), |
|||
), |
|||
drawerEnableOpenDragGesture: false, |
|||
body: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Row( |
|||
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
|||
children: [ |
|||
IconButton( |
|||
icon: Icon( |
|||
Icons.folder_open, |
|||
color: isDarkMode ? Colors.white : Colors.black, |
|||
), |
|||
onPressed: () { |
|||
scaffoldKey.currentState?.openDrawer(); |
|||
}, |
|||
|
|||
return Scaffold( |
|||
key: controller.scaffoldKey, |
|||
backgroundColor: MeetingUIUtils.getBackgroundColor(isDarkMode), |
|||
body: PageView( |
|||
physics: const NeverScrollableScrollPhysics(), |
|||
controller: controller.pageController, |
|||
children: controller.pageList, |
|||
), |
|||
drawer: Drawer( |
|||
width: 260.w, |
|||
backgroundColor: isDarkMode ? const Color(0xFF1A1A1A) : Colors.grey[50], |
|||
child: const FiltrateDrawer(), |
|||
), |
|||
drawerEnableOpenDragGesture: false, |
|||
bottomNavigationBar: Stack( |
|||
alignment: Alignment.center, |
|||
clipBehavior: Clip.none, |
|||
children: [ |
|||
Obx( |
|||
() => BottomNavigationBar( |
|||
items: const [ |
|||
BottomNavigationBarItem( |
|||
icon: Icon(Icons.folder_outlined), |
|||
activeIcon: Icon(Icons.folder), |
|||
label: '文件', |
|||
), |
|||
_buildConnect(isDarkMode), |
|||
], |
|||
), |
|||
Expanded( |
|||
child: _buildBody(context, isDarkMode), |
|||
), |
|||
], |
|||
), |
|||
floatingActionButton: FloatingActionButton( |
|||
heroTag: "addAudio", |
|||
onPressed: () { |
|||
Get.bottomSheet( |
|||
const ImportAudioBottomSheet(), |
|||
isScrollControlled: true, |
|||
backgroundColor: Colors.grey[100], |
|||
shape: RoundedRectangleBorder( |
|||
borderRadius: BorderRadius.vertical( |
|||
top: Radius.circular(12.r), |
|||
BottomNavigationBarItem( |
|||
icon: SizedBox(), |
|||
label: '', |
|||
), |
|||
), |
|||
); |
|||
}, |
|||
child: const Icon(Icons.add), |
|||
), |
|||
), |
|||
); |
|||
} |
|||
|
|||
Widget _buildConnect(bool isDarkMode) { |
|||
return GestureDetector( |
|||
onTap: () { |
|||
Get.toNamed(Routes.meetingConnect); |
|||
}, |
|||
child: Container( |
|||
padding: EdgeInsets.all(5.w), |
|||
margin: EdgeInsets.symmetric( |
|||
horizontal: 12.w, |
|||
vertical: 10.w, |
|||
), |
|||
decoration: BoxDecoration( |
|||
color: isDarkMode ? Colors.white.withOpacity(0.05) : Colors.white, |
|||
borderRadius: BorderRadius.circular(50.w), |
|||
boxShadow: [ |
|||
BoxShadow( |
|||
color: isDarkMode |
|||
? Colors.black.withOpacity(0.2) |
|||
: Colors.black.withOpacity(0.05), |
|||
blurRadius: 10, |
|||
offset: const Offset(0, 2), |
|||
), |
|||
], |
|||
), |
|||
child: Row( |
|||
mainAxisSize: MainAxisSize.min, |
|||
children: [ |
|||
Container( |
|||
width: 32.w, |
|||
height: 32.w, |
|||
decoration: BoxDecoration( |
|||
color: Colors.black, |
|||
borderRadius: BorderRadius.circular(50.w), |
|||
), |
|||
child: Icon( |
|||
Icons.add, |
|||
color: Colors.white, |
|||
size: 20.sp, |
|||
), |
|||
), |
|||
Padding( |
|||
padding: EdgeInsets.symmetric(horizontal: 5.w), |
|||
child: Text( |
|||
'连接', |
|||
style: TextStyle( |
|||
color: isDarkMode ? Colors.white : Colors.black87, |
|||
fontSize: 14.sp, |
|||
BottomNavigationBarItem( |
|||
icon: Icon(Icons.person_outline), |
|||
activeIcon: Icon(Icons.person), |
|||
label: '我的', |
|||
), |
|||
), |
|||
) |
|||
], |
|||
), |
|||
), |
|||
); |
|||
} |
|||
|
|||
// 构建应用栏 |
|||
PreferredSizeWidget _buildAppBar(bool isDarkMode) { |
|||
return AppBar( |
|||
backgroundColor: isDarkMode ? Colors.black : Colors.white, |
|||
surfaceTintColor: Colors.transparent, |
|||
elevation: 0.5, |
|||
leading: IconButton( |
|||
icon: Icon( |
|||
Icons.arrow_back_ios, |
|||
color: isDarkMode ? Colors.white : Colors.black, |
|||
size: 20.sp, |
|||
), |
|||
onPressed: () => Get.back(), |
|||
), |
|||
title: Text( |
|||
'conferenceAssistant'.tr, // 商务会议助手 |
|||
style: TextStyle( |
|||
fontSize: 17.sp, |
|||
fontWeight: FontWeight.w600, |
|||
color: isDarkMode ? Colors.white : Colors.black, |
|||
), |
|||
), |
|||
centerTitle: true, |
|||
); |
|||
} |
|||
|
|||
// 构建主体内容 |
|||
Widget _buildBody(BuildContext context, bool isDarkMode) { |
|||
return Obx( |
|||
() => ListView.builder( |
|||
controller: controller.listController, |
|||
itemCount: controller.dataList.length, |
|||
padding: EdgeInsets.fromLTRB(12.w, 5.w, 12.w, 90.w), |
|||
itemBuilder: (BuildContext context, int index) { |
|||
MeetingModel data = MeetingModel.fromJson(controller.dataList[index]); |
|||
return _item(context, isDarkMode, data); |
|||
}, |
|||
), |
|||
); |
|||
} |
|||
|
|||
Widget _item(BuildContext context, bool isDarkMode, MeetingModel data) { |
|||
return GestureDetector( |
|||
onTap: () { |
|||
Get.toNamed(Routes.meetingDetails, arguments: { |
|||
'id': data.id, |
|||
}); |
|||
}, |
|||
onLongPressStart: (LongPressStartDetails details) { |
|||
_onLongPressItem(context, isDarkMode, details, data); |
|||
}, |
|||
child: Container( |
|||
padding: EdgeInsets.symmetric(horizontal: 15.w, vertical: 10.w), |
|||
margin: EdgeInsets.symmetric(vertical: 5.w), |
|||
decoration: BoxDecoration( |
|||
color: isDarkMode ? Colors.white.withOpacity(0.05) : Colors.white, |
|||
borderRadius: BorderRadius.circular(12.w), |
|||
boxShadow: [ |
|||
BoxShadow( |
|||
color: isDarkMode |
|||
? Colors.black.withOpacity(0.2) |
|||
: Colors.black.withOpacity(0.05), |
|||
blurRadius: 10, |
|||
offset: const Offset(0, 2), |
|||
), |
|||
], |
|||
), |
|||
child: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Text( |
|||
data.title, |
|||
style: TextStyle( |
|||
color: isDarkMode ? Colors.white : Colors.black87, |
|||
fontSize: 18.sp, |
|||
), |
|||
overflow: TextOverflow.ellipsis, |
|||
], |
|||
backgroundColor: isDarkMode ? const Color(0xFF1A1A1A) : Colors.white, |
|||
currentIndex: controller.pageIndex.value, |
|||
unselectedItemColor: Colors.grey, |
|||
selectedItemColor: isDarkMode ? Colors.white : Colors.blue, |
|||
unselectedFontSize: 10.sp, |
|||
selectedFontSize: 10.sp, |
|||
onTap: (int index) { |
|||
if (index == 1 || index == controller.pageIndex.value) return; |
|||
if (index == 0) { |
|||
controller.pageIndex.value = 0; |
|||
controller.pageController.jumpToPage(0); |
|||
} else { |
|||
controller.pageIndex.value = 2; |
|||
controller.pageController.jumpToPage(1); |
|||
} |
|||
}, |
|||
), |
|||
Padding( |
|||
padding: EdgeInsets.symmetric(vertical: 5.w), |
|||
child: Row( |
|||
children: [ |
|||
Icon( |
|||
Icons.calendar_month, |
|||
size: 14.w, |
|||
color: isDarkMode ? Colors.grey[400] : Colors.grey[500], |
|||
), |
|||
2.horizontalSpace, |
|||
Text( |
|||
DateUtil.formatDateMs( |
|||
data.creationtime, |
|||
format: DateFormats.y_mo_d_h_m, |
|||
), |
|||
style: TextStyle( |
|||
color: isDarkMode ? Colors.grey[400] : Colors.grey[500], |
|||
fontSize: 12.sp, |
|||
), |
|||
), |
|||
20.horizontalSpace, |
|||
Icon( |
|||
Icons.access_time, |
|||
size: 14.w, |
|||
color: isDarkMode ? Colors.grey[400] : Colors.grey[500], |
|||
), |
|||
Positioned( |
|||
top: -18.w, |
|||
child: GestureDetector( |
|||
onTap: () { |
|||
Get.bottomSheet( |
|||
BackdropFilter( |
|||
filter: ImageFilter.blur(sigmaX: 10.0, sigmaY: 10.0), |
|||
child: const NavigationBarBottomSheet(), |
|||
), |
|||
2.horizontalSpace, |
|||
Text( |
|||
_formatDateSeconds(data.seconds), |
|||
style: TextStyle( |
|||
color: isDarkMode ? Colors.grey[400] : Colors.grey[500], |
|||
fontSize: 12.sp, |
|||
isScrollControlled: true, |
|||
); |
|||
}, |
|||
child: Container( |
|||
width: 60.w, |
|||
height: 60.w, |
|||
padding: EdgeInsets.all(5.w), |
|||
alignment: Alignment.center, |
|||
decoration: BoxDecoration( |
|||
color: isDarkMode ? const Color(0xFF1A1A1A) : Colors.white, |
|||
borderRadius: BorderRadius.circular(60.w), |
|||
boxShadow: [ |
|||
BoxShadow( |
|||
color: Colors.black.withValues(alpha: isDarkMode ? 0.3 : 0.1), |
|||
blurRadius: 8, |
|||
offset: const Offset(0, 2), |
|||
), |
|||
), |
|||
], |
|||
), |
|||
), |
|||
Row( |
|||
children: [ |
|||
Container( |
|||
padding: EdgeInsets.symmetric(horizontal: 5.w, vertical: 2.w), |
|||
margin: EdgeInsets.only(top: 5.w), |
|||
], |
|||
), |
|||
child: Container( |
|||
alignment: Alignment.center, |
|||
decoration: BoxDecoration( |
|||
color: isDarkMode ? Colors.grey[900] : Colors.grey[100], |
|||
borderRadius: BorderRadius.circular(4.w), |
|||
color: MeetingUIUtils.getButtonColor(isDarkMode), |
|||
borderRadius: BorderRadius.circular(60.w), |
|||
), |
|||
child: Text( |
|||
data.type, |
|||
style: TextStyle( |
|||
color: isDarkMode ? Colors.grey[400] : Colors.grey[600], |
|||
fontSize: 10.sp, |
|||
), |
|||
child: Icon( |
|||
Icons.add, |
|||
color: Colors.white, |
|||
size: 30.w, |
|||
), |
|||
), |
|||
const Spacer(), |
|||
Obx(() { |
|||
Map uploadData = |
|||
controller.meetingUploadService.uploadList.firstWhere( |
|||
(element) => element['id'] == data.id, |
|||
orElse: () => {}, |
|||
); |
|||
return uploadData.isNotEmpty |
|||
? Stack( |
|||
alignment: Alignment.center, |
|||
children: [ |
|||
if (uploadData['id'] == |
|||
controller.meetingUploadService.meetingId.value) |
|||
Obx( |
|||
() => SizedBox( |
|||
width: 20.w, |
|||
height: 20.w, |
|||
child: CircularProgressIndicator( |
|||
value: controller.meetingUploadService |
|||
.uploadProgress.value, |
|||
valueColor: |
|||
const AlwaysStoppedAnimation<Color>( |
|||
Colors.red), |
|||
strokeWidth: 1.w, |
|||
), |
|||
), |
|||
), |
|||
SizedBox( |
|||
width: 20.w, |
|||
height: 20.w, |
|||
child: Icon( |
|||
uploadData['audiourl'].isNotEmpty |
|||
? Icons.cloud_done |
|||
: Icons.cloud_upload, |
|||
color: Colors.blue, |
|||
size: 16.w, |
|||
), |
|||
), |
|||
], |
|||
) |
|||
: const SizedBox(); |
|||
}), |
|||
Obx(() { |
|||
Map taskData = |
|||
controller.meetingTaskService.taskFinishList.firstWhere( |
|||
(element) => element['id'] == data.id, |
|||
orElse: () => {}, |
|||
); |
|||
return taskData.isNotEmpty |
|||
? Padding( |
|||
padding: EdgeInsets.only(top: 5.w), |
|||
child: Text( |
|||
taskData['tasktype'] == 1 |
|||
? '正在转写...' |
|||
: taskData['tasktype'] == 2 |
|||
? '正在总结...' |
|||
: '总结完成', |
|||
style: TextStyle( |
|||
color: Colors.blue, |
|||
fontSize: 10.sp, |
|||
), |
|||
), |
|||
) |
|||
: const SizedBox(); |
|||
}), |
|||
], |
|||
), |
|||
], |
|||
), |
|||
), |
|||
); |
|||
} |
|||
|
|||
void _onLongPressItem(BuildContext context, bool isDarkMode, |
|||
LongPressStartDetails details, MeetingModel data) { |
|||
Haptics.vibrate(HapticsType.success); |
|||
RenderBox? renderBox = |
|||
Overlay.of(context).context.findRenderObject() as RenderBox; |
|||
final RelativeRect position = RelativeRect.fromRect( |
|||
Rect.fromLTRB( |
|||
details.globalPosition.dx, |
|||
details.globalPosition.dy, |
|||
details.globalPosition.dx + 110, // 菜单显示位置X轴坐标 |
|||
details.globalPosition.dy - 40, // 菜单显示位置Y轴坐标 |
|||
), |
|||
Offset.zero & renderBox.size, |
|||
); |
|||
|
|||
showMenu( |
|||
context: context, |
|||
position: position, |
|||
color: isDarkMode ? AppColors.cardBackground : Colors.white, |
|||
shape: RoundedRectangleBorder( |
|||
borderRadius: BorderRadius.circular(12.r), |
|||
), |
|||
items: [ |
|||
_popupMenuEntry( |
|||
Icon( |
|||
Icons.edit_calendar, |
|||
size: 20.w, |
|||
color: isDarkMode ? Colors.white : Colors.black87, |
|||
), |
|||
'edit'.tr, // 编辑 |
|||
isDarkMode ? Colors.white : Colors.black87, |
|||
() { |
|||
final dotIndex = data.title.lastIndexOf('.'); |
|||
controller.titleController.text = |
|||
dotIndex != -1 ? data.title.substring(0, dotIndex) : data.title; |
|||
controller.titleCSuffix = |
|||
data.title.substring(dotIndex, data.title.length); |
|||
Get.bottomSheet( |
|||
EditTitleBottomSheet(data.id), |
|||
isScrollControlled: true, |
|||
backgroundColor: Colors.grey[100], |
|||
shape: RoundedRectangleBorder( |
|||
borderRadius: BorderRadius.vertical( |
|||
top: Radius.circular(12.r), |
|||
), |
|||
), |
|||
); |
|||
}, |
|||
), |
|||
_popupMenuEntry( |
|||
Icon( |
|||
Icons.delete_outline, |
|||
size: 20.w, |
|||
color: isDarkMode ? Colors.red[700] : Colors.red, |
|||
), |
|||
'delete'.tr, // 删除 |
|||
isDarkMode ? Colors.red[700]! : Colors.red, |
|||
() { |
|||
controller.deleteMeeting(data.id, data.filepath); |
|||
}, |
|||
), |
|||
], |
|||
); |
|||
} |
|||
|
|||
PopupMenuEntry _popupMenuEntry( |
|||
Icon icon, |
|||
String title, |
|||
Color color, |
|||
Function() onTap, |
|||
) { |
|||
return PopupMenuItem( |
|||
onTap: onTap, |
|||
child: SizedBox( |
|||
width: 140.w, |
|||
child: Row( |
|||
children: [ |
|||
icon, |
|||
8.horizontalSpace, |
|||
Text( |
|||
title, |
|||
style: TextStyle( |
|||
color: color, |
|||
fontSize: 14.sp, |
|||
), |
|||
), |
|||
], |
|||
), |
|||
), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
String _formatDateSeconds(int seconds) { |
|||
if (seconds < 60) { |
|||
return '${seconds}s'; |
|||
} else if (seconds < 3600) { |
|||
int minutes = seconds ~/ 60; |
|||
int remainingSeconds = seconds % 60; |
|||
return '${minutes}m ${remainingSeconds}s'; |
|||
} else { |
|||
int hours = seconds ~/ 3600; |
|||
int remainingMinutes = (seconds % 3600) ~/ 60; |
|||
int remainingSeconds = seconds % 60; |
|||
return '${hours}h ${remainingMinutes}m ${remainingSeconds}s'; |
|||
} |
|||
} |
|||
} |
|||
|
|||
@ -0,0 +1,488 @@ |
|||
import 'dart:io'; |
|||
import 'package:flutter/material.dart'; |
|||
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|||
import 'package:get/get.dart'; |
|||
import '../controllers/profile_controller.dart'; |
|||
import 'unified_avatar.dart'; |
|||
|
|||
class ProfileMeetingView extends GetView<ProfileController> { |
|||
ProfileMeetingView({super.key}); |
|||
|
|||
// 昵称编辑控制器 - 与 profile_view.dart 保持一致 |
|||
final TextEditingController nameController = TextEditingController(); |
|||
|
|||
@override |
|||
Widget build(BuildContext context) { |
|||
final isDarkMode = Theme.of(context).brightness == Brightness.dark; |
|||
|
|||
return Scaffold( |
|||
backgroundColor: isDarkMode ? Colors.black : Colors.white, |
|||
appBar: _buildAppBar(isDarkMode), |
|||
body: _buildBody(isDarkMode), |
|||
); |
|||
} |
|||
|
|||
PreferredSizeWidget _buildAppBar(bool isDarkMode) { |
|||
// 初始化 nameController 的文本内容 |
|||
nameController.text = controller.userInfo.value.name; |
|||
return AppBar( |
|||
backgroundColor: isDarkMode ? Colors.black : Colors.white, |
|||
elevation: 0, |
|||
leading: IconButton( |
|||
icon: Icon( |
|||
Icons.arrow_back_ios, |
|||
color: isDarkMode ? Colors.white : Colors.black87, |
|||
size: 20.w, |
|||
), |
|||
onPressed: () { |
|||
controller.forceRollbackIfUnsaved(); |
|||
Get.back(); |
|||
}, |
|||
), |
|||
title: Text( |
|||
'账号和资料', |
|||
style: TextStyle( |
|||
color: isDarkMode ? Colors.white : Colors.black87, |
|||
fontSize: 18.sp, |
|||
fontWeight: FontWeight.w600, |
|||
), |
|||
), |
|||
centerTitle: true, |
|||
actions: [ |
|||
Obx(() => Visibility( |
|||
visible: controller.hasEdits.value, |
|||
child: controller.isUpdating.value |
|||
? Padding( |
|||
padding: EdgeInsets.all(16.w), |
|||
child: SizedBox( |
|||
width: 20.w, |
|||
height: 20.w, |
|||
child: CircularProgressIndicator( |
|||
strokeWidth: 2, |
|||
valueColor: AlwaysStoppedAnimation<Color>( |
|||
isDarkMode ? Colors.blue[300]! : Colors.blue, |
|||
), |
|||
), |
|||
), |
|||
) |
|||
: TextButton( |
|||
onPressed: () async { |
|||
// 保存用户名和头像 |
|||
await controller |
|||
.updateUserNameAndAvatar(nameController.text); |
|||
// 如果保存成功(没有编辑状态),延迟返回上一级页面让用户看到成功提示 |
|||
if (!controller.hasEdits.value) { |
|||
// 延迟跳转,让用户看到成功提示 |
|||
Future.delayed(const Duration(milliseconds: 500), () { |
|||
if (Get.isRegistered<ProfileController>()) { |
|||
Get.back(); |
|||
} |
|||
}); |
|||
} |
|||
}, |
|||
child: Text( |
|||
'save'.tr, |
|||
style: TextStyle( |
|||
fontSize: 16.sp, |
|||
color: isDarkMode ? Colors.blue[300] : Colors.blue, |
|||
fontWeight: FontWeight.w500, |
|||
), |
|||
), |
|||
), |
|||
)) |
|||
], |
|||
); |
|||
} |
|||
|
|||
Widget _buildBody(bool isDarkMode) { |
|||
return WillPopScope( |
|||
onWillPop: () async { |
|||
controller.forceRollbackIfUnsaved(); |
|||
return true; |
|||
}, |
|||
child: SafeArea( |
|||
child: ListView( |
|||
padding: EdgeInsets.symmetric(horizontal: 16.w), |
|||
children: [ |
|||
SizedBox(height: 4.h), |
|||
_buildAvatarSection(isDarkMode), |
|||
SizedBox(height: 16.h), |
|||
_buildNicknameItem(isDarkMode), |
|||
Divider( |
|||
height: 0.5.h, |
|||
color: isDarkMode ? Colors.grey[800] : Colors.grey[200]), |
|||
_buildLoginMethodItem(isDarkMode), |
|||
Divider( |
|||
height: 0.5.h, |
|||
color: isDarkMode ? Colors.grey[800] : Colors.grey[200]), |
|||
_buildManageLoginItem(isDarkMode), |
|||
Divider( |
|||
height: 0.5.h, |
|||
color: isDarkMode ? Colors.grey[800] : Colors.grey[200]), |
|||
SizedBox(height: 32.h), |
|||
_buildLogoutButton(isDarkMode), |
|||
SizedBox(height: 16.h), |
|||
_buildDeleteAccountButton(isDarkMode), |
|||
SizedBox(height: 8.h), |
|||
_buildWarningText(), |
|||
SizedBox(height: 32.h), |
|||
], |
|||
), |
|||
)); |
|||
} |
|||
|
|||
Widget _buildAvatarSection(bool isDarkMode) { |
|||
return Center( |
|||
child: Column( |
|||
children: [ |
|||
// 使用统一头像组件,避免闪烁问题 |
|||
GestureDetector( |
|||
onTap: () => controller.updateUserAvatar(), |
|||
child: Stack( |
|||
children: [ |
|||
UnifiedAvatar( |
|||
radius: 40.r, |
|||
backgroundColor: |
|||
isDarkMode ? Colors.grey[800] : Colors.grey[200], |
|||
iconColor: isDarkMode ? Colors.grey[400] : Colors.grey[600], |
|||
iconSize: 30.r, |
|||
isDarkMode: isDarkMode, |
|||
), |
|||
Positioned( |
|||
bottom: 0, |
|||
right: 0, |
|||
child: Container( |
|||
width: 22.w, |
|||
height: 22.w, |
|||
decoration: BoxDecoration( |
|||
color: isDarkMode ? Colors.grey[600] : Colors.grey[350], |
|||
shape: BoxShape.circle, |
|||
border: Border.all( |
|||
color: isDarkMode ? Colors.black : Colors.white, |
|||
width: 0.w, |
|||
), |
|||
), |
|||
child: Icon( |
|||
Icons.camera_alt, |
|||
size: 14.w, |
|||
color: Colors.black, |
|||
), |
|||
), |
|||
), |
|||
], |
|||
), |
|||
), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
// 昵称设置项(双模式内联编辑)- 与 profile_view.dart 完全相同的逻辑 |
|||
Widget _buildNicknameItem(bool isDarkMode) { |
|||
return Obx(() { |
|||
// 根据编辑状态显示不同的UI |
|||
if (controller.isEditingNickname.value) { |
|||
return _buildNicknameEditingMode(isDarkMode, nameController); |
|||
} else { |
|||
return _buildNicknameDisplayMode(isDarkMode, nameController); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
// 构建昵称显示模式 - 适配 profile_meeting_view 的 ListTile 样式 |
|||
Widget _buildNicknameDisplayMode( |
|||
bool isDarkMode, TextEditingController nameController) { |
|||
return ListTile( |
|||
onTap: () { |
|||
// 开始内联编辑 |
|||
nameController.text = controller.userInfo.value.name; |
|||
controller.startEditingNickname(); |
|||
}, |
|||
// 禁用水波纹效果 |
|||
splashColor: Colors.transparent, |
|||
title: Text( |
|||
'nickname'.tr, |
|||
style: TextStyle( |
|||
fontSize: 16.sp, |
|||
color: isDarkMode ? Colors.grey[400] : Colors.grey[600], |
|||
), |
|||
), |
|||
trailing: Row( |
|||
mainAxisSize: MainAxisSize.min, |
|||
children: [ |
|||
Obx(() => Text( |
|||
controller.userInfo.value.name.isNotEmpty |
|||
? controller.userInfo.value.name |
|||
: '未设置昵称', |
|||
style: TextStyle( |
|||
fontSize: 16.sp, |
|||
color: isDarkMode ? Colors.white : Colors.grey[600], |
|||
), |
|||
)), |
|||
SizedBox(width: 8.w), |
|||
Icon( |
|||
Icons.arrow_forward_ios, |
|||
size: 16.w, |
|||
color: isDarkMode ? Colors.grey[400] : Colors.grey[600], |
|||
), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
// 构建昵称编辑模式 - 适配 profile_meeting_view 的 ListTile 样式 |
|||
Widget _buildNicknameEditingMode( |
|||
bool isDarkMode, TextEditingController nameController) { |
|||
return ListTile( |
|||
title: Text( |
|||
'nickname'.tr, |
|||
style: TextStyle( |
|||
fontSize: 16.sp, |
|||
color: isDarkMode ? Colors.grey[400] : Colors.grey[600], |
|||
), |
|||
), |
|||
trailing: Row( |
|||
mainAxisSize: MainAxisSize.min, |
|||
children: [ |
|||
// 编辑输入框 - 替换原来的昵称文本显示区域 |
|||
Container( |
|||
constraints: BoxConstraints( |
|||
minWidth: 80.w, // 确保有足够的编辑空间 |
|||
maxWidth: 120.w, // 限制最大宽度保持布局稳定 |
|||
), |
|||
child: TextField( |
|||
controller: nameController, |
|||
autofocus: true, |
|||
textAlign: TextAlign.right, // 右对齐,与原昵称显示保持一致 |
|||
style: TextStyle( |
|||
fontSize: 16.sp, |
|||
color: isDarkMode ? Colors.white : Colors.grey[600], |
|||
), |
|||
decoration: InputDecoration( |
|||
hintText: '输入昵称', |
|||
hintStyle: TextStyle( |
|||
color: isDarkMode ? Colors.grey[500] : Colors.grey[400], |
|||
fontSize: 16.sp, |
|||
), |
|||
border: InputBorder.none, // 移除边框,保持简洁 |
|||
enabledBorder: InputBorder.none, |
|||
focusedBorder: InputBorder.none, |
|||
contentPadding: EdgeInsets.zero, // 移除内边距 |
|||
isDense: true, |
|||
), |
|||
onSubmitted: (value) { |
|||
// 按回车确认编辑 |
|||
controller.finishEditingNickname(value); |
|||
}, |
|||
onTapOutside: (event) { |
|||
// 点击外部区域确认编辑 |
|||
controller.finishEditingNickname(nameController.text); |
|||
}, |
|||
), |
|||
), |
|||
SizedBox(width: 8.w), |
|||
// 保持原有的箭头图标位置,但在编辑模式下可以考虑隐藏或改变 |
|||
Icon( |
|||
Icons.arrow_forward_ios, |
|||
size: 16.w, |
|||
color: isDarkMode ? Colors.grey[400] : Colors.grey[600], |
|||
), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
Widget _buildLoginMethodItem(bool isDarkMode) { |
|||
return ListTile( |
|||
title: Text( |
|||
'登录方式', |
|||
style: TextStyle( |
|||
fontSize: 16.sp, |
|||
color: isDarkMode ? Colors.grey[400] : Colors.grey[600], |
|||
), |
|||
), |
|||
trailing: Icon( |
|||
Icons.arrow_forward_ios, |
|||
size: 16.w, |
|||
color: isDarkMode ? Colors.grey[400] : Colors.grey[600], |
|||
), |
|||
); |
|||
} |
|||
|
|||
Widget _buildManageLoginItem(bool isDarkMode) { |
|||
return ListTile( |
|||
title: Text( |
|||
'管理我的登录', |
|||
style: TextStyle( |
|||
fontSize: 16.sp, |
|||
color: isDarkMode ? Colors.grey[400] : Colors.grey[600], |
|||
), |
|||
), |
|||
trailing: Icon( |
|||
Icons.arrow_forward_ios, |
|||
size: 16.w, |
|||
color: isDarkMode ? Colors.grey[400] : Colors.grey[600], |
|||
), |
|||
); |
|||
} |
|||
|
|||
Widget _buildLogoutButton(bool isDarkMode) { |
|||
return SizedBox( |
|||
width: double.infinity, |
|||
height: 48.h, |
|||
child: OutlinedButton( |
|||
onPressed: () => _showLogoutDialog(isDarkMode), |
|||
style: OutlinedButton.styleFrom( |
|||
backgroundColor: isDarkMode ? Colors.grey[700] : Colors.grey[200], |
|||
side: BorderSide.none, |
|||
shape: RoundedRectangleBorder( |
|||
borderRadius: BorderRadius.circular(8.r), |
|||
), |
|||
), |
|||
child: Text( |
|||
'退出登录', |
|||
style: TextStyle( |
|||
fontSize: 16.sp, |
|||
color: isDarkMode ? Colors.white : Colors.black87, |
|||
), |
|||
), |
|||
), |
|||
); |
|||
} |
|||
|
|||
Widget _buildDeleteAccountButton(bool isDarkMode) { |
|||
return SizedBox( |
|||
width: double.infinity, |
|||
height: 48.h, |
|||
child: OutlinedButton( |
|||
onPressed: () => _showDeleteAccountDialog(isDarkMode), |
|||
style: OutlinedButton.styleFrom( |
|||
side: BorderSide.none, |
|||
backgroundColor: isDarkMode ? Colors.grey[700] : Colors.grey[200], |
|||
shape: RoundedRectangleBorder( |
|||
borderRadius: BorderRadius.circular(8.r), |
|||
), |
|||
), |
|||
child: Text( |
|||
'删除账号', |
|||
style: TextStyle( |
|||
fontSize: 16.sp, |
|||
color: Colors.red[600], |
|||
), |
|||
), |
|||
), |
|||
); |
|||
} |
|||
|
|||
Widget _buildWarningText() { |
|||
return Text.rich( |
|||
TextSpan( |
|||
children: [ |
|||
TextSpan( |
|||
text: '警告:', |
|||
style: TextStyle( |
|||
fontSize: 12.sp, // 稍微大一点的字号 |
|||
color: Colors.red[800], // 更深的红色 |
|||
//fontStyle: FontStyle.italic, // 斜体 |
|||
), |
|||
), |
|||
TextSpan( |
|||
text: '您的账号信息和所有文件将被永久删除。', |
|||
style: TextStyle( |
|||
fontSize: 11.sp, |
|||
color: Colors.grey[600], |
|||
), |
|||
), |
|||
], |
|||
), |
|||
textAlign: TextAlign.center, |
|||
); |
|||
} |
|||
|
|||
//退出登录提示窗 |
|||
void _showLogoutDialog(bool isDarkMode) { |
|||
Get.dialog( |
|||
AlertDialog( |
|||
backgroundColor: isDarkMode ? Colors.grey[900] : Colors.white, |
|||
title: Text( |
|||
'退出登录', |
|||
style: TextStyle( |
|||
color: isDarkMode ? Colors.white : Colors.black87, |
|||
), |
|||
), |
|||
content: Text( |
|||
'确定要退出当前账号吗?\n请确认所有转写任务已完成。', |
|||
style: TextStyle( |
|||
color: isDarkMode ? Colors.grey[400] : Colors.grey[600], |
|||
), |
|||
), |
|||
actions: [ |
|||
TextButton( |
|||
onPressed: () => Get.back(), |
|||
child: Text( |
|||
'cancel'.tr, |
|||
style: TextStyle( |
|||
color: isDarkMode ? Colors.grey[400] : Colors.grey[600], |
|||
), |
|||
), |
|||
), |
|||
TextButton( |
|||
onPressed: () { |
|||
Get.back(); |
|||
controller.signOut(); |
|||
}, |
|||
child: Text( |
|||
'confirm'.tr, |
|||
style: const TextStyle( |
|||
color: Colors.red, |
|||
), |
|||
), |
|||
), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
void _showDeleteAccountDialog(bool isDarkMode) { |
|||
Get.dialog( |
|||
AlertDialog( |
|||
backgroundColor: isDarkMode ? Colors.grey[900] : Colors.white, |
|||
title: Text( |
|||
'删除账号', |
|||
style: TextStyle( |
|||
color: isDarkMode ? Colors.white : Colors.black87, |
|||
), |
|||
), |
|||
content: Text( |
|||
'警告:您的账号信息和所有文件将被永久删除。', |
|||
style: TextStyle( |
|||
color: isDarkMode ? Colors.grey[400] : Colors.grey[600], |
|||
), |
|||
), |
|||
actions: [ |
|||
TextButton( |
|||
onPressed: () => Get.back(), |
|||
child: Text( |
|||
'取消', |
|||
style: TextStyle( |
|||
color: isDarkMode ? Colors.grey[400] : Colors.grey[600], |
|||
), |
|||
), |
|||
), |
|||
TextButton( |
|||
onPressed: () { |
|||
Get.back(); |
|||
controller.deleteAccount(); |
|||
}, |
|||
child: Text( |
|||
'confirm'.tr, |
|||
style: const TextStyle( |
|||
color: Colors.red, |
|||
), |
|||
), |
|||
), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
} |
|||
@ -0,0 +1,137 @@ |
|||
import 'dart:io'; |
|||
import 'package:flutter/material.dart'; |
|||
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|||
import 'package:get/get.dart'; |
|||
import '../controllers/profile_controller.dart'; |
|||
|
|||
/// 统一头像显示组件 |
|||
/// 确保所有页面的头像显示逻辑一致,避免闪烁问题 |
|||
class UnifiedAvatar extends StatelessWidget { |
|||
final double radius; |
|||
final Color? backgroundColor; |
|||
final Color? iconColor; |
|||
final double? iconSize; |
|||
final bool isDarkMode; |
|||
final VoidCallback? onTap; |
|||
|
|||
const UnifiedAvatar({ |
|||
super.key, |
|||
required this.radius, |
|||
this.backgroundColor, |
|||
this.iconColor, |
|||
this.iconSize, |
|||
this.isDarkMode = false, |
|||
this.onTap, |
|||
}); |
|||
|
|||
@override |
|||
Widget build(BuildContext context) { |
|||
final ProfileController profileCtrl = Get.find<ProfileController>(); |
|||
|
|||
return GestureDetector( |
|||
onTap: onTap, |
|||
child: Obx(() { |
|||
// 等待头像数据准备完成,避免闪烁 |
|||
if (!profileCtrl.isAvatarReady.value) { |
|||
return _buildLoadingAvatar(); |
|||
} |
|||
|
|||
return _buildAvatar(profileCtrl); |
|||
}), |
|||
); |
|||
} |
|||
|
|||
/// 构建加载中的头像占位符 |
|||
Widget _buildLoadingAvatar() { |
|||
return CircleAvatar( |
|||
radius: radius, |
|||
backgroundColor: |
|||
backgroundColor ?? (isDarkMode ? Colors.grey[800] : Colors.grey[200]), |
|||
child: Icon( |
|||
Icons.person, |
|||
size: iconSize ?? (radius * 0.6), |
|||
color: iconColor ?? (isDarkMode ? Colors.grey[400] : Colors.grey[600]), |
|||
), |
|||
); |
|||
} |
|||
|
|||
/// 构建实际头像 |
|||
Widget _buildAvatar(ProfileController profileCtrl) { |
|||
ImageProvider<Object>? imageProvider; |
|||
|
|||
// 统一头像显示优先级:本地头像 > 网络头像 > 默认图标 |
|||
if (profileCtrl.localAvatar.value.isNotEmpty) { |
|||
imageProvider = FileImage(File(profileCtrl.localAvatar.value)); |
|||
} else if (profileCtrl.userInfo.value.avatar.isNotEmpty) { |
|||
imageProvider = NetworkImage(profileCtrl.userInfo.value.avatar); |
|||
} |
|||
|
|||
return CircleAvatar( |
|||
radius: radius, |
|||
backgroundImage: imageProvider, |
|||
backgroundColor: |
|||
backgroundColor ?? (isDarkMode ? Colors.grey[800] : Colors.grey[200]), |
|||
child: (profileCtrl.localAvatar.value.isEmpty && |
|||
profileCtrl.userInfo.value.avatar.isEmpty) |
|||
? Icon( |
|||
Icons.person, |
|||
size: iconSize ?? (radius * 0.6), |
|||
color: iconColor ?? |
|||
(isDarkMode ? Colors.grey[400] : Colors.grey[600]), |
|||
) |
|||
: null, |
|||
); |
|||
} |
|||
} |
|||
|
|||
/// 带容器装饰的统一头像组件 |
|||
class UnifiedAvatarWithContainer extends StatelessWidget { |
|||
final double width; |
|||
final double height; |
|||
final double radius; |
|||
final Color? backgroundColor; |
|||
final Color? iconColor; |
|||
final double? iconSize; |
|||
final bool isDarkMode; |
|||
final VoidCallback? onTap; |
|||
final EdgeInsetsGeometry? margin; |
|||
final List<BoxShadow>? boxShadow; |
|||
|
|||
const UnifiedAvatarWithContainer({ |
|||
super.key, |
|||
required this.width, |
|||
required this.height, |
|||
required this.radius, |
|||
this.backgroundColor, |
|||
this.iconColor, |
|||
this.iconSize, |
|||
this.isDarkMode = false, |
|||
this.onTap, |
|||
this.margin, |
|||
this.boxShadow, |
|||
}); |
|||
|
|||
@override |
|||
Widget build(BuildContext context) { |
|||
return Container( |
|||
width: width, |
|||
height: height, |
|||
margin: margin, |
|||
decoration: BoxDecoration( |
|||
borderRadius: BorderRadius.circular(radius), |
|||
boxShadow: boxShadow, |
|||
), |
|||
child: ClipRRect( |
|||
borderRadius: BorderRadius.circular(radius), |
|||
child: UnifiedAvatar( |
|||
radius: radius / 2, |
|||
backgroundColor: backgroundColor, |
|||
iconColor: iconColor, |
|||
iconSize: iconSize, |
|||
isDarkMode: isDarkMode, |
|||
onTap: onTap, |
|||
), |
|||
), |
|||
); |
|||
} |
|||
} |
|||
@ -1,11 +1,11 @@ |
|||
import 'package:get/get.dart'; |
|||
import '../../profile/controllers/profile_controller.dart'; |
|||
import '../controllers/settings_controller.dart'; |
|||
|
|||
class SettingsBinding extends Bindings { |
|||
@override |
|||
void dependencies() { |
|||
Get.lazyPut<SettingsController>( |
|||
() => SettingsController(), |
|||
); |
|||
Get.lazyPut<SettingsController>(() => SettingsController()); |
|||
Get.lazyPut<ProfileController>(() => ProfileController()); |
|||
} |
|||
} |
|||
} |
|||
|
|||
@ -0,0 +1,18 @@ |
|||
import 'package:get/get.dart'; |
|||
import '../controllers/translation_stats_controller.dart'; |
|||
import '../../../data/services/translation_usage_service.dart'; |
|||
|
|||
class TranslationStatsBinding implements Bindings { |
|||
@override |
|||
void dependencies() { |
|||
// 确保服务已注册 |
|||
if (!Get.isRegistered<TranslationUsageService>()) { |
|||
Get.put(TranslationUsageService(), permanent: true); |
|||
} |
|||
|
|||
// 注册控制器 |
|||
Get.lazyPut<TranslationStatsController>( |
|||
() => TranslationStatsController(), |
|||
); |
|||
} |
|||
} |
|||
@ -0,0 +1,258 @@ |
|||
import 'package:get/get.dart'; |
|||
import 'package:flutter/material.dart'; |
|||
import '../../../data/services/translation_usage_service.dart'; |
|||
import '../../../core/utils/logger.dart'; |
|||
|
|||
/// 翻译成本配置控制器 |
|||
class TranslationCostController extends GetxController { |
|||
// 依赖注入 |
|||
final TranslationUsageService _usageService = Get.find<TranslationUsageService>(); |
|||
|
|||
// 成本配置 |
|||
final RxMap<String, double> costConfig = <String, double>{}.obs; |
|||
final RxDouble dailyCostThreshold = 10.0.obs; |
|||
final RxDouble monthlyCostThreshold = 300.0.obs; |
|||
|
|||
// 表单控制器 |
|||
final translationCostController = TextEditingController(); |
|||
final asrCostController = TextEditingController(); |
|||
final ttsCostController = TextEditingController(); |
|||
final aiSummaryCostController = TextEditingController(); |
|||
final dailyThresholdController = TextEditingController(); |
|||
final monthlyThresholdController = TextEditingController(); |
|||
|
|||
// 状态变量 |
|||
final isLoading = false.obs; |
|||
final isSaving = false.obs; |
|||
|
|||
@override |
|||
void onInit() { |
|||
super.onInit(); |
|||
_loadCurrentConfig(); |
|||
} |
|||
|
|||
@override |
|||
void onClose() { |
|||
// 释放控制器 |
|||
translationCostController.dispose(); |
|||
asrCostController.dispose(); |
|||
ttsCostController.dispose(); |
|||
aiSummaryCostController.dispose(); |
|||
dailyThresholdController.dispose(); |
|||
monthlyThresholdController.dispose(); |
|||
super.onClose(); |
|||
} |
|||
|
|||
/// 加载当前配置 |
|||
void _loadCurrentConfig() { |
|||
isLoading.value = true; |
|||
|
|||
try { |
|||
// 从服务获取当前配置 |
|||
costConfig.value = Map<String, double>.from(_usageService.costConfig); |
|||
dailyCostThreshold.value = _usageService.dailyCostThreshold.value; |
|||
monthlyCostThreshold.value = _usageService.monthlyCostThreshold.value; |
|||
|
|||
// 更新表单控制器 |
|||
translationCostController.text = (costConfig['translation_per_1k_chars'] ?? 0.05).toString(); |
|||
asrCostController.text = (costConfig['asr_per_minute'] ?? 0.02).toString(); |
|||
ttsCostController.text = (costConfig['tts_per_1k_chars'] ?? 0.03).toString(); |
|||
aiSummaryCostController.text = (costConfig['ai_summary_per_request'] ?? 0.01).toString(); |
|||
dailyThresholdController.text = dailyCostThreshold.value.toString(); |
|||
monthlyThresholdController.text = monthlyCostThreshold.value.toString(); |
|||
|
|||
Logger.info('成本配置加载完成'); |
|||
} catch (e) { |
|||
Logger.error('加载成本配置失败: $e'); |
|||
_showErrorSnackbar('加载配置失败'); |
|||
} finally { |
|||
isLoading.value = false; |
|||
} |
|||
} |
|||
|
|||
/// 保存配置 |
|||
Future<void> saveConfig() async { |
|||
if (isSaving.value) return; |
|||
|
|||
isSaving.value = true; |
|||
|
|||
try { |
|||
// 验证输入 |
|||
if (!_validateInputs()) { |
|||
return; |
|||
} |
|||
|
|||
// 更新成本配置 |
|||
final newCostConfig = { |
|||
'translation_per_1k_chars': double.parse(translationCostController.text), |
|||
'asr_per_minute': double.parse(asrCostController.text), |
|||
'tts_per_1k_chars': double.parse(ttsCostController.text), |
|||
'ai_summary_per_request': double.parse(aiSummaryCostController.text), |
|||
}; |
|||
|
|||
final newDailyThreshold = double.parse(dailyThresholdController.text); |
|||
final newMonthlyThreshold = double.parse(monthlyThresholdController.text); |
|||
|
|||
// 保存到服务 |
|||
_usageService.updateCostConfig(newCostConfig); |
|||
_usageService.updateThresholds( |
|||
dailyThreshold: newDailyThreshold, |
|||
monthlyThreshold: newMonthlyThreshold, |
|||
); |
|||
|
|||
// 更新本地状态 |
|||
costConfig.value = newCostConfig; |
|||
dailyCostThreshold.value = newDailyThreshold; |
|||
monthlyCostThreshold.value = newMonthlyThreshold; |
|||
|
|||
_showSuccessSnackbar('配置保存成功'); |
|||
Logger.info('成本配置保存成功'); |
|||
|
|||
} catch (e) { |
|||
Logger.error('保存成本配置失败: $e'); |
|||
_showErrorSnackbar('保存配置失败'); |
|||
} finally { |
|||
isSaving.value = false; |
|||
} |
|||
} |
|||
|
|||
/// 重置为默认配置 |
|||
void resetToDefault() { |
|||
translationCostController.text = '0.05'; |
|||
asrCostController.text = '0.02'; |
|||
ttsCostController.text = '0.03'; |
|||
aiSummaryCostController.text = '0.01'; |
|||
dailyThresholdController.text = '10.0'; |
|||
monthlyThresholdController.text = '300.0'; |
|||
|
|||
_showInfoSnackbar('已重置为默认配置,请点击保存生效'); |
|||
} |
|||
|
|||
/// 验证输入 |
|||
bool _validateInputs() { |
|||
try { |
|||
final translationCost = double.parse(translationCostController.text); |
|||
final asrCost = double.parse(asrCostController.text); |
|||
final ttsCost = double.parse(ttsCostController.text); |
|||
final aiSummaryCost = double.parse(aiSummaryCostController.text); |
|||
final dailyThreshold = double.parse(dailyThresholdController.text); |
|||
final monthlyThreshold = double.parse(monthlyThresholdController.text); |
|||
|
|||
if (translationCost < 0 || asrCost < 0 || ttsCost < 0 || aiSummaryCost < 0) { |
|||
_showErrorSnackbar('成本不能为负数'); |
|||
return false; |
|||
} |
|||
|
|||
if (dailyThreshold < 0 || monthlyThreshold < 0) { |
|||
_showErrorSnackbar('预警阈值不能为负数'); |
|||
return false; |
|||
} |
|||
|
|||
if (dailyThreshold > monthlyThreshold) { |
|||
_showErrorSnackbar('日预警阈值不能大于月预警阈值'); |
|||
return false; |
|||
} |
|||
|
|||
return true; |
|||
} catch (e) { |
|||
_showErrorSnackbar('请输入有效的数字'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 计算预估月成本 |
|||
double calculateEstimatedMonthlyCost() { |
|||
try { |
|||
final dailyStats = _usageService.todayStats.value; |
|||
if (dailyStats.estimatedCost == 0) return 0.0; |
|||
|
|||
// 简单按30天计算 |
|||
return dailyStats.estimatedCost * 30; |
|||
} catch (e) { |
|||
Logger.error('计算预估月成本失败: $e'); |
|||
return 0.0; |
|||
} |
|||
} |
|||
|
|||
/// 获取成本节约建议 |
|||
List<String> getCostSavingTips() { |
|||
final tips = <String>[]; |
|||
|
|||
try { |
|||
final dailyStats = _usageService.todayStats.value; |
|||
|
|||
// 基于使用情况给出建议 |
|||
if (dailyStats.translationApiCalls > 100) { |
|||
tips.add('考虑批量翻译以减少API调用次数'); |
|||
} |
|||
|
|||
if (dailyStats.ttsApiCalls > 50) { |
|||
tips.add('可以关闭自动语音播放以节省TTS成本'); |
|||
} |
|||
|
|||
if (dailyStats.aiSummaryApiCalls > 10) { |
|||
tips.add('AI摘要功能按需使用,避免频繁调用'); |
|||
} |
|||
|
|||
if (dailyStats.effectiveAudioDuration > 3600) { // 超过1小时 |
|||
tips.add('长时间录音会增加ASR成本,建议分段录音'); |
|||
} |
|||
|
|||
if (tips.isEmpty) { |
|||
tips.add('您的使用习惯很好,继续保持!'); |
|||
} |
|||
|
|||
} catch (e) { |
|||
Logger.error('获取成本节约建议失败: $e'); |
|||
tips.add('暂无建议'); |
|||
} |
|||
|
|||
return tips; |
|||
} |
|||
|
|||
/// 显示成功提示 |
|||
void _showSuccessSnackbar(String message) { |
|||
Get.snackbar( |
|||
'成功', |
|||
message, |
|||
snackPosition: SnackPosition.TOP, |
|||
backgroundColor: Get.theme.colorScheme.primary.withOpacity(0.1), |
|||
colorText: Get.theme.colorScheme.primary, |
|||
duration: const Duration(seconds: 3), |
|||
); |
|||
} |
|||
|
|||
/// 显示错误提示 |
|||
void _showErrorSnackbar(String message) { |
|||
Get.snackbar( |
|||
'错误', |
|||
message, |
|||
snackPosition: SnackPosition.TOP, |
|||
backgroundColor: Get.theme.colorScheme.error.withOpacity(0.1), |
|||
colorText: Get.theme.colorScheme.error, |
|||
duration: const Duration(seconds: 3), |
|||
); |
|||
} |
|||
|
|||
/// 显示信息提示 |
|||
void _showInfoSnackbar(String message) { |
|||
Get.snackbar( |
|||
'提示', |
|||
message, |
|||
snackPosition: SnackPosition.TOP, |
|||
backgroundColor: Get.theme.colorScheme.surface.withOpacity(0.9), |
|||
colorText: Get.theme.colorScheme.onSurface, |
|||
duration: const Duration(seconds: 3), |
|||
); |
|||
} |
|||
|
|||
/// 格式化成本显示 |
|||
String formatCost(double cost) { |
|||
return '¥${cost.toStringAsFixed(2)}'; |
|||
} |
|||
|
|||
/// 格式化百分比显示 |
|||
String formatPercentage(double percentage) { |
|||
return '${percentage.toStringAsFixed(1)}%'; |
|||
} |
|||
} |
|||
@ -0,0 +1,358 @@ |
|||
import 'package:get/get.dart'; |
|||
import 'package:flutter/material.dart'; |
|||
import '../../../data/services/translation_usage_service.dart'; |
|||
import '../../../data/models/translation_usage_stats.dart'; |
|||
import '../../../core/utils/logger.dart'; |
|||
|
|||
/// 翻译统计页面控制器 |
|||
class TranslationStatsController extends GetxController { |
|||
// 依赖注入 |
|||
final TranslationUsageService _usageService = |
|||
Get.find<TranslationUsageService>(); |
|||
|
|||
// 状态变量 |
|||
final isLoading = false.obs; |
|||
final selectedTabIndex = 0.obs; // 0=今日, 1=本周, 2=本月, 3=历史 |
|||
final selectedTimeRange = '今日'.obs; |
|||
|
|||
// 统计数据 |
|||
final Rx<TranslationUsageStats> todayStats = |
|||
TranslationUsageStats.empty('').obs; |
|||
final Rx<TranslationUsageStats> weekStats = |
|||
TranslationUsageStats.empty('').obs; |
|||
final Rx<TranslationUsageStats> monthStats = |
|||
TranslationUsageStats.empty('').obs; |
|||
final RxList<TranslationUsageStats> historyStats = |
|||
<TranslationUsageStats>[].obs; |
|||
|
|||
// 实时统计数据 |
|||
RealtimeUsageStats get realtimeStats => _usageService.realtimeStats.value; |
|||
|
|||
// 成本配置 |
|||
final RxMap<String, double> costConfig = <String, double>{}.obs; |
|||
final RxDouble dailyCostThreshold = 10.0.obs; |
|||
final RxDouble monthlyCostThreshold = 300.0.obs; |
|||
|
|||
// 图表数据 |
|||
final RxList<Map<String, dynamic>> chartData = <Map<String, dynamic>>[].obs; |
|||
|
|||
@override |
|||
void onInit() { |
|||
super.onInit(); |
|||
_initializeData(); |
|||
} |
|||
|
|||
/// 初始化数据 |
|||
void _initializeData() { |
|||
// 监听使用服务的数据变化 |
|||
ever(_usageService.todayStats, (stats) { |
|||
todayStats.value = stats; |
|||
_updateChartData(); |
|||
}); |
|||
|
|||
ever(_usageService.costConfig, (config) { |
|||
costConfig.value = config; |
|||
}); |
|||
|
|||
ever(_usageService.dailyCostThreshold, (threshold) { |
|||
dailyCostThreshold.value = threshold; |
|||
}); |
|||
|
|||
ever(_usageService.monthlyCostThreshold, (threshold) { |
|||
monthlyCostThreshold.value = threshold; |
|||
}); |
|||
|
|||
// 初始加载数据 |
|||
loadAllStats(); |
|||
} |
|||
|
|||
/// 加载所有统计数据 |
|||
Future<void> loadAllStats() async { |
|||
isLoading.value = true; |
|||
|
|||
try { |
|||
// 加载今日数据 |
|||
todayStats.value = _usageService.todayStats.value; |
|||
|
|||
// 加载本周数据 |
|||
await _loadWeekStats(); |
|||
|
|||
// 加载本月数据 |
|||
await _loadMonthStats(); |
|||
|
|||
// 加载历史数据(最近7天) |
|||
await _loadHistoryStats(); |
|||
|
|||
// 更新图表数据 |
|||
_updateChartData(); |
|||
|
|||
Logger.info('统计数据加载完成'); |
|||
} catch (e) { |
|||
Logger.error('加载统计数据失败: $e'); |
|||
Get.snackbar( |
|||
'加载失败', |
|||
'统计数据加载失败,请稍后重试', |
|||
snackPosition: SnackPosition.TOP, |
|||
backgroundColor: Get.theme.colorScheme.error.withOpacity(0.1), |
|||
colorText: Get.theme.colorScheme.error, |
|||
); |
|||
} finally { |
|||
isLoading.value = false; |
|||
} |
|||
} |
|||
|
|||
/// 加载本周统计数据 |
|||
Future<void> _loadWeekStats() async { |
|||
try { |
|||
final now = DateTime.now(); |
|||
final weekStart = now.subtract(Duration(days: now.weekday - 1)); |
|||
final weekEnd = weekStart.add(const Duration(days: 6)); |
|||
|
|||
final dailyStats = await _usageService.getHistoryStats( |
|||
startDate: weekStart, |
|||
endDate: weekEnd, |
|||
); |
|||
|
|||
weekStats.value = _aggregateStats(dailyStats, 'week'); |
|||
} catch (e) { |
|||
Logger.error('加载本周统计失败: $e'); |
|||
} |
|||
} |
|||
|
|||
/// 加载本月统计数据 |
|||
Future<void> _loadMonthStats() async { |
|||
try { |
|||
final now = DateTime.now(); |
|||
monthStats.value = |
|||
await _usageService.getMonthlyStats(now.year, now.month); |
|||
} catch (e) { |
|||
Logger.error('加载本月统计失败: $e'); |
|||
} |
|||
} |
|||
|
|||
/// 加载历史统计数据 |
|||
Future<void> _loadHistoryStats() async { |
|||
try { |
|||
final endDate = DateTime.now(); |
|||
final startDate = endDate.subtract(const Duration(days: 7)); |
|||
|
|||
historyStats.value = await _usageService.getHistoryStats( |
|||
startDate: startDate, |
|||
endDate: endDate, |
|||
); |
|||
|
|||
// 按日期排序 |
|||
historyStats.sort((a, b) => b.date.compareTo(a.date)); |
|||
} catch (e) { |
|||
Logger.error('加载历史统计失败: $e'); |
|||
} |
|||
} |
|||
|
|||
/// 聚合统计数据 |
|||
TranslationUsageStats _aggregateStats( |
|||
List<TranslationUsageStats> statsList, String period) { |
|||
if (statsList.isEmpty) { |
|||
return TranslationUsageStats.empty(_usageService.getCurrentUserId()); |
|||
} |
|||
|
|||
int totalTranslationCalls = 0; |
|||
int totalAsrCalls = 0; |
|||
int totalTtsCalls = 0; |
|||
int totalAiSummaryCalls = 0; |
|||
int totalSourceChars = 0; |
|||
int totalTargetChars = 0; |
|||
int totalRecordingDuration = 0; |
|||
int totalTtsPlayDuration = 0; |
|||
int totalSessions = 0; |
|||
double totalCost = 0.0; |
|||
|
|||
final Map<String, int> combinedLanguagePairUsage = {}; |
|||
final Map<String, int> combinedModeUsage = {}; |
|||
|
|||
for (final stats in statsList) { |
|||
totalTranslationCalls += stats.translationApiCalls; |
|||
totalAsrCalls += stats.asrApiCalls; |
|||
totalTtsCalls += stats.ttsApiCalls; |
|||
totalAiSummaryCalls += stats.aiSummaryApiCalls; |
|||
totalSourceChars += stats.sourceTextCharacters; |
|||
totalTargetChars += stats.ttsTextCharacters; |
|||
totalRecordingDuration += stats.effectiveAudioDuration; |
|||
totalTtsPlayDuration += stats.totalSessionDuration; |
|||
totalSessions += stats.sessionCount; |
|||
totalCost += stats.estimatedCost; |
|||
|
|||
// 合并语言对使用统计(使用兼容性getter) |
|||
stats.languagePairUsage.forEach((key, value) { |
|||
combinedLanguagePairUsage[key] = |
|||
(combinedLanguagePairUsage[key] ?? 0) + value; |
|||
}); |
|||
|
|||
// 合并模式使用统计(使用兼容性getter) |
|||
stats.modeUsage.forEach((key, value) { |
|||
combinedModeUsage[key] = (combinedModeUsage[key] ?? 0) + value; |
|||
}); |
|||
} |
|||
|
|||
return TranslationUsageStats( |
|||
id: '${period}_${DateTime.now().millisecondsSinceEpoch}', |
|||
date: DateTime.now(), |
|||
userId: 'current_user', |
|||
timeGranularity: period, |
|||
translationApiCalls: totalTranslationCalls, |
|||
asrApiCalls: totalAsrCalls, |
|||
ttsApiCalls: totalTtsCalls, |
|||
aiSummaryApiCalls: totalAiSummaryCalls, |
|||
sourceTextCharacters: totalSourceChars, |
|||
ttsTextCharacters: totalTargetChars, |
|||
effectiveAudioDuration: totalRecordingDuration, |
|||
totalSessionDuration: totalTtsPlayDuration, |
|||
sessionCount: totalSessions, |
|||
estimatedCost: totalCost, |
|||
); |
|||
} |
|||
|
|||
/// 更新图表数据 |
|||
void _updateChartData() { |
|||
final List<Map<String, dynamic>> data = []; |
|||
|
|||
// 根据选中的时间范围生成图表数据 |
|||
switch (selectedTabIndex.value) { |
|||
case 0: // 今日 |
|||
data.add({ |
|||
'label': '今日', |
|||
'apiCalls': todayStats.value.totalApiCalls, |
|||
'cost': todayStats.value.estimatedCost, |
|||
'characters': todayStats.value.totalCharacterCount, |
|||
}); |
|||
break; |
|||
case 1: // 本周 |
|||
data.add({ |
|||
'label': '本周', |
|||
'apiCalls': weekStats.value.totalApiCalls, |
|||
'cost': weekStats.value.estimatedCost, |
|||
'characters': weekStats.value.totalCharacterCount, |
|||
}); |
|||
break; |
|||
case 2: // 本月 |
|||
data.add({ |
|||
'label': '本月', |
|||
'apiCalls': monthStats.value.totalApiCalls, |
|||
'cost': monthStats.value.estimatedCost, |
|||
'characters': monthStats.value.totalCharacterCount, |
|||
}); |
|||
break; |
|||
case 3: // 历史 |
|||
for (final stats in historyStats.take(7)) { |
|||
data.add({ |
|||
'label': '${stats.date.month}/${stats.date.day}', |
|||
'apiCalls': stats.totalApiCalls, |
|||
'cost': stats.estimatedCost, |
|||
'characters': stats.totalCharacterCount, |
|||
}); |
|||
} |
|||
break; |
|||
} |
|||
|
|||
chartData.value = data; |
|||
} |
|||
|
|||
/// 切换时间范围 |
|||
void switchTimeRange(int index) { |
|||
selectedTabIndex.value = index; |
|||
|
|||
switch (index) { |
|||
case 0: |
|||
selectedTimeRange.value = '今日'; |
|||
break; |
|||
case 1: |
|||
selectedTimeRange.value = '本周'; |
|||
break; |
|||
case 2: |
|||
selectedTimeRange.value = '本月'; |
|||
break; |
|||
case 3: |
|||
selectedTimeRange.value = '历史'; |
|||
break; |
|||
} |
|||
|
|||
_updateChartData(); |
|||
} |
|||
|
|||
/// 获取当前显示的统计数据 |
|||
TranslationUsageStats get currentStats { |
|||
switch (selectedTabIndex.value) { |
|||
case 0: |
|||
return todayStats.value; |
|||
case 1: |
|||
return weekStats.value; |
|||
case 2: |
|||
return monthStats.value; |
|||
default: |
|||
return todayStats.value; |
|||
} |
|||
} |
|||
|
|||
/// 刷新数据 |
|||
Future<void> refreshData() async { |
|||
await loadAllStats(); |
|||
} |
|||
|
|||
/// 清理过期数据 |
|||
void cleanupOldData() { |
|||
_usageService.cleanupOldData(); |
|||
Get.snackbar( |
|||
'清理完成', |
|||
'已清理90天前的过期数据', |
|||
snackPosition: SnackPosition.TOP, |
|||
backgroundColor: Get.theme.colorScheme.primary.withOpacity(0.1), |
|||
colorText: Get.theme.colorScheme.primary, |
|||
); |
|||
} |
|||
|
|||
/// 导出统计数据 |
|||
Future<void> exportStats() async { |
|||
try { |
|||
// TODO: 实现数据导出功能 |
|||
Get.snackbar( |
|||
'导出功能', |
|||
'数据导出功能正在开发中', |
|||
snackPosition: SnackPosition.TOP, |
|||
backgroundColor: Get.theme.colorScheme.primary.withOpacity(0.1), |
|||
colorText: Get.theme.colorScheme.primary, |
|||
); |
|||
} catch (e) { |
|||
Logger.error('导出统计数据失败: $e'); |
|||
} |
|||
} |
|||
|
|||
/// 格式化时长显示 |
|||
String formatDuration(int seconds) { |
|||
if (seconds < 60) { |
|||
return '${seconds}秒'; |
|||
} else if (seconds < 3600) { |
|||
final minutes = seconds ~/ 60; |
|||
final remainingSeconds = seconds % 60; |
|||
return '${minutes}分${remainingSeconds}秒'; |
|||
} else { |
|||
final hours = seconds ~/ 3600; |
|||
final minutes = (seconds % 3600) ~/ 60; |
|||
return '${hours}小时${minutes}分'; |
|||
} |
|||
} |
|||
|
|||
/// 格式化成本显示 |
|||
String formatCost(double cost) { |
|||
return '¥${cost.toStringAsFixed(2)}'; |
|||
} |
|||
|
|||
/// 格式化数字显示 |
|||
String formatNumber(int number) { |
|||
if (number < 1000) { |
|||
return number.toString(); |
|||
} else if (number < 1000000) { |
|||
return '${(number / 1000).toStringAsFixed(1)}K'; |
|||
} else { |
|||
return '${(number / 1000000).toStringAsFixed(1)}M'; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,757 @@ |
|||
import 'package:flutter/material.dart'; |
|||
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|||
import 'package:get/get.dart'; |
|||
import '../controllers/translation_stats_controller.dart'; |
|||
import '../../../core/theme/app_colors.dart'; |
|||
|
|||
/// 翻译统计页面 |
|||
class TranslationStatsView extends GetView<TranslationStatsController> { |
|||
const TranslationStatsView({Key? key}) : super(key: key); |
|||
|
|||
@override |
|||
Widget build(BuildContext context) { |
|||
final isDarkMode = Theme.of(context).brightness == Brightness.dark; |
|||
|
|||
return Scaffold( |
|||
backgroundColor: isDarkMode ? AppColors.darkBackground : Colors.grey[50], |
|||
appBar: _buildAppBar(isDarkMode), |
|||
body: Obx(() => controller.isLoading.value |
|||
? _buildLoadingWidget() |
|||
: _buildBody(isDarkMode)), |
|||
); |
|||
} |
|||
|
|||
/// 构建应用栏 |
|||
PreferredSizeWidget _buildAppBar(bool isDarkMode) { |
|||
return AppBar( |
|||
backgroundColor: isDarkMode ? AppColors.darkBackground : Colors.white, |
|||
elevation: 0.5, |
|||
title: Text( |
|||
'翻译统计', // translationStats |
|||
style: TextStyle( |
|||
fontSize: 17.sp, |
|||
fontWeight: FontWeight.w600, |
|||
color: isDarkMode ? Colors.white : Colors.black, |
|||
), |
|||
), |
|||
centerTitle: true, |
|||
actions: [ |
|||
// 刷新按钮 |
|||
IconButton( |
|||
icon: Icon( |
|||
Icons.refresh, |
|||
color: isDarkMode ? Colors.white : Colors.black, |
|||
), |
|||
onPressed: controller.refreshData, |
|||
), |
|||
// 更多操作菜单 |
|||
PopupMenuButton<String>( |
|||
icon: Icon( |
|||
Icons.more_vert, |
|||
color: isDarkMode ? Colors.white : Colors.black, |
|||
), |
|||
onSelected: (value) { |
|||
switch (value) { |
|||
case 'cleanup': |
|||
controller.cleanupOldData(); |
|||
break; |
|||
case 'export': |
|||
controller.exportStats(); |
|||
break; |
|||
} |
|||
}, |
|||
itemBuilder: (context) => [ |
|||
const PopupMenuItem( |
|||
value: 'cleanup', |
|||
child: Text('清理过期数据'), |
|||
), |
|||
const PopupMenuItem( |
|||
value: 'export', |
|||
child: Text('导出数据'), |
|||
), |
|||
], |
|||
), |
|||
], |
|||
); |
|||
} |
|||
|
|||
/// 构建加载组件 |
|||
Widget _buildLoadingWidget() { |
|||
return const Center( |
|||
child: CircularProgressIndicator(), |
|||
); |
|||
} |
|||
|
|||
/// 构建主体内容 |
|||
Widget _buildBody(bool isDarkMode) { |
|||
return Column( |
|||
children: [ |
|||
// 时间范围选择器 |
|||
_buildTimeRangeSelector(isDarkMode), |
|||
// 统计内容 |
|||
Expanded( |
|||
child: SingleChildScrollView( |
|||
padding: EdgeInsets.all(16.w), |
|||
child: Column( |
|||
children: [ |
|||
// 实时统计卡片 |
|||
if (controller.selectedTabIndex.value == 0) |
|||
_buildRealtimeStatsCard(isDarkMode), |
|||
SizedBox(height: 16.h), |
|||
// 概览统计卡片 |
|||
_buildOverviewCard(isDarkMode), |
|||
SizedBox(height: 16.h), |
|||
// API调用统计 |
|||
_buildApiCallsCard(isDarkMode), |
|||
SizedBox(height: 16.h), |
|||
// 数据量统计 |
|||
_buildDataVolumeCard(isDarkMode), |
|||
SizedBox(height: 16.h), |
|||
// 成本统计 |
|||
_buildCostCard(isDarkMode), |
|||
SizedBox(height: 16.h), |
|||
// 使用行为统计 |
|||
_buildUsageBehaviorCard(isDarkMode), |
|||
], |
|||
), |
|||
), |
|||
), |
|||
], |
|||
); |
|||
} |
|||
|
|||
/// 构建时间范围选择器 |
|||
Widget _buildTimeRangeSelector(bool isDarkMode) { |
|||
return Container( |
|||
margin: EdgeInsets.all(16.w), |
|||
decoration: BoxDecoration( |
|||
color: isDarkMode ? AppColors.cardBackground : Colors.white, |
|||
borderRadius: BorderRadius.circular(12.r), |
|||
boxShadow: [ |
|||
BoxShadow( |
|||
color: Colors.black.withOpacity(0.05), |
|||
blurRadius: 10, |
|||
offset: const Offset(0, 2), |
|||
), |
|||
], |
|||
), |
|||
child: Obx(() => Row( |
|||
children: [ |
|||
_buildTimeRangeTab('今日', 0, isDarkMode), |
|||
_buildTimeRangeTab('本周', 1, isDarkMode), |
|||
_buildTimeRangeTab('本月', 2, isDarkMode), |
|||
_buildTimeRangeTab('历史', 3, isDarkMode), |
|||
], |
|||
)), |
|||
); |
|||
} |
|||
|
|||
/// 构建时间范围标签 |
|||
Widget _buildTimeRangeTab(String title, int index, bool isDarkMode) { |
|||
final isSelected = controller.selectedTabIndex.value == index; |
|||
|
|||
return Expanded( |
|||
child: GestureDetector( |
|||
onTap: () => controller.switchTimeRange(index), |
|||
child: Container( |
|||
padding: EdgeInsets.symmetric(vertical: 12.h), |
|||
decoration: BoxDecoration( |
|||
color: isSelected |
|||
? (isDarkMode ? AppColors.primary : AppColors.primary) |
|||
: Colors.transparent, |
|||
borderRadius: BorderRadius.circular(12.r), |
|||
), |
|||
child: Text( |
|||
title, |
|||
textAlign: TextAlign.center, |
|||
style: TextStyle( |
|||
fontSize: 14.sp, |
|||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, |
|||
color: isSelected |
|||
? Colors.white |
|||
: (isDarkMode ? Colors.white70 : Colors.black54), |
|||
), |
|||
), |
|||
), |
|||
), |
|||
); |
|||
} |
|||
|
|||
/// 构建实时统计卡片 |
|||
Widget _buildRealtimeStatsCard(bool isDarkMode) { |
|||
return Container( |
|||
padding: EdgeInsets.all(16.w), |
|||
decoration: BoxDecoration( |
|||
color: isDarkMode ? AppColors.cardBackground : Colors.white, |
|||
borderRadius: BorderRadius.circular(12.r), |
|||
boxShadow: [ |
|||
BoxShadow( |
|||
color: Colors.black.withOpacity(0.05), |
|||
blurRadius: 10, |
|||
offset: const Offset(0, 2), |
|||
), |
|||
], |
|||
), |
|||
child: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Row( |
|||
children: [ |
|||
Icon( |
|||
Icons.access_time, |
|||
color: AppColors.primary, |
|||
size: 20.sp, |
|||
), |
|||
SizedBox(width: 8.w), |
|||
Text( |
|||
'当前会话统计', |
|||
style: TextStyle( |
|||
fontSize: 16.sp, |
|||
fontWeight: FontWeight.w600, |
|||
color: isDarkMode ? Colors.white : Colors.black, |
|||
), |
|||
), |
|||
], |
|||
), |
|||
SizedBox(height: 16.h), |
|||
Obx(() => Row( |
|||
children: [ |
|||
Expanded( |
|||
child: _buildStatItem( |
|||
'翻译次数', |
|||
controller.realtimeStats.translationCount.toString(), |
|||
Icons.translate, |
|||
isDarkMode, |
|||
), |
|||
), |
|||
Expanded( |
|||
child: _buildStatItem( |
|||
'会话时长', |
|||
'${controller.realtimeStats.sessionDurationMinutes}分钟', |
|||
Icons.timer, |
|||
isDarkMode, |
|||
), |
|||
), |
|||
], |
|||
)), |
|||
SizedBox(height: 12.h), |
|||
Obx(() => Row( |
|||
children: [ |
|||
Expanded( |
|||
child: _buildStatItem( |
|||
'字符数', |
|||
controller.formatNumber( |
|||
controller.realtimeStats.sourceCharacters + |
|||
controller.realtimeStats.targetCharacters, |
|||
), |
|||
Icons.text_fields, |
|||
isDarkMode, |
|||
), |
|||
), |
|||
Expanded( |
|||
child: _buildStatItem( |
|||
'录音时长', |
|||
controller.formatDuration(controller.realtimeStats.recordingDuration), |
|||
Icons.mic, |
|||
isDarkMode, |
|||
), |
|||
), |
|||
], |
|||
)), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
/// 构建概览卡片 |
|||
Widget _buildOverviewCard(bool isDarkMode) { |
|||
return Container( |
|||
padding: EdgeInsets.all(16.w), |
|||
decoration: BoxDecoration( |
|||
color: isDarkMode ? AppColors.cardBackground : Colors.white, |
|||
borderRadius: BorderRadius.circular(12.r), |
|||
boxShadow: [ |
|||
BoxShadow( |
|||
color: Colors.black.withOpacity(0.05), |
|||
blurRadius: 10, |
|||
offset: const Offset(0, 2), |
|||
), |
|||
], |
|||
), |
|||
child: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Row( |
|||
children: [ |
|||
Icon( |
|||
Icons.dashboard, |
|||
color: AppColors.primary, |
|||
size: 20.sp, |
|||
), |
|||
SizedBox(width: 8.w), |
|||
Obx(() => Text( |
|||
'${controller.selectedTimeRange.value}概览', |
|||
style: TextStyle( |
|||
fontSize: 16.sp, |
|||
fontWeight: FontWeight.w600, |
|||
color: isDarkMode ? Colors.white : Colors.black, |
|||
), |
|||
)), |
|||
], |
|||
), |
|||
SizedBox(height: 16.h), |
|||
Obx(() => Row( |
|||
children: [ |
|||
Expanded( |
|||
child: _buildStatItem( |
|||
'总调用次数', |
|||
controller.formatNumber(controller.currentStats.totalApiCalls), |
|||
Icons.api, |
|||
isDarkMode, |
|||
), |
|||
), |
|||
Expanded( |
|||
child: _buildStatItem( |
|||
'预估成本', |
|||
controller.formatCost(controller.currentStats.estimatedCost), |
|||
Icons.attach_money, |
|||
isDarkMode, |
|||
), |
|||
), |
|||
], |
|||
)), |
|||
SizedBox(height: 12.h), |
|||
Obx(() => Row( |
|||
children: [ |
|||
Expanded( |
|||
child: _buildStatItem( |
|||
'会话数量', |
|||
controller.currentStats.sessionCount.toString(), |
|||
Icons.chat, |
|||
isDarkMode, |
|||
), |
|||
), |
|||
Expanded( |
|||
child: _buildStatItem( |
|||
'总字符数', |
|||
controller.formatNumber(controller.currentStats.totalCharacterCount), |
|||
Icons.text_snippet, |
|||
isDarkMode, |
|||
), |
|||
), |
|||
], |
|||
)), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
/// 构建统计项 |
|||
Widget _buildStatItem(String label, String value, IconData icon, bool isDarkMode) { |
|||
return Column( |
|||
children: [ |
|||
Icon( |
|||
icon, |
|||
color: AppColors.primary, |
|||
size: 24.sp, |
|||
), |
|||
SizedBox(height: 8.h), |
|||
Text( |
|||
value, |
|||
style: TextStyle( |
|||
fontSize: 18.sp, |
|||
fontWeight: FontWeight.bold, |
|||
color: isDarkMode ? Colors.white : Colors.black, |
|||
), |
|||
), |
|||
SizedBox(height: 4.h), |
|||
Text( |
|||
label, |
|||
style: TextStyle( |
|||
fontSize: 12.sp, |
|||
color: isDarkMode ? Colors.white70 : Colors.black54, |
|||
), |
|||
), |
|||
], |
|||
); |
|||
} |
|||
|
|||
/// 构建API调用统计卡片 |
|||
Widget _buildApiCallsCard(bool isDarkMode) { |
|||
return Container( |
|||
padding: EdgeInsets.all(16.w), |
|||
decoration: BoxDecoration( |
|||
color: isDarkMode ? AppColors.cardBackground : Colors.white, |
|||
borderRadius: BorderRadius.circular(12.r), |
|||
boxShadow: [ |
|||
BoxShadow( |
|||
color: Colors.black.withOpacity(0.05), |
|||
blurRadius: 10, |
|||
offset: const Offset(0, 2), |
|||
), |
|||
], |
|||
), |
|||
child: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Row( |
|||
children: [ |
|||
Icon( |
|||
Icons.api, |
|||
color: AppColors.primary, |
|||
size: 20.sp, |
|||
), |
|||
SizedBox(width: 8.w), |
|||
Text( |
|||
'API调用统计', |
|||
style: TextStyle( |
|||
fontSize: 16.sp, |
|||
fontWeight: FontWeight.w600, |
|||
color: isDarkMode ? Colors.white : Colors.black, |
|||
), |
|||
), |
|||
], |
|||
), |
|||
SizedBox(height: 16.h), |
|||
Obx(() => Column( |
|||
children: [ |
|||
_buildApiCallRow('翻译API', controller.currentStats.translationApiCalls, Icons.translate, isDarkMode), |
|||
SizedBox(height: 12.h), |
|||
_buildApiCallRow('语音识别', controller.currentStats.asrApiCalls, Icons.mic, isDarkMode), |
|||
SizedBox(height: 12.h), |
|||
_buildApiCallRow('语音合成', controller.currentStats.ttsApiCalls, Icons.volume_up, isDarkMode), |
|||
SizedBox(height: 12.h), |
|||
_buildApiCallRow('AI摘要', controller.currentStats.aiSummaryApiCalls, Icons.summarize, isDarkMode), |
|||
], |
|||
)), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
/// 构建API调用行 |
|||
Widget _buildApiCallRow(String label, int count, IconData icon, bool isDarkMode) { |
|||
return Row( |
|||
children: [ |
|||
Icon( |
|||
icon, |
|||
color: AppColors.primary, |
|||
size: 18.sp, |
|||
), |
|||
SizedBox(width: 12.w), |
|||
Expanded( |
|||
child: Text( |
|||
label, |
|||
style: TextStyle( |
|||
fontSize: 14.sp, |
|||
color: isDarkMode ? Colors.white : Colors.black, |
|||
), |
|||
), |
|||
), |
|||
Text( |
|||
controller.formatNumber(count), |
|||
style: TextStyle( |
|||
fontSize: 14.sp, |
|||
fontWeight: FontWeight.w600, |
|||
color: isDarkMode ? Colors.white : Colors.black, |
|||
), |
|||
), |
|||
], |
|||
); |
|||
} |
|||
|
|||
/// 构建数据量统计卡片 |
|||
Widget _buildDataVolumeCard(bool isDarkMode) { |
|||
return Container( |
|||
padding: EdgeInsets.all(16.w), |
|||
decoration: BoxDecoration( |
|||
color: isDarkMode ? AppColors.cardBackground : Colors.white, |
|||
borderRadius: BorderRadius.circular(12.r), |
|||
boxShadow: [ |
|||
BoxShadow( |
|||
color: Colors.black.withOpacity(0.05), |
|||
blurRadius: 10, |
|||
offset: const Offset(0, 2), |
|||
), |
|||
], |
|||
), |
|||
child: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Row( |
|||
children: [ |
|||
Icon( |
|||
Icons.data_usage, |
|||
color: AppColors.primary, |
|||
size: 20.sp, |
|||
), |
|||
SizedBox(width: 8.w), |
|||
Text( |
|||
'数据量统计', |
|||
style: TextStyle( |
|||
fontSize: 16.sp, |
|||
fontWeight: FontWeight.w600, |
|||
color: isDarkMode ? Colors.white : Colors.black, |
|||
), |
|||
), |
|||
], |
|||
), |
|||
SizedBox(height: 16.h), |
|||
Obx(() => Column( |
|||
children: [ |
|||
Row( |
|||
children: [ |
|||
Expanded( |
|||
child: _buildStatItem( |
|||
'源文本字符', |
|||
controller.formatNumber(controller.currentStats.sourceTextCharacters), |
|||
Icons.text_fields, |
|||
isDarkMode, |
|||
), |
|||
), |
|||
Expanded( |
|||
child: _buildStatItem( |
|||
'TTS文本字符', |
|||
controller.formatNumber(controller.currentStats.ttsTextCharacters), |
|||
Icons.translate, |
|||
isDarkMode, |
|||
), |
|||
), |
|||
], |
|||
), |
|||
SizedBox(height: 16.h), |
|||
Row( |
|||
children: [ |
|||
Expanded( |
|||
child: _buildStatItem( |
|||
'有效音频时长', |
|||
controller.formatDuration(controller.currentStats.effectiveAudioDuration), |
|||
Icons.mic, |
|||
isDarkMode, |
|||
), |
|||
), |
|||
Expanded( |
|||
child: _buildStatItem( |
|||
'总会话时长', |
|||
controller.formatDuration(controller.currentStats.totalSessionDuration), |
|||
Icons.volume_up, |
|||
isDarkMode, |
|||
), |
|||
), |
|||
], |
|||
), |
|||
], |
|||
)), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
/// 构建成本统计卡片 |
|||
Widget _buildCostCard(bool isDarkMode) { |
|||
return Container( |
|||
padding: EdgeInsets.all(16.w), |
|||
decoration: BoxDecoration( |
|||
color: isDarkMode ? AppColors.cardBackground : Colors.white, |
|||
borderRadius: BorderRadius.circular(12.r), |
|||
boxShadow: [ |
|||
BoxShadow( |
|||
color: Colors.black.withOpacity(0.05), |
|||
blurRadius: 10, |
|||
offset: const Offset(0, 2), |
|||
), |
|||
], |
|||
), |
|||
child: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Row( |
|||
children: [ |
|||
Icon( |
|||
Icons.attach_money, |
|||
color: AppColors.primary, |
|||
size: 20.sp, |
|||
), |
|||
SizedBox(width: 8.w), |
|||
Text( |
|||
'成本统计', |
|||
style: TextStyle( |
|||
fontSize: 16.sp, |
|||
fontWeight: FontWeight.w600, |
|||
color: isDarkMode ? Colors.white : Colors.black, |
|||
), |
|||
), |
|||
], |
|||
), |
|||
SizedBox(height: 16.h), |
|||
Obx(() => Column( |
|||
children: [ |
|||
// 当前成本 |
|||
Container( |
|||
padding: EdgeInsets.all(12.w), |
|||
decoration: BoxDecoration( |
|||
color: AppColors.primary.withOpacity(0.1), |
|||
borderRadius: BorderRadius.circular(8.r), |
|||
), |
|||
child: Row( |
|||
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
|||
children: [ |
|||
Text( |
|||
'${controller.selectedTimeRange.value}预估成本', |
|||
style: TextStyle( |
|||
fontSize: 14.sp, |
|||
fontWeight: FontWeight.w500, |
|||
color: isDarkMode ? Colors.white : Colors.black, |
|||
), |
|||
), |
|||
Text( |
|||
controller.formatCost(controller.currentStats.estimatedCost), |
|||
style: TextStyle( |
|||
fontSize: 18.sp, |
|||
fontWeight: FontWeight.bold, |
|||
color: AppColors.primary, |
|||
), |
|||
), |
|||
], |
|||
), |
|||
), |
|||
SizedBox(height: 12.h), |
|||
// 预警阈值 |
|||
Row( |
|||
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
|||
children: [ |
|||
Text( |
|||
'日成本预警阈值', |
|||
style: TextStyle( |
|||
fontSize: 12.sp, |
|||
color: isDarkMode ? Colors.white70 : Colors.black54, |
|||
), |
|||
), |
|||
Text( |
|||
controller.formatCost(controller.dailyCostThreshold.value), |
|||
style: TextStyle( |
|||
fontSize: 12.sp, |
|||
color: isDarkMode ? Colors.white70 : Colors.black54, |
|||
), |
|||
), |
|||
], |
|||
), |
|||
SizedBox(height: 4.h), |
|||
Row( |
|||
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
|||
children: [ |
|||
Text( |
|||
'月成本预警阈值', |
|||
style: TextStyle( |
|||
fontSize: 12.sp, |
|||
color: isDarkMode ? Colors.white70 : Colors.black54, |
|||
), |
|||
), |
|||
Text( |
|||
controller.formatCost(controller.monthlyCostThreshold.value), |
|||
style: TextStyle( |
|||
fontSize: 12.sp, |
|||
color: isDarkMode ? Colors.white70 : Colors.black54, |
|||
), |
|||
), |
|||
], |
|||
), |
|||
], |
|||
)), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
/// 构建使用行为统计卡片 |
|||
Widget _buildUsageBehaviorCard(bool isDarkMode) { |
|||
return Container( |
|||
padding: EdgeInsets.all(16.w), |
|||
decoration: BoxDecoration( |
|||
color: isDarkMode ? AppColors.cardBackground : Colors.white, |
|||
borderRadius: BorderRadius.circular(12.r), |
|||
boxShadow: [ |
|||
BoxShadow( |
|||
color: Colors.black.withOpacity(0.05), |
|||
blurRadius: 10, |
|||
offset: const Offset(0, 2), |
|||
), |
|||
], |
|||
), |
|||
child: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Row( |
|||
children: [ |
|||
Icon( |
|||
Icons.analytics, |
|||
color: AppColors.primary, |
|||
size: 20.sp, |
|||
), |
|||
SizedBox(width: 8.w), |
|||
Text( |
|||
'使用行为统计', |
|||
style: TextStyle( |
|||
fontSize: 16.sp, |
|||
fontWeight: FontWeight.w600, |
|||
color: isDarkMode ? Colors.white : Colors.black, |
|||
), |
|||
), |
|||
], |
|||
), |
|||
SizedBox(height: 16.h), |
|||
Obx(() => Column( |
|||
children: [ |
|||
// 最常用语言对 |
|||
_buildUsageBehaviorRow( |
|||
'最常用语言对', |
|||
controller.currentStats.mostUsedLanguagePair, |
|||
Icons.language, |
|||
isDarkMode, |
|||
), |
|||
SizedBox(height: 12.h), |
|||
// 最常用翻译模式 |
|||
_buildUsageBehaviorRow( |
|||
'最常用模式', |
|||
controller.currentStats.mostUsedMode, |
|||
Icons.settings, |
|||
isDarkMode, |
|||
), |
|||
], |
|||
)), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
/// 构建使用行为行 |
|||
Widget _buildUsageBehaviorRow(String label, String value, IconData icon, bool isDarkMode) { |
|||
return Row( |
|||
children: [ |
|||
Icon( |
|||
icon, |
|||
color: AppColors.primary, |
|||
size: 18.sp, |
|||
), |
|||
SizedBox(width: 12.w), |
|||
Expanded( |
|||
child: Text( |
|||
label, |
|||
style: TextStyle( |
|||
fontSize: 14.sp, |
|||
color: isDarkMode ? Colors.white : Colors.black, |
|||
), |
|||
), |
|||
), |
|||
Text( |
|||
value, |
|||
style: TextStyle( |
|||
fontSize: 14.sp, |
|||
fontWeight: FontWeight.w600, |
|||
color: isDarkMode ? Colors.white : Colors.black, |
|||
), |
|||
), |
|||
], |
|||
); |
|||
} |
|||
} |
|||
Loading…
Reference in new issue