Browse Source

feat(mobile_elf): 添加移动精灵模块及相关功能

newdev_shunjiawei
liwei1dao 7 months ago
parent
commit
cc069113ed
  1. 24
      lib/modules/home/views/home_view.dart
  2. 9
      lib/modules/mobile_elf/bindings/mobile_elf_binding.dart
  3. 572
      lib/modules/mobile_elf/controllers/mobile_elf_controller.dart
  4. 508
      lib/modules/mobile_elf/views/mobile_elf_view.dart
  5. 9
      lib/routes/app_pages.dart
  6. 3
      lib/routes/app_routes.dart

24
lib/modules/home/views/home_view.dart

@ -145,6 +145,9 @@ class HomeView extends GetView<HomeController> {
_buildSectionTitle(isDarkMode), _buildSectionTitle(isDarkMode),
SizedBox(height: 16.h), SizedBox(height: 16.h),
_buildPremiumAILayout(), _buildPremiumAILayout(),
SizedBox(height: 16.h),
_buildMobileElfCard(isDarkMode),
], ],
), ),
), ),
@ -899,6 +902,27 @@ class HomeView extends GetView<HomeController> {
); );
} }
// 移动精灵卡片
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({ Widget _buildFeatureCard({
required String title, required String title,

9
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>(() => MobileElfController());
}
}

572
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 = <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;
}
}
}

508
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<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 '点击说话';
}
}
}

9
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/views/goodsmeet_view.dart';
import '../modules/goods/bindings/goodsai_binding.dart'; import '../modules/goods/bindings/goodsai_binding.dart';
import '../modules/goods/views/goodsai_view.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 { abstract class AppPages {
static final pages = [ static final pages = [
@ -369,5 +371,12 @@ abstract class AppPages {
transition: Transition.rightToLeft, transition: Transition.rightToLeft,
transitionDuration: Duration(milliseconds: 250), transitionDuration: Duration(milliseconds: 250),
), ),
GetPage(
name: Routes.mobileElf,
page: () => const MobileElfView(),
binding: MobileElfBinding(),
transition: Transition.rightToLeft,
transitionDuration: Duration(milliseconds: 250),
),
]; ];
} }

3
lib/routes/app_routes.dart

@ -60,6 +60,9 @@ abstract class Routes {
// 实时语音对话 // 实时语音对话
static const realtime = '/realtime'; static const realtime = '/realtime';
// 移动精灵
static const mobileElf = '/mobile_elf';
// 设备管理 // 设备管理
static const devices = '/devices'; static const devices = '/devices';

Loading…
Cancel
Save