From 130755f9e1aeebb894d7b161edfd6c56ca65a53f Mon Sep 17 00:00:00 2001 From: Chen Li <422043296@qq.com> Date: Tue, 12 Aug 2025 13:36:42 +0800 Subject: [PATCH] 0812 --- android/app/build.gradle.kts | 4 +- android/app/src/debug/AndroidManifest.xml | 1 + android/app/src/main/AndroidManifest.xml | 5 +- .../ai_chat_assistant/MainActivity.java | 6 + android/app/src/profile/AndroidManifest.xml | 2 + android/build.gradle.kts | 1 + .../reports/problems/problems-report.html | 663 ++++++++++++++++++ android/gradle.properties | 4 +- lib/app.dart | 22 +- lib/config/database_helper.dart | 56 -- lib/enums/message_service_state.dart | 6 + lib/enums/message_status.dart | 16 + lib/enums/vehicle_command_type.dart | 29 + lib/main.dart | 16 +- lib/models/chat_message.dart | 8 +- lib/models/message_model.dart | 13 - lib/models/vehicle_cmd.dart | 36 + lib/models/vehicle_cmd_response.dart | 11 + lib/models/vehicle_status_info.dart | 90 +++ lib/screens/chat_screen.dart | 210 ------ lib/screens/full_screen.dart | 88 +++ lib/screens/main_screen.dart | 41 ++ lib/screens/part_screen.dart | 171 +++++ lib/services/audio_recorder_service.dart | 83 +++ lib/services/chat_sse_service.dart | 123 ++++ lib/services/classification_service.dart | 27 + lib/services/command_service.dart | 126 ++++ lib/services/control_recognition_service.dart | 69 ++ lib/services/location_service.dart | 20 + lib/services/message_service.dart | 381 ++++++++++ lib/services/redis_service.dart | 33 + lib/services/tts_service.dart | 608 ++++++++++++++++ lib/services/vehicle_state_service.dart | 98 +++ lib/services/voice_recognition_service.dart | 61 ++ .../theme.dart => themes/AppTheme.dart} | 0 lib/utils/common_util.dart | 60 ++ lib/utils/tts_engine_manager.dart | 56 ++ .../{ai_avatar.dart => assistant_avatar.dart} | 8 +- lib/widgets/chat_box.dart | 35 + lib/widgets/chat_bubble.dart | 348 ++++++++- lib/widgets/chat_footer.dart | 91 +++ lib/widgets/chat_header.dart | 76 ++ lib/widgets/chat_input.dart | 351 ---------- lib/widgets/floating_icon.dart | 98 +++ lib/widgets/gradient_background.dart | 21 +- lib/widgets/voice_animation.dart | 113 +++ pubspec.yaml | 104 +-- 47 files changed, 3728 insertions(+), 761 deletions(-) create mode 100644 android/app/src/main/java/com/example/ai_chat_assistant/MainActivity.java create mode 100644 android/build/reports/problems/problems-report.html delete mode 100644 lib/config/database_helper.dart create mode 100644 lib/enums/message_service_state.dart create mode 100644 lib/enums/message_status.dart create mode 100644 lib/enums/vehicle_command_type.dart delete mode 100644 lib/models/message_model.dart create mode 100644 lib/models/vehicle_cmd.dart create mode 100644 lib/models/vehicle_cmd_response.dart create mode 100644 lib/models/vehicle_status_info.dart delete mode 100644 lib/screens/chat_screen.dart create mode 100644 lib/screens/full_screen.dart create mode 100644 lib/screens/main_screen.dart create mode 100644 lib/screens/part_screen.dart create mode 100644 lib/services/audio_recorder_service.dart create mode 100644 lib/services/chat_sse_service.dart create mode 100644 lib/services/classification_service.dart create mode 100644 lib/services/command_service.dart create mode 100644 lib/services/control_recognition_service.dart create mode 100644 lib/services/location_service.dart create mode 100644 lib/services/message_service.dart create mode 100644 lib/services/redis_service.dart create mode 100644 lib/services/tts_service.dart create mode 100644 lib/services/vehicle_state_service.dart create mode 100644 lib/services/voice_recognition_service.dart rename lib/{config/theme.dart => themes/AppTheme.dart} (100%) create mode 100644 lib/utils/common_util.dart create mode 100644 lib/utils/tts_engine_manager.dart rename lib/widgets/{ai_avatar.dart => assistant_avatar.dart} (73%) create mode 100644 lib/widgets/chat_box.dart create mode 100644 lib/widgets/chat_footer.dart create mode 100644 lib/widgets/chat_header.dart delete mode 100644 lib/widgets/chat_input.dart create mode 100644 lib/widgets/floating_icon.dart create mode 100644 lib/widgets/voice_animation.dart diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 9f11899..c444ffa 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -6,7 +6,7 @@ plugins { } android { - namespace = "com.example.app003" + namespace = "com.example.ai_chat_assistant" compileSdk = flutter.compileSdkVersion ndkVersion = "29.0.13599879" @@ -21,7 +21,7 @@ android { defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId = "com.example.app003" + applicationId = "com.example.ai_chat_assistant" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = 24 diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml index 399f698..d3aa09c 100644 --- a/android/app/src/debug/AndroidManifest.xml +++ b/android/app/src/debug/AndroidManifest.xml @@ -4,4 +4,5 @@ to allow setting breakpoints, to provide hot reload, etc. --> + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 83953f6..fbfedbd 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,6 @@ + + + diff --git a/android/app/src/main/java/com/example/ai_chat_assistant/MainActivity.java b/android/app/src/main/java/com/example/ai_chat_assistant/MainActivity.java new file mode 100644 index 0000000..250bda3 --- /dev/null +++ b/android/app/src/main/java/com/example/ai_chat_assistant/MainActivity.java @@ -0,0 +1,6 @@ +package com.example.ai_chat_assistant; + +import io.flutter.embedding.android.FlutterActivity; + +public class MainActivity extends FlutterActivity { +} diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml index c872ab8..2debf5a 100644 --- a/android/app/src/profile/AndroidManifest.xml +++ b/android/app/src/profile/AndroidManifest.xml @@ -9,4 +9,6 @@ + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts index b17e8f3..0a2b9d0 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -5,6 +5,7 @@ allprojects { maven { url = uri("https://maven.aliyun.com/repository/public/") } maven { url = uri("https://maven.aliyun.com/repository/google/") } maven { url = uri("https://maven.aliyun.com/repository/gradle-plugin/") } + maven { url = uri("https://storage.googleapis.com/download.flutter.io")} } } diff --git a/android/build/reports/problems/problems-report.html b/android/build/reports/problems/problems-report.html new file mode 100644 index 0000000..cc1a359 --- /dev/null +++ b/android/build/reports/problems/problems-report.html @@ -0,0 +1,663 @@ + + + + + + + + + + + + + Gradle Configuration Cache + + + +
+ +
+ Loading... +
+ + + + + + diff --git a/android/gradle.properties b/android/gradle.properties index 8bf6f1f..6667208 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,5 +1,5 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true android.enableJetifier=true -systemProp.https.proxyHost=127.0.0.1 -systemProp.https.proxyPort=7890 \ No newline at end of file +#systemProp.https.proxyHost=127.0.0.1 +#systemProp.https.proxyPort=7890 \ No newline at end of file diff --git a/lib/app.dart b/lib/app.dart index b84622b..81e730a 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -1,18 +1,26 @@ +import 'package:ai_chat_assistant/themes/AppTheme.dart'; import 'package:flutter/material.dart'; -import 'config/theme.dart'; -import 'screens/chat_screen.dart'; +import 'services/command_service.dart'; +import 'screens/main_screen.dart'; -class AIChatApp extends StatelessWidget { - const AIChatApp({super.key}); +class ChatAssistantApp extends StatelessWidget { + const ChatAssistantApp({super.key}); + + /// 初始化聊天助手 + /// + /// [commandCallback] 接收来自AI的车控命令并执行 + static void initialize({required CommandCallback commandCallback}) { + CommandService.registerCallback(commandCallback); + } @override Widget build(BuildContext context) { return MaterialApp( - title: 'AI Chat Assistant', + title: 'ai_chat_assistant', theme: AppTheme.lightTheme, darkTheme: AppTheme.darkTheme, themeMode: ThemeMode.system, - home: const ChatScreen(), + home: const MainScreen(), ); } -} \ No newline at end of file +} diff --git a/lib/config/database_helper.dart b/lib/config/database_helper.dart deleted file mode 100644 index 3aa4799..0000000 --- a/lib/config/database_helper.dart +++ /dev/null @@ -1,56 +0,0 @@ -import 'dart:io'; -import 'package:sqflite/sqflite.dart'; -import 'package:path/path.dart'; -import 'package:path_provider/path_provider.dart'; - -class DatabaseHelper { - static final DatabaseHelper _instance = DatabaseHelper._internal(); - factory DatabaseHelper() => _instance; - static Database? _database; - - DatabaseHelper._internal(); - - Future get database async { - if (_database != null) return _database!; - _database = await _initDatabase(); - return _database!; - } - - Future _initDatabase() async { - Directory documentsDirectory = await getApplicationDocumentsDirectory(); - String path = join(documentsDirectory.path, 'chat.db'); - return await openDatabase( - path, - version: 1, - onCreate: _onCreate, - ); - } - - Future _onCreate(Database db, int version) async { - await db.execute(''' - CREATE TABLE messages ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - role TEXT NOT NULL, - message TEXT NOT NULL, - timestamp INTEGER DEFAULT (strftime('%s', 'now')) - '''); - } - - // 插入一条消息 - Future insertMessage(Map message) async { - Database db = await database; - return await db.insert('messages', message); - } - - // 获取所有消息(按时间排序) - Future>> getMessages() async { - Database db = await database; - return await db.query('messages', orderBy: 'timestamp ASC'); - } - - // 删除所有消息 - Future clearMessages() async { - Database db = await database; - return await db.delete('messages'); - } -} \ No newline at end of file diff --git a/lib/enums/message_service_state.dart b/lib/enums/message_service_state.dart new file mode 100644 index 0000000..1531820 --- /dev/null +++ b/lib/enums/message_service_state.dart @@ -0,0 +1,6 @@ +enum MessageServiceState { + idle, + recording, + recognizing, + replying, +} \ No newline at end of file diff --git a/lib/enums/message_status.dart b/lib/enums/message_status.dart new file mode 100644 index 0000000..1692036 --- /dev/null +++ b/lib/enums/message_status.dart @@ -0,0 +1,16 @@ +enum MessageStatus { + normal('普通消息', 'Normal'), + listening('聆听中', 'Listening'), + recognizing('识别中', 'Recognizing'), + thinking('思考中', 'Thinking'), + completed('完成回答', 'Completed'), + executing('执行中', 'Executing'), + success('执行成功', 'Success'), + failure('执行失败', 'Failure'), + aborted('已中止', 'Aborted'); + + const MessageStatus(this.chinese, this.english); + + final String chinese; + final String english; +} diff --git a/lib/enums/vehicle_command_type.dart b/lib/enums/vehicle_command_type.dart new file mode 100644 index 0000000..56af941 --- /dev/null +++ b/lib/enums/vehicle_command_type.dart @@ -0,0 +1,29 @@ +enum VehicleCommandType { + unknown('未知', 'unknown'), + lock('上锁车门', 'lock'), + unlock('解锁车门', 'unlock'), + openWindow('打开车窗', 'open window'), + closeWindow('关闭车窗', 'close window'), + appointAC('预约空调', 'appoint AC'), + openAC('打开空调', 'open AC'), + closeAC('关闭空调', 'close AC'), + changeACTemp('修改空调温度', 'change AC temperature'), + coolSharply('极速降温', 'cool sharply'), + prepareCar('一键备车', 'prepare car'), + meltSnow('一键融雪', 'melt snow'), + openTrunk('打开后备箱', 'open trunk'), + closeTrunk('关闭后备箱', 'close trunk'), + honk('鸣笛', 'honk'), + locateCar('定位车辆', 'locate car'), + openWheelHeat('开启方向盘加热', 'open wheel heat'), + closeWheelHeat('关闭方向盘加热', 'close wheel heat'), + openMainSeatHeat('开启主座椅加热', 'open main seat heat'), + closeMainSeatHeat('关闭主座椅加热', 'close main seat heat'), + openMinorSeatHeat('开启副座椅加热', 'open minor seat heat'), + closeMinorSeatHeat('关闭副座椅加热', 'close minor seat heat'); + + const VehicleCommandType(this.chinese, this.english); + + final String chinese; + final String english; +} \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 6964b0c..10b0e39 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,6 +1,18 @@ import 'package:flutter/material.dart'; +import 'package:permission_handler/permission_handler.dart'; import 'app.dart'; +import 'package:provider/provider.dart'; +import 'services/message_service.dart'; -void main() { - runApp(const AIChatApp()); +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + if (!await Permission.microphone.isGranted) { + await Permission.microphone.request(); + } + runApp( + ChangeNotifierProvider( + create: (_) => MessageService(), + child: const ChatAssistantApp(), + ), + ); } diff --git a/lib/models/chat_message.dart b/lib/models/chat_message.dart index 751ab39..110aa6d 100644 --- a/lib/models/chat_message.dart +++ b/lib/models/chat_message.dart @@ -1,11 +1,17 @@ +import '../enums/message_status.dart'; + class ChatMessage { + final String id; final String text; final bool isUser; final DateTime timestamp; + MessageStatus status; ChatMessage({ + required this.id, required this.text, required this.isUser, required this.timestamp, + this.status = MessageStatus.normal, }); -} \ No newline at end of file +} diff --git a/lib/models/message_model.dart b/lib/models/message_model.dart deleted file mode 100644 index ac86645..0000000 --- a/lib/models/message_model.dart +++ /dev/null @@ -1,13 +0,0 @@ -class Message { - final String role; // 'user' 或 'assistant' - final String message; - - Message({required this.role, required this.message}); - - Map toMap() { - return { - 'role': role, - 'message': message, - }; - } -} \ No newline at end of file diff --git a/lib/models/vehicle_cmd.dart b/lib/models/vehicle_cmd.dart new file mode 100644 index 0000000..0ca9732 --- /dev/null +++ b/lib/models/vehicle_cmd.dart @@ -0,0 +1,36 @@ +import '../enums/vehicle_command_type.dart'; + +class VehicleCommand { + final VehicleCommandType type; + final Map? params; + final String error; + + VehicleCommand({required this.type, this.params, this.error = ''}); + + // 从字符串创建命令(用于从API响应解析) + factory VehicleCommand.fromString( + String commandStr, Map? params, String error) { + // 将字符串转换为枚举值 + VehicleCommandType type; + try { + type = VehicleCommandType.values.firstWhere( + (e) => e.name == commandStr, + orElse: () => VehicleCommandType.unknown, + ); + } catch (e) { + print('Error parsing command string: $e'); + // 默认为搜车指令,或者你可以选择抛出异常 + type = VehicleCommandType.unknown; + } + + return VehicleCommand(type: type, params: params, error: error); + } + + // 将命令转换为字符串(用于API请求) + String get commandString => type.name; + + @override + String toString() { + return 'VehicleCommand(command: ${type.name}, params: $params)'; + } +} diff --git a/lib/models/vehicle_cmd_response.dart b/lib/models/vehicle_cmd_response.dart new file mode 100644 index 0000000..e5c18dd --- /dev/null +++ b/lib/models/vehicle_cmd_response.dart @@ -0,0 +1,11 @@ +import 'package:ai_chat_assistant/models/vehicle_cmd.dart'; + +class VehicleCommandResponse { + final String? tips; + final List commands; + + VehicleCommandResponse({ + this.tips, + required this.commands, + }); +} diff --git a/lib/models/vehicle_status_info.dart b/lib/models/vehicle_status_info.dart new file mode 100644 index 0000000..af7dc63 --- /dev/null +++ b/lib/models/vehicle_status_info.dart @@ -0,0 +1,90 @@ +class VehicleStatusInfo { + /// 对应车架号 + String vin = ""; + + /// 剩余里程 + double remainingMileage = 0; + + /// 空调开关状态 开启true,false关闭 + bool acState = false; + + /// 空调温度 未拿到、不支持都返回0;原始值 + double acTemp = 0; + + /// AC开关 true打开,false关闭 + bool acSwitch = false; + + /// 左前锁状态 true解锁,false闭锁 + bool leftFrontLockState = false; + + /// 右前锁状态 true解锁,false闭锁 + bool rightFrontLockState = false; + + /// 左后锁状态 true解锁,false闭锁 + bool leftRearLockState = false; + + /// 右后锁状态 true解锁,false闭锁 + bool rightRearLockState = false; + + /// 左前门状态 true打开,false关闭 + bool leftFrontDoorState = false; + + /// 右前门状态 true打开,false关闭 + bool rightFrontDoorState = false; + + /// 左后门状态 true打开,false关闭 + bool leftRearDoorState = false; + + /// 右后门状态 true打开,false关闭 + bool rightRearDoorState = false; + + /// 左前窗状态 true打开,false关闭 + bool leftFrontWindowState = false; + + /// 右前窗状态 true打开,false关闭 + bool rightFrontWindowState = false; + + /// 左后窗状态 true打开,false关闭 + bool leftRearWindowState = false; + + /// 右后窗状态 true打开,false关闭 + bool rightRearWindowState = false; + + /// 后备箱状态 true打开,false关闭 + bool trunkState = false; + + /// 电量百分比 + int soc = 0; + + /// 车内温度 + double temperatureInside = 0; + + /// 方向盘加热状态 false:未加热 true:加热中 + bool wheelHeat = false; + + /// 主座椅加热状态 false:未加热 true:加热中 + bool mainSeatHeat = false; + + /// 副座椅加热档位 false:未加热 true:加热中 + bool minorSeatHeat = false; + + /// 是否行驶中,仅用于车控前置条件判断 + bool isDriving = false; + + /// 会员是否过期 true过期,false不过期 + bool vipExpired = false; + + /// 会员有效期,时间戳,单位毫秒 + int vipTime = 0; + + /// 距离过期还剩多少天 + int vipRemainCount = 0; + + /// 车况更新时间 ms + int statusUpdateTime = 0; + + @override + String toString() { + return 'IGKVehicleStatusInfo{vin:$vin, LockState:($leftFrontLockState,$rightFrontLockState,$leftRearLockState,$rightRearLockState), DoorState:($leftFrontDoorState,$rightFrontDoorState,$leftRearDoorState,$rightRearDoorState), WindowState: ($leftFrontWindowState,$rightFrontWindowState,$leftRearWindowState,$rightRearWindowState), trunkState: $trunkState, soc: $soc, remainingMileage: $remainingMileage, acState: $acState, acTemp: $acTemp, acSwitch:$acSwitch, isDriving:$isDriving, vipExpired: $vipExpired, vipTime: $vipTime, vipRemainCount: $vipRemainCount}'; + } +} diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart deleted file mode 100644 index d5bb6ef..0000000 --- a/lib/screens/chat_screen.dart +++ /dev/null @@ -1,210 +0,0 @@ -import 'package:flutter/material.dart'; -import '../widgets/ai_avatar.dart'; -import '../widgets/chat_bubble.dart'; -import '../widgets/chat_input.dart'; -import '../widgets/gradient_background.dart'; -import '../models/chat_message.dart'; -//import 'dart:convert'; -import '../config/database_helper.dart'; -import '../models/message_model.dart'; - -class ChatScreen extends StatefulWidget { - const ChatScreen({super.key}); - - @override - State createState() => _ChatScreenState(); -} - -class _ChatScreenState extends State { - final List _messages = []; - int? _currentStreamingMessageIndex; - int _lastUserMessageCount = 0; // 添加这个变量跟踪用户消息数量 - - final DatabaseHelper _dbHelper = DatabaseHelper(); - List _messagesDB = []; - - void _handleSendMessage(String text) { - if (text.trim().isEmpty) return; - setState(() { - _messages.add(ChatMessage( - text: text, - isUser: true, - timestamp: DateTime.now(), - )); - _lastUserMessageCount = _getUserMessageCount(); // 更新用户消息计数 - _currentStreamingMessageIndex = null; // 重置流索引,强制创建新气泡 - }); - } - - // 处理语音识别后的文本 - void _handleAIResponse(String text) { - setState(() { - _messages.add(ChatMessage( - text: text, - isUser: true, // 用户的消息 - timestamp: DateTime.now(), - )); - _lastUserMessageCount = _getUserMessageCount(); // 更新用户消息计数 - _currentStreamingMessageIndex = null; // 重置流索引,强制创建新气泡 - - _addMessage(Message( - role: 'user', - message: text, - )); - - }); - } - - // 计算用户消息总数 - int _getUserMessageCount() { - return _messages.where((msg) => msg.isUser).length; - } - - // 处理 AI 流式响应 - void _handleAIStreamResponse(String text, bool isComplete) { - // 检查是否有新的用户消息,如果有,应该创建新的AI响应 - final currentUserMessageCount = _getUserMessageCount(); - final hasNewUserMessage = currentUserMessageCount > _lastUserMessageCount; - - setState(() { - if (hasNewUserMessage || _currentStreamingMessageIndex == null) { - // 有新用户消息或首次响应,创建新气泡 - _messages.add(ChatMessage( - text: text, - isUser: false, - timestamp: DateTime.now(), - )); - _currentStreamingMessageIndex = _messages.length - 1; - _lastUserMessageCount = currentUserMessageCount; // 更新追踪 - } else { - // 更新现有气泡 - if (_currentStreamingMessageIndex! < _messages.length) { - _messages[_currentStreamingMessageIndex!] = ChatMessage( - text: text, - isUser: false, - timestamp: _messages[_currentStreamingMessageIndex!].timestamp, - ); - } - } - - _addMessage(Message( - role: 'assistant', - message: text, - )); - - // 如果完成了,重置索引 - if (isComplete) { - _currentStreamingMessageIndex = null; - } - }); - } - - Future _loadMessages() async { - List> records = await _dbHelper.getMessages(); - setState(() { - _messagesDB = records.map((record) => Message( - role: record['role'], - message: record['message'], - )).toList(); - }); - } - - Future _addMessage(Message message) async { - await _dbHelper.insertMessage(message.toMap()); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - body: GradientBackground( - child: SafeArea( - child: Column( - children: [ - // Header with AI avatar - SizedBox( - height: 140, - child: Stack( - children: [ - // 背景渐变已由外层GradientBackground提供 - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // AI贴图 - const Padding( - padding: EdgeInsets.only(left: 16, top: 0), - child: AIAvatar(width: 120, height: 140), - ), - const SizedBox(width: 8), - // 文字整体下移 - Padding( - padding: const EdgeInsets.only(top: 48), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Hi, 我是众众!', - style: TextStyle( - fontSize: 22, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), - const SizedBox(height: 8), - Text( - '您的专属看、选、买、用车助手', - style: TextStyle( - fontSize: 14, - color: Colors.white.withOpacity(0.8), - ), - ), - ], - ), - ), - // 可选:右上角关闭按钮 - const Spacer(), - Padding( - padding: const EdgeInsets.only(top: 16, right: 16), - child: Icon(Icons.close, color: Colors.white), - ), - ], - ), - // 底部分割线 - Positioned( - left: 0, - right: 0, - bottom: 0, - child: Container( - height: 1, - color: Colors.white.withOpacity(0.15), - ), - ), - ], - ), - ), - - // Chat messages - Expanded( - child: ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: 16), - itemCount: _messages.length, - reverse: true, - itemBuilder: (context, index) { - final message = _messages[_messages.length - 1 - index]; - return ChatBubble(message: message); - }, - ), - ), - - // Input area - ChatInput( - onSendMessage: _handleSendMessage, - onAIResponse: _handleAIResponse, - onAIStreamResponse: _handleAIStreamResponse, // 添加新回调 - ), - ], - ), - ), - ), - ); - } -} \ No newline at end of file diff --git a/lib/screens/full_screen.dart b/lib/screens/full_screen.dart new file mode 100644 index 0000000..dcc3c6c --- /dev/null +++ b/lib/screens/full_screen.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import '../widgets/chat_box.dart'; +import '../widgets/gradient_background.dart'; +import '../services/message_service.dart'; +import 'package:provider/provider.dart'; +import '../widgets/chat_header.dart'; +import '../widgets/chat_footer.dart'; + +class FullScreen extends StatefulWidget { + const FullScreen({super.key}); + + @override + State createState() => _FullScreenState(); +} + +class _FullScreenState extends State { + final ScrollController _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + void _scrollToBottom() { + if (_scrollController.hasClients) { + _scrollController.animateTo( + 0.0, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + ); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: GradientBackground( + colors: const [ + Color(0XFF3B0A3F), + Color(0xFF0E0E24), + Color(0xFF0C0B33), + ], + child: SafeArea( + child: Column( + children: [ + ChatHeader( + onClose: () { + Provider.of(context, listen: false) + .abortReply(); + Navigator.pop(context); + }, + ), + const SizedBox(height: 12), + Expanded( + child: Consumer( + builder: (context, handler, child) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _scrollToBottom(); + }); + return ChatBox( + scrollController: _scrollController, + messages: handler.messages, + ); + }, + ), + ), + const SizedBox(height: 12), + Consumer( + builder: (context, messageService, child) => ChatFooter( + onClear: () { + messageService.abortReply(); + messageService.clearMessages(); + }, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart new file mode 100644 index 0000000..9d736ed --- /dev/null +++ b/lib/screens/main_screen.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import '../widgets/floating_icon.dart'; + +class MainScreen extends StatefulWidget { + const MainScreen({super.key}); + + @override + State createState() => _MainScreenState(); +} + +class _MainScreenState extends State { + @override + void initState() { + super.initState(); + } + + @override + void dispose() { + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.transparent, + body: Stack( + children: [ + Container( + decoration: const BoxDecoration( + image: DecorationImage( + image: AssetImage('assets/images/bg.jpg'), + fit: BoxFit.cover, + ), + ), + ), + FloatingIcon(), + ], + ), + ); + } +} diff --git a/lib/screens/part_screen.dart b/lib/screens/part_screen.dart new file mode 100644 index 0000000..929c3f2 --- /dev/null +++ b/lib/screens/part_screen.dart @@ -0,0 +1,171 @@ +import 'package:flutter/material.dart'; +import 'dart:ui'; +import '../widgets/chat_box.dart'; +import '../screens/full_screen.dart'; +import 'package:provider/provider.dart'; +import '../services/message_service.dart'; +import '../widgets/gradient_background.dart'; + +class PartScreen extends StatefulWidget { + final VoidCallback? onHide; + + const PartScreen({super.key, this.onHide}); + + @override + State createState() => _PartScreenState(); +} + +class _PartScreenState extends State { + final ScrollController _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + void _scrollToBottom() { + if (_scrollController.hasClients) { + _scrollController.animateTo( + 0.0, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + ); + } + } + + void _openFullScreen() async { + widget.onHide?.call(); + await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const FullScreen(), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.transparent, + body: LayoutBuilder( + builder: (context, constraints) { + final double minHeight = constraints.maxHeight * 0.18; + final double maxHeight = constraints.maxHeight * 0.4; + final double chatWidth = constraints.maxWidth * 0.94; + final int messageCount = + context.watch().messages.length; + double chatHeight; + if (messageCount <= 1) { + chatHeight = minHeight; + } else { + final height = minHeight + (messageCount * 78); + chatHeight = height < maxHeight ? height : maxHeight; + } + return Stack( + children: [ + Positioned.fill( + child: IgnorePointer( + ignoring: false, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () { + Provider.of(context, listen: false) + .abortReply(); + if (widget.onHide != null) widget.onHide!(); + }, + child: Container( + color: Colors.transparent, + ), + ), + ), + ), + Padding( + padding: EdgeInsets.only(bottom: constraints.maxHeight * 0.22), + child: Align( + alignment: Alignment.bottomCenter, + child: Selector( + selector: (_, service) => service.messages.length, + builder: (context, messageCount, child) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _scrollToBottom(); + }); + return Consumer( + builder: (context, handler, child) { + final messages = handler.messages; + return AnimatedContainer( + duration: const Duration(milliseconds: 180), + curve: Curves.easeInOut, + width: chatWidth, + height: chatHeight, + child: ClipRRect( + borderRadius: BorderRadius.circular(24), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12), + child: GradientBackground( + colors: const [ + Color(0XBF3B0A3F), + Color(0xBF0E0E24), + Color(0xBF0C0B33), + ], + borderRadius: BorderRadius.circular(6), + child: Stack( + children: [ + Padding( + padding: const EdgeInsets.only( + top: 42, + left: 6, + right: 6, + bottom: 6), + child: Column( + children: [ + Expanded( + child: Padding( + padding: EdgeInsets.only( + top: 12, bottom: 12), + child: ChatBox( + scrollController: + _scrollController, + messages: messages, + ), + ), + ), + ], + ), + ), + Positioned( + top: 6, + right: 6, + child: IconButton( + icon: const Icon( + Icons.open_in_full, + color: Colors.white, + size: 24, + ), + onPressed: _openFullScreen, + padding: EdgeInsets.zero, + ), + ), + ], + ), + ), + ), + ), + ); + }, + ); + }, + ), + ), + ), + ], + ); + }, + ), + ); + } +} diff --git a/lib/services/audio_recorder_service.dart b/lib/services/audio_recorder_service.dart new file mode 100644 index 0000000..f44efc8 --- /dev/null +++ b/lib/services/audio_recorder_service.dart @@ -0,0 +1,83 @@ +import 'package:record/record.dart'; +import 'package:path_provider/path_provider.dart'; +import 'dart:async'; +import 'dart:io'; + +// 负责录音相关功能 +class AudioRecorderService { + final AudioRecorder _recorder = AudioRecorder(); + bool _isRecording = false; + String? _tempFilePath; + + bool get isRecording => _isRecording; + + Future hasPermission() => _recorder.hasPermission(); + + Future checkPermission() async { + final hasPermission = await _recorder.hasPermission(); + print('麦克风权限状态: $hasPermission'); + } + + Future startRecording() async { + print('开始录音...'); + + try { + // 使用文件录音可能更稳定 + final tempDir = await getTemporaryDirectory(); + _tempFilePath = + '${tempDir.path}/temp_audio_${DateTime.now().millisecondsSinceEpoch}.opus'; + + print('录音文件路径: $_tempFilePath'); + + if (await _recorder.hasPermission()) { + await _recorder.start( + const RecordConfig(encoder: AudioEncoder.opus), + path: _tempFilePath!, + ); + + _isRecording = true; + print('录音已开始,使用OPUS格式,文件: $_tempFilePath'); + } else { + print('没有麦克风权限,无法开始录音'); + } + } catch (e) { + print('录音开始出错: $e'); + _isRecording = false; + } + } + + Future?> stopRecording() async { + if (!_isRecording) return null; + + _isRecording = false; + + print('停止录音...'); + try { + final path = await _recorder.stop(); + print('录音已停止,文件路径: $path'); + + if (path != null) { + final file = File(path); + if (await file.exists()) { + final bytes = await file.readAsBytes(); + print('读取到录音数据: ${bytes.length} 字节'); + + if (bytes.isNotEmpty) { + return bytes; + } else { + print('录音数据为空'); + } + } else { + print('录音文件不存在: $path'); + } + } else { + print('录音路径为空'); + } + } catch (e) { + print('停止录音或发送过程出错: $e'); + } + return null; + } + + void dispose() => _recorder.dispose(); +} diff --git a/lib/services/chat_sse_service.dart b/lib/services/chat_sse_service.dart new file mode 100644 index 0000000..575a9ce --- /dev/null +++ b/lib/services/chat_sse_service.dart @@ -0,0 +1,123 @@ +// 负责SSE通信 +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; + +class ChatSseService { + // 缓存用户ID和会话ID + String? _cachedUserId; + String? _cachedConversationId; + + HttpClient? _currentClient; + HttpClientResponse? _currentResponse; + bool _isAborted = true; + + String? get conversationId => _cachedConversationId; + + void request({ + required String messageId, + required String text, + required Function(String, String, String, bool) onStreamResponse, + }) async { + _isAborted = false; + if (_cachedUserId == null) { + _cachedUserId = _generateRandomUserId(6); + print('初始化用户ID: $_cachedUserId'); + } + String responseText = ''; + String tempText = ''; + _currentClient = HttpClient(); + try { + final chatUri = Uri.parse('http://143.64.185.20:18606/chat'); + final request = await _currentClient!.postUrl(chatUri); + request.headers.set('Content-Type', 'application/json'); + request.headers.set('Accept', 'text/event-stream'); + final body = { + 'message': text, + 'user': _cachedUserId, + }; + if (_cachedConversationId != null) { + body['conversation_id'] = _cachedConversationId; + } + request.add(utf8.encode(json.encode(body))); + _currentResponse = await request.close(); + if (_currentResponse!.statusCode == 200) { + await for (final line in _currentResponse! + .transform(utf8.decoder) + .transform(const LineSplitter())) { + if (_isAborted) { + break; + } + if (line.startsWith('data:')) { + final jsonStr = line.substring(5).trim(); + if (jsonStr == '[DONE]' || jsonStr.contains('message_end')) { + onStreamResponse(messageId, responseText, tempText, true); + break; + } + try { + final jsonData = json.decode(jsonStr); + if (jsonData.containsKey('conversation_id') && + _cachedConversationId == null) { + _cachedConversationId = jsonData['conversation_id']; + } + if (jsonData['event'].toString().contains('message')) { + final textChunk = + jsonData.containsKey('answer') ? jsonData['answer'] : ''; + responseText += textChunk; + tempText += textChunk; + int endIndex = _getCompleteTextEndIndex(tempText); + final completeText = tempText.substring(0, endIndex).trim(); + tempText = tempText.substring(endIndex).trim(); + onStreamResponse(messageId, responseText, completeText, false); + } + } catch (e) { + print('解析 SSE 数据出错: $e, 原始数据: $jsonStr'); + } + } + } + } else { + print('SSE 连接失败,状态码: ${_currentResponse!.statusCode}'); + } + } catch (e) { + // todo + } finally { + resetRequest(); + } + } + + Future abort() async { + _isAborted = true; + resetRequest(); + } + + void resetRequest() { + _currentResponse?.detachSocket().then((socket) { + socket.destroy(); + }); + _currentClient?.close(force: true); + _currentClient = null; + _currentResponse = null; + } + + int _getCompleteTextEndIndex(String buffer) { + // 支持句号、问号、感叹号和换行符作为分割依据 + final sentenceEnders = RegExp(r'[。!?\n]'); + final matches = sentenceEnders.allMatches(buffer); + return matches.isEmpty ? 0 : matches.last.end; + } + + // 新增:重置会话方法 + void resetSession() { + _cachedUserId = null; + _cachedConversationId = null; + print('SSE会话已重置'); + } + + String _generateRandomUserId(int length) { + const chars = + 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + final random = Random(); + return String.fromCharCodes(Iterable.generate( + length, (_) => chars.codeUnitAt(random.nextInt(chars.length)))); + } +} diff --git a/lib/services/classification_service.dart b/lib/services/classification_service.dart new file mode 100644 index 0000000..f63db8b --- /dev/null +++ b/lib/services/classification_service.dart @@ -0,0 +1,27 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; + +class TextClassificationService { + Future classifyText(String text) async { + try { + final uri = Uri.parse('http://143.64.185.20:18606/classify'); + + final response = await http.post( + uri, + headers: {'Content-Type': 'application/json'}, + body: json.encode({'text': text}), + ); + + if (response.statusCode == 200) { + return json.decode(response.body)['category']; + } else { + print( + 'Classification failed: ${response.statusCode}, ${response.body}'); + return -1; + } + } catch (e) { + print('Error during text classification: $e'); + return -1; + } + } +} diff --git a/lib/services/command_service.dart b/lib/services/command_service.dart new file mode 100644 index 0000000..7f54c9e --- /dev/null +++ b/lib/services/command_service.dart @@ -0,0 +1,126 @@ +import '../enums/vehicle_command_type.dart'; +import '../models/vehicle_status_info.dart'; + +/// 命令处理回调函数定义 +typedef CommandCallback = Future<(bool, Map? params)> Function( + VehicleCommandType type, Map? params); + +/// 命令处理器类 - 负责处理来自AI的车辆控制命令 +class CommandService { + /// 保存主应用注册的回调函数 + static CommandCallback? onCommandReceived; + + /// 注册命令处理回调 + static void registerCallback(CommandCallback callback) { + onCommandReceived = callback; + } + + /// 执行车控命令 + /// 返回命令执行是否成功 + static Future<(bool, Map? params)> executeCommand( + VehicleCommandType type, + {Map? params}) async { + if (onCommandReceived != null) { + try { + return await onCommandReceived!(type, params); + } catch (e) { + print('执行命令出错: $e'); + } + } else { + print('警告: 命令处理回调未注册'); + } + return (false, null); + } + + static bool checkState( + VehicleCommandType type, Map? params) { + // 获取车辆状态信息 + VehicleStatusInfo vehicleStatusInfo = VehicleStatusInfo(); + switch (type) { + // 车辆开锁 + case VehicleCommandType.lock: + return vehicleStatusInfo.leftFrontLockState && + vehicleStatusInfo.rightFrontLockState && + vehicleStatusInfo.leftRearLockState && + vehicleStatusInfo.rightRearLockState; + // 车辆关锁 + case VehicleCommandType.unlock: + return !(vehicleStatusInfo.leftFrontLockState && + vehicleStatusInfo.rightFrontLockState && + vehicleStatusInfo.leftRearLockState && + vehicleStatusInfo.rightRearLockState); + // 打开窗户 + case VehicleCommandType.openWindow: + return vehicleStatusInfo.leftFrontWindowState && + vehicleStatusInfo.rightFrontWindowState && + vehicleStatusInfo.leftRearWindowState && + vehicleStatusInfo.rightRearWindowState; + // 关闭窗户 + case VehicleCommandType.closeWindow: + return !(vehicleStatusInfo.leftFrontWindowState && + vehicleStatusInfo.rightFrontWindowState && + vehicleStatusInfo.leftRearWindowState && + vehicleStatusInfo.rightRearWindowState); + // 预约空调 + case VehicleCommandType.appointAC: + // todo + return false; + // 打开空调 + case VehicleCommandType.openAC: + return vehicleStatusInfo.acState && vehicleStatusInfo.acSwitch; + // 关闭空调 + case VehicleCommandType.closeAC: + return !vehicleStatusInfo.acState && !vehicleStatusInfo.acSwitch; + // 极速降温 + case VehicleCommandType.coolSharply: + // todo + return false; + // 一键备车 + case VehicleCommandType.prepareCar: + return false; + // 一键融雪 + case VehicleCommandType.meltSnow: + // todo + return false; + // 打开后备箱 + case VehicleCommandType.openTrunk: + return vehicleStatusInfo.trunkState; + // 关闭后备箱 + case VehicleCommandType.closeTrunk: + return !vehicleStatusInfo.trunkState; + // 车辆鸣笛 + case VehicleCommandType.honk: + // todo + return false; + // 寻找车辆 + case VehicleCommandType.locateCar: + // todo + return false; + // 修改空调温度 + case VehicleCommandType.changeACTemp: + return vehicleStatusInfo.acState && + vehicleStatusInfo.acSwitch && + vehicleStatusInfo.acTemp == params?['temperature']; + // 开启方向盘加热 + case VehicleCommandType.openWheelHeat: + return vehicleStatusInfo.wheelHeat; + // 关闭方向盘加热 + case VehicleCommandType.closeWheelHeat: + return !vehicleStatusInfo.wheelHeat; + // 开启主座椅加热 + case VehicleCommandType.openMainSeatHeat: + return vehicleStatusInfo.mainSeatHeat; + // 关闭主座椅加热 + case VehicleCommandType.closeMainSeatHeat: + return !vehicleStatusInfo.mainSeatHeat; + // 开启副座椅加热 + case VehicleCommandType.openMinorSeatHeat: + return vehicleStatusInfo.minorSeatHeat; + // 关闭副座椅加热 + case VehicleCommandType.closeMinorSeatHeat: + return !vehicleStatusInfo.minorSeatHeat; + default: + return false; + } + } +} diff --git a/lib/services/control_recognition_service.dart b/lib/services/control_recognition_service.dart new file mode 100644 index 0000000..653ccab --- /dev/null +++ b/lib/services/control_recognition_service.dart @@ -0,0 +1,69 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import '../models/vehicle_cmd.dart'; +import '../models/vehicle_cmd_response.dart'; +import 'vehicle_state_service.dart'; + +class VehicleCommandService { + // final VehicleStateService vehicleStateService = VehicleStateService(); + + Future getCommandFromText(String text) async { + try { + final uri = Uri.parse( + 'http://143.64.185.20:18606/control'); + + final response = await http.post( + uri, + headers: {'Content-Type': 'application/json'}, + body: json.encode({'text': text}), + ); + + if (response.statusCode == 200) { + final decoded = json.decode(response.body); + final tips = decoded['tips']; + final commandList = decoded['commands']; + List vehicleCommandList = (commandList as List) + .map((item) => VehicleCommand.fromString( + item['command'] as String, + item['params'] as Map?, + item['error'] ?? '')) + .toList(); + return VehicleCommandResponse(tips: tips, commands: vehicleCommandList); + } else { + print( + 'Vehicle command query failed: ${response.statusCode}, ${response.body}'); + return null; + } + } catch (e) { + print('Error during vehicle command processing: $e'); + return null; + } + } + + // 执行车辆控制命令的方法 + Future executeCommand(VehicleCommand command) async { + try { + final uri = Uri.parse('http://143.64.185.20:18607/executeCommand'); + + final response = await http.post( + uri, + headers: {'Content-Type': 'application/json'}, + body: json.encode({ + 'command': command.commandString, // 使用getter转换为字符串 + 'params': command.params, + }), + ); + + if (response.statusCode == 200) { + print('命令执行成功: ${command.commandString}'); + return true; + } else { + print('命令执行失败: ${response.statusCode}, ${response.body}'); + return false; + } + } catch (e) { + print('命令执行出错: $e'); + return false; + } + } +} diff --git a/lib/services/location_service.dart b/lib/services/location_service.dart new file mode 100644 index 0000000..d48aad0 --- /dev/null +++ b/lib/services/location_service.dart @@ -0,0 +1,20 @@ +import 'dart:convert'; +import 'package:basic_intl/intl.dart'; +import 'package:http/http.dart' as http; + +class LocationService { + String lang = Intl.getCurrentLocale().startsWith('zh') ? 'zh' : 'en'; + Future search(double lon, double lat) async { + try { + final uri = Uri.parse( + 'http://143.64.185.20:18606/location_info?lon=${lon}&lat=${lat}&lang=${lang}'); + final response = await http.get(uri); + if (response.statusCode == 200) { + return json.decode(response.body)['address']; + } + } catch (e) { + print('Error during text classification: $e'); + } + return ""; + } +} diff --git a/lib/services/message_service.dart b/lib/services/message_service.dart new file mode 100644 index 0000000..f998bfc --- /dev/null +++ b/lib/services/message_service.dart @@ -0,0 +1,381 @@ +import 'dart:io'; + +import 'package:ai_chat_assistant/utils/common_util.dart'; +import 'package:basic_intl/intl.dart'; +import 'package:flutter/foundation.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:uuid/uuid.dart'; +import '../enums/vehicle_command_type.dart'; +import '../enums/message_service_state.dart'; +import '../enums/message_status.dart'; +import '../models/chat_message.dart'; +import '../models/vehicle_cmd.dart'; +import '../services/chat_sse_service.dart'; +import '../services/classification_service.dart'; +import '../services/control_recognition_service.dart'; +import '../services/audio_recorder_service.dart'; +import '../services/voice_recognition_service.dart'; +import '../services/tts_service.dart'; +import 'command_service.dart'; +import 'package:fluttertoast/fluttertoast.dart'; + +class MessageService extends ChangeNotifier { + static final MessageService _instance = MessageService._internal(); + + factory MessageService() => _instance; + + MessageService._internal(); + + final ChatSseService _chatSseService = ChatSseService(); + final LocalTtsService _ttsService = LocalTtsService(); + final AudioRecorderService _audioService = AudioRecorderService(); + final VoiceRecognitionService _recognitionService = VoiceRecognitionService(); + final TextClassificationService _classificationService = + TextClassificationService(); + final VehicleCommandService _vehicleCommandService = VehicleCommandService(); + + final List _messages = [ + ChatMessage( + id: Uuid().v1(), + text: Intl.getCurrentLocale().startsWith('zh') + ? "您好,我是众众,请问有什么可以帮您?" + : "Hi, I'm Zhongzhong, may I help you ? ", + isUser: false, + timestamp: DateTime.now(), + status: MessageStatus.normal) + ]; + + List get messages => List.unmodifiable(_messages); + + String? _latestUserMessageId; + String? _latestAssistantMessageId; + + MessageServiceState _state = MessageServiceState.idle; + + bool get isRecording => _state == MessageServiceState.recording; + + bool get isRecognizing => _state == MessageServiceState.recognizing; + + bool get isReplying => _state == MessageServiceState.replying; + + bool get isBusy => _state != MessageServiceState.idle; + + bool _isReplyAborted = true; + + Future startVoiceInput() async { + if (await Permission.microphone.status == PermissionStatus.denied) { + PermissionStatus status = await Permission.microphone.request(); + if (status == PermissionStatus.permanentlyDenied) { + await Fluttertoast.showToast( + msg: Intl.getCurrentLocale().startsWith('zh') + ? "请在设置中开启麦克风权限" + : "Please enable microphone in the settings"); + } + return; + } + if (_state != MessageServiceState.idle) { + return; + } + try { + abortReply(); + _latestUserMessageId = null; + _latestAssistantMessageId = null; + _isReplyAborted = false; + changeState(MessageServiceState.recording); + await _audioService.startRecording(); + addMessage("", true, MessageStatus.listening); + _latestUserMessageId = messages.last.id; + } catch (e) { + print('录音开始出错: $e'); + } + } + + void changeState(MessageServiceState state) { + _state = state; + notifyListeners(); + } + + void addMessage(String text, bool isUser, MessageStatus status) { + _messages.add(ChatMessage( + id: Uuid().v1(), + text: text, + isUser: isUser, + timestamp: DateTime.now(), + status: status)); + notifyListeners(); + } + + Future stopAndProcessVoiceInput() async { + if (_state != MessageServiceState.recording) { + changeState(MessageServiceState.idle); + return; + } + try { + changeState(MessageServiceState.recognizing); + final audioData = await _audioService.stopRecording(); + replaceMessage( + id: _latestUserMessageId!, + text: "", + status: MessageStatus.recognizing); + if (audioData == null || audioData.isEmpty) { + removeMessageById(_latestUserMessageId!); + return; + } + final recognizedText = + await _recognitionService.recognizeSpeech(audioData); + if (recognizedText == null || recognizedText.isEmpty) { + removeMessageById(_latestUserMessageId!); + return; + } + replaceMessage( + id: _latestUserMessageId!, + text: recognizedText, + status: MessageStatus.normal); + changeState(MessageServiceState.replying); + await reply(recognizedText); + } catch (e) { + // todo + } finally { + changeState(MessageServiceState.idle); + } + } + + int findMessageIndexById(String? id) { + return id == null ? -1 : _messages.indexWhere((msg) => msg.id == id); + } + + void replaceMessage({ + required String id, + String? text, + MessageStatus? status, + }) { + final index = findMessageIndexById(id); + if (index == -1) { + return; + } + final message = _messages[index]; + _messages[index] = ChatMessage( + id: message.id, + text: text ?? message.text, + isUser: message.isUser, + timestamp: message.timestamp, + status: status ?? message.status, + ); + notifyListeners(); + } + + Future reply(String text) async { + addMessage("", false, MessageStatus.thinking); + _latestAssistantMessageId = messages.last.id; + bool isChinese = CommonUtil.containChinese(text); + try { + if (_isReplyAborted) { + return; + } + final category = await _classificationService.classifyText(text); + switch (category) { + case -1: + await occurError(isChinese); + break; + case 2: + await handleVehicleControl(text, isChinese); + break; + case 4: + await answerWrongQuestion(isChinese); + break; + default: + await answerQuestion(text, isChinese); + break; + } + } catch (e) { + await occurError(isChinese); + } + } + + Future handleVehicleControl(String text, bool isChinese) async { + if (_isReplyAborted) { + return; + } + final vehicleCommandResponse = + await _vehicleCommandService.getCommandFromText(text); + if (vehicleCommandResponse == null) { + if (_isReplyAborted) { + return; + } + String msg = isChinese + ? "无法识别车辆控制命令,请重试" + : "Cannot recognize the vehicle control command, please try again"; + replaceMessage( + id: _latestAssistantMessageId!, + text: msg, + status: MessageStatus.normal); + } else { + if (_isReplyAborted) { + return; + } + replaceMessage( + id: _latestAssistantMessageId!, + text: vehicleCommandResponse.tips!, + status: MessageStatus.executing); + if (!_isReplyAborted) { + _ttsService.pushTextForStreamTTS(vehicleCommandResponse.tips!); + _ttsService.markSSEStreamCompleted(); + } + bool containOpenAC = false; + for (var command in vehicleCommandResponse.commands) { + if (_isReplyAborted) { + return; + } + if (command.type == VehicleCommandType.unknown || + command.error.isNotEmpty) { + continue; + } + if (command.type == VehicleCommandType.openAC) { + containOpenAC = true; + } + if (containOpenAC && command.type == VehicleCommandType.changeACTemp) { + await Future.delayed(const Duration(milliseconds: 2000), + () => processCommand(command, isChinese)); + } else { + await processCommand(command, isChinese); + } + } + replaceMessage( + id: _latestAssistantMessageId!, + text: vehicleCommandResponse.tips!, + status: MessageStatus.normal); + notifyListeners(); + } + } + + Future processCommand(VehicleCommand command, bool isChinese) async { + String msg; + MessageStatus status; + final (isSuccess, result) = await CommandService.executeCommand( + command.type, + params: command.params); + if (command.type == VehicleCommandType.locateCar) { + msg = isChinese + ? "很抱歉,定位车辆位置失败" + : "Sorry, locate the vehicle unsuccessfully"; + status = MessageStatus.failure; + if (isSuccess && result != null && result.isNotEmpty) { + String address = result['address']; + if (address.isNotEmpty) { + msg = isChinese + ? "已为您找到车辆位置:\"$address\"" + : "The vehicle location is \"$address\""; + status = MessageStatus.success; + } + } + } else { + if (isSuccess) { + msg = isChinese + ? "为您执行\"${command.type.chinese}\"成功" + : "Execute \"${command.type.english}\" successful"; + status = MessageStatus.success; + } else { + msg = isChinese + ? "很抱歉,\"${command.type.chinese}\"失败" + : "Sorry, execute \"${command.type.english}\" unsuccessfully"; + status = MessageStatus.failure; + } + } + addMessage(msg, false, status); + } + + Future answerWrongQuestion(bool isChinese) async { + if (_isReplyAborted) { + return; + } + const chineseAnswer = "尊敬的ID.UNYX车主:\n" + + "很抱歉,我暂时无法理解您的需求,请您更详细地描述,以便我能更好地为您服务。\n" + + "作为您的智能助手,我专注于以下服务:\n" + + "1. ID.UNYX车型功能咨询,例如:如何调节座椅记忆功能?\n" + + "2. 日常用车问题解答,例如:充电注意事项有哪些?\n" + + "3. 基础车辆控制协助,例如:帮我打开空调。"; + const englishAnswer = "Dear ID.UNYX Owner,\n" + + "I'm sorry that unable to understand your request currently. Please describe it in more detail so that I can better assist you.\n" + + "As your intelligent assistant, I specialize in the following services:\n" + + "1. ID.UNYX model feature consultation, such as: How do I adjust the seat memory function?\n" + + "2. Daily vehicle usage inquiries, such as: What are the charging precautions?\n" + + "3. Basic vehicle control assistance, such as: Help me turn on the air conditioning."; + replaceMessage( + id: _latestAssistantMessageId!, + text: isChinese ? chineseAnswer : englishAnswer, + status: MessageStatus.normal); + } + + Future answerQuestion(String text, isChinese) async { + if (_isReplyAborted) { + return; + } + _chatSseService.request( + messageId: _latestAssistantMessageId!, + text: text, + onStreamResponse: handleStreamResponse, + ); + } + + Future occurError(bool isChinese) async { + if (_isReplyAborted) { + return; + } + replaceMessage( + id: _latestAssistantMessageId!, + text: isChinese + ? "很抱歉,众众暂时无法回答这个问题,请重试" + : "Sorry, I can not answer this question for the moment, please try again", + status: MessageStatus.normal); + } + + void handleStreamResponse(String messageId, String responseText, + String completeText, bool isComplete) { + if (_isReplyAborted) { + return; + } + try { + if (isComplete) { + if (completeText.isNotEmpty) { + _ttsService.pushTextForStreamTTS(completeText); + } + _ttsService.markSSEStreamCompleted(); + replaceMessage( + id: messageId, + text: responseText, + status: MessageStatus.completed, + ); + } else { + if (completeText.isNotEmpty) { + _ttsService.pushTextForStreamTTS(completeText); + } + replaceMessage( + id: messageId, text: responseText, status: MessageStatus.thinking); + } + } catch (e) { + abortReply(); + } + } + + Future abortReply() async { + _isReplyAborted = true; + _ttsService.stop(); + _chatSseService.abort(); + int index = findMessageIndexById(_latestAssistantMessageId); + if (index == -1 || messages[index].status != MessageStatus.thinking) { + return; + } + replaceMessage( + id: _latestAssistantMessageId!, status: MessageStatus.aborted); + } + + void removeMessageById(String id) { + _messages.removeWhere((msg) => msg.id == id); + notifyListeners(); + } + + void clearMessages() { + _messages.clear(); + notifyListeners(); + } +} diff --git a/lib/services/redis_service.dart b/lib/services/redis_service.dart new file mode 100644 index 0000000..98977c8 --- /dev/null +++ b/lib/services/redis_service.dart @@ -0,0 +1,33 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; + +class RedisService { + static final RedisService _instance = RedisService._internal(); + + factory RedisService() => _instance; + + RedisService._internal(); + + Future setKeyValue(String key, Object value) async { + final uri = Uri.parse('http://143.64.185.20:18606/redis/set'); + await http.post( + uri, + headers: {'Content-Type': 'application/json'}, + body: json.encode({'key': key, 'value': value}), + ); + } + + Future getValue(String key) async { + try { + final uri = Uri.parse('http://143.64.185.20:18606/redis/get?key=$key'); + final response = await http.get(uri); + if (response.statusCode == 200) { + return json.decode(response.body); + } else { + return null; + } + } catch (e) { + return null; + } + } +} diff --git a/lib/services/tts_service.dart b/lib/services/tts_service.dart new file mode 100644 index 0000000..1c1cd51 --- /dev/null +++ b/lib/services/tts_service.dart @@ -0,0 +1,608 @@ +import 'dart:async'; +import 'dart:collection'; +import 'package:ai_chat_assistant/utils/common_util.dart'; + +// ...existing code... +import 'package:flutter/material.dart'; +import 'package:flutter_tts/flutter_tts.dart'; + +class LocalTtsService { + final FlutterTts _flutterTts = FlutterTts(); + bool _isPlaying = false; + + // TTS 播放队列 - 存储文本段 + final Queue _ttsQueue = Queue(); + + // TTS 事件回调 + VoidCallback? _onStartHandler; + VoidCallback? _onCompletionHandler; + Function(String)? _onErrorHandler; + + // 有序播放队列 - 确保播放顺序与文本顺序一致 + List _orderedTasks = []; // 有序任务队列 + int _nextTaskIndex = 0; // 下一个要处理的任务索引 + bool _isProcessingTasks = false; // 是否正在处理任务 + bool _streamCompleted = false; // 流是否完成 + + LocalTtsService() { + _initializeTts(); + } + + /// 初始化TTS引擎 + Future _initializeTts() async { + try { + // 获取可用的TTS引擎列表 + await _printAvailableEngines(); + + // 推荐并设置最佳引擎 + // await setEngine("com.k2fsa.sherpa.onnx.tts.engine"); + + // 设置TTS参数 + await _flutterTts.setLanguage("en-US"); // 默认英文 + await _flutterTts.setSpeechRate(0.5); // 语速 + await _flutterTts.setVolume(1.0); // 音量 + await _flutterTts.setPitch(1.0); // 音调 + + // 设置TTS事件回调 + _flutterTts.setStartHandler(() { + print('TTS开始播放'); + _isPlaying = true; + _onStartHandler?.call(); + }); + + _flutterTts.setCompletionHandler(() { + print('TTS播放完成'); + _isPlaying = false; + _handleTtsPlaybackComplete(); + }); + + _flutterTts.setErrorHandler((msg) { + print('TTS错误: $msg'); + _isPlaying = false; + _onErrorHandler?.call(msg); + _handleTtsPlaybackComplete(); // 错误时也要处理下一个任务 + }); + + print('TTS引擎初始化完成'); + } catch (e) { + print('TTS引擎初始化失败: $e'); + } + } + + /// 设置最佳TTS引擎 + // 已移除TtsEngineManager相关代码,避免未定义错误。 + + /// 打印可用的TTS引擎 + Future _printAvailableEngines() async { + try { + // 获取可用的TTS引擎 + dynamic engines = await _flutterTts.getEngines; + print('可用的TTS引擎: $engines'); + + // 获取可用的语言 + dynamic languages = await _flutterTts.getLanguages; + print('支持的语言: $languages'); + + // 获取当前默认引擎 + dynamic defaultEngine = await _flutterTts.getDefaultEngine; + print('当前默认引擎: $defaultEngine'); + } catch (e) { + print('获取TTS引擎信息失败: $e'); + } + } + + /// 清空所有队列和缓存 + void clearAll() { + _ttsQueue.clear(); + _orderedTasks.clear(); + _nextTaskIndex = 0; + _isProcessingTasks = false; + _streamCompleted = false; + _isPlaying = false; // 重置播放状态 + print('所有TTS队列和缓存已清空'); + } + + /// 推送文本到TTS队列进行流式处理(保证顺序) + void pushTextForStreamTTS(String text) { + if (text.trim().isEmpty) { + print('接收到空字符串,跳过推送到TTS队列'); + return; + } + + String cleanedText = CommonUtil.cleanText(text, true); + if (cleanedText.isEmpty) { + print('清理后的文本为空,跳过推送到TTS队列'); + return; + } + + // 创建有序任务 + int taskIndex = _orderedTasks.length; + TtsTask task = TtsTask( + index: taskIndex, + text: cleanedText, + status: TtsTaskStatus.ready, + ); + + _orderedTasks.add(task); + print('添加TTS任务 (索引: $taskIndex): $cleanedText'); + + // 尝试处理队列中的下一个任务 + _processNextReadyTask(); + } + + /// 处理队列中下一个就绪的任务 + void _processNextReadyTask() { + // 如果正在播放,则不处理下一个任务 + if (_isPlaying) { + print('当前正在播放音频,等待播放完成'); + return; + } + + // 寻找下一个可以播放的任务 + while (_nextTaskIndex < _orderedTasks.length) { + TtsTask task = _orderedTasks[_nextTaskIndex]; + if (task.status == TtsTaskStatus.ready) { + print('按顺序播放TTS (索引: ${task.index}): ${task.text}'); + _playTts(task.text); + return; // 等待当前音频播放完成 + } else { + // 跳过已处理的任务 + _nextTaskIndex++; + continue; + } + } + + // 检查是否所有任务都已处理完 + if (_streamCompleted && _nextTaskIndex >= _orderedTasks.length) { + print('=== 所有TTS任务播放完成 ==='); + _isProcessingTasks = false; + _onCompletionHandler?.call(); + } + } + + /// 播放TTS + Future _playTts(String text) async { + if (_isPlaying) { + print('已有TTS正在播放,跳过本次播放'); + return; + } + + try { + print('开始播放TTS: $text'); + // 检测语言并设置 + bool isChinese = CommonUtil.containChinese(text); + String targetLanguage = isChinese ? "zh-CN" : "en-US"; + // 检查语言是否可用,如果不可用则使用默认语言 + bool isLanguageOk = await isLanguageAvailable(targetLanguage); + if (!isLanguageOk) { + print('语言 $targetLanguage 不可用,使用默认语言'); + // 可以在这里设置备用语言或保持当前语言 + } else { + await _flutterTts.setLanguage(targetLanguage); + print('设置TTS语言为: $targetLanguage'); + } + // 播放TTS + await _flutterTts.speak(text); + } catch (e) { + print('播放TTS失败: $e'); + // 播放失败,重置状态并继续下一个 + _isPlaying = false; + _handleTtsPlaybackComplete(); + } + } + + /// 处理TTS播放完成 + void _handleTtsPlaybackComplete() { + if (!_isPlaying) { + print('TTS已停止播放,处理下一个任务'); + // 标记当前任务为已完成 + if (_nextTaskIndex < _orderedTasks.length && + _orderedTasks[_nextTaskIndex].status == TtsTaskStatus.ready) { + _orderedTasks[_nextTaskIndex].status = TtsTaskStatus.completed; + _nextTaskIndex++; + } + // 处理下一个任务 + _processNextReadyTask(); + } + } + + /// 标记流完成 + void markSSEStreamCompleted() { + print('=== 标记SSE流完成 ==='); + _streamCompleted = true; + + // 尝试完成剩余任务 + _processNextReadyTask(); + } + + /// 停止播放 + void stopSSEPlayback() { + print("停止SSE播放"); + // 立即停止TTS播放,无论当前是否在播报 + _flutterTts.stop(); + _isPlaying = false; + clearAll(); + } + + // 设置事件处理器 + void setStartHandler(VoidCallback handler) { + _onStartHandler = handler; + } + + void setCompletionHandler(VoidCallback handler) { + _onCompletionHandler = handler; + } + + void setErrorHandler(Function(String) handler) { + _onErrorHandler = handler; + } + + // 保留原有的播放逻辑用于完整文本处理 + Future processCompleteText(String text) async { + if (text.trim().isEmpty) { + print("文本为空,跳过TTS处理"); + return; + } + + print("开始处理完整文本TTS,原始文本长度: ${text.length}字"); + + // 清理缓存和重置队列 + clearAll(); + + // 确保停止当前播放 + await _stopCurrentPlayback(); + + // 直接使用流式处理 + await _processCompleteTextAsStream(text); + } + + /// 将完整文本作为流式处理 + Future _processCompleteTextAsStream(String text) async { + // 清理markdown和异常字符 + String cleanedText = _cleanTextForTts(text); + print("清理后文本长度: ${cleanedText.length}字"); + + if (cleanedText.trim().isEmpty) { + print("清理后文本为空,跳过TTS"); + return; + } + + // 分割成句子并逐个处理 + List sentences = _splitTextIntoSentences(cleanedText); + print("=== 文本分段完成,共 ${sentences.length} 个句子 ==="); + + // 推送每个句子到流式TTS处理 + for (int i = 0; i < sentences.length; i++) { + final sentence = sentences[i].trim(); + if (sentence.isEmpty) continue; + + print("处理句子 ${i + 1}/${sentences.length}: $sentence"); + pushTextForStreamTTS(sentence); + } + + // 标记流完成 + markSSEStreamCompleted(); + } + + // 停止当前播放的辅助方法 + Future _stopCurrentPlayback() async { + try { + print("停止当前播放"); + + if (_isPlaying) { + await _flutterTts.stop(); + _isPlaying = false; + print("TTS.stop() 调用完成"); + } + + // 短暂延迟确保播放器状态稳定 + await Future.delayed(Duration(milliseconds: 300)); + print("播放器状态稳定延迟完成"); + } catch (e) { + print('停止当前播放错误: $e'); + } + } + + // 保留原有的方法用于兼容性 + List _splitTextIntoSentences(String text) { + List result = []; + + if (text.length <= 50) { + result.add(text); + return result; + } + + // 支持中英文句子分割 + List sentenceEnders = [ + '。', '!', '?', ';', '\n', // 中文 + '.', '!', '?', ';' // 英文 + ]; + + String remainingText = text; + + while (remainingText.isNotEmpty) { + if (remainingText.length <= 50) { + result.add(remainingText.trim()); + break; + } + + // 在50字以内查找最佳分割点 + String candidate = remainingText.substring(0, 50); + + int lastSentenceEndIndex = -1; + for (int i = candidate.length - 1; i >= 0; i--) { + if (sentenceEnders.contains(candidate[i])) { + lastSentenceEndIndex = i; + break; + } + } + + if (lastSentenceEndIndex != -1) { + // 找到句子结束符,以此为分割点 + String chunk = candidate.substring(0, lastSentenceEndIndex + 1).trim(); + if (chunk.isNotEmpty) { + result.add(chunk); + } + remainingText = + remainingText.substring(lastSentenceEndIndex + 1).trim(); + } else { + // 没找到句子结束符,寻找逗号分割点(中英文逗号) + int lastCommaIndex = -1; + for (int i = candidate.length - 1; i >= 20; i--) { + if (candidate[i] == ',' || candidate[i] == ',') { + lastCommaIndex = i; + break; + } + } + + if (lastCommaIndex != -1) { + String chunk = candidate.substring(0, lastCommaIndex + 1).trim(); + if (chunk.isNotEmpty) { + result.add(chunk); + } + remainingText = remainingText.substring(lastCommaIndex + 1).trim(); + } else { + // 没找到合适的分割点,强制在50字处分割 + result.add(candidate.trim()); + remainingText = remainingText.substring(50).trim(); + } + } + } + + return result.where((chunk) => chunk.trim().isNotEmpty).toList(); + } + + String _cleanTextForTts(String text) { + String cleanText = text + // 修正markdown粗体格式,保留内容 + .replaceAllMapped(RegExp(r'\*\*(.*?)\*\*'), + (m) => m.group(1) ?? '') // 粗体:**文字** -> 文字 + .replaceAllMapped( + RegExp(r'\*(.*?)\*'), (m) => m.group(1) ?? '') // 斜体:*文字* -> 文字 + .replaceAllMapped( + RegExp(r'`([^`]+)`'), (m) => m.group(1) ?? '') // 行内代码:`代码` -> 代码 + .replaceAll(RegExp(r'```[^`]*```', multiLine: true), '') // 代码块:完全移除 + .replaceAll(RegExp(r'```[\s\S]*?```'), '') // 代码块:完全移除(备用) + .replaceAll( + RegExp(r'^#{1,6}\s+(.*)$', multiLine: true), r'$1') // 标题:# 标题 -> 标题 + .replaceAll( + RegExp(r'\[([^\]]+)\]\([^\)]+\)'), r'$1') // 链接:[文字](url) -> 文字 + .replaceAll( + RegExp(r'^>\s*(.*)$', multiLine: true), r'$1') // 引用:> 文字 -> 文字 + .replaceAll(RegExp(r'^[-*+]\s+(.*)$', multiLine: true), + r'$1') // 无序列表:- 项目 -> 项目 + .replaceAll(RegExp(r'^\d+\.\s+(.*)$', multiLine: true), + r'$1') // 有序列表:1. 项目 -> 项目 + .replaceAll(RegExp(r'^\s*[-*]{3,}\s*$', multiLine: true), '') // 分隔线:移除 + .replaceAll(RegExp(r'\n\s*\n'), '\n') // 多个换行替换为单个换行 + .replaceAll(RegExp(r'\n'), ' ') // 换行替换为空格 + + // 移除代码相关的变量和符号(但保留正常文字中的这些字符) + .replaceAll(RegExp(r'\$\d+(?=\s|$|[,。!?;:])'), '') // 移除独立的 $1, $2, $3 等 + .replaceAll(RegExp(r'\$\{[^}]*\}'), '') // 移除 ${variable} 格式 + .replaceAll(RegExp(r'(?<=\s|^)\$[a-zA-Z_]\w*(?=\s|$|[,。!?;:])'), + '') // 移除独立的 $variable 格式 + + // 移除HTML相关内容 + .replaceAll(RegExp(r'\\[ntr]'), ' ') // 移除转义字符 + .replaceAll(RegExp(r'&[a-zA-Z0-9]+;'), ' ') // 移除HTML实体 + .replaceAll(RegExp(r'<[^>]*>'), '') // 秼除HTML标签 + + // 移除明显的代码结构(保留正常文字中的括号) + .replaceAll(RegExp(r'\{[^}]*\}(?=\s|$)'), '') // 移除独立的花括号内容 + .replaceAll( + RegExp(r'(?<=\s|^)@[a-zA-Z_]\w*(?=\s|$)'), '') // 移除独立的 @mentions + + // 移除纯编程符号行,但保留文字中的标点 + .replaceAll( + RegExp(r'^[#%&*+=|\\/<>^~`\s]*$', multiLine: true), '') // 移除纯符号行 + .replaceAll( + RegExp(r'(?<=\s)[#%&*+=|\\/<>^~`]+(?=\s)'), ' ') // 移除词间的编程符号 + + // 移除表情符号 + .replaceAll(RegExp(r'[\u{1F600}-\u{1F64F}]', unicode: true), '') // 表情符号 + .replaceAll( + RegExp(r'[\u{1F300}-\u{1F5FF}]', unicode: true), '') // 符号和象形文字 + .replaceAll( + RegExp(r'[\u{1F680}-\u{1F6FF}]', unicode: true), '') // 运输和地图符号 + .replaceAll(RegExp(r'[\u{1F1E0}-\u{1F1FF}]', unicode: true), '') // 旗帜 + .replaceAll(RegExp(r'[\u{2600}-\u{26FF}]', unicode: true), '') // 杂项符号 + + // 移除特殊的非文字字符,但保留基本标点 + .replaceAll( + RegExp(r'[^\u4e00-\u9fa5a-zA-Z0-9\s\.,!?;:,。!?;:、""' + '()()[\]【】《》〈〉「」『』\-—_\n]'), + ' '); + + // 清理多余的空格和标点 + cleanText = cleanText + .replaceAll(RegExp(r'\s+'), ' ') // 多个空格替换为单个空格 + .replaceAll(RegExp(r'[。]{2,}'), '。') // 多个句号替换为单个 + .replaceAll(RegExp(r'[,]{2,}'), ',') // 多个逗号替换为单个 + .replaceAll(RegExp(r'[!]{2,}'), '!') // 多个感叹号替换为单个 + .replaceAll(RegExp(r'[?]{2,}'), '?') // 多个问号替换为单个 + .replaceAll(RegExp(r'^\s+|\s+$'), '') // 移除首尾空格 + .trim(); + + return cleanText; + } + + // 单次语音播放方法 + Future speak(String text) async { + if (text.trim().isEmpty) return false; + + try { + await stop(); + await processCompleteText(text); + return true; + } catch (e) { + print('TTS 播放错误: $e'); + _onErrorHandler?.call('TTS 播放错误: $e'); + return false; + } + } + + // 停止播放 + Future stop() async { + try { + await _stopCurrentPlayback(); + clearAll(); + } catch (e) { + print('停止音频播放错误: $e'); + } + } + + Future pause() async { + try { + if (_isPlaying) { + await _flutterTts.pause(); + } + } catch (e) { + print('暂停音频播放错误: $e'); + } + } + + Future resume() async { + try { + // flutter_tts没有resume方法,需要重新播放当前文本 + // 这里可以根据需要实现 + print('TTS不支持恢复,需要重新播放'); + } catch (e) { + print('恢复音频播放错误: $e'); + } + } + + bool get isPlaying => _isPlaying; + + bool get hasQueuedItems => _orderedTasks.isNotEmpty; + + int get queueLength => _orderedTasks.length; + + // 获取当前播放状态 + bool get isCurrentlyPlaying => _isPlaying; + + // 获取任务队列状态信息 + Map get sseQueueStatus => { + 'totalTasks': _orderedTasks.length, + 'completedTasks': _nextTaskIndex, + 'isProcessing': _isProcessingTasks, + 'streamCompleted': _streamCompleted, + }; + + // 获取队列状态信息(保留兼容性) + Map get queueStatus => { + 'totalUrls': 0, + 'currentIndex': 0, + 'isQueuePlaying': false, + 'isAllSegmentsProcessed': false, + 'expectedSegments': 0, + }; + + // 获取有序任务数量(用于测试) + int getOrderedTasksCount() => _orderedTasks.length; + + // 获取下一个任务索引(用于测试) + int getNextTaskIndex() => _nextTaskIndex; + + // dispose方法 + void dispose() { + clearAll(); + // flutter_tts会自动清理资源 + } + + /// 获取可用的TTS引擎列表 + Future> getAvailableEngines() async { + try { + dynamic engines = await _flutterTts.getEngines; + return engines ?? []; + } catch (e) { + print('获取TTS引擎列表失败: $e'); + return []; + } + } + + /// 设置TTS引擎 + Future setEngine(String engineName) async { + try { + int result = await _flutterTts.setEngine(engineName); + print('设置TTS引擎: $engineName, 结果: $result'); + return result == 1; // 1表示成功 + } catch (e) { + print('设置TTS引擎失败: $e'); + return false; + } + } + + /// 获取当前默认引擎 + Future getCurrentEngine() async { + try { + dynamic engine = await _flutterTts.getDefaultEngine; + return engine?.toString(); + } catch (e) { + print('获取当前TTS引擎失败: $e'); + return null; + } + } + + /// 获取支持的语言列表 + Future> getSupportedLanguages() async { + try { + dynamic languages = await _flutterTts.getLanguages; + return languages ?? []; + } catch (e) { + print('获取支持的语言列表失败: $e'); + return []; + } + } + + /// 检查特定语言是否可用 + Future isLanguageAvailable(String language) async { + try { + dynamic result = await _flutterTts.isLanguageAvailable(language); + return result == true; + } catch (e) { + print('检查语言可用性失败: $e'); + return false; + } + } +} + +/// TTS任务类 +class TtsTask { + final int index; + final String text; + TtsTaskStatus status; + + TtsTask({ + required this.index, + required this.text, + required this.status, + }); +} + +/// TTS任务状态枚举 +enum TtsTaskStatus { + ready, // 就绪 + completed, // 已完成 + failed, // 失败 +} diff --git a/lib/services/vehicle_state_service.dart b/lib/services/vehicle_state_service.dart new file mode 100644 index 0000000..c25535c --- /dev/null +++ b/lib/services/vehicle_state_service.dart @@ -0,0 +1,98 @@ +// import 'redis_service.dart'; +// import 'package:flutter_ingeek_carkey/flutter_ingeek_carkey_platform_interface.dart'; +// import 'package:app_car/src/app_ingeek_mdk/ingeek_mdk_instance.dart'; +// import 'package:app_car/src/app_ingeek_mdk/ingeek_mdk_service.dart'; +// +// class VehicleState { +// String vin; +// bool isStarted; +// bool isUnlock; +// bool isDoorUnlock; +// bool isWindowOpen; +// bool isTrunkOpen; +// int power; +// String mileage; +// String innerTemperature; +// bool climatisationOn; +// String climatisationState; +// double targetTemperature; +// bool wheelHeatOn; +// bool mainSeatHeatOn; +// bool minorSeatHeatOn; +// +// VehicleState({ +// required this.vin, +// required this.isStarted, +// required this.isUnlock, +// required this.isDoorUnlock, +// required this.isWindowOpen, +// required this.isTrunkOpen, +// required this.power, +// required this.mileage, +// required this.innerTemperature, +// required this.climatisationOn, +// required this.climatisationState, +// required this.targetTemperature, +// required this.wheelHeatOn, +// required this.mainSeatHeatOn, +// required this.minorSeatHeatOn, +// }); +// +// Map toJson() => { +// "vin": vin, +// "isStarted": isStarted, +// "isUnlock": isUnlock, +// "isDoorUnlock": isDoorUnlock, +// "isWindowOpen": isWindowOpen, +// "isTrunkOpen": isTrunkOpen, +// "power": power, +// "mileage": mileage, +// "innerTemperature": innerTemperature, +// "climatisationOn": climatisationOn, +// "climatisationState": climatisationState, +// "targetTemperature": targetTemperature, +// "wheelHeatOn": wheelHeatOn, +// "mainSeatHeatOn": mainSeatHeatOn, +// "minorSeatHeatOn": minorSeatHeatOn, +// }; +// } +// +// class VehicleStateService { +// MDKObserverClient? _mdkClient; +// String vin = ''; +// +// static final VehicleStateService _instance = VehicleStateService._internal(); +// +// factory VehicleStateService() => _instance; +// +// VehicleStateService._internal() { +// _mdkClient = InGeekMdkInstance().addObserve( +// (IGKVehicleStatusInfo data) { +// vin = data.vin; +// final vehicleState = VehicleState( +// vin: data.vin, +// isStarted: InGeekMdkInstance().getCarIsStart(), +// isUnlock: InGeekMdkInstance().getMDKCarIsUnLock(), +// isDoorUnlock: InGeekMdkInstance().getMDKDoorIsUnLock(), +// isWindowOpen: InGeekMdkInstance().getMDKWindowIsOpen(), +// isTrunkOpen: InGeekMdkInstance().getMDKTrunkIsOpen(), +// power: InGeekMdkInstance().getMDKPower(), +// mileage: InGeekMdkInstance().getMDKMileage(), +// innerTemperature: InGeekMdkInstance().getMdkInnerTemperature(), +// climatisationOn: InGeekMdkInstance().getMdkClimatizationOnoff(), +// climatisationState: InGeekMdkInstance().getMdkClimatisationState(), +// targetTemperature: InGeekMdkInstance().getMdkTargetTemperature(), +// wheelHeatOn: InGeekMdkInstance().getMdkWheelHeatOn(), +// mainSeatHeatOn: InGeekMdkInstance().getMdkMainSeatHeatOn(), +// minorSeatHeatOn: InGeekMdkInstance().getMdkMinorSeatHeatOn()); +// RedisService().setKeyValue(data.vin, vehicleState); +// }, +// ); +// } +// +// void dispose() { +// if (_mdkClient != null) { +// InGeekMdkInstance().removeObserve(_mdkClient!); +// } +// } +// } diff --git a/lib/services/voice_recognition_service.dart b/lib/services/voice_recognition_service.dart new file mode 100644 index 0000000..6b26e3b --- /dev/null +++ b/lib/services/voice_recognition_service.dart @@ -0,0 +1,61 @@ +import 'package:http/http.dart' as http; +import 'dart:convert'; +import 'package:http_parser/http_parser.dart'; + +class VoiceRecognitionService { + Future recognizeSpeech(List audioBytes, + {String lang = 'cn'}) async { + try { + print('准备发送OPUS音频数据,大小: ${audioBytes.length} 字节'); + final uri = Uri.parse('http://143.64.185.20:18606/voice'); + print('发送到: $uri'); + + final request = http.MultipartRequest('POST', uri); + request.files.add( + http.MultipartFile.fromBytes( + 'audio', + audioBytes, + filename: 'record.wav', + contentType: MediaType('audio', 'wav'), + ), + ); + request.fields['lang'] = lang; // 根据参数设置语言 + + print('发送请求...'); + final streamResponse = await request.send(); + print('收到响应,状态码: ${streamResponse.statusCode}'); + + final response = await http.Response.fromStream(streamResponse); + + if (response.statusCode == 200) { + print('响应内容: ${response.body}'); + final text = _parseTextFromJson(response.body); + if (text != null) { + print('解析出文本: $text'); + return text; // 返回识别的文本 + } else { + print('解析文本失败'); + } + } else { + print('请求失败,状态码: ${response.statusCode},响应: ${response.body}'); + } + } catch (e) { + print('发送录音到服务器时出错: $e'); + } + return null; + } + + // 移到类内部作为私有方法 + String? _parseTextFromJson(String body) { + try { + final decoded = jsonDecode(body); + if (decoded.containsKey('text')) { + return decoded['text'] as String?; + } + return null; + } catch (e) { + print('JSON解析错误: $e, 原始数据: $body'); + return null; + } + } +} diff --git a/lib/config/theme.dart b/lib/themes/AppTheme.dart similarity index 100% rename from lib/config/theme.dart rename to lib/themes/AppTheme.dart diff --git a/lib/utils/common_util.dart b/lib/utils/common_util.dart new file mode 100644 index 0000000..ae5abc9 --- /dev/null +++ b/lib/utils/common_util.dart @@ -0,0 +1,60 @@ +import 'dart:ui'; + +class CommonUtil { + static const Color commonColor = Color(0xFF6F72F1); + + static bool containChinese(String text) { + return RegExp(r'[\u4E00-\u9FFF]').hasMatch(text); + } + + static String cleanText(String text, bool forTts) { + String cleanedText = text + // 修正:使用 replaceAllMapped 确保安全提取内容 + .replaceAllMapped(RegExp(r'\*\*(.*?)\*\*'), (m) => m.group(1) ?? '') // 粗体 + .replaceAllMapped(RegExp(r'\*(.*?)\*'), (m) => m.group(1) ?? '') // 斜体 + .replaceAllMapped(RegExp(r'`([^`]+)`'), (m) => m.group(1) ?? '') // 行内代码 + + // 修正:处理不完整的代码块 + .replaceAll(RegExp(r'```[^`]*```', multiLine: true), '') // 完整代码块 + .replaceAll(RegExp(r'```[^`]*$', multiLine: true), '') // 不完整代码块开始 + .replaceAll(RegExp(r'^[^`]*```', multiLine: true), '') // 不完整代码块结束 + + // 新增:处理 markdown 表格 + .replaceAll(RegExp(r'^\s*\|.*\|\s*$', multiLine: true), '') // 表格行:| 内容 | 内容 | + .replaceAll(RegExp(r'^\s*[\|\-\:\+\=\s]+\s*$', multiLine: true), '') // 表格分隔符行:|---|---| + .replaceAll(RegExp(r'\|'), ' ') // 清理残留的竖线 + + // 修正:使用 replaceAllMapped 避免 $1 问题 + .replaceAllMapped(RegExp(r'^#{1,6}\s+(.*)$', multiLine: true), + (m) => m.group(1) ?? '') + .replaceAllMapped(RegExp(r'\[([^\]]+)\]\([^\)]+\)'), (m) => m.group(1) ?? '') + + // 修正:处理不完整的链接 + .replaceAll(RegExp(r'\[([^\]]*)\](?!\()'), r'$1') // 只有方括号的链接 + .replaceAll(RegExp(r'\]\([^\)]*\)'), '') // 只有圆括号部分 + + .replaceAll(RegExp(r'!\[([^\]]*)\]\([^\)]+\)'), '') // 图片链接 + .replaceAllMapped(RegExp(r'^>\s*(.*)$', multiLine: true), (m) => m.group(1) ?? '') + .replaceAllMapped(RegExp(r'^\s*[–—\-*+-]+\s*(.*)$', multiLine: true), (m) => m.group(1) ?? '') + .replaceAllMapped(RegExp(r'^\s*\d+\.\s+(.*)$', multiLine: true), (m) => m.group(1) ?? '') + .replaceAll(RegExp(r'^\s*[-*]{3,}\s*$', multiLine: true), '') + + // 修正:清理残留的 markdown 符号 + .replaceAll(RegExp(r'\*+'), '') // 清理残留星号 + .replaceAll(RegExp(r'`+'), '') // 清理残留反引号 + .replaceAll(RegExp(r'#+'), '') // 清理残留井号 + + .replaceAll(RegExp(r'\n\s*\n'), '\n') + .replaceAll(RegExp(r'\n'), ' ') + .replaceAll(RegExp(r'!\$\d'), '') // 清理占位符 + .replaceAll(RegExp(r'\$\d+'), '') // 清理所有 $数字 占位符 + .trim(); + + if (forTts) { + cleanedText = cleanedText.replaceAllMapped( + RegExp(r'ID\.UNYX', caseSensitive: false), (m) => 'I D Unix'); + } + + return cleanedText; + } +} diff --git a/lib/utils/tts_engine_manager.dart b/lib/utils/tts_engine_manager.dart new file mode 100644 index 0000000..c2713d2 --- /dev/null +++ b/lib/utils/tts_engine_manager.dart @@ -0,0 +1,56 @@ +import 'package:flutter_tts/flutter_tts.dart'; + +typedef TtsCompletionCallback = void Function(); +typedef TtsErrorCallback = void Function(String error); + +class TtsEngineManager { + static final TtsEngineManager _instance = TtsEngineManager._internal(); + factory TtsEngineManager() => _instance; + TtsEngineManager._internal(); + + final FlutterTts _flutterTts = FlutterTts(); + + TtsCompletionCallback? onComplete; + TtsErrorCallback? onError; + + bool _isInitialized = false; + + Future init({String language = 'zh-CN', double speechRate = 0.5}) async { + if (_isInitialized) return; + await _flutterTts.setLanguage(language); + await _flutterTts.setSpeechRate(speechRate); + _flutterTts.setCompletionHandler(() { + if (onComplete != null) onComplete!(); + }); + _flutterTts.setErrorHandler((msg) { + if (onError != null) onError!(msg); + }); + _isInitialized = true; + } + + Future speak(String text, {String? language, double? speechRate}) async { + if (!_isInitialized) { + await init(language: language ?? 'zh-CN', speechRate: speechRate ?? 0.5); + } + if (language != null) await _flutterTts.setLanguage(language); + if (speechRate != null) await _flutterTts.setSpeechRate(speechRate); + await _flutterTts.speak(text); + } + + Future stop() async { + await _flutterTts.stop(); + } + + Future setLanguage(String language) async { + await _flutterTts.setLanguage(language); + } + + Future setSpeechRate(double rate) async { + await _flutterTts.setSpeechRate(rate); + } + + void dispose() { + _flutterTts.stop(); + _isInitialized = false; + } +} \ No newline at end of file diff --git a/lib/widgets/ai_avatar.dart b/lib/widgets/assistant_avatar.dart similarity index 73% rename from lib/widgets/ai_avatar.dart rename to lib/widgets/assistant_avatar.dart index a2c0b8f..fa97dcd 100644 --- a/lib/widgets/ai_avatar.dart +++ b/lib/widgets/assistant_avatar.dart @@ -1,13 +1,13 @@ import 'package:flutter/material.dart'; -class AIAvatar extends StatelessWidget { +class AssistantAvatar extends StatelessWidget { final double width; final double height; - const AIAvatar({ + const AssistantAvatar({ super.key, - this.width = 120, - this.height = 140, + this.width = 132, + this.height = 132, }); @override diff --git a/lib/widgets/chat_box.dart b/lib/widgets/chat_box.dart new file mode 100644 index 0000000..b11ffe9 --- /dev/null +++ b/lib/widgets/chat_box.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import '../models/chat_message.dart'; +import 'chat_bubble.dart'; + +class ChatBox extends StatelessWidget { + final ScrollController scrollController; + final List messages; + const ChatBox({ + super.key, + required this.scrollController, + required this.messages, + }); + + @override + Widget build(BuildContext context) { + return ListView.builder( + controller: scrollController, + itemCount: messages.length, + reverse: true, + padding: const EdgeInsets.symmetric(horizontal: 12), + itemBuilder: (context, index) { + final message = messages[messages.length - 1 - index]; + final isTop = index == messages.length - 1; + final isBottom = index == 0; + return Padding( + padding: EdgeInsets.only( + top: isTop ? 0 : 6, + bottom: isBottom ? 0 : 6, + ), + child: ChatBubble(message: message), + ); + }, + ); + } +} diff --git a/lib/widgets/chat_bubble.dart b/lib/widgets/chat_bubble.dart index 8a1ef08..8e79b56 100644 --- a/lib/widgets/chat_bubble.dart +++ b/lib/widgets/chat_bubble.dart @@ -1,32 +1,346 @@ +import 'package:ai_chat_assistant/utils/common_util.dart'; +import 'package:basic_intl/intl.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import '../enums/message_status.dart'; import '../models/chat_message.dart'; +import 'package:provider/provider.dart'; +import '../services/message_service.dart'; +import 'package:flutter/services.dart'; +import 'package:fluttertoast/fluttertoast.dart'; -class ChatBubble extends StatelessWidget { +class ChatBubble extends StatefulWidget { final ChatMessage message; const ChatBubble({super.key, required this.message}); + @override + State createState() => _ChatBubbleState(); +} + +class _ChatBubbleState extends State { + bool _liked = false; + bool _disliked = false; + + ChatMessage get message => widget.message; + @override Widget build(BuildContext context) { + final isThinking = message.status == MessageStatus.thinking; return Align( alignment: message.isUser ? Alignment.centerRight : Alignment.centerLeft, - child: Container( - margin: const EdgeInsets.symmetric(vertical: 8), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - decoration: BoxDecoration( - color: message.isUser - ? const Color(0xFF6C63FF) - : Colors.white.withOpacity(0.1), - borderRadius: BorderRadius.circular(16), - ), - child: Text( - message.text, - style: TextStyle( - color: message.isUser ? Colors.white : Colors.white, - fontSize: 16, + child: Column( + crossAxisAlignment: + message.isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start, + children: [ + Container( + constraints: const BoxConstraints( + minWidth: 50, + ), + width: isThinking ? double.infinity : null, + child: isThinking + ? Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 12), + decoration: BoxDecoration( + color: message.isUser + ? CommonUtil.commonColor + : Colors.white.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(12), + ), + child: _buildAssistantContent(isThinking), + ) + : IntrinsicWidth( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 12), + decoration: BoxDecoration( + color: message.isUser + ? CommonUtil.commonColor + : Colors.white.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(12), + ), + child: message.isUser + ? _buildUserContent() + : _buildAssistantContent(isThinking), + ), + ), ), - ), + ], ), ); } -} \ No newline at end of file + + Widget _buildUserContent() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (message.text.trim().isEmpty) ...[ + _buildStatusRow(hasBottomMargin: false), + ] else ...[ + _buildStatusRow(hasBottomMargin: true), + Text( + message.text, + style: TextStyle( + color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold), + ) + ], + ], + ); + } + + Widget _buildAssistantContent(bool isThinking) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildStatusRow(hasBottomMargin: message.text.trim().isNotEmpty), + if (message.text.trim().isNotEmpty) _buildMarkdownBody(), + _buildDisclaimer(isThinking), + ], + ); + } + + Widget _buildStatusRow({required bool hasBottomMargin}) { + Widget icon; + Color color; + + switch (message.status) { + case MessageStatus.listening: + case MessageStatus.recognizing: + case MessageStatus.thinking: + case MessageStatus.executing: + icon = const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.blue)), + ); + color = Colors.white; + break; + case MessageStatus.completed: + case MessageStatus.success: + icon = const Icon(Icons.check_circle, size: 16, color: Colors.green); + color = Colors.green; + break; + case MessageStatus.failure: + icon = const Icon(Icons.error, size: 16, color: Colors.red); + color = Colors.red; + break; + case MessageStatus.aborted: + icon = const Icon(Icons.block, size: 16, color: Colors.grey); + color = Colors.grey; + break; + default: + return const SizedBox.shrink(); + } + return _statusRow( + icon: icon, color: color, hasBottomMargin: hasBottomMargin); + } + + Widget _statusRow({ + required Widget icon, + required Color color, + required bool hasBottomMargin, + Color? backgroundColor, + BorderRadius? borderRadius, + }) { + return Container( + margin: hasBottomMargin ? const EdgeInsets.only(bottom: 6) : null, + decoration: backgroundColor != null + ? BoxDecoration( + color: backgroundColor, + borderRadius: borderRadius ?? BorderRadius.circular(0)) + : null, + padding: backgroundColor != null ? const EdgeInsets.all(6) : null, + child: Row( + mainAxisAlignment: + message.isUser ? MainAxisAlignment.end : MainAxisAlignment.start, + children: [ + icon, + const SizedBox(width: 6), + Text( + Intl.getCurrentLocale().startsWith('zh') + ? message.status.chinese + : message.status.english, + style: TextStyle( + fontSize: 16, color: color, fontWeight: FontWeight.bold), + ), + ], + ), + ); + } + + Widget _buildMarkdownBody() { + return MarkdownBody( + data: message.text, + styleSheet: _markdownStyleSheet, + sizedImageBuilder: (config) { + return Image.network( + config.uri.toString(), + width: config.width, + height: config.height, + errorBuilder: (context, error, stackTrace) { + return const SizedBox.shrink(); + }, + ); + }, + onTapLink: (text, href, title) { + // todo + }, + ); + } + + static final MarkdownStyleSheet _markdownStyleSheet = MarkdownStyleSheet( + p: const TextStyle(fontSize: 16, color: Colors.white, height: 1.5), + h1: const TextStyle( + fontSize: 20, fontWeight: FontWeight.bold, color: Colors.white), + h2: const TextStyle( + fontSize: 18, fontWeight: FontWeight.bold, color: Colors.white), + h3: const TextStyle( + fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white), + h4: const TextStyle(color: Colors.white, fontSize: 12, height: 1.5), + h5: const TextStyle(color: Colors.white, fontSize: 12, height: 1.5), + h6: const TextStyle(color: Colors.white, fontSize: 12, height: 1.5), + strong: const TextStyle( + color: Colors.white, + fontSize: 15, + height: 1.5, + fontWeight: FontWeight.bold), + em: const TextStyle( + color: Colors.white, + fontSize: 12, + height: 1.5, + fontStyle: FontStyle.italic), + code: const TextStyle( + fontSize: 16, backgroundColor: Color(0xFFF5F5F5), color: Colors.white), + codeblockDecoration: BoxDecoration( + color: const Color(0xFFF5F5F5), borderRadius: BorderRadius.circular(4)), + blockquote: const TextStyle(color: Colors.white, fontSize: 12, height: 1.5), + blockquoteDecoration: BoxDecoration( + border: Border(left: BorderSide(color: Colors.grey, width: 4))), + listBullet: const TextStyle(color: Colors.white, fontSize: 12, height: 1.5), + tableHead: const TextStyle( + color: Colors.white, + fontSize: 12, + height: 1.5, + fontWeight: FontWeight.bold), + tableBody: const TextStyle(color: Colors.white, fontSize: 12, height: 1.5), + tableBorder: TableBorder.all(color: Colors.grey, width: 1), + tableCellsPadding: const EdgeInsets.all(8), + tableColumnWidth: const FlexColumnWidth(), + ); + + Widget _buildDisclaimer(bool isThinking) { + if (!isThinking && message.status != MessageStatus.completed) { + return const SizedBox.shrink(); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 6), + const Divider(color: Colors.grey, height: 1), + const SizedBox(height: 6), + SizedBox( + height: 24, + child: isThinking + ? Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + InkWell( + onTap: () { + Provider.of(context, listen: false) + .abortReply(); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.stop_circle_outlined, + size: 16, color: CommonUtil.commonColor), + const SizedBox(width: 4), + Text( + Intl.getCurrentLocale().startsWith('zh') + ? '停止回答' + : 'Stop', + style: const TextStyle( + color: Colors.white, fontSize: 12)), + ], + ), + ), + ], + ) + : Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + Intl.getCurrentLocale().startsWith('zh') + ? '内容由AI生成,具体以实际情况为准' + : 'Generated by AI, for reference only.', + style: + const TextStyle(fontSize: 12, color: Colors.grey), + ), + ), + Row( + children: [ + InkWell( + onTap: () async { + await Clipboard.setData(ClipboardData( + text: + CommonUtil.cleanText(message.text, false))); + Fluttertoast.showToast( + msg: Intl.getCurrentLocale().startsWith('zh') + ? '已复制到剪贴板' + : 'Copied to clipboard'); + }, + child: const Padding( + padding: EdgeInsets.only(left: 12), + child: Icon(Icons.content_copy, + size: 16, color: Colors.white), + ), + ), + InkWell( + onTap: () { + setState(() { + _liked = !_liked; + if (_liked) _disliked = false; + }); + }, + child: Padding( + padding: const EdgeInsets.only(left: 12), + child: Icon( + Icons.thumb_up_off_alt, + size: 16, + color: _liked + ? CommonUtil.commonColor + : Colors.white, + ), + ), + ), + InkWell( + onTap: () { + setState(() { + _disliked = !_disliked; + if (_disliked) _liked = false; + }); + }, + child: Padding( + padding: const EdgeInsets.only(left: 12), + child: Icon( + Icons.thumb_down_off_alt, + size: 16, + color: _disliked + ? CommonUtil.commonColor + : Colors.white, + ), + ), + ), + ], + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/widgets/chat_footer.dart b/lib/widgets/chat_footer.dart new file mode 100644 index 0000000..a515eda --- /dev/null +++ b/lib/widgets/chat_footer.dart @@ -0,0 +1,91 @@ +import 'package:basic_intl/intl.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; +import '../services/message_service.dart'; +import '../utils/common_util.dart'; +import 'voice_animation.dart'; + +class ChatFooter extends StatelessWidget { + final VoidCallback? onClear; + + const ChatFooter({super.key, this.onClear}); + + @override + Widget build(BuildContext context) { + return Container( + color: Colors.transparent, + child: Row( + children: [ + Padding( + padding: const EdgeInsets.only(left: 12, bottom: 12), + child: IconButton( + icon: const Icon(Icons.delete_outline, + color: CommonUtil.commonColor), + iconSize: 30, + tooltip: '清除会话', + onPressed: onClear, + ), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Consumer( + builder: (context, messageService, child) { + return GestureDetector( + onTap: () { + HapticFeedback.heavyImpact(); + messageService.abortReply(); + }, + onLongPress: () { + HapticFeedback.mediumImpact(); + Future.delayed(const Duration(milliseconds: 100), () { + HapticFeedback.heavyImpact(); + }); + messageService.startVoiceInput(); + }, + onLongPressUp: () { + HapticFeedback.lightImpact(); + messageService.stopAndProcessVoiceInput(); + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + height: 48, + width: double.infinity, + alignment: Alignment.center, + decoration: BoxDecoration( + color: messageService.isRecording + ? Color(0xFF8E24AA).withValues(alpha: 0.42) + : Colors.white.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(12), + ), + child: messageService.isRecording + ? const VoiceWaveAnimation() + : Text( + Intl.getCurrentLocale().startsWith('zh') + ? '按 住 说 话' + : 'Hold to Speak', + style: TextStyle( + color: CommonUtil.commonColor, + fontSize: 18, + fontWeight: FontWeight.bold), + ), + ), + ); + }, + ), + ), + ), + Padding( + padding: const EdgeInsets.only(bottom: 12, right: 12), + child: IconButton( + icon: const Icon(Icons.keyboard, color: CommonUtil.commonColor), + iconSize: 30, + onPressed: () {}, + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/chat_header.dart b/lib/widgets/chat_header.dart new file mode 100644 index 0000000..a354244 --- /dev/null +++ b/lib/widgets/chat_header.dart @@ -0,0 +1,76 @@ +import 'package:basic_intl/intl.dart'; +import 'package:flutter/material.dart'; +import 'assistant_avatar.dart'; + +class ChatHeader extends StatelessWidget { + final VoidCallback? onClose; + const ChatHeader({super.key, this.onClose}); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + SizedBox( + height: 120, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.only(left: 6), + child: SizedBox( + child: AssistantAvatar(), + ), + ), + const SizedBox(width: 6), + Expanded( + child: Padding( + padding: const EdgeInsets.only(top: 48), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + Intl.getCurrentLocale().startsWith('zh') + ? 'Hi, 我是众众!' + : 'Hi, I\'m Zhongzhong!', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + SizedBox(height: 12), + Text( + Intl.getCurrentLocale().startsWith('zh') + ? '您的专属看、选、买、用车助手' + : 'Your exclusive assistant', + style: TextStyle( + fontSize: 12, + color: Colors.white70, + ), + ), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.only(top: 6, right: 6), + child: IconButton( + icon: const Icon( + Icons.close, + color: Colors.white, + size: 24, + ), + onPressed: onClose, + ), + ), + ], + ), + ), + Container( + height: 1, + color: Colors.white.withValues(alpha: 0.15), + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/widgets/chat_input.dart b/lib/widgets/chat_input.dart deleted file mode 100644 index ad6f0f4..0000000 --- a/lib/widgets/chat_input.dart +++ /dev/null @@ -1,351 +0,0 @@ -import 'dart:async'; -import 'dart:io'; -import 'package:flutter/material.dart'; -import 'package:record/record.dart'; -import 'package:http/http.dart' as http; -import 'package:http_parser/http_parser.dart'; -import 'dart:typed_data'; -import 'dart:convert'; -import 'package:path_provider/path_provider.dart'; -import 'dart:math'; - -class ChatInput extends StatefulWidget { - final Function(String) onSendMessage; - final Function(String) onAIResponse; - // 新增一个回调,用于处理 AI 流式回复 - final Function(String, bool) onAIStreamResponse; - - const ChatInput({ - super.key, - required this.onSendMessage, - required this.onAIResponse, - required this.onAIStreamResponse, // 添加这个参数 - }); - - @override - State createState() => _ChatInputState(); -} - -class _ChatInputState extends State { - final TextEditingController _controller = TextEditingController(); - final AudioRecorder _recorder = AudioRecorder(); - List _audioBuffer = []; - String? _tempFilePath; - bool _isRecording = false; - - // 添加静态变量存储用户ID和会话ID - static String? _cachedUserId; - static String? _cachedConversationId; - - @override - void initState() { - super.initState(); - _checkPermission(); - } - - Future _checkPermission() async { - final hasPermission = await _recorder.hasPermission(); - print('麦克风权限状态: $hasPermission'); - } - - @override - void dispose() { - _controller.dispose(); - _recorder.dispose(); - super.dispose(); - } - - void _handleSubmit() { - final text = _controller.text; - if (text.trim().isNotEmpty) { - widget.onSendMessage(text); - _controller.clear(); - } - } - - Future _startRecording() async { - setState(() { - _isRecording = true; - _audioBuffer = []; - }); - - print('开始录音...'); - - try { - // 使用文件录音可能更稳定 - final tempDir = await getTemporaryDirectory(); - _tempFilePath = '${tempDir.path}/temp_audio_${DateTime.now().millisecondsSinceEpoch}.opus'; - - print('录音文件路径: $_tempFilePath'); - - if (await _recorder.hasPermission()) { - - await _recorder.start( - RecordConfig(encoder: AudioEncoder.opus), - path: _tempFilePath!, - ); - - print('录音已开始,使用OPUS格式,文件: $_tempFilePath'); - } else { - print('没有麦克风权限,无法开始录音'); - } - } catch (e) { - print('录音开始出错: $e'); - setState(() { - _isRecording = false; - }); - } - } - - Future _stopAndSendRecording() async { - if (!_isRecording) return; - - setState(() { - _isRecording = false; - }); - - print('停止录音...'); - try { - final path = await _recorder.stop(); - print('录音已停止,文件路径: $path'); - - if (path != null) { - final file = File(path); - if (await file.exists()) { - final bytes = await file.readAsBytes(); - print('读取到录音数据: ${bytes.length} 字节'); - - if (bytes.isNotEmpty) { - // 第一步: 发送音频到语音识别接口 - final recognizedText = await _sendAudioToServer(bytes); - if (recognizedText != null) { - // 把识别的文本作为用户消息展示 - widget.onAIResponse(recognizedText); - - // 第二步: 将识别的文本发送到 Chat SSE 接口 - _sendTextToChatSSE(recognizedText); - } - } else { - print('录音数据为空'); - } - } else { - print('录音文件不存在: $path'); - } - } else { - print('录音路径为空'); - } - } catch (e) { - print('停止录音或发送过程出错: $e'); - } - } - - // 修改返回类型,以便获取识别的文本 - Future _sendAudioToServer(List audioBytes) async { - try { - print('准备发送OPUS音频数据,大小: ${audioBytes.length} 字节'); - final uri = Uri.parse('http://143.64.185.20:18606/voice'); - print('发送到: $uri'); - - final request = http.MultipartRequest('POST', uri); - request.files.add( - http.MultipartFile.fromBytes( - 'audio', - audioBytes, - filename: 'record.wav', - contentType: MediaType('audio', 'wav'), - ), - ); - request.fields['lang'] = 'cn'; - - print('发送请求...'); - final streamResponse = await request.send(); - print('收到响应,状态码: ${streamResponse.statusCode}'); - - final response = await http.Response.fromStream(streamResponse); - - if (response.statusCode == 200) { - print('响应内容: ${response.body}'); - final text = _parseTextFromJson(response.body); - if (text != null) { - print('解析出文本: $text'); - return text; // 返回识别的文本 - } else { - print('解析文本失败'); - } - } else { - print('请求失败,状态码: ${response.statusCode},响应: ${response.body}'); - } - } catch (e) { - print('发送录音到服务器时出错: $e'); - } - return null; - } - - // 添加这个方法,用于从JSON响应中解析文本 - String? _parseTextFromJson(String body) { - try { - final decoded = jsonDecode(body); - if (decoded.containsKey('text')) { - return decoded['text'] as String?; - } - return null; - } catch (e) { - print('JSON解析错误: $e, 原始数据: $body'); - return null; - } - } - - // 新增方法:发送文本到 Chat SSE 接口 - void _sendTextToChatSSE(String text) async { - print('将识别的文本发送到 Chat SSE 接口: $text'); - - // 如果用户ID未初始化,则生成一个 - if (_cachedUserId == null) { - _cachedUserId = _generateRandomUserId(6); // 生成6位随机ID - print('初始化用户ID: $_cachedUserId'); - } - - try { - // 使用 HttpClient 来处理 SSE - final client = HttpClient(); - // 设置 URL 和参数 - final chatUri = Uri.parse('http://143.64.185.20:18606/chat'); - final request = await client.postUrl(chatUri); - - // 设置请求头 - request.headers.set('Content-Type', 'application/json'); - request.headers.set('Accept', 'text/event-stream'); - - // 设置请求体 - final body = { - 'message': text, - 'user': _cachedUserId, - }; - - // 如果有缓存的会话ID,则添加到请求体 - if (_cachedConversationId != null) { - body['conversation_id'] = _cachedConversationId; - print('使用缓存的会话ID: $_cachedConversationId'); - } else { - print('首次请求,不使用会话ID'); - } - - request.add(utf8.encode(json.encode(body))); - - // 发送请求并获取 SSE 流 - final response = await request.close(); - - if (response.statusCode == 200) { - print('SSE 连接成功,开始接收流数据'); - - // 创建一个变量保存累积的 AI 回复 - String accumulatedResponse = ''; - - // 使用 transform 将流数据转换为字符串 - await for (final data in response.transform(utf8.decoder)) { - // 处理 SSE 数据(格式为 "data: {...}\n\n") - final lines = data.split('\n'); - - for (var line in lines) { - if (line.startsWith('data:')) { - // 提取 JSON 数据部分 - final jsonStr = line.substring(5).trim(); - - if (jsonStr == '[DONE]') { - // 流结束 - print('SSE 流结束'); - // 发送最终完整的回复,标记为完成 - widget.onAIStreamResponse(accumulatedResponse, true); - break; - } - - try { - final jsonData = json.decode(jsonStr); - - // 尝试提取会话ID (如果存在) - if (jsonData.containsKey('conversation_id') && _cachedConversationId == null) { - _cachedConversationId = jsonData['conversation_id']; - print('从响应中提取并缓存会话ID: $_cachedConversationId'); - } - - // 提取并累加内容片段 - if(jsonData['event'].toString().contains('message')){ - // 代表是有实际消息的数据 - final textChunk = jsonData.containsKey('answer') ? - jsonData['answer'] : ''; - accumulatedResponse += textChunk; - widget.onAIStreamResponse(accumulatedResponse, false); - } - } catch (e) { - print('解析 SSE 数据出错: $e, 原始数据: $jsonStr'); - } - } - } - } - } else { - print('SSE 连接失败,状态码: ${response.statusCode}'); - } - } catch (e) { - print('SSE 连接出错: $e'); - } - } - - // 生成随机用户ID的辅助方法 - String _generateRandomUserId(int length) { - const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; - final random = Random(); - return String.fromCharCodes( - Iterable.generate(length, (_) => chars.codeUnitAt(random.nextInt(chars.length))) - ); - } - - // 添加一个新方法来呼出键盘 - void _showKeyboard() { - FocusScope.of(context).requestFocus(FocusNode()); - // 给输入框焦点,触发键盘弹出 - Future.delayed(const Duration(milliseconds: 50), () { - FocusScope.of(context).requestFocus( - FocusNode()..requestFocus() - ); - }); - } - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(8.0), - color: Colors.transparent, - child: Row( - children: [ - Expanded( - child: GestureDetector( - onLongPress: _startRecording, - onLongPressUp: _stopAndSendRecording, - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - height: 48, - alignment: Alignment.center, - decoration: BoxDecoration( - color: _isRecording - ? Colors.white.withOpacity(0.25) - : Colors.white.withOpacity(0.1), - borderRadius: BorderRadius.circular(24), - ), - child: Text( - _isRecording ? '正在说话中...' : '按住说话', - style: const TextStyle(color: Colors.white70, fontSize: 16), - ), - ), - ), - ), - IconButton( - // 替换为键盘图标 - icon: const Icon(Icons.keyboard, color: Colors.white), - // 修改点击行为为呼出键盘 - onPressed: _showKeyboard, - ), - ], - ), - ); - } -} \ No newline at end of file diff --git a/lib/widgets/floating_icon.dart b/lib/widgets/floating_icon.dart new file mode 100644 index 0000000..f2087db --- /dev/null +++ b/lib/widgets/floating_icon.dart @@ -0,0 +1,98 @@ +import 'package:ai_chat_assistant/services/vehicle_state_service.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../services/message_service.dart'; +import '../screens/full_screen.dart'; +import '../screens/part_screen.dart'; + +class FloatingIcon extends StatefulWidget { + const FloatingIcon({super.key}); + + @override + State createState() => _FloatingIconState(); +} + +class _FloatingIconState extends State { + Offset _position = const Offset(10, 120); + final iconSize = 80.0; + bool _isShowPartScreen = false; + + @override + void initState() { + super.initState(); + // VehicleStateService(); + } + + void _showPartScreen() { + setState(() { + _isShowPartScreen = true; + }); + } + + void _hidePartScreen() { + setState(() { + _isShowPartScreen = false; + }); + } + + void _showFullScreen() async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const FullScreen(), + ), + ); + } + + void _updatePosition(Offset delta) { + setState(() { + final screenSize = MediaQuery.of(context).size; + double newX = + (_position.dx - delta.dx).clamp(0.0, screenSize.width - iconSize); + double newY = + (_position.dy - delta.dy).clamp(0.0, screenSize.height - iconSize); + _position = Offset(newX, newY); + }); + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + if (_isShowPartScreen) + PartScreen( + onHide: _hidePartScreen, + ), + Positioned( + bottom: _position.dy, + right: _position.dx, + child: Consumer( + builder: (context, messageService, child) { + return GestureDetector( + onTap: _showFullScreen, + onLongPress: () async { + _showPartScreen(); + await messageService.startVoiceInput(); + }, + onLongPressUp: () async { + await messageService.stopAndProcessVoiceInput(); + final hasMessage = messageService.messages.isNotEmpty; + if (!hasMessage) { + _hidePartScreen(); + } + }, + onPanUpdate: (details) => _updatePosition(details.delta), + child: Image.asset( + messageService.isRecording + ? 'assets/images/ai2.png' + : 'assets/images/ai1.png', + width: iconSize, + height: iconSize, + ), + ); + }, + ), + ), + ], + ); + } +} diff --git a/lib/widgets/gradient_background.dart b/lib/widgets/gradient_background.dart index 00fbdda..56c7787 100644 --- a/lib/widgets/gradient_background.dart +++ b/lib/widgets/gradient_background.dart @@ -2,22 +2,25 @@ import 'package:flutter/material.dart'; class GradientBackground extends StatelessWidget { final Widget child; + final List colors; + final BorderRadius? borderRadius; - const GradientBackground({super.key, required this.child}); + const GradientBackground( + {super.key, + required this.child, + required this.colors, + this.borderRadius}); @override Widget build(BuildContext context) { return Container( - decoration: const BoxDecoration( + decoration: BoxDecoration( gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Color(0xFF451663), - Color(0xFF17042B), - Color(0xFF0B021D), - ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: colors, ), + borderRadius: borderRadius, ), child: child, ); diff --git a/lib/widgets/voice_animation.dart b/lib/widgets/voice_animation.dart new file mode 100644 index 0000000..cb3c2e0 --- /dev/null +++ b/lib/widgets/voice_animation.dart @@ -0,0 +1,113 @@ +import 'dart:math'; +import 'package:flutter/material.dart'; + +class VoiceWaveAnimation extends StatefulWidget { + const VoiceWaveAnimation({super.key}); + + @override + State createState() => _VoiceWaveAnimationState(); +} + +class _VoiceWaveAnimationState extends State with TickerProviderStateMixin { + late AnimationController _controller; + + @override + void initState() { + super.initState(); + + // 创建一个持续动画控制器 + _controller = AnimationController( + duration: const Duration(milliseconds: 1500), + vsync: this, + )..repeat(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + return SizedBox( + width: 160, + height: 30, + child: CustomPaint( + painter: SineWavePainter( + animation: _controller, + count: 2, // 波浪数量 + color: Colors.white, + amplitudeFactor: 0.5, // 波幅因子 + ), + ), + ); + }, + ); + } +} + +class SineWavePainter extends CustomPainter { + final Animation animation; + final int count; // 波浪数量 + final Color color; + final double amplitudeFactor; // 波幅因子 + + SineWavePainter({ + required this.animation, + this.count = 2, + required this.color, + this.amplitudeFactor = 1.0, + }); + + @override + void paint(Canvas canvas, Size size) { + final Paint paint = Paint() + ..color = color.withValues(alpha: 0.7) + ..style = PaintingStyle.stroke + ..strokeWidth = 2.0; + + final Paint paint2 = Paint() + ..color = color.withValues(alpha: 0.3) + ..style = PaintingStyle.stroke + ..strokeWidth = 2.0; + + final double width = size.width; + final double height = size.height; + final double centerY = height / 2; + + // 绘制第一个波浪 + final path = Path(); + final path2 = Path(); + + // 创建两条不同相位的波形,一条主要一条次要 + path.moveTo(0, centerY); + path2.moveTo(0, centerY); + + for (int i = 0; i < width; i++) { + // 主波形 + final double x = i.toDouble(); + final double normalizedX = (x / width) * count * 2 * pi; + final double offset = 2 * pi * animation.value; + + // 使用随机振幅变化模拟声音波动 + final double randomAmplitude = (sin(normalizedX + offset) + 1) / 2 * amplitudeFactor; + path.lineTo(x, centerY - randomAmplitude * 10); + + // 次波形,相位滞后 + final double randomAmplitude2 = (sin(normalizedX + offset + pi / 6) + 1) / 2 * amplitudeFactor; + path2.lineTo(x, centerY - randomAmplitude2 * 5); + } + + canvas.drawPath(path, paint); + canvas.drawPath(path2, paint2); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) { + return true; + } +} \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index 516af79..4e4bd22 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,97 +1,29 @@ -name: app003 -description: "A new Flutter project." -# The following line prevents the package from being accidentally published to -# pub.dev using `flutter pub publish`. This is preferred for private packages. -publish_to: 'none' # Remove this line if you wish to publish to pub.dev - -# The following defines the version and build number for your application. -# A version number is three numbers separated by dots, like 1.2.43 -# followed by an optional build number separated by a +. -# Both the version and the builder number may be overridden in flutter -# build by specifying --build-name and --build-number, respectively. -# In Android, build-name is used as versionName while build-number used as versionCode. -# Read more about Android versioning at https://developer.android.com/studio/publish/versioning -# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. -# Read more about iOS versioning at -# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -# In Windows, build-name is used as the major, minor, and patch parts -# of the product and file versions while build-number is used as the build suffix. +name: ai_chat_assistant +description: "ai_chat_assistant" +publish_to: 'none' version: 1.0.0+1 - environment: - sdk: ^3.8.1 - -# Dependencies specify other packages that your package needs in order to work. -# To automatically upgrade your package dependencies to the latest versions -# consider running `flutter pub upgrade --major-versions`. Alternatively, -# dependencies can be manually updated by changing the version numbers below to -# the latest version available on pub.dev. To see which dependencies have newer -# versions available, run `flutter pub outdated`. + sdk: ^3.6.2 dependencies: flutter: sdk: flutter - - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. - cupertino_icons: ^1.0.8 - fluttertoast: ^8.2.4 + fluttertoast: ^8.2.12 record: ^6.0.0 - http: ^1.2.1 - path_provider: ^2.1.2 - sqflite: ^2.4.2 - path: ^1.9.1 - + http: ^1.4.0 + path_provider: ^2.1.5 + flutter_markdown: ^0.7.7+1 + audioplayers: ^5.2.1 + uuid: ^3.0.5 + permission_handler: ^10.3.0 + provider: ^6.1.5 + flutter_tts: ^4.2.0 + basic_intl: ^0.2.0 +# flutter_ingeek_carkey: 1.4.7 +# app_car: +# path: ../app_car dev_dependencies: - flutter_test: - sdk: flutter - - # The "flutter_lints" package below contains a set of recommended lints to - # encourage good coding practices. The lint set provided by the package is - # activated in the `analysis_options.yaml` file located at the root of your - # package. See that file for information about deactivating specific lint - # rules and activating additional ones. flutter_lints: ^5.0.0 - -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter packages. flutter: - - # The following line ensures that the Material Icons font is - # included with your application, so that you can use the icons in - # the material Icons class. uses-material-design: true assets: - - assets/images/ - - # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/to/resolution-aware-images - - # For details regarding adding assets from package dependencies, see - # https://flutter.dev/to/asset-from-package - - # To add custom fonts to your application, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts from package dependencies, - # see https://flutter.dev/to/font-from-package + - assets/images/ \ No newline at end of file