diff --git a/lib/modules/home/views/home_view.dart b/lib/modules/home/views/home_view.dart index dab865a45..b9f7d8eeb 100644 --- a/lib/modules/home/views/home_view.dart +++ b/lib/modules/home/views/home_view.dart @@ -145,6 +145,9 @@ class HomeView extends GetView { _buildSectionTitle(isDarkMode), SizedBox(height: 16.h), _buildPremiumAILayout(), + + SizedBox(height: 16.h), + _buildMobileElfCard(isDarkMode), ], ), ), @@ -899,6 +902,27 @@ class HomeView extends GetView { ); } + // 移动精灵卡片 + Widget _buildMobileElfCard(bool isDarkMode) { + return _buildFeatureCard( + title: '移动精灵', + description: '智能语音助手,移动AI全场景对话', + icon: Icons.cell_tower, + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + const Color(0xFF0091EA).withOpacity(0.3), + const Color(0xFF00BFA5).withOpacity(0.3), + ], + ), + onTap: () => Get.toNamed(Routes.mobileElf), + height: 70.h, + isDarkMode: isDarkMode, + iconColor: const Color(0xFF0091EA), + ); + } + // 特性卡片构建方法 Widget _buildFeatureCard({ required String title, diff --git a/lib/modules/mobile_elf/bindings/mobile_elf_binding.dart b/lib/modules/mobile_elf/bindings/mobile_elf_binding.dart new file mode 100644 index 000000000..193e9fbe3 --- /dev/null +++ b/lib/modules/mobile_elf/bindings/mobile_elf_binding.dart @@ -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()); + } +} diff --git a/lib/modules/mobile_elf/controllers/mobile_elf_controller.dart b/lib/modules/mobile_elf/controllers/mobile_elf_controller.dart new file mode 100644 index 000000000..1525e5731 --- /dev/null +++ b/lib/modules/mobile_elf/controllers/mobile_elf_controller.dart @@ -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 = [].obs; + final realtimeText = ''.obs; + final errorMsg = ''.obs; + + // WebSocket + WebSocketChannel? _channel; + StreamSubscription? _wsSubscription; + + // 录音 + final AudioRecorder _recorder = AudioRecorder(); + StreamSubscription? _audioSubscription; + final List _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 _fetchA80CredentialsIfNeeded() async { + try { + final btManager = Get.find(); + 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(_kDeviceNo) ?? ''; + final cachedSecret = _storage.read(_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; + if (body['code'].toString() == '200' && body['success'] == true) { + final data = body['data'] as Map; + _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 _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 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 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 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 msg) { + final data = msg['data'] as Map?; + 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; + cleanAnswer = decoded['content'] as String? ?? assistantAnswer; + } catch (_) {} + + // 提取 TTS URL(Speak 指令) + String? ttsUrl; + final directives = data['data'] as List? ?? []; + for (final item in directives) { + final directive = item as Map; + final header = directive['header'] as Map?; + 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?; + 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 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 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> _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 retryCredentials() async { + credentialError.value = ''; + await _fetchA80CredentialsIfNeeded(); + } + + /// 按钮主操作:空闲/错误 → 开始录音;录音中 → 停止;播放中 → 停止 + Future 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; + } + } +} diff --git a/lib/modules/mobile_elf/views/mobile_elf_view.dart b/lib/modules/mobile_elf/views/mobile_elf_view.dart new file mode 100644 index 000000000..51db8c287 --- /dev/null +++ b/lib/modules/mobile_elf/views/mobile_elf_view.dart @@ -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 { + 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 '点击说话'; + } + } +} diff --git a/lib/routes/app_pages.dart b/lib/routes/app_pages.dart index de6142ab3..c90f995b7 100644 --- a/lib/routes/app_pages.dart +++ b/lib/routes/app_pages.dart @@ -80,6 +80,8 @@ import '../modules/goods/bindings/goodsmeet_binding.dart'; import '../modules/goods/views/goodsmeet_view.dart'; import '../modules/goods/bindings/goodsai_binding.dart'; import '../modules/goods/views/goodsai_view.dart'; +import '../modules/mobile_elf/bindings/mobile_elf_binding.dart'; +import '../modules/mobile_elf/views/mobile_elf_view.dart'; abstract class AppPages { static final pages = [ @@ -369,5 +371,12 @@ abstract class AppPages { transition: Transition.rightToLeft, transitionDuration: Duration(milliseconds: 250), ), + GetPage( + name: Routes.mobileElf, + page: () => const MobileElfView(), + binding: MobileElfBinding(), + transition: Transition.rightToLeft, + transitionDuration: Duration(milliseconds: 250), + ), ]; } diff --git a/lib/routes/app_routes.dart b/lib/routes/app_routes.dart index be5b03701..0d69db214 100644 --- a/lib/routes/app_routes.dart +++ b/lib/routes/app_routes.dart @@ -60,6 +60,9 @@ abstract class Routes { // 实时语音对话 static const realtime = '/realtime'; + // 移动精灵 + static const mobileElf = '/mobile_elf'; + // 设备管理 static const devices = '/devices';