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 'package:get/get.dart'; |
||||
import '../controllers/meeting_controller.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 { |
class MeetingBinding extends Bindings { |
||||
@override |
@override |
||||
void dependencies() { |
void dependencies() { |
||||
// 注册控制器 |
// 注册控制器 |
||||
Get.lazyPut<MeetingController>(() => MeetingController()); |
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/material.dart'; |
||||
import 'package:flutter_easyloading/flutter_easyloading.dart'; |
|
||||
import 'package:get/get.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 '../views/meeting_home_view.dart'; |
||||
|
import '../views/meeting_mine_view.dart'; |
||||
import '../../../data/services/meeting/meeting_task_service.dart'; |
|
||||
import '../../../data/services/meeting/meeting_upload_service.dart'; |
|
||||
import '../../../routes/app_routes.dart'; |
|
||||
|
|
||||
class MeetingController extends GetxController { |
class MeetingController extends GetxController { |
||||
final meetingUploadService = Get.find<MeetingUploadService>(); |
final GlobalKey<ScaffoldState> scaffoldKey = GlobalKey<ScaffoldState>(); |
||||
final meetingTaskService = Get.find<MeetingTaskService>(); |
|
||||
|
|
||||
final ScrollController listController = ScrollController(); |
|
||||
final AudioPlayer _audioPlayer = AudioPlayer(); |
|
||||
|
|
||||
// 是否还有更多数据 |
late PageController pageController; |
||||
bool isGetMore = true; |
|
||||
bool isLoading = false; |
|
||||
int page = 1; |
|
||||
int addQuantity = 0; |
|
||||
List<String> selected = []; |
|
||||
|
|
||||
final appDocDir = getApplicationDocumentsDirectory(); |
final List<Widget> pageList = [ |
||||
List<FileSystemEntity> files = []; |
const MeetingHomeView(), |
||||
TextEditingController titleController = TextEditingController(); // 录音标题 |
const MeetingMineView(), |
||||
String titleCSuffix = ""; // 录音标题后缀 |
]; |
||||
RxList dataList = [].obs; |
|
||||
|
|
||||
// 蓝牙服务 |
RxInt pageIndex = 0.obs; |
||||
final bleManager = Get.find<BleManager>(); |
|
||||
|
|
||||
@override |
@override |
||||
void onInit() { |
void onInit() { |
||||
super.onInit(); |
super.onInit(); |
||||
listController.addListener(_onScroll); |
pageController = PageController(initialPage: 0); |
||||
_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(); |
|
||||
} |
|
||||
} |
} |
||||
} |
} |
||||
|
|||||
@ -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/material.dart'; |
||||
import 'package:flutter_screenutil/flutter_screenutil.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}); |
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 |
@override |
||||
Widget build(BuildContext context) { |
Widget build(BuildContext context) { |
||||
return SafeArea( |
return SafeArea( |
||||
child: Column( |
child: Padding( |
||||
crossAxisAlignment: CrossAxisAlignment.start, |
padding: EdgeInsets.symmetric(horizontal: 12.w), |
||||
children: [ |
child: Column( |
||||
Padding( |
crossAxisAlignment: CrossAxisAlignment.start, |
||||
padding: EdgeInsets.all(20.w), |
children: [ |
||||
child: TextField( |
Container( |
||||
decoration: InputDecoration( |
padding: EdgeInsets.symmetric(horizontal: 10.w), |
||||
hintText: '在所有文件中搜索', |
margin: EdgeInsets.symmetric(vertical: 15.w), |
||||
prefixIcon: Icon(Icons.search), |
alignment: Alignment.center, |
||||
border: OutlineInputBorder( |
decoration: BoxDecoration( |
||||
borderRadius: BorderRadius.circular(12.w), |
color: Colors.grey[200], |
||||
borderSide: BorderSide.none, |
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/material.dart'; |
||||
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
||||
import 'package:get/get.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 '../controllers/meeting_controller.dart'; |
||||
import '../model/meeting_model.dart'; |
import '../utils/meeting_ui_utils.dart'; |
||||
import 'bottomSheet/edit_title_bottom_sheet.dart'; |
|
||||
import 'bottomSheet/filtrate_drawer.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> { |
class MeetingView extends GetView<MeetingController> { |
||||
const MeetingView({super.key}); |
const MeetingView({super.key}); |
||||
|
|
||||
@override |
@override |
||||
Widget build(BuildContext context) { |
Widget build(BuildContext context) { |
||||
final GlobalKey<ScaffoldState> scaffoldKey = GlobalKey<ScaffoldState>(); |
|
||||
final isDarkMode = Theme.of(context).brightness == Brightness.dark; |
final isDarkMode = Theme.of(context).brightness == Brightness.dark; |
||||
|
|
||||
return PopScope( |
return Scaffold( |
||||
onPopInvokedWithResult: controller.onPopInvokedWithResult, |
key: controller.scaffoldKey, |
||||
child: Scaffold( |
backgroundColor: MeetingUIUtils.getBackgroundColor(isDarkMode), |
||||
key: scaffoldKey, |
body: PageView( |
||||
backgroundColor: isDarkMode ? Colors.black : Colors.grey[50], |
physics: const NeverScrollableScrollPhysics(), |
||||
appBar: _buildAppBar(isDarkMode), |
controller: controller.pageController, |
||||
drawer: Drawer( |
children: controller.pageList, |
||||
width: 260.w, |
), |
||||
child: const FiltrateDrawer(), |
drawer: Drawer( |
||||
), |
width: 260.w, |
||||
drawerEnableOpenDragGesture: false, |
backgroundColor: isDarkMode ? const Color(0xFF1A1A1A) : Colors.grey[50], |
||||
body: Column( |
child: const FiltrateDrawer(), |
||||
crossAxisAlignment: CrossAxisAlignment.start, |
), |
||||
children: [ |
drawerEnableOpenDragGesture: false, |
||||
Row( |
bottomNavigationBar: Stack( |
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
alignment: Alignment.center, |
||||
children: [ |
clipBehavior: Clip.none, |
||||
IconButton( |
children: [ |
||||
icon: Icon( |
Obx( |
||||
Icons.folder_open, |
() => BottomNavigationBar( |
||||
color: isDarkMode ? Colors.white : Colors.black, |
items: const [ |
||||
), |
BottomNavigationBarItem( |
||||
onPressed: () { |
icon: Icon(Icons.folder_outlined), |
||||
scaffoldKey.currentState?.openDrawer(); |
activeIcon: Icon(Icons.folder), |
||||
}, |
label: '文件', |
||||
), |
), |
||||
_buildConnect(isDarkMode), |
BottomNavigationBarItem( |
||||
], |
icon: SizedBox(), |
||||
), |
label: '', |
||||
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: Icon(Icons.person_outline), |
||||
}, |
activeIcon: Icon(Icons.person), |
||||
child: const Icon(Icons.add), |
label: '我的', |
||||
), |
|
||||
), |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
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, |
|
||||
), |
), |
||||
), |
], |
||||
) |
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; |
||||
PreferredSizeWidget _buildAppBar(bool isDarkMode) { |
if (index == 0) { |
||||
return AppBar( |
controller.pageIndex.value = 0; |
||||
backgroundColor: isDarkMode ? Colors.black : Colors.white, |
controller.pageController.jumpToPage(0); |
||||
surfaceTintColor: Colors.transparent, |
} else { |
||||
elevation: 0.5, |
controller.pageIndex.value = 2; |
||||
leading: IconButton( |
controller.pageController.jumpToPage(1); |
||||
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, |
|
||||
), |
), |
||||
Padding( |
), |
||||
padding: EdgeInsets.symmetric(vertical: 5.w), |
Positioned( |
||||
child: Row( |
top: -18.w, |
||||
children: [ |
child: GestureDetector( |
||||
Icon( |
onTap: () { |
||||
Icons.calendar_month, |
Get.bottomSheet( |
||||
size: 14.w, |
BackdropFilter( |
||||
color: isDarkMode ? Colors.grey[400] : Colors.grey[500], |
filter: ImageFilter.blur(sigmaX: 10.0, sigmaY: 10.0), |
||||
), |
child: const NavigationBarBottomSheet(), |
||||
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, |
isScrollControlled: true, |
||||
Text( |
); |
||||
_formatDateSeconds(data.seconds), |
}, |
||||
style: TextStyle( |
child: Container( |
||||
color: isDarkMode ? Colors.grey[400] : Colors.grey[500], |
width: 60.w, |
||||
fontSize: 12.sp, |
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), |
||||
), |
), |
||||
), |
], |
||||
], |
), |
||||
), |
child: Container( |
||||
), |
alignment: Alignment.center, |
||||
Row( |
|
||||
children: [ |
|
||||
Container( |
|
||||
padding: EdgeInsets.symmetric(horizontal: 5.w, vertical: 2.w), |
|
||||
margin: EdgeInsets.only(top: 5.w), |
|
||||
decoration: BoxDecoration( |
decoration: BoxDecoration( |
||||
color: isDarkMode ? Colors.grey[900] : Colors.grey[100], |
color: MeetingUIUtils.getButtonColor(isDarkMode), |
||||
borderRadius: BorderRadius.circular(4.w), |
borderRadius: BorderRadius.circular(60.w), |
||||
), |
), |
||||
child: Text( |
child: Icon( |
||||
data.type, |
Icons.add, |
||||
style: TextStyle( |
color: Colors.white, |
||||
color: isDarkMode ? Colors.grey[400] : Colors.grey[600], |
size: 30.w, |
||||
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: 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 'package:get/get.dart'; |
||||
|
import '../../profile/controllers/profile_controller.dart'; |
||||
import '../controllers/settings_controller.dart'; |
import '../controllers/settings_controller.dart'; |
||||
|
|
||||
class SettingsBinding extends Bindings { |
class SettingsBinding extends Bindings { |
||||
@override |
@override |
||||
void dependencies() { |
void dependencies() { |
||||
Get.lazyPut<SettingsController>( |
Get.lazyPut<SettingsController>(() => 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