6 changed files with 1125 additions and 0 deletions
@ -0,0 +1,9 @@ |
|||
import 'package:get/get.dart'; |
|||
import '../controllers/mobile_elf_controller.dart'; |
|||
|
|||
class MobileElfBinding extends Bindings { |
|||
@override |
|||
void dependencies() { |
|||
Get.lazyPut<MobileElfController>(() => MobileElfController()); |
|||
} |
|||
} |
|||
@ -0,0 +1,572 @@ |
|||
import 'dart:async'; |
|||
import 'dart:convert'; |
|||
import 'dart:typed_data'; |
|||
import 'package:crypto/crypto.dart'; |
|||
import 'package:dio/dio.dart'; |
|||
import 'package:flutter/foundation.dart' show kIsWeb; |
|||
import 'package:flutter_dotenv/flutter_dotenv.dart'; |
|||
import 'package:get/get.dart'; |
|||
import 'package:get_storage/get_storage.dart'; |
|||
import 'package:just_audio/just_audio.dart'; |
|||
import 'package:record/record.dart'; |
|||
import 'package:uuid/uuid.dart'; |
|||
import 'package:web_socket_channel/io.dart'; |
|||
import 'package:web_socket_channel/web_socket_channel.dart'; |
|||
import '../../../core/utils/logger.dart'; |
|||
import '../../../data/models/user_Info.dart'; |
|||
import '../../../data/services/bluetooth_manager.dart'; |
|||
|
|||
enum MobileElfStatus { idle, connecting, recording, thinking, speaking, error } |
|||
|
|||
class MobileElfMessage { |
|||
final bool isUser; |
|||
final String text; |
|||
final DateTime time; |
|||
final String? ttsUrl; |
|||
|
|||
MobileElfMessage({ |
|||
required this.isUser, |
|||
required this.text, |
|||
required this.time, |
|||
this.ttsUrl, |
|||
}); |
|||
} |
|||
|
|||
class MobileElfController extends GetxController { |
|||
// 状态 |
|||
final status = MobileElfStatus.idle.obs; |
|||
final messages = <MobileElfMessage>[].obs; |
|||
final realtimeText = ''.obs; |
|||
final errorMsg = ''.obs; |
|||
|
|||
// WebSocket |
|||
WebSocketChannel? _channel; |
|||
StreamSubscription? _wsSubscription; |
|||
|
|||
// 录音 |
|||
final AudioRecorder _recorder = AudioRecorder(); |
|||
StreamSubscription<Uint8List>? _audioSubscription; |
|||
final List<int> _audioBuffer = []; |
|||
static const int _frameSize = 5120; // 160ms @ 16kHz 16bit mono |
|||
|
|||
// 播放 |
|||
final AudioPlayer _player = AudioPlayer(); |
|||
|
|||
// 配置 |
|||
late String _deviceNo; |
|||
late String _deviceId; |
|||
late String _productId; |
|||
late String _productKey; |
|||
late String _deviceSecret; |
|||
|
|||
// A80 凭证状态 |
|||
final credentialReady = false.obs; |
|||
final credentialError = ''.obs; |
|||
|
|||
final _storage = GetStorage(); |
|||
static const _kDeviceId = 'mobile_elf_device_id'; |
|||
static const _kDeviceSecret = 'mobile_elf_device_secret'; |
|||
static const _kDeviceNo = 'mobile_elf_device_no'; |
|||
|
|||
// 当前对话 ID |
|||
String _dialogRequestId = ''; |
|||
|
|||
final _uuid = const Uuid(); |
|||
|
|||
@override |
|||
void onInit() { |
|||
super.onInit(); |
|||
_loadConfig(); |
|||
_fetchA80CredentialsIfNeeded(); |
|||
} |
|||
|
|||
@override |
|||
void onClose() { |
|||
disconnect(); |
|||
_recorder.dispose(); |
|||
_player.dispose(); |
|||
super.onClose(); |
|||
} |
|||
|
|||
void _loadConfig() { |
|||
_productId = dotenv.env['MOBILE_ELF_PRODUCT_ID'] ?? '1899007766144614402'; |
|||
_productKey = dotenv.env['MOBILE_ELF_PRODUCT_KEY'] ?? 'd0aI2yidbUUXS'; |
|||
// 优先使用缓存的 A80 凭证,否则降级到 .env 默认值 |
|||
_deviceNo = _storage.read(_kDeviceNo) ?? |
|||
dotenv.env['MOBILE_ELF_DEVICE_NO'] ?? '869700070000570'; |
|||
_deviceId = _storage.read(_kDeviceId) ?? |
|||
dotenv.env['MOBILE_ELF_DEVICE_ID'] ?? '1899013964170694658'; |
|||
_deviceSecret = _storage.read(_kDeviceSecret) ?? |
|||
dotenv.env['MOBILE_ELF_DEVICE_SECRET'] ?? ''; |
|||
credentialReady.value = _deviceSecret.isNotEmpty; |
|||
} |
|||
|
|||
/// 检测连接的耳机是否为 A80,如是则用其 license 换取平台凭证 |
|||
Future<void> _fetchA80CredentialsIfNeeded() async { |
|||
try { |
|||
final btManager = Get.find<BluetoothManager>(); |
|||
final deviceName = btManager.currentDevice?.name ?? ''; |
|||
final deviceMac = btManager.currentDevice?.address ?? ''; |
|||
final license = _findLicense(deviceMac); |
|||
|
|||
Logger.i('[MobileElf]', '=== 设备信息 ==='); |
|||
Logger.i('[MobileElf]', '设备名称: $deviceName'); |
|||
Logger.i('[MobileElf]', '设备MAC: $deviceMac'); |
|||
Logger.i('[MobileElf]', '匹配license: $license'); |
|||
Logger.i('[MobileElf]', '==============='); |
|||
|
|||
if (!deviceName.toUpperCase().contains('A80')) { |
|||
credentialReady.value = _deviceSecret.isNotEmpty; |
|||
return; |
|||
} |
|||
|
|||
// 已有缓存凭证且设备号未变则直接使用 |
|||
final cachedNo = _storage.read<String>(_kDeviceNo) ?? ''; |
|||
final cachedSecret = _storage.read<String>(_kDeviceSecret) ?? ''; |
|||
|
|||
if (license == null || license.isEmpty) { |
|||
Logger.w('[MobileElf]', 'A80 设备未找到 license,使用默认凭证'); |
|||
credentialReady.value = _deviceSecret.isNotEmpty; |
|||
return; |
|||
} |
|||
|
|||
if (cachedNo == license && cachedSecret.isNotEmpty) { |
|||
Logger.i('[MobileElf]', 'A80 凭证命中缓存,跳过请求'); |
|||
credentialReady.value = true; |
|||
return; |
|||
} |
|||
|
|||
credentialError.value = ''; |
|||
Logger.i('[MobileElf]', 'A80 设备 license=$license,开始请求平台凭证'); |
|||
|
|||
final dio = Dio(BaseOptions( |
|||
baseUrl: 'https://ivs.chinamobiledevice.com:11443', |
|||
connectTimeout: const Duration(seconds: 10), |
|||
receiveTimeout: const Duration(seconds: 10), |
|||
)); |
|||
|
|||
final resp = await dio.post( |
|||
'/v2/customer/device/secret/info', |
|||
data: { |
|||
'deviceNoType': 'SN', |
|||
'deviceNo': license, |
|||
'productId': _productId, |
|||
'productKey': _productKey, |
|||
}, |
|||
); |
|||
|
|||
final body = resp.data as Map<String, dynamic>; |
|||
if (body['code'].toString() == '200' && body['success'] == true) { |
|||
final data = body['data'] as Map<String, dynamic>; |
|||
_deviceNo = data['deviceNo'] as String? ?? license; |
|||
_deviceId = data['deviceId'] as String? ?? _deviceId; |
|||
_deviceSecret = data['deviceSecret'] as String? ?? ''; |
|||
|
|||
await _storage.write(_kDeviceNo, _deviceNo); |
|||
await _storage.write(_kDeviceId, _deviceId); |
|||
await _storage.write(_kDeviceSecret, _deviceSecret); |
|||
|
|||
credentialReady.value = _deviceSecret.isNotEmpty; |
|||
Logger.i('[MobileElf]', 'A80 凭证获取成功 deviceId=$_deviceId'); |
|||
} else { |
|||
credentialError.value = body['message']?.toString() ?? '凭证请求失败'; |
|||
Logger.e('[MobileElf]', '平台返回错误: ${body['message']}'); |
|||
} |
|||
} catch (e) { |
|||
credentialError.value = '凭证请求异常: $e'; |
|||
Logger.e('[MobileElf]', '获取 A80 凭证失败: $e'); |
|||
} |
|||
} |
|||
|
|||
/// 从用户设备列表中按蓝牙 MAC 匹配 license |
|||
String? _findLicense(String? bluetoothMac) { |
|||
if (!User.isLoggedIn()) return null; |
|||
final devices = User.instance.devices; |
|||
if (bluetoothMac != null && bluetoothMac.isNotEmpty) { |
|||
final matched = devices.where((d) => |
|||
d.devicemac.toUpperCase() == bluetoothMac.toUpperCase()).toList(); |
|||
if (matched.isNotEmpty) return matched.first.license; |
|||
} |
|||
// 按设备名兜底匹配 |
|||
final byName = devices.where((d) => |
|||
d.devicename.toUpperCase().contains('A80')).toList(); |
|||
return byName.isNotEmpty ? byName.first.license : null; |
|||
} |
|||
|
|||
// ── 连接 ────────────────────────────────────────────────────────────────── |
|||
|
|||
Future<void> _connect() async { |
|||
if (_channel != null) return; |
|||
|
|||
status.value = MobileElfStatus.connecting; |
|||
errorMsg.value = ''; |
|||
|
|||
try { |
|||
final ts = DateTime.now().millisecondsSinceEpoch.toString(); |
|||
final sn = _uuid.v4(); |
|||
final sign = _calcSign(_deviceSecret, ts); |
|||
|
|||
final url = 'wss://ivs.chinamobiledevice.com:11443/app-ws/v2/asr' |
|||
'?deviceNo=$_deviceNo' |
|||
'&deviceId=$_deviceId' |
|||
'&productId=$_productId' |
|||
'&productKey=$_productKey' |
|||
'&sign=$sign' |
|||
'&sn=$sn' |
|||
'&ts=$ts'; |
|||
|
|||
if (kIsWeb) { |
|||
_channel = WebSocketChannel.connect(Uri.parse(url)); |
|||
} else { |
|||
_channel = IOWebSocketChannel.connect(Uri.parse(url)); |
|||
} |
|||
|
|||
_wsSubscription = _channel!.stream.listen( |
|||
_handleMessage, |
|||
onError: _handleWsError, |
|||
onDone: _handleWsDone, |
|||
); |
|||
|
|||
Logger.info('[MobileElf] WebSocket 连接中: $url'); |
|||
} catch (e) { |
|||
Logger.error('[MobileElf] 连接失败: $e'); |
|||
_setError('连接失败: $e'); |
|||
} |
|||
} |
|||
|
|||
// ── 开始对话(录音) ───────────────────────────────────────────────────── |
|||
|
|||
Future<void> startRecording() async { |
|||
if (status.value == MobileElfStatus.recording) return; |
|||
if (status.value == MobileElfStatus.thinking) return; |
|||
|
|||
await _connect(); |
|||
|
|||
final hasPermission = await _recorder.hasPermission(); |
|||
if (!hasPermission) { |
|||
_setError('无麦克风权限'); |
|||
return; |
|||
} |
|||
|
|||
_dialogRequestId = _uuid.v4(); |
|||
realtimeText.value = ''; |
|||
|
|||
try { |
|||
final stream = await _recorder.startStream( |
|||
const RecordConfig( |
|||
encoder: AudioEncoder.pcm16bits, |
|||
sampleRate: 16000, |
|||
numChannels: 1, |
|||
), |
|||
); |
|||
|
|||
_audioBuffer.clear(); |
|||
_audioSubscription = stream.listen((chunk) { |
|||
_audioBuffer.addAll(chunk); |
|||
while (_audioBuffer.length >= _frameSize) { |
|||
_sendAudioChunk( |
|||
Uint8List.fromList(_audioBuffer.sublist(0, _frameSize))); |
|||
_audioBuffer.removeRange(0, _frameSize); |
|||
} |
|||
}); |
|||
|
|||
status.value = MobileElfStatus.recording; |
|||
Logger.info('[MobileElf] 开始录音'); |
|||
} catch (e) { |
|||
Logger.error('[MobileElf] 录音失败: $e'); |
|||
_setError('录音失败: $e'); |
|||
} |
|||
} |
|||
|
|||
Future<void> stopRecording() async { |
|||
if (status.value != MobileElfStatus.recording) return; |
|||
|
|||
await _audioSubscription?.cancel(); |
|||
_audioSubscription = null; |
|||
|
|||
// 发送剩余缓冲数据 |
|||
if (_audioBuffer.isNotEmpty) { |
|||
_sendAudioChunk(Uint8List.fromList(_audioBuffer)); |
|||
_audioBuffer.clear(); |
|||
} |
|||
|
|||
await _recorder.stop(); |
|||
_sendFinish(); |
|||
|
|||
status.value = MobileElfStatus.thinking; |
|||
Logger.info('[MobileElf] 停止录音,等待 AI 回答'); |
|||
} |
|||
|
|||
// ── 发送帧 ──────────────────────────────────────────────────────────────── |
|||
|
|||
void _sendStartFrame() { |
|||
if (_channel == null) return; |
|||
|
|||
final startFrame = { |
|||
'type': 'start', |
|||
'data': { |
|||
'cuid': _deviceId, |
|||
'format': 'pcm', |
|||
'sample': 16000, |
|||
'support_dcs': 2, |
|||
'chunk_size': 10240, |
|||
'support_tts': true, |
|||
'support_text2dcs': true, |
|||
'user_agent': 'voitrans/1.0', |
|||
'dialog_request_id': _dialogRequestId, |
|||
'access_rc': true, |
|||
'support_part_tts': true, |
|||
'need_stoplisten': true, |
|||
'need_dialogue_finish': true, |
|||
'result_trans2directive': false, |
|||
'rc_version': '5', |
|||
'messages': _buildContextMessages(), |
|||
'client_context': [], |
|||
}, |
|||
}; |
|||
|
|||
_channel!.sink.add(jsonEncode(startFrame)); |
|||
Logger.info('[MobileElf] 已发送 start 帧'); |
|||
} |
|||
|
|||
void _sendAudioChunk(Uint8List data) { |
|||
try { |
|||
_channel?.sink.add(data); |
|||
} catch (e) { |
|||
Logger.error('[MobileElf] 发送音频帧失败: $e'); |
|||
} |
|||
} |
|||
|
|||
void _sendFinish() { |
|||
try { |
|||
_channel?.sink.add(jsonEncode({'type': 'finish'})); |
|||
Logger.info('[MobileElf] 已发送 finish 帧'); |
|||
} catch (e) { |
|||
Logger.error('[MobileElf] 发送 finish 帧失败: $e'); |
|||
} |
|||
} |
|||
|
|||
// ── 接收消息处理 ────────────────────────────────────────────────────────── |
|||
|
|||
void _handleMessage(dynamic raw) { |
|||
try { |
|||
if (raw is! String) return; |
|||
final Map<String, dynamic> msg = jsonDecode(raw); |
|||
final type = msg['type'] as String? ?? ''; |
|||
|
|||
switch (type) { |
|||
case 'ready': |
|||
Logger.info('[MobileElf] 收到 ready,发送 start 帧'); |
|||
_sendStartFrame(); |
|||
break; |
|||
|
|||
case 'mid_result': |
|||
final result = msg['result'] as String? ?? ''; |
|||
realtimeText.value = result; |
|||
break; |
|||
|
|||
case 'fin_result': |
|||
final result = msg['result'] as String? ?? ''; |
|||
if (result.isNotEmpty) { |
|||
realtimeText.value = result; |
|||
// 用户消息在 fin_result 时确认写入 |
|||
_addUserMessage(result); |
|||
} |
|||
final end = msg['end'] as int? ?? 0; |
|||
if (end == 1) _handleSessionEnd(); |
|||
break; |
|||
|
|||
case 'inside_rc': |
|||
_handleInsideRc(msg); |
|||
break; |
|||
|
|||
case 'dcs_decide': |
|||
final end = msg['end'] as int? ?? 0; |
|||
if (end == 1) _handleSessionEnd(); |
|||
break; |
|||
|
|||
default: |
|||
Logger.info('[MobileElf] 未知消息类型: $type'); |
|||
} |
|||
} catch (e) { |
|||
Logger.error('[MobileElf] 处理消息失败: $e'); |
|||
} |
|||
} |
|||
|
|||
void _handleInsideRc(Map<String, dynamic> msg) { |
|||
final data = msg['data'] as Map<String, dynamic>?; |
|||
if (data == null) return; |
|||
|
|||
final code = data['code'] as int? ?? -1; |
|||
if (code != 0) { |
|||
Logger.error('[MobileElf] inside_rc 错误: ${data['msg']}'); |
|||
return; |
|||
} |
|||
|
|||
// 提取 AI 文本回答 |
|||
final assistantAnswer = data['assistant_answer'] as String? ?? ''; |
|||
String cleanAnswer = assistantAnswer; |
|||
// assistant_answer 可能是 JSON 字符串 |
|||
try { |
|||
final decoded = jsonDecode(assistantAnswer) as Map<String, dynamic>; |
|||
cleanAnswer = decoded['content'] as String? ?? assistantAnswer; |
|||
} catch (_) {} |
|||
|
|||
// 提取 TTS URL(Speak 指令) |
|||
String? ttsUrl; |
|||
final directives = data['data'] as List<dynamic>? ?? []; |
|||
for (final item in directives) { |
|||
final directive = item as Map<String, dynamic>; |
|||
final header = directive['header'] as Map<String, dynamic>?; |
|||
final ns = header?['namespace'] as String? ?? ''; |
|||
final name = header?['name'] as String? ?? ''; |
|||
if (ns == 'ai.fxzsos.device_interface.voice_output' && name == 'Speak') { |
|||
final payload = directive['payload'] as Map<String, dynamic>?; |
|||
ttsUrl = payload?['url'] as String?; |
|||
break; |
|||
} |
|||
} |
|||
|
|||
if (cleanAnswer.isNotEmpty) { |
|||
_addAiMessage(cleanAnswer, ttsUrl: ttsUrl); |
|||
} |
|||
|
|||
// 播放 TTS |
|||
if (ttsUrl != null && ttsUrl.isNotEmpty) { |
|||
playTts(ttsUrl); |
|||
} |
|||
|
|||
final end = msg['end'] as int? ?? 0; |
|||
if (end == 1) _handleSessionEnd(); |
|||
} |
|||
|
|||
void _handleSessionEnd() { |
|||
if (status.value != MobileElfStatus.speaking) { |
|||
status.value = MobileElfStatus.idle; |
|||
} |
|||
realtimeText.value = ''; |
|||
// 断开当前 WebSocket,下次对话重新连接(协议要求每次新对话重连) |
|||
_closeWs(); |
|||
} |
|||
|
|||
void _handleWsError(dynamic error) { |
|||
Logger.error('[MobileElf] WebSocket 错误: $error'); |
|||
_setError('连接错误: $error'); |
|||
_closeWs(); |
|||
} |
|||
|
|||
void _handleWsDone() { |
|||
Logger.info('[MobileElf] WebSocket 关闭'); |
|||
if (status.value == MobileElfStatus.connecting || |
|||
status.value == MobileElfStatus.recording) { |
|||
_setError('连接已断开'); |
|||
} |
|||
_channel = null; |
|||
_wsSubscription = null; |
|||
} |
|||
|
|||
// ── TTS 播放 ────────────────────────────────────────────────────────────── |
|||
|
|||
Future<void> playTts(String url) async { |
|||
try { |
|||
status.value = MobileElfStatus.speaking; |
|||
await _player.setUrl(url); |
|||
await _player.play(); |
|||
await _player.processingStateStream |
|||
.firstWhere((s) => s == ProcessingState.completed); |
|||
status.value = MobileElfStatus.idle; |
|||
Logger.info('[MobileElf] TTS 播放完成'); |
|||
} catch (e) { |
|||
Logger.error('[MobileElf] TTS 播放失败: $e'); |
|||
status.value = MobileElfStatus.idle; |
|||
} |
|||
} |
|||
|
|||
Future<void> stopSpeaking() async { |
|||
await _player.stop(); |
|||
status.value = MobileElfStatus.idle; |
|||
} |
|||
|
|||
// ── 断开连接 ────────────────────────────────────────────────────────────── |
|||
|
|||
void disconnect() { |
|||
_audioSubscription?.cancel(); |
|||
_audioSubscription = null; |
|||
_recorder.cancel(); |
|||
_closeWs(); |
|||
status.value = MobileElfStatus.idle; |
|||
} |
|||
|
|||
void _closeWs() { |
|||
_wsSubscription?.cancel(); |
|||
_wsSubscription = null; |
|||
_channel?.sink.close(); |
|||
_channel = null; |
|||
} |
|||
|
|||
// ── 辅助方法 ────────────────────────────────────────────────────────────── |
|||
|
|||
String _calcSign(String deviceSecret, String ts) { |
|||
final bytes = utf8.encode('$deviceSecret$ts'); |
|||
return md5.convert(bytes).toString(); |
|||
} |
|||
|
|||
List<Map<String, String>> _buildContextMessages() { |
|||
return messages.map((m) => { |
|||
'role': m.isUser ? 'user' : 'assistant', |
|||
'content': m.text, |
|||
}).toList(); |
|||
} |
|||
|
|||
void _addUserMessage(String text) { |
|||
// 避免重复添加 |
|||
if (messages.isNotEmpty && |
|||
messages.last.isUser && |
|||
messages.last.text == text) { |
|||
return; |
|||
} |
|||
messages.add(MobileElfMessage( |
|||
isUser: true, |
|||
text: text, |
|||
time: DateTime.now(), |
|||
)); |
|||
} |
|||
|
|||
void _addAiMessage(String text, {String? ttsUrl}) { |
|||
messages.add(MobileElfMessage( |
|||
isUser: false, |
|||
text: text, |
|||
time: DateTime.now(), |
|||
ttsUrl: ttsUrl, |
|||
)); |
|||
} |
|||
|
|||
void _setError(String msg) { |
|||
errorMsg.value = msg; |
|||
status.value = MobileElfStatus.error; |
|||
} |
|||
|
|||
/// 重试获取 A80 凭证 |
|||
Future<void> retryCredentials() async { |
|||
credentialError.value = ''; |
|||
await _fetchA80CredentialsIfNeeded(); |
|||
} |
|||
|
|||
/// 按钮主操作:空闲/错误 → 开始录音;录音中 → 停止;播放中 → 停止 |
|||
Future<void> onMicTap() async { |
|||
switch (status.value) { |
|||
case MobileElfStatus.idle: |
|||
case MobileElfStatus.error: |
|||
await startRecording(); |
|||
break; |
|||
case MobileElfStatus.recording: |
|||
await stopRecording(); |
|||
break; |
|||
case MobileElfStatus.speaking: |
|||
await stopSpeaking(); |
|||
break; |
|||
default: |
|||
break; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,508 @@ |
|||
import 'package:flutter/material.dart'; |
|||
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|||
import 'package:get/get.dart'; |
|||
import '../controllers/mobile_elf_controller.dart'; |
|||
|
|||
class MobileElfView extends GetView<MobileElfController> { |
|||
const MobileElfView({Key? key}) : super(key: key); |
|||
|
|||
@override |
|||
Widget build(BuildContext context) { |
|||
final bool isDark = Theme.of(context).brightness == Brightness.dark; |
|||
return Scaffold( |
|||
backgroundColor: isDark ? Colors.black : const Color(0xFFF9FAFC), |
|||
appBar: _buildAppBar(isDark), |
|||
body: Obx(() { |
|||
final err = controller.credentialError.value; |
|||
if (err.isNotEmpty) { |
|||
return Center( |
|||
child: Padding( |
|||
padding: EdgeInsets.all(24.w), |
|||
child: Column( |
|||
mainAxisSize: MainAxisSize.min, |
|||
children: [ |
|||
Icon(Icons.error_outline, size: 48.sp, color: Colors.red), |
|||
SizedBox(height: 12.h), |
|||
Text(err, |
|||
textAlign: TextAlign.center, |
|||
style: TextStyle(fontSize: 13.sp, color: Colors.red)), |
|||
SizedBox(height: 16.h), |
|||
ElevatedButton( |
|||
onPressed: controller.retryCredentials, |
|||
child: const Text('重试'), |
|||
), |
|||
], |
|||
), |
|||
), |
|||
); |
|||
} |
|||
return Column( |
|||
children: [ |
|||
_buildStatusBanner(), |
|||
Expanded(child: _buildMessageList(isDark)), |
|||
_buildBottomBar(isDark), |
|||
], |
|||
); |
|||
}), |
|||
); |
|||
} |
|||
|
|||
PreferredSizeWidget _buildAppBar(bool isDark) { |
|||
return AppBar( |
|||
backgroundColor: isDark ? const Color(0xFF1A1A1A) : Colors.white, |
|||
elevation: 0.5, |
|||
leading: IconButton( |
|||
icon: Icon(Icons.arrow_back_ios, |
|||
size: 18.sp, |
|||
color: isDark ? Colors.white : Colors.grey[800]), |
|||
onPressed: () => Get.back(), |
|||
), |
|||
title: Row( |
|||
mainAxisSize: MainAxisSize.min, |
|||
children: [ |
|||
Container( |
|||
width: 32.w, |
|||
height: 32.w, |
|||
decoration: BoxDecoration( |
|||
gradient: const LinearGradient( |
|||
colors: [Color(0xFF0091EA), Color(0xFF00BFA5)], |
|||
), |
|||
borderRadius: BorderRadius.circular(8.r), |
|||
), |
|||
child: Icon(Icons.cell_tower, color: Colors.white, size: 18.sp), |
|||
), |
|||
SizedBox(width: 8.w), |
|||
Text( |
|||
'移动精灵', |
|||
style: TextStyle( |
|||
fontSize: 16.sp, |
|||
fontWeight: FontWeight.bold, |
|||
color: isDark ? Colors.white : Colors.grey[800], |
|||
), |
|||
), |
|||
], |
|||
), |
|||
actions: [ |
|||
Obx(() => Padding( |
|||
padding: EdgeInsets.only(right: 16.w), |
|||
child: _buildStatusDot(), |
|||
)), |
|||
], |
|||
); |
|||
} |
|||
|
|||
Widget _buildStatusDot() { |
|||
final color = _statusColor(controller.status.value); |
|||
return Row( |
|||
children: [ |
|||
Container( |
|||
width: 8.w, |
|||
height: 8.w, |
|||
decoration: BoxDecoration( |
|||
color: color, |
|||
shape: BoxShape.circle, |
|||
boxShadow: [ |
|||
BoxShadow(color: color.withOpacity(0.4), blurRadius: 4) |
|||
], |
|||
), |
|||
), |
|||
SizedBox(width: 4.w), |
|||
Text( |
|||
_statusLabel(controller.status.value), |
|||
style: TextStyle(fontSize: 11.sp, color: color), |
|||
), |
|||
], |
|||
); |
|||
} |
|||
|
|||
Widget _buildStatusBanner() { |
|||
return Obx(() { |
|||
final s = controller.status.value; |
|||
if (s == MobileElfStatus.idle) return const SizedBox.shrink(); |
|||
|
|||
final color = _statusColor(s); |
|||
final label = _statusBannerLabel(s); |
|||
|
|||
return Container( |
|||
width: double.infinity, |
|||
padding: EdgeInsets.symmetric(vertical: 6.h), |
|||
color: color.withOpacity(0.12), |
|||
child: Row( |
|||
mainAxisAlignment: MainAxisAlignment.center, |
|||
children: [ |
|||
if (s == MobileElfStatus.thinking || s == MobileElfStatus.connecting) |
|||
SizedBox( |
|||
width: 12.w, |
|||
height: 12.w, |
|||
child: CircularProgressIndicator( |
|||
strokeWidth: 1.5, |
|||
color: color, |
|||
), |
|||
), |
|||
if (s == MobileElfStatus.recording) |
|||
Icon(Icons.mic, size: 14.sp, color: color), |
|||
if (s == MobileElfStatus.speaking) |
|||
Icon(Icons.volume_up, size: 14.sp, color: color), |
|||
if (s == MobileElfStatus.error) |
|||
Icon(Icons.error_outline, size: 14.sp, color: color), |
|||
SizedBox(width: 6.w), |
|||
Text(label, |
|||
style: TextStyle( |
|||
fontSize: 12.sp, |
|||
color: color, |
|||
fontWeight: FontWeight.w500)), |
|||
], |
|||
), |
|||
); |
|||
}); |
|||
} |
|||
|
|||
Widget _buildMessageList(bool isDark) { |
|||
return Obx(() { |
|||
if (controller.messages.isEmpty) { |
|||
return _buildEmptyHint(isDark); |
|||
} |
|||
return ListView.builder( |
|||
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 12.h), |
|||
reverse: false, |
|||
itemCount: controller.messages.length, |
|||
itemBuilder: (_, i) => _buildBubble(controller.messages[i], isDark), |
|||
); |
|||
}); |
|||
} |
|||
|
|||
Widget _buildEmptyHint(bool isDark) { |
|||
return Center( |
|||
child: Column( |
|||
mainAxisSize: MainAxisSize.min, |
|||
children: [ |
|||
Container( |
|||
width: 72.w, |
|||
height: 72.w, |
|||
decoration: BoxDecoration( |
|||
gradient: const LinearGradient( |
|||
colors: [Color(0xFF0091EA), Color(0xFF00BFA5)], |
|||
), |
|||
shape: BoxShape.circle, |
|||
), |
|||
child: Icon(Icons.cell_tower, color: Colors.white, size: 36.sp), |
|||
), |
|||
SizedBox(height: 16.h), |
|||
Text( |
|||
'移动精灵', |
|||
style: TextStyle( |
|||
fontSize: 18.sp, |
|||
fontWeight: FontWeight.bold, |
|||
color: isDark ? Colors.white : Colors.grey[800], |
|||
), |
|||
), |
|||
SizedBox(height: 8.h), |
|||
Text( |
|||
'按住麦克风按钮开始语音对话', |
|||
style: TextStyle( |
|||
fontSize: 13.sp, |
|||
color: isDark ? Colors.grey[400] : Colors.grey[500], |
|||
), |
|||
), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
Widget _buildBubble(MobileElfMessage msg, bool isDark) { |
|||
final isUser = msg.isUser; |
|||
return Padding( |
|||
padding: EdgeInsets.only(bottom: 12.h), |
|||
child: Row( |
|||
mainAxisAlignment: |
|||
isUser ? MainAxisAlignment.end : MainAxisAlignment.start, |
|||
crossAxisAlignment: CrossAxisAlignment.end, |
|||
children: [ |
|||
if (!isUser) ...[ |
|||
_buildAvatar(isUser: false), |
|||
SizedBox(width: 8.w), |
|||
], |
|||
Flexible( |
|||
child: Container( |
|||
padding: |
|||
EdgeInsets.symmetric(horizontal: 14.w, vertical: 10.h), |
|||
decoration: BoxDecoration( |
|||
color: isUser |
|||
? const Color(0xFF0091EA) |
|||
: (isDark |
|||
? const Color(0xFF2A2A2A) |
|||
: Colors.white), |
|||
borderRadius: BorderRadius.only( |
|||
topLeft: Radius.circular(16.r), |
|||
topRight: Radius.circular(16.r), |
|||
bottomLeft: |
|||
isUser ? Radius.circular(16.r) : Radius.circular(4.r), |
|||
bottomRight: |
|||
isUser ? Radius.circular(4.r) : Radius.circular(16.r), |
|||
), |
|||
boxShadow: [ |
|||
BoxShadow( |
|||
color: Colors.black.withOpacity(0.06), |
|||
blurRadius: 6, |
|||
offset: const Offset(0, 2), |
|||
), |
|||
], |
|||
), |
|||
child: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Text( |
|||
msg.text, |
|||
style: TextStyle( |
|||
fontSize: 14.sp, |
|||
color: isUser |
|||
? Colors.white |
|||
: (isDark ? Colors.white : Colors.grey[800]), |
|||
height: 1.4, |
|||
), |
|||
), |
|||
if (!isUser && msg.ttsUrl != null) ...[ |
|||
SizedBox(height: 6.h), |
|||
GestureDetector( |
|||
onTap: () => controller.playTts(msg.ttsUrl!), |
|||
child: Row( |
|||
mainAxisSize: MainAxisSize.min, |
|||
children: [ |
|||
Icon( |
|||
Icons.play_circle_outline, |
|||
size: 16.sp, |
|||
color: const Color(0xFF0091EA), |
|||
), |
|||
SizedBox(width: 4.w), |
|||
Text( |
|||
'重播语音', |
|||
style: TextStyle( |
|||
fontSize: 11.sp, |
|||
color: const Color(0xFF0091EA), |
|||
), |
|||
), |
|||
], |
|||
), |
|||
), |
|||
], |
|||
], |
|||
), |
|||
), |
|||
), |
|||
if (isUser) ...[ |
|||
SizedBox(width: 8.w), |
|||
_buildAvatar(isUser: true), |
|||
], |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
Widget _buildAvatar({required bool isUser}) { |
|||
return Container( |
|||
width: 32.w, |
|||
height: 32.w, |
|||
decoration: BoxDecoration( |
|||
color: isUser |
|||
? const Color(0xFF0091EA).withOpacity(0.15) |
|||
: const Color(0xFF00BFA5).withOpacity(0.15), |
|||
shape: BoxShape.circle, |
|||
), |
|||
child: Icon( |
|||
isUser ? Icons.person : Icons.cell_tower, |
|||
size: 16.sp, |
|||
color: isUser ? const Color(0xFF0091EA) : const Color(0xFF00BFA5), |
|||
), |
|||
); |
|||
} |
|||
|
|||
Widget _buildBottomBar(bool isDark) { |
|||
return Container( |
|||
padding: EdgeInsets.fromLTRB(16.w, 8.h, 16.w, 24.h), |
|||
decoration: BoxDecoration( |
|||
color: isDark ? const Color(0xFF1A1A1A) : Colors.white, |
|||
boxShadow: [ |
|||
BoxShadow( |
|||
color: Colors.black.withOpacity(0.05), |
|||
blurRadius: 8, |
|||
offset: const Offset(0, -2), |
|||
), |
|||
], |
|||
), |
|||
child: Column( |
|||
mainAxisSize: MainAxisSize.min, |
|||
children: [ |
|||
// 实时识别字幕 |
|||
Obx(() { |
|||
final text = controller.realtimeText.value; |
|||
if (text.isEmpty) return const SizedBox.shrink(); |
|||
return Padding( |
|||
padding: EdgeInsets.only(bottom: 8.h), |
|||
child: Text( |
|||
text, |
|||
style: TextStyle( |
|||
fontSize: 13.sp, |
|||
color: isDark ? Colors.grey[400] : Colors.grey[500], |
|||
fontStyle: FontStyle.italic, |
|||
), |
|||
textAlign: TextAlign.center, |
|||
maxLines: 2, |
|||
overflow: TextOverflow.ellipsis, |
|||
), |
|||
); |
|||
}), |
|||
|
|||
// 错误提示 |
|||
Obx(() { |
|||
final err = controller.errorMsg.value; |
|||
if (err.isEmpty) return const SizedBox.shrink(); |
|||
return Padding( |
|||
padding: EdgeInsets.only(bottom: 8.h), |
|||
child: Text( |
|||
err, |
|||
style: TextStyle( |
|||
fontSize: 12.sp, color: Colors.red[400]), |
|||
textAlign: TextAlign.center, |
|||
), |
|||
); |
|||
}), |
|||
|
|||
// 麦克风按钮 |
|||
Obx(() => _buildMicButton(controller.status.value)), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
Widget _buildMicButton(MobileElfStatus status) { |
|||
final bool isThinking = status == MobileElfStatus.thinking || |
|||
status == MobileElfStatus.connecting; |
|||
final Color btnColor = _statusColor(status); |
|||
final IconData icon = _micIcon(status); |
|||
final String label = _micLabel(status); |
|||
|
|||
return Column( |
|||
children: [ |
|||
GestureDetector( |
|||
onTap: isThinking ? null : controller.onMicTap, |
|||
child: AnimatedContainer( |
|||
duration: const Duration(milliseconds: 200), |
|||
width: 68.w, |
|||
height: 68.w, |
|||
decoration: BoxDecoration( |
|||
shape: BoxShape.circle, |
|||
color: isThinking |
|||
? Colors.grey.withOpacity(0.2) |
|||
: btnColor.withOpacity(0.15), |
|||
border: Border.all( |
|||
color: isThinking ? Colors.grey.withOpacity(0.3) : btnColor, |
|||
width: 2, |
|||
), |
|||
), |
|||
child: isThinking |
|||
? Center( |
|||
child: SizedBox( |
|||
width: 24.w, |
|||
height: 24.w, |
|||
child: CircularProgressIndicator( |
|||
strokeWidth: 2.5, color: Colors.grey), |
|||
), |
|||
) |
|||
: Icon(icon, size: 30.sp, color: btnColor), |
|||
), |
|||
), |
|||
SizedBox(height: 6.h), |
|||
Text( |
|||
label, |
|||
style: TextStyle( |
|||
fontSize: 11.sp, |
|||
color: isThinking ? Colors.grey : btnColor, |
|||
), |
|||
), |
|||
], |
|||
); |
|||
} |
|||
|
|||
// ── 辅助 ────────────────────────────────────────────────────────────────── |
|||
|
|||
Color _statusColor(MobileElfStatus s) { |
|||
switch (s) { |
|||
case MobileElfStatus.connecting: |
|||
return Colors.orange; |
|||
case MobileElfStatus.recording: |
|||
return const Color(0xFF0066CC); |
|||
case MobileElfStatus.thinking: |
|||
return Colors.orange; |
|||
case MobileElfStatus.speaking: |
|||
return const Color(0xFF4CAF50); |
|||
case MobileElfStatus.error: |
|||
return Colors.red; |
|||
case MobileElfStatus.idle: |
|||
return const Color(0xFF0091EA); |
|||
} |
|||
} |
|||
|
|||
String _statusLabel(MobileElfStatus s) { |
|||
switch (s) { |
|||
case MobileElfStatus.connecting: |
|||
return '连接中'; |
|||
case MobileElfStatus.recording: |
|||
return '录音中'; |
|||
case MobileElfStatus.thinking: |
|||
return '思考中'; |
|||
case MobileElfStatus.speaking: |
|||
return '播报中'; |
|||
case MobileElfStatus.error: |
|||
return '错误'; |
|||
case MobileElfStatus.idle: |
|||
return '就绪'; |
|||
} |
|||
} |
|||
|
|||
String _statusBannerLabel(MobileElfStatus s) { |
|||
switch (s) { |
|||
case MobileElfStatus.connecting: |
|||
return '正在连接移动精灵...'; |
|||
case MobileElfStatus.recording: |
|||
return '正在聆听,请说话'; |
|||
case MobileElfStatus.thinking: |
|||
return 'AI 思考中,请稍候'; |
|||
case MobileElfStatus.speaking: |
|||
return '正在播报回答'; |
|||
case MobileElfStatus.error: |
|||
return controller.errorMsg.value; |
|||
case MobileElfStatus.idle: |
|||
return ''; |
|||
} |
|||
} |
|||
|
|||
IconData _micIcon(MobileElfStatus s) { |
|||
switch (s) { |
|||
case MobileElfStatus.recording: |
|||
return Icons.stop_circle_outlined; |
|||
case MobileElfStatus.speaking: |
|||
return Icons.stop_circle_outlined; |
|||
case MobileElfStatus.error: |
|||
return Icons.refresh; |
|||
default: |
|||
return Icons.mic; |
|||
} |
|||
} |
|||
|
|||
String _micLabel(MobileElfStatus s) { |
|||
switch (s) { |
|||
case MobileElfStatus.recording: |
|||
return '点击停止'; |
|||
case MobileElfStatus.speaking: |
|||
return '点击停止播报'; |
|||
case MobileElfStatus.error: |
|||
return '重试'; |
|||
case MobileElfStatus.thinking: |
|||
return '思考中...'; |
|||
case MobileElfStatus.connecting: |
|||
return '连接中...'; |
|||
default: |
|||
return '点击说话'; |
|||
} |
|||
} |
|||
} |
|||
Loading…
Reference in new issue