FloatingIcon 加入拖动的动画,以及自动吸附
This commit is contained in:
311
CODE_ANALYSIS.md
311
CODE_ANALYSIS.md
@@ -84,7 +84,7 @@ class VehicleCommand {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🔄 核心服务层 (Services)
|
## 🔄 核心服务层 (Services) - 完整解析
|
||||||
|
|
||||||
### 1. MessageService - 核心消息管理服务
|
### 1. MessageService - 核心消息管理服务
|
||||||
|
|
||||||
@@ -94,6 +94,7 @@ class VehicleCommand {
|
|||||||
- 统一管理聊天消息
|
- 统一管理聊天消息
|
||||||
- 协调语音识别、AI对话、车控命令执行流程
|
- 协调语音识别、AI对话、车控命令执行流程
|
||||||
- 维护应用状态机
|
- 维护应用状态机
|
||||||
|
- 通过Method Channel与原生ASR服务通信
|
||||||
|
|
||||||
**核心方法分析**:
|
**核心方法分析**:
|
||||||
|
|
||||||
@@ -101,16 +102,16 @@ class VehicleCommand {
|
|||||||
```dart
|
```dart
|
||||||
// 开始语音输入
|
// 开始语音输入
|
||||||
Future<void> startVoiceInput() async {
|
Future<void> startVoiceInput() async {
|
||||||
// 1. 权限检查
|
// 1. 权限检查 - 使用permission_handler检查麦克风权限
|
||||||
// 2. 状态验证
|
// 2. 状态验证 - 确保当前状态为idle
|
||||||
// 3. 初始化录音
|
// 3. 初始化录音 - 创建用户消息占位符
|
||||||
// 4. 调用原生ASR服务
|
// 4. 调用原生ASR服务 - 通过Method Channel启动阿里云ASR
|
||||||
}
|
}
|
||||||
|
|
||||||
// 停止并处理语音输入
|
// 停止并处理语音输入
|
||||||
Future<void> stopAndProcessVoiceInput() async {
|
Future<void> stopAndProcessVoiceInput() async {
|
||||||
// 1. 停止录音
|
// 1. 停止录音 - 调用原生ASR停止接口
|
||||||
// 2. 等待ASR结果
|
// 2. 等待ASR结果 - 使用Completer等待异步结果
|
||||||
// 3. 调用reply()处理识别结果
|
// 3. 调用reply()处理识别结果
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -143,60 +144,213 @@ Future<void> handleVehicleControl(String text, bool isChinese) async {
|
|||||||
- 通过`_state`维护服务状态机
|
- 通过`_state`维护服务状态机
|
||||||
- 使用`_isReplyAborted`实现流程中断控制
|
- 使用`_isReplyAborted`实现流程中断控制
|
||||||
|
|
||||||
### 2. CommandService - 车控命令处理服务
|
### 2. ChatSseService - SSE流式通信服务
|
||||||
|
|
||||||
**设计模式**: 静态方法 + 回调机制
|
**设计模式**: 单例服务 + 流式处理
|
||||||
|
|
||||||
**职责**:
|
**核心功能**:
|
||||||
- 提供车控命令执行接口
|
- 基于SSE的实时AI对话
|
||||||
- 维护主应用注册的回调函数
|
- 流式文本处理和TTS播报
|
||||||
- 实现命令执行的解耦
|
- 会话状态管理
|
||||||
|
- 智能分句和语音合成
|
||||||
|
|
||||||
|
**关键实现**:
|
||||||
|
|
||||||
|
#### SSE连接管理
|
||||||
```dart
|
```dart
|
||||||
// 命令回调函数定义
|
void request({
|
||||||
typedef CommandCallback = Future<(bool, Map<String, dynamic>? params)> Function(
|
required String messageId,
|
||||||
VehicleCommandType type, Map<String, dynamic>? params);
|
required String text,
|
||||||
|
required bool isChinese,
|
||||||
// 执行车控命令
|
required Function(String, String, bool) onStreamResponse,
|
||||||
static Future<(bool, Map<String, dynamic>? params)> executeCommand(
|
}) async {
|
||||||
VehicleCommandType type, {Map<String, dynamic>? params}) async {
|
// 1. 初始化用户ID和会话ID
|
||||||
// 调用注册的回调函数执行实际车控逻辑
|
// 2. 建立HTTP SSE连接
|
||||||
|
// 3. 处理流式响应数据
|
||||||
|
// 4. 实时文本清理和分句
|
||||||
|
// 5. 协调TTS播报
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. TextClassificationService - 文本分类服务
|
#### 智能分句算法
|
||||||
|
```dart
|
||||||
|
// 支持中英文分句符识别
|
||||||
|
final enEnders = RegExp(r'[.!?]');
|
||||||
|
final zhEnders = RegExp(r'[。!?]');
|
||||||
|
|
||||||
**职责**: 对用户输入文本进行意图分类
|
// Markdown内容清理
|
||||||
|
// 1. 清理图片语法 ![...]
|
||||||
|
// 2. 移除列表序号
|
||||||
|
// 3. 处理不完整语法
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. AudioRecorderService - 音频录制服务
|
||||||
|
|
||||||
|
**核心功能**:
|
||||||
|
- 基于record插件的音频录制
|
||||||
|
- OPUS格式音频编码
|
||||||
|
- 权限管理
|
||||||
|
- 临时文件管理
|
||||||
|
|
||||||
|
**实现细节**:
|
||||||
|
```dart
|
||||||
|
class AudioRecorderService {
|
||||||
|
final AudioRecorder _recorder = AudioRecorder();
|
||||||
|
bool _isRecording = false;
|
||||||
|
String? _tempFilePath;
|
||||||
|
|
||||||
|
Future<void> startRecording() async {
|
||||||
|
// 1. 生成临时文件路径
|
||||||
|
// 2. 配置OPUS编码格式
|
||||||
|
// 3. 启动录音
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<int>?> stopRecording() async {
|
||||||
|
// 1. 停止录音
|
||||||
|
// 2. 读取音频文件
|
||||||
|
// 3. 返回字节数组
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. VoiceRecognitionService - 语音识别服务
|
||||||
|
|
||||||
|
**功能**:
|
||||||
|
- HTTP多媒体文件上传
|
||||||
|
- 音频格式转换
|
||||||
|
- 中英文语言识别
|
||||||
|
|
||||||
|
**核心逻辑**:
|
||||||
|
```dart
|
||||||
|
Future<String?> recognizeSpeech(List<int> audioBytes, {String lang = 'cn'}) async {
|
||||||
|
// 1. 构建MultipartRequest
|
||||||
|
// 2. 上传音频文件到识别服务
|
||||||
|
// 3. 解析JSON响应获取文本
|
||||||
|
// 4. 错误处理和日志记录
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. LocalTtsService - 语音合成服务
|
||||||
|
|
||||||
|
**设计亮点**: 流式TTS处理 + 有序播放队列
|
||||||
|
|
||||||
|
**核心特性**:
|
||||||
|
- 基于flutter_tts的跨平台TTS
|
||||||
|
- 流式文本分句播报
|
||||||
|
- 有序任务队列管理
|
||||||
|
- 中英文语言自动切换
|
||||||
|
|
||||||
|
**关键数据结构**:
|
||||||
|
```dart
|
||||||
|
class TtsTask {
|
||||||
|
final int index; // 任务索引
|
||||||
|
final String text; // 播报文本
|
||||||
|
TtsTaskStatus status; // 任务状态(ready/completed)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TtsTaskStatus {
|
||||||
|
ready, // 准备播放
|
||||||
|
completed, // 播放完成
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**流式播放流程**:
|
||||||
|
```dart
|
||||||
|
void pushTextForStreamTTS(String text) {
|
||||||
|
// 1. 创建有序任务
|
||||||
|
// 2. 添加到任务队列
|
||||||
|
// 3. 触发下一个任务处理
|
||||||
|
}
|
||||||
|
|
||||||
|
void _processNextReadyTask() {
|
||||||
|
// 1. 检查播放状态
|
||||||
|
// 2. 按序查找就绪任务
|
||||||
|
// 3. 执行TTS播放
|
||||||
|
// 4. 等待播放完成回调
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. TextClassificationService - 文本分类服务
|
||||||
|
|
||||||
|
**功能**: NLP文本意图分类
|
||||||
|
|
||||||
|
**分类结果**:
|
||||||
|
- `-1`: 系统错误
|
||||||
|
- `2`: 车控命令
|
||||||
|
- `4`: 无效问题
|
||||||
|
- 其他: 普通问答
|
||||||
|
|
||||||
```dart
|
```dart
|
||||||
Future<int> classifyText(String text) async {
|
Future<int> classifyText(String text) async {
|
||||||
// HTTP POST请求到分类服务
|
// HTTP POST到分类服务
|
||||||
// 返回分类结果:
|
// 返回分类category整数
|
||||||
// -1: 错误
|
|
||||||
// 2: 车控命令
|
|
||||||
// 4: 错误问题
|
|
||||||
// 其他: 普通问答
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. VehicleCommandService - 车控命令解析服务
|
### 7. VehicleCommandService - 车控命令解析服务
|
||||||
|
|
||||||
**职责**:
|
**职责**:
|
||||||
- 解析自然语言为车控命令
|
- 自然语言转车控命令
|
||||||
- 生成命令执行反馈
|
- 命令参数解析
|
||||||
|
- 执行结果反馈生成
|
||||||
|
|
||||||
|
**核心方法**:
|
||||||
```dart
|
```dart
|
||||||
Future<VehicleCommandResponse?> getCommandFromText(String text) async {
|
Future<VehicleCommandResponse?> getCommandFromText(String text) async {
|
||||||
// 1. 发送文本到车控解析服务
|
// 1. 发送文本到NLP解析服务
|
||||||
// 2. 解析返回的命令列表
|
// 2. 解析返回的命令列表
|
||||||
// 3. 返回VehicleCommandResponse对象
|
// 3. 构建VehicleCommandResponse对象
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String> getControlResponse(List<String> successCommandList) async {
|
Future<String> getControlResponse(List<String> successCommandList) async {
|
||||||
// 根据成功执行的命令列表生成友好的反馈文本
|
// 根据成功命令列表生成友好反馈文本
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 8. CommandService - 车控命令执行服务
|
||||||
|
|
||||||
|
**设计模式**: 静态方法 + 回调机制
|
||||||
|
|
||||||
|
**解耦设计**:
|
||||||
|
```dart
|
||||||
|
typedef CommandCallback = Future<(bool, Map<String, dynamic>? params)> Function(
|
||||||
|
VehicleCommandType type, Map<String, dynamic>? params);
|
||||||
|
|
||||||
|
static Future<(bool, Map<String, dynamic>? params)> executeCommand(
|
||||||
|
VehicleCommandType type, {Map<String, dynamic>? params}) async {
|
||||||
|
// 调用主应用注册的回调函数
|
||||||
|
// 返回执行结果元组(成功状态, 返回参数)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9. RedisService - 缓存服务
|
||||||
|
|
||||||
|
**功能**: 基于HTTP的Redis缓存操作
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class RedisService {
|
||||||
|
Future<void> setKeyValue(String key, Object value) async {
|
||||||
|
// HTTP POST设置键值对
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> getValue(String key) async {
|
||||||
|
// HTTP GET获取值
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10. LocationService - 位置服务
|
||||||
|
|
||||||
|
用于车辆定位相关功能(具体实现需查看完整代码)
|
||||||
|
|
||||||
|
### 11. VehicleStateService - 车辆状态服务
|
||||||
|
|
||||||
|
**注释代码分析**:
|
||||||
|
- 原设计基于InGeek MDK车载SDK
|
||||||
|
- 支持车辆状态实时监听
|
||||||
|
- 与Redis服务配合缓存状态数据
|
||||||
|
- 当前版本已注释,可能在插件化过程中移除
|
||||||
|
|
||||||
## 🎨 UI层架构分析
|
## 🎨 UI层架构分析
|
||||||
|
|
||||||
### 1. 主界面 (MainScreen)
|
### 1. 主界面 (MainScreen)
|
||||||
@@ -286,11 +440,11 @@ _asrChannel.setMethodCallHandler((call) async {
|
|||||||
**Markdown清理逻辑**:
|
**Markdown清理逻辑**:
|
||||||
```dart
|
```dart
|
||||||
static String cleanText(String text, bool forTts) {
|
static String cleanText(String text, bool forTts) {
|
||||||
// 1. 清理粗体/斜体标记
|
// 1. 清理粗体/斜体标记 **text** *text*
|
||||||
// 2. 处理代码块
|
// 2. 处理代码块 ```code```
|
||||||
// 3. 清理表格格式
|
// 3. 清理表格格式 |---|---|
|
||||||
// 4. 处理链接和图片
|
// 4. 处理链接和图片 [text](url) 
|
||||||
// 5. 清理列表和引用
|
// 5. 清理列表和引用 1. - * >
|
||||||
// 6. 规范化空白字符
|
// 6. 规范化空白字符
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -311,13 +465,30 @@ graph TD
|
|||||||
H --> I[文本分类]
|
H --> I[文本分类]
|
||||||
I --> J{分类结果}
|
I --> J{分类结果}
|
||||||
J -->|车控命令| K[handleVehicleControl]
|
J -->|车控命令| K[handleVehicleControl]
|
||||||
J -->|普通问答| L[answerQuestion]
|
J -->|普通问答| L[answerQuestion - ChatSseService]
|
||||||
J -->|错误问题| M[answerWrongQuestion]
|
J -->|错误问题| M[answerWrongQuestion]
|
||||||
K --> N[解析车控命令]
|
K --> N[解析车控命令]
|
||||||
N --> O[执行TTS播报]
|
N --> O[执行TTS播报]
|
||||||
O --> P[执行车控命令]
|
O --> P[执行车控命令]
|
||||||
P --> Q[生成执行反馈]
|
P --> Q[生成执行反馈]
|
||||||
Q --> R[更新UI显示]
|
Q --> R[更新UI显示]
|
||||||
|
L --> S[SSE流式对话]
|
||||||
|
S --> T[实时TTS播报]
|
||||||
|
T --> R
|
||||||
|
```
|
||||||
|
|
||||||
|
### 服务间协作关系
|
||||||
|
|
||||||
|
```
|
||||||
|
MessageService (核心协调器)
|
||||||
|
├── ChatSseService (AI对话)
|
||||||
|
│ └── LocalTtsService (流式TTS)
|
||||||
|
├── AudioRecorderService (音频录制)
|
||||||
|
├── VoiceRecognitionService (语音识别)
|
||||||
|
├── TextClassificationService (文本分类)
|
||||||
|
├── VehicleCommandService (车控解析)
|
||||||
|
│ └── CommandService (命令执行)
|
||||||
|
└── RedisService (状态缓存)
|
||||||
```
|
```
|
||||||
|
|
||||||
### 状态管理流程
|
### 状态管理流程
|
||||||
@@ -332,6 +503,11 @@ idle -> recording -> recognizing -> replying -> idle
|
|||||||
listening -> normal -> thinking -> executing -> success/failure
|
listening -> normal -> thinking -> executing -> success/failure
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**TTS任务状态**:
|
||||||
|
```
|
||||||
|
ready -> playing -> completed
|
||||||
|
```
|
||||||
|
|
||||||
## 🔧 配置与扩展
|
## 🔧 配置与扩展
|
||||||
|
|
||||||
### 1. 插件初始化
|
### 1. 插件初始化
|
||||||
@@ -352,6 +528,9 @@ ChatAssistantApp.initialize(
|
|||||||
- 文本分类: `http://143.64.185.20:18606/classify`
|
- 文本分类: `http://143.64.185.20:18606/classify`
|
||||||
- 车控解析: `http://143.64.185.20:18606/control`
|
- 车控解析: `http://143.64.185.20:18606/control`
|
||||||
- 控制反馈: `http://143.64.185.20:18606/control_resp`
|
- 控制反馈: `http://143.64.185.20:18606/control_resp`
|
||||||
|
- SSE对话: `http://143.64.185.20:18606/chat`
|
||||||
|
- 语音识别: `http://143.64.185.20:18606/voice`
|
||||||
|
- Redis缓存: `http://143.64.185.20:18606/redis/*`
|
||||||
|
|
||||||
**建议改进**: 将服务端地址配置化,支持动态配置
|
**建议改进**: 将服务端地址配置化,支持动态配置
|
||||||
|
|
||||||
@@ -377,7 +556,15 @@ ChatAssistantApp.initialize(
|
|||||||
部分网络请求缺少完善的错误处理和重试机制。
|
部分网络请求缺少完善的错误处理和重试机制。
|
||||||
|
|
||||||
### 4. 内存管理
|
### 4. 内存管理
|
||||||
长时间运行可能存在内存泄漏风险,需要关注Completer和监听器的清理。
|
长时间运行可能存在内存泄漏风险,需要关注:
|
||||||
|
- Completer的正确释放
|
||||||
|
- HTTP连接的及时关闭
|
||||||
|
- TTS任务队列的清理
|
||||||
|
|
||||||
|
### 5. 并发控制
|
||||||
|
- SSE连接的并发控制
|
||||||
|
- TTS播放队列的线程安全
|
||||||
|
- 语音识别状态的同步
|
||||||
|
|
||||||
## 🚀 扩展建议
|
## 🚀 扩展建议
|
||||||
|
|
||||||
@@ -385,21 +572,31 @@ ChatAssistantApp.initialize(
|
|||||||
- 服务端地址配置化
|
- 服务端地址配置化
|
||||||
- 支持多语言配置
|
- 支持多语言配置
|
||||||
- 主题自定义配置
|
- 主题自定义配置
|
||||||
|
- TTS引擎选择配置
|
||||||
|
|
||||||
### 2. 功能增强
|
### 2. 功能增强
|
||||||
- 添加离线语音识别支持
|
- 添加离线语音识别支持
|
||||||
- 实现消息历史持久化
|
- 实现消息历史持久化
|
||||||
- 添加更多车控命令类型
|
- 添加更多车控命令类型
|
||||||
|
- 支持自定义唤醒词
|
||||||
|
|
||||||
### 3. 性能优化
|
### 3. 性能优化
|
||||||
- 实现网络请求缓存
|
- 实现网络请求缓存
|
||||||
- 优化UI渲染性能
|
- 优化UI渲染性能
|
||||||
- 添加资源预加载
|
- 添加资源预加载
|
||||||
|
- TTS队列优化
|
||||||
|
|
||||||
### 4. 测试完善
|
### 4. 测试完善
|
||||||
- 添加单元测试
|
- 添加单元测试覆盖所有服务
|
||||||
- 集成测试覆盖
|
- 集成测试覆盖完整流程
|
||||||
- 性能测试工具
|
- 性能测试工具
|
||||||
|
- 错误场景测试
|
||||||
|
|
||||||
|
### 5. 架构优化
|
||||||
|
- 依赖注入容器
|
||||||
|
- 事件总线机制
|
||||||
|
- 插件热重载支持
|
||||||
|
- 模块化拆分
|
||||||
|
|
||||||
## 📝 开发流程建议
|
## 📝 开发流程建议
|
||||||
|
|
||||||
@@ -414,12 +611,34 @@ ChatAssistantApp.initialize(
|
|||||||
- 使用`debugPrint`添加关键节点日志
|
- 使用`debugPrint`添加关键节点日志
|
||||||
- 通过Provider DevTools监控状态变化
|
- 通过Provider DevTools监控状态变化
|
||||||
- 使用Flutter Inspector检查UI结构
|
- 使用Flutter Inspector检查UI结构
|
||||||
|
- 监控Method Channel通信日志
|
||||||
|
|
||||||
### 3. 集成测试
|
### 3. 集成测试
|
||||||
- 在example项目中测试完整流程
|
- 在example项目中测试完整流程
|
||||||
- 验证车控命令回调机制
|
- 验证车控命令回调机制
|
||||||
- 测试不同场景下的错误处理
|
- 测试不同场景下的错误处理
|
||||||
|
- 验证资源文件引用
|
||||||
|
|
||||||
|
## 💡 技术亮点总结
|
||||||
|
|
||||||
|
### 1. 流式处理架构
|
||||||
|
- SSE实时对话流
|
||||||
|
- TTS流式播报
|
||||||
|
- 有序任务队列管理
|
||||||
|
|
||||||
|
### 2. 智能语音处理
|
||||||
|
- 实时语音识别显示
|
||||||
|
- 中英文自动检测
|
||||||
|
- 智能分句算法
|
||||||
|
|
||||||
|
### 3. 解耦设计
|
||||||
|
- 插件与主应用解耦
|
||||||
|
- 服务间松耦合
|
||||||
|
- 回调机制灵活扩展
|
||||||
|
|
||||||
|
### 4. 状态管理
|
||||||
|
- 响应式UI更新
|
||||||
|
- 多层状态协调
|
||||||
|
- 异步状态同步
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
这份文档涵盖了项目的核心架构、关键代码逻辑、数据流分析和扩展建议,可以帮助您快速理解项目结构并进行后续开发。如有具体问题,可以针对特定模块进行深入分析。
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<application
|
<application
|
||||||
android:label="example"
|
android:label="AI助手"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/ic_launcher">
|
||||||
<activity
|
<activity
|
||||||
|
|||||||
3
example/devtools_options.yaml
Normal file
3
example/devtools_options.yaml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
description: This file stores settings for Dart & Flutter DevTools.
|
||||||
|
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
|
||||||
|
extensions:
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
// This is a basic Flutter widget test.
|
|
||||||
//
|
|
||||||
// To perform an interaction with a widget in your test, use the WidgetTester
|
|
||||||
// utility in the flutter_test package. For example, you can send tap and scroll
|
|
||||||
// gestures. You can also use WidgetTester to find child widgets in the widget
|
|
||||||
// tree, read text, and verify that the values of widget properties are correct.
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
|
|
||||||
import 'package:example/main.dart';
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
|
||||||
// Build our app and trigger a frame.
|
|
||||||
await tester.pumpWidget(const MyApp());
|
|
||||||
|
|
||||||
// Verify that our counter starts at 0.
|
|
||||||
expect(find.text('0'), findsOneWidget);
|
|
||||||
expect(find.text('1'), findsNothing);
|
|
||||||
|
|
||||||
// Tap the '+' icon and trigger a frame.
|
|
||||||
await tester.tap(find.byIcon(Icons.add));
|
|
||||||
await tester.pump();
|
|
||||||
|
|
||||||
// Verify that our counter has incremented.
|
|
||||||
expect(find.text('0'), findsNothing);
|
|
||||||
expect(find.text('1'), findsOneWidget);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -8,8 +8,15 @@ import '../widgets/gradient_background.dart';
|
|||||||
|
|
||||||
class PartScreen extends StatefulWidget {
|
class PartScreen extends StatefulWidget {
|
||||||
final VoidCallback? onHide;
|
final VoidCallback? onHide;
|
||||||
|
final Offset floatingIconPosition;
|
||||||
|
final double iconSize;
|
||||||
|
|
||||||
const PartScreen({super.key, this.onHide});
|
const PartScreen({
|
||||||
|
super.key,
|
||||||
|
this.onHide,
|
||||||
|
required this.floatingIconPosition,
|
||||||
|
required this.iconSize,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<PartScreen> createState() => _PartScreenState();
|
State<PartScreen> createState() => _PartScreenState();
|
||||||
@@ -58,6 +65,45 @@ class _PartScreenState extends State<PartScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 计算PartScreen的位置,使其显示在FloatingIcon附近
|
||||||
|
EdgeInsets _calculatePosition(BoxConstraints constraints) {
|
||||||
|
final screenWidth = constraints.maxWidth;
|
||||||
|
final screenHeight = constraints.maxHeight;
|
||||||
|
final iconX = screenWidth - widget.floatingIconPosition.dx;
|
||||||
|
final iconY = screenHeight - widget.floatingIconPosition.dy;
|
||||||
|
|
||||||
|
// 聊天框的尺寸
|
||||||
|
final chatWidth = screenWidth * 0.94;
|
||||||
|
final maxChatHeight = screenHeight * 0.4;
|
||||||
|
|
||||||
|
// 判断FloatingIcon在屏幕的哪一边
|
||||||
|
final isOnRightSide = iconX < screenWidth / 2;
|
||||||
|
|
||||||
|
// 计算水平位置
|
||||||
|
double leftPadding;
|
||||||
|
if (isOnRightSide) {
|
||||||
|
// 图标在右边,聊天框显示在左边
|
||||||
|
leftPadding = (screenWidth - chatWidth) * 0.1;
|
||||||
|
} else {
|
||||||
|
// 图标在左边,聊天框显示在右边
|
||||||
|
leftPadding = screenWidth - chatWidth - (screenWidth - chatWidth) * 0.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算垂直位置,确保聊天框在图标上方
|
||||||
|
double bottomPadding = iconY + widget.iconSize + 20;
|
||||||
|
|
||||||
|
// 确保聊天框不会超出屏幕
|
||||||
|
final minBottomPadding = screenHeight * 0.15;
|
||||||
|
final maxBottomPadding = screenHeight - maxChatHeight - 50;
|
||||||
|
bottomPadding = bottomPadding.clamp(minBottomPadding, maxBottomPadding);
|
||||||
|
|
||||||
|
return EdgeInsets.only(
|
||||||
|
left: leftPadding,
|
||||||
|
right: screenWidth - leftPadding - chatWidth,
|
||||||
|
bottom: bottomPadding,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -67,9 +113,12 @@ class _PartScreenState extends State<PartScreen> {
|
|||||||
if (!_isInitialized) {
|
if (!_isInitialized) {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final position = _calculatePosition(constraints);
|
||||||
final double minHeight = constraints.maxHeight * 0.16;
|
final double minHeight = constraints.maxHeight * 0.16;
|
||||||
final double maxHeight = constraints.maxHeight * 0.4;
|
final double maxHeight = constraints.maxHeight * 0.4;
|
||||||
final double chatWidth = constraints.maxWidth * 0.94;
|
final double chatWidth = constraints.maxWidth * 0.94;
|
||||||
|
|
||||||
return Stack(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
@@ -92,71 +141,81 @@ class _PartScreenState extends State<PartScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Padding(
|
Positioned(
|
||||||
padding: EdgeInsets.only(bottom: constraints.maxHeight * 0.22),
|
left: position.left,
|
||||||
child: Align(
|
right: position.right,
|
||||||
alignment: Alignment.bottomCenter,
|
bottom: position.bottom,
|
||||||
child: Consumer<MessageService>(
|
child: Consumer<MessageService>(
|
||||||
builder: (context, messageService, child) {
|
builder: (context, messageService, child) {
|
||||||
final messageCount = messageService.messages.length;
|
final messageCount = messageService.messages.length;
|
||||||
if (messageCount > _lastMessageCount) {
|
if (messageCount > _lastMessageCount) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
_scrollToBottom();
|
_scrollToBottom();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
_lastMessageCount = messageCount;
|
_lastMessageCount = messageCount;
|
||||||
return Container(
|
|
||||||
width: chatWidth,
|
return TweenAnimationBuilder<double>(
|
||||||
constraints: BoxConstraints(
|
tween: Tween<double>(begin: 0.8, end: 1.0),
|
||||||
minHeight: minHeight,
|
duration: const Duration(milliseconds: 200),
|
||||||
maxHeight: maxHeight,
|
curve: Curves.easeOutBack,
|
||||||
),
|
builder: (context, scale, child) {
|
||||||
child: ClipRRect(
|
return Transform.scale(
|
||||||
borderRadius: BorderRadius.circular(24),
|
scale: scale,
|
||||||
child: BackdropFilter(
|
child: Container(
|
||||||
filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12),
|
width: chatWidth,
|
||||||
child: GradientBackground(
|
constraints: BoxConstraints(
|
||||||
colors: const [
|
minHeight: minHeight,
|
||||||
Color(0XBF3B0A3F),
|
maxHeight: maxHeight,
|
||||||
Color(0xBF0E0E24),
|
),
|
||||||
Color(0xBF0C0B33),
|
child: ClipRRect(
|
||||||
],
|
borderRadius: BorderRadius.circular(24),
|
||||||
borderRadius: BorderRadius.circular(6),
|
child: BackdropFilter(
|
||||||
child: Stack(
|
filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12),
|
||||||
children: [
|
child: GradientBackground(
|
||||||
Padding(
|
colors: const [
|
||||||
padding: const EdgeInsets.only(
|
Color(0XBF3B0A3F),
|
||||||
top: 50, left: 6, right: 6, bottom: 30),
|
Color(0xBF0E0E24),
|
||||||
child: LayoutBuilder(
|
Color(0xBF0C0B33),
|
||||||
builder: (context, boxConstraints) {
|
],
|
||||||
return ChatBox(
|
borderRadius: BorderRadius.circular(6),
|
||||||
scrollController: _scrollController,
|
child: Stack(
|
||||||
messages: messageService.messages,
|
children: [
|
||||||
);
|
Padding(
|
||||||
}),
|
padding: const EdgeInsets.only(
|
||||||
),
|
top: 50, left: 6, right: 6, bottom: 30),
|
||||||
Positioned(
|
child: LayoutBuilder(
|
||||||
top: 6,
|
builder: (context, boxConstraints) {
|
||||||
right: 6,
|
return ChatBox(
|
||||||
child: IconButton(
|
scrollController: _scrollController,
|
||||||
icon: Image.asset(
|
messages: messageService.messages,
|
||||||
'assets/images/open_in_full.png',
|
);
|
||||||
width: 24,
|
}),
|
||||||
height: 24,
|
|
||||||
package: 'ai_chat_assistant',
|
|
||||||
),
|
),
|
||||||
onPressed: _openFullScreen,
|
Positioned(
|
||||||
padding: EdgeInsets.zero,
|
top: 6,
|
||||||
),
|
right: 6,
|
||||||
|
child: IconButton(
|
||||||
|
icon: Image.asset(
|
||||||
|
'assets/images/open_in_full.png',
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
package: 'ai_chat_assistant',
|
||||||
|
),
|
||||||
|
onPressed: _openFullScreen,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
},
|
||||||
},
|
);
|
||||||
),
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -135,7 +135,9 @@ class _ChatFooterState extends State<ChatFooter>
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 12, right: 16),
|
padding: const EdgeInsets.only(bottom: 12, right: 16),
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () {},
|
onTap: () {
|
||||||
|
|
||||||
|
},
|
||||||
child: Image.asset(
|
child: Image.asset(
|
||||||
'assets/images/keyboard_mini.png',package: 'ai_chat_assistant',
|
'assets/images/keyboard_mini.png',package: 'ai_chat_assistant',
|
||||||
width: 24,
|
width: 24,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import '../services/message_service.dart';
|
|||||||
import '../screens/full_screen.dart';
|
import '../screens/full_screen.dart';
|
||||||
import '../screens/part_screen.dart';
|
import '../screens/part_screen.dart';
|
||||||
import 'floating_icon_with_wave.dart';
|
import 'floating_icon_with_wave.dart';
|
||||||
import 'dart:async'; // 添加此行
|
import 'dart:async';
|
||||||
import 'package:basic_intl/intl.dart';
|
import 'package:basic_intl/intl.dart';
|
||||||
|
|
||||||
class FloatingIcon extends StatefulWidget {
|
class FloatingIcon extends StatefulWidget {
|
||||||
@@ -14,15 +14,25 @@ class FloatingIcon extends StatefulWidget {
|
|||||||
State<FloatingIcon> createState() => _FloatingIconState();
|
State<FloatingIcon> createState() => _FloatingIconState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _FloatingIconState extends State<FloatingIcon>
|
class _FloatingIconState extends State<FloatingIcon> with TickerProviderStateMixin {
|
||||||
with SingleTickerProviderStateMixin {
|
|
||||||
Offset _position = const Offset(10, 120);
|
Offset _position = const Offset(10, 120);
|
||||||
final iconSize = 80.0;
|
final iconSize = 80.0;
|
||||||
bool _isShowPartScreen = false;
|
bool _isShowPartScreen = false;
|
||||||
|
bool _isDragging = false;
|
||||||
|
|
||||||
|
bool _isAnimating = false;
|
||||||
|
Offset _targetPosition = const Offset(10, 120);
|
||||||
|
|
||||||
|
// 水波纹动画控制器
|
||||||
late AnimationController _waveAnimationController;
|
late AnimationController _waveAnimationController;
|
||||||
|
// PartScreen 显示动画控制器
|
||||||
|
late AnimationController _partScreenAnimationController;
|
||||||
|
// 图片切换定时器
|
||||||
|
Timer? _imageTimer;
|
||||||
|
// 添加长按状态标记
|
||||||
|
bool _isLongPressing = false;
|
||||||
|
|
||||||
// 新增:图片切换相关
|
// 图片切换相关
|
||||||
int _imageIndex = 0;
|
int _imageIndex = 0;
|
||||||
late final List<String> _iconImages = [
|
late final List<String> _iconImages = [
|
||||||
'assets/images/ai1_hd.png',
|
'assets/images/ai1_hd.png',
|
||||||
@@ -33,7 +43,6 @@ class _FloatingIconState extends State<FloatingIcon>
|
|||||||
'assets/images/ai1_hd_en.png',
|
'assets/images/ai1_hd_en.png',
|
||||||
'assets/images/ai0_hd_en.png',
|
'assets/images/ai0_hd_en.png',
|
||||||
];
|
];
|
||||||
Timer? _imageTimer; // 用于定时切换图片
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -42,18 +51,28 @@ class _FloatingIconState extends State<FloatingIcon>
|
|||||||
duration: const Duration(milliseconds: 1000),
|
duration: const Duration(milliseconds: 1000),
|
||||||
vsync: this,
|
vsync: this,
|
||||||
);
|
);
|
||||||
// 使用Timer.periodic定时切换图片
|
|
||||||
|
// PartScreen动画控制器
|
||||||
|
_partScreenAnimationController = AnimationController(
|
||||||
|
duration: const Duration(milliseconds: 250),
|
||||||
|
vsync: this,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 图片切换定时器
|
||||||
_imageTimer = Timer.periodic(const Duration(seconds: 3), (timer) {
|
_imageTimer = Timer.periodic(const Duration(seconds: 3), (timer) {
|
||||||
setState(() {
|
if (mounted && !_isDragging) {
|
||||||
_imageIndex = (_imageIndex + 1) % _iconImages.length;
|
setState(() {
|
||||||
});
|
_imageIndex = (_imageIndex + 1) % _iconImages.length;
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_waveAnimationController.dispose();
|
_waveAnimationController.dispose();
|
||||||
_imageTimer?.cancel(); // 释放Timer
|
_partScreenAnimationController.dispose();
|
||||||
|
_imageTimer?.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,14 +80,20 @@ class _FloatingIconState extends State<FloatingIcon>
|
|||||||
setState(() {
|
setState(() {
|
||||||
_isShowPartScreen = true;
|
_isShowPartScreen = true;
|
||||||
});
|
});
|
||||||
|
_partScreenAnimationController.forward();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _hidePartScreen() {
|
void _hidePartScreen() {
|
||||||
setState(() {
|
_partScreenAnimationController.reverse().then((_) {
|
||||||
_isShowPartScreen = false;
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isShowPartScreen = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 显示全屏界面
|
||||||
void _showFullScreen() async {
|
void _showFullScreen() async {
|
||||||
_hidePartScreen();
|
_hidePartScreen();
|
||||||
await Navigator.of(context).push(
|
await Navigator.of(context).push(
|
||||||
@@ -78,50 +103,106 @@ class _FloatingIconState extends State<FloatingIcon>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 更新位置
|
||||||
void _updatePosition(Offset delta) {
|
void _updatePosition(Offset delta) {
|
||||||
|
if (!_isDragging) return;
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
final screenSize = MediaQuery.of(context).size;
|
final screenSize = MediaQuery.of(context).size;
|
||||||
double newX =
|
double newX = (_position.dx - delta.dx).clamp(0.0, screenSize.width - iconSize);
|
||||||
(_position.dx - delta.dx).clamp(0.0, screenSize.width - iconSize);
|
double newY = (_position.dy - delta.dy).clamp(0.0, screenSize.height - iconSize);
|
||||||
double newY =
|
|
||||||
(_position.dy - delta.dy).clamp(0.0, screenSize.height - iconSize);
|
|
||||||
_position = Offset(newX, newY);
|
_position = Offset(newX, newY);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 吸附到屏幕边缘
|
||||||
|
void _snapToEdge() {
|
||||||
|
final screenSize = MediaQuery.of(context).size;
|
||||||
|
final centerX = screenSize.width / 2;
|
||||||
|
|
||||||
|
// 判断应该吸到左边还是右边
|
||||||
|
final shouldSnapToLeft = _position.dx < centerX - iconSize / 2;
|
||||||
|
final targetX = shouldSnapToLeft ? 0.0 : screenSize.width - iconSize;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_targetPosition = Offset(targetX, _position.dy);
|
||||||
|
_isAnimating = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 使用定时器在动画完成后更新状态
|
||||||
|
Timer(const Duration(milliseconds: 100), () {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_position = _targetPosition;
|
||||||
|
_isAnimating = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPanStart(DragStartDetails details) {
|
||||||
|
_isDragging = true;
|
||||||
|
_waveAnimationController.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPanEnd(DragEndDetails details) {
|
||||||
|
_isDragging = false;
|
||||||
|
_snapToEdge();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
// 奇怪的代码,不应该放在build里面
|
||||||
|
// WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
// if (messageService.isRecording) {
|
||||||
|
// if (!_waveAnimationController.isAnimating) {
|
||||||
|
// _waveAnimationController.repeat();
|
||||||
|
// }
|
||||||
|
// } else {
|
||||||
|
// if (_waveAnimationController.isAnimating) {
|
||||||
|
// _waveAnimationController.stop();
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
|
||||||
return Stack(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
if (_isShowPartScreen)
|
if (_isShowPartScreen)
|
||||||
PartScreen(
|
FadeTransition(
|
||||||
onHide: _hidePartScreen,
|
opacity: _partScreenAnimationController,
|
||||||
|
child: SlideTransition(
|
||||||
|
position: Tween<Offset>(
|
||||||
|
begin: const Offset(0, 0.3),
|
||||||
|
end: Offset.zero,
|
||||||
|
).animate(CurvedAnimation(
|
||||||
|
parent: _partScreenAnimationController,
|
||||||
|
curve: Curves.easeOutQuart,
|
||||||
|
)),
|
||||||
|
child: PartScreen(
|
||||||
|
onHide: _hidePartScreen,
|
||||||
|
floatingIconPosition: _position,
|
||||||
|
iconSize: iconSize,
|
||||||
|
)),
|
||||||
),
|
),
|
||||||
Positioned(
|
AnimatedPositioned(
|
||||||
bottom: _position.dy,
|
duration: const Duration(milliseconds: 250),
|
||||||
right: _position.dx,
|
curve: Curves.easeOutBack,
|
||||||
|
bottom: _isAnimating ? _targetPosition.dy : _position.dy,
|
||||||
|
right: _isAnimating ? _targetPosition.dx : _position.dx,
|
||||||
child: Consumer<MessageService>(
|
child: Consumer<MessageService>(
|
||||||
builder: (context, messageService, child) {
|
builder: (context, messageService, child) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
if (messageService.isRecording) {
|
|
||||||
if (!_waveAnimationController.isAnimating) {
|
|
||||||
_waveAnimationController.repeat();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (_waveAnimationController.isAnimating) {
|
|
||||||
_waveAnimationController.stop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: _showFullScreen,
|
onTap: _showFullScreen,
|
||||||
onLongPress: () async {
|
onLongPress: () async {
|
||||||
|
debugPrint('Long press');
|
||||||
|
_isLongPressing = true; // 标记开始长按
|
||||||
_showPartScreen();
|
_showPartScreen();
|
||||||
_waveAnimationController.repeat();
|
_waveAnimationController.repeat();
|
||||||
await messageService.startVoiceInput();
|
await messageService.startVoiceInput();
|
||||||
},
|
},
|
||||||
onLongPressUp: () async {
|
onLongPressUp: () async {
|
||||||
|
debugPrint('Long press up');
|
||||||
|
_isLongPressing = false; // 清除长按标记
|
||||||
_waveAnimationController.stop();
|
_waveAnimationController.stop();
|
||||||
await messageService.stopAndProcessVoiceInput();
|
await messageService.stopAndProcessVoiceInput();
|
||||||
final hasMessage = messageService.messages.isNotEmpty;
|
final hasMessage = messageService.messages.isNotEmpty;
|
||||||
@@ -129,23 +210,49 @@ class _FloatingIconState extends State<FloatingIcon>
|
|||||||
_hidePartScreen();
|
_hidePartScreen();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onPanUpdate: (details) => _updatePosition(details.delta),
|
onLongPressEnd: (d) {
|
||||||
child: messageService.isRecording
|
debugPrint('Long press end $d');
|
||||||
? FloatingIconWithWave(
|
_isLongPressing = false; // 清除长按标记
|
||||||
animationController: _waveAnimationController,
|
},
|
||||||
iconSize: iconSize,
|
onPanStart: (details) {
|
||||||
waveColor: Colors.white,
|
// 只有在非长按状态下才允许拖拽
|
||||||
)
|
if (!_isLongPressing) {
|
||||||
: Image.asset(
|
_onPanStart(details);
|
||||||
Intl.getCurrentLocale().startsWith('zh')
|
}
|
||||||
? _iconImages[_imageIndex]:_iconImagesEn[_imageIndex],
|
},
|
||||||
width: iconSize,
|
onPanUpdate: (details) {
|
||||||
height: iconSize,package: 'ai_chat_assistant',
|
// 只有在拖拽状态下才更新位置
|
||||||
),
|
if (_isDragging && !_isLongPressing) {
|
||||||
|
_updatePosition(details.delta);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onPanEnd: (details) {
|
||||||
|
if (!_isLongPressing) {
|
||||||
|
_onPanEnd(details);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: AnimatedScale(
|
||||||
|
scale: _isDragging ? 1.1 : 1.0,
|
||||||
|
duration: const Duration(milliseconds: 150),
|
||||||
|
child: messageService.isRecording
|
||||||
|
? FloatingIconWithWave(
|
||||||
|
animationController: _waveAnimationController,
|
||||||
|
iconSize: iconSize,
|
||||||
|
waveColor: Colors.white,
|
||||||
|
)
|
||||||
|
: Image.asset(
|
||||||
|
Intl.getCurrentLocale().startsWith('zh')
|
||||||
|
? _iconImages[_imageIndex]
|
||||||
|
: _iconImagesEn[_imageIndex],
|
||||||
|
width: iconSize,
|
||||||
|
height: iconSize,
|
||||||
|
package: 'ai_chat_assistant',
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
)
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user