Browse Source

[New]新增录音逻辑

zhipeng 1 year ago
parent
commit
1b8a433a95

+ 3 - 0
android/app/src/main/AndroidManifest.xml

@@ -2,6 +2,9 @@
     package="com.atmob.elec_asst">
 
     <uses-permission android:name="android.permission.INTERNET" />
+    <uses-permission android:name="android.permission.RECORD_AUDIO" />
+    <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
+    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
 
     <application
         android:name="${applicationName}"

BIN
assets/images/icon_record_replay_pause.webp


assets/images/icon_record_replay_start.webp → assets/images/icon_record_resume.webp


+ 3 - 0
assets/string/base/string.xml

@@ -60,4 +60,7 @@
     <string name="talk_todo_all">所有待办</string>
     <string name="talk_todo_set_mine">设为我的</string>
     <string name="talk_todo_cancel_mine">取消待办</string>
+    <string name="record_status_pending">准备开始录音</string>
+    <string name="record_status_recording">我正在听...</string>
+    <string name="record_status_paused">录音已暂停</string>
 </resources>

+ 2 - 0
ios/Runner/Info.plist

@@ -45,5 +45,7 @@
 	<true/>
 	<key>UIApplicationSupportsIndirectInputEvents</key>
 	<true/>
+	<key>NSMicrophoneUsageDescription</key>
+    <string>谈话录音功能</string>
 </dict>
 </plist>

+ 12 - 1
lib/data/api/atmob_api.dart

@@ -7,10 +7,11 @@ import 'package:electronic_assistant/data/api/request/agenda_status_request.dart
 import 'package:electronic_assistant/data/api/request/agenda_todo_request.dart';
 import 'package:electronic_assistant/data/api/request/chat_history_request.dart';
 import 'package:electronic_assistant/data/api/request/login_request.dart';
+import 'package:electronic_assistant/data/api/request/talk_create_request.dart';
 import 'package:electronic_assistant/data/api/request/talk_delete_request.dart';
 import 'package:electronic_assistant/data/api/request/talk_generate_request.dart';
-import 'package:electronic_assistant/data/api/request/talk_request.dart';
 import 'package:electronic_assistant/data/api/request/talk_rename_request.dart';
+import 'package:electronic_assistant/data/api/request/talk_request.dart';
 import 'package:electronic_assistant/data/api/request/user_info_update_request.dart';
 import 'package:electronic_assistant/data/api/request/verification_code_request.dart';
 import 'package:electronic_assistant/data/api/response/agenda_list_all_response.dart';
@@ -23,6 +24,7 @@ import 'package:electronic_assistant/data/api/response/talk_check_electric_respo
 import 'package:electronic_assistant/data/api/response/talk_info_response.dart';
 import 'package:electronic_assistant/data/bean/talk_info.dart';
 import 'package:electronic_assistant/data/api/response/talk_original_response.dart';
+import 'package:electronic_assistant/data/bean/talks.dart';
 import 'package:electronic_assistant/data/consts/constants.dart';
 import 'package:retrofit/http.dart';
 
@@ -32,16 +34,20 @@ part 'atmob_api.g.dart';
 abstract class AtmobApi {
   factory AtmobApi(Dio dio, {String baseUrl}) = _AtmobApi;
 
+  /// 获取验证码
   @POST("/project/secretary/v1/user/code")
   Future<BaseResponse> getVerificationCode(
       @Body() VerificationCodeRequest request);
 
+  /// 登录
   @POST("/project/secretary/v1/user/login")
   Future<BaseResponse<LoginResponse>> login(@Body() LoginRequest request);
 
+  /// 更新用户信息
   @POST("/project/secretary/v1/user/info/update")
   Future<BaseResponse> updateUserInfo(@Body() UserInfoUpdateRequest request);
 
+  /// 首页信息
   @POST("/project/secretary/v1/home/info")
   Future<BaseResponse<HomeInfoResponse>> homeInfo(
       @Body() AppBaseRequest request);
@@ -59,6 +65,7 @@ abstract class AtmobApi {
   @POST("/project/secretary/v1/agenda/complete")
   Future<BaseResponse> agendaFinish(@Body() AgendaStatusRequest request);
 
+  /// 聊天记录
   @POST("/project/secretary/v1/chat/page")
   Future<BaseResponse<ChatHistoryResponse>> chatHistory(
       @Body() ChatHistoryRequest request);
@@ -84,6 +91,10 @@ abstract class AtmobApi {
 
   @POST("/project/secretary/v1/agenda/todo")
   Future<BaseResponse> agendaTodo(@Body() AgendaTodoRequest request);
+
+  /// 录音完成,创建谈话记录
+  @POST("/project/secretary/v1/talk/create")
+  Future<BaseResponse<TalkBean>> talkCreate(@Body() TalkCreateRequest request);
 }
 
 final atmobApi = AtmobApi(defaultDio, baseUrl: Constants.baseUrl);

+ 19 - 0
lib/data/api/request/talk_create_request.dart

@@ -0,0 +1,19 @@
+import 'package:json_annotation/json_annotation.dart';
+
+import '../../../base/app_base_request.dart';
+
+part 'talk_create_request.g.dart';
+
+@JsonSerializable()
+class TalkCreateRequest extends AppBaseRequest {
+  @JsonKey(name: 'duration')
+  final int duration;
+
+  @JsonKey(name: 'requestId')
+  final String requestId;
+
+  TalkCreateRequest(this.duration, this.requestId);
+
+  @override
+  Map<String, dynamic> toJson() => _$TalkCreateRequestToJson(this);
+}

+ 2 - 2
lib/data/bean/talks.dart

@@ -5,7 +5,7 @@ part 'talks.g.dart';
 @JsonSerializable()
 class TalkBean {
   @JsonKey(name: 'id')
-  String? id;
+  late final String id;
 
   @JsonKey(name: 'taskId')
   String? taskId;
@@ -38,7 +38,7 @@ class TalkBean {
   bool? isExample;
 
   TalkBean(
-      {this.id,
+      {required this.id,
       this.taskId,
       this.ssid,
       this.audioUrl,

+ 8 - 2
lib/data/repositories/talk_repository.dart

@@ -1,4 +1,5 @@
 import 'package:electronic_assistant/data/api/atmob_api.dart';
+import 'package:electronic_assistant/data/api/request/talk_create_request.dart';
 import 'package:electronic_assistant/data/api/request/talk_delete_request.dart';
 
 import '../../utils/http_handler.dart';
@@ -7,9 +8,8 @@ import '../api/request/talk_rename_request.dart';
 import '../api/request/talk_request.dart';
 import '../api/response/talk_check_electric_response.dart';
 import '../api/response/talk_info_response.dart';
-import '../bean/talk_info.dart';
-import '../api/response/talk_original_response.dart';
 import '../bean/talk_original.dart';
+import '../bean/talks.dart';
 
 class TalkRepository {
   TalkRepository._();
@@ -49,6 +49,12 @@ class TalkRepository {
         .talkDelete(TalkDeleteRequest(id))
         .then(HttpHandler.handle(true));
   }
+
+  Future<TalkBean> talkCreate(String requestId, int duration) {
+    return atmobApi
+        .talkCreate(TalkCreateRequest(duration, requestId))
+        .then(HttpHandler.handle(true));
+  }
 }
 
 final talkRepository = TalkRepository._();

lib/widget/alert_dialog.dart → lib/dialog/alert_dialog.dart


+ 5 - 0
lib/module/chat/view.dart

@@ -23,6 +23,11 @@ class ChatPage extends BasePage<ChatController> {
   }
 
   @override
+  Color navigationBarColor() {
+    return "#F6F6F6".color;
+  }
+
+  @override
   Widget buildBody(BuildContext context) {
     // 第一次启动时弹出定制窗口
     controller.showStartSheet(context);

+ 1 - 1
lib/module/login/controller.dart

@@ -4,7 +4,7 @@ import 'package:electronic_assistant/popup/talk_popup.dart';
 import 'package:electronic_assistant/utils/error_handler.dart';
 import 'package:electronic_assistant/utils/expand.dart';
 import 'package:electronic_assistant/utils/toast_util.dart';
-import 'package:electronic_assistant/widget/alert_dialog.dart';
+import 'package:electronic_assistant/dialog/alert_dialog.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_screenutil/flutter_screenutil.dart';
 import 'package:get/get.dart';

+ 58 - 0
lib/module/record/constants.dart

@@ -0,0 +1,58 @@
+import 'package:flutter/cupertino.dart';
+import 'package:get/get.dart';
+
+import '../../resource/assets.gen.dart';
+import '../../resource/string.gen.dart';
+
+enum RecordStatus {
+  pending,
+  recording,
+  paused,
+}
+
+extension RecordStatusExtension on RecordStatus {
+  String get desc {
+    switch (this) {
+      case RecordStatus.pending:
+        return StringName.recordStatusPending.tr;
+      case RecordStatus.recording:
+        return StringName.recordStatusRecording.tr;
+      case RecordStatus.paused:
+        return StringName.recordStatusPaused.tr;
+    }
+  }
+
+  ImageProvider get actionButtonImage {
+    switch (this) {
+      case RecordStatus.pending:
+        return Assets.images.iconRecordStart.provider();
+      case RecordStatus.recording:
+        return Assets.images.iconRecordPause.provider();
+      case RecordStatus.paused:
+        return Assets.images.iconRecordResume.provider();
+    }
+  }
+
+  ImageProvider get saveButtonImage {
+    return this == RecordStatus.pending
+        ? Assets.images.iconRecordSaveDisable.provider()
+        : Assets.images.iconRecordSaveEnable.provider();
+  }
+
+  ImageProvider get cancelButtonImage {
+    return this == RecordStatus.pending
+        ? Assets.images.iconRecordCancelDisable.provider()
+        : Assets.images.iconRecordCancelEnable.provider();
+  }
+
+  RecordStatus get nextStatus {
+    switch (this) {
+      case RecordStatus.pending:
+        return RecordStatus.recording;
+      case RecordStatus.recording:
+        return RecordStatus.paused;
+      case RecordStatus.paused:
+        return RecordStatus.recording;
+    }
+  }
+}

+ 183 - 3
lib/module/record/controller.dart

@@ -1,18 +1,198 @@
+import 'dart:io';
+import 'dart:typed_data';
+
 import 'package:electronic_assistant/base/base_controller.dart';
+import 'package:electronic_assistant/data/repositories/talk_repository.dart';
+import 'package:electronic_assistant/dialog/alert_dialog.dart';
+import 'package:electronic_assistant/module/record/constants.dart';
+import 'package:electronic_assistant/module/talk/view.dart';
+import 'package:electronic_assistant/utils/mmkv_util.dart';
+import 'package:flutter/cupertino.dart';
 import 'package:get/get.dart';
+import 'package:path_provider/path_provider.dart';
+import 'package:record/record.dart';
+import 'package:uuid/uuid.dart';
 
+import '../../utils/pcm_wav_converter.dart';
 import '../../widget/frame_animation_view.dart';
 
 class RecordController extends BaseController {
-  final FrameAnimationController frameAnimationController =
-      FrameAnimationController();
+  static const String keyLastRecordId = "last_record_id";
 
-  final RxString recordStatus = '准备开始录音'.obs;
+  final FrameAnimationController frameAnimationController =
+      FrameAnimationController(autoPlay: false);
+  final Rx<RecordStatus> currentStatus = RecordStatus.pending.obs;
+  final RxDouble currentDuration = 0.0.obs;
+  final AudioRecorder record = AudioRecorder();
+  final RecordConfig recordConfig = const RecordConfig(
+      encoder: AudioEncoder.pcm16bits,
+      bitRate: 128000,
+      sampleRate: 44100,
+      numChannels: 2);
+  late final String lastRecordId;
 
   @override
   void onInit() {
     super.onInit();
+    _initLastRecordId();
+    _initLastRecordStatus();
+  }
+
+  void _initLastRecordId() {
+    String? lastRecordId = KVUtil.getString(keyLastRecordId, null);
+    if (lastRecordId == null || lastRecordId.isEmpty) {
+      this.lastRecordId = const Uuid().v4();
+      KVUtil.putString(keyLastRecordId, this.lastRecordId);
+    } else {
+      this.lastRecordId = lastRecordId;
+    }
+  }
+
+  Future<void> _initLastRecordStatus() async {
+    var currentRecordFile = await _getCurrentRecordFile();
+    var fileLength = currentRecordFile.lengthSync();
+    if (currentRecordFile.existsSync() && fileLength > 0) {
+      currentStatus.value = RecordStatus.paused;
+      currentDuration.value = await _getPcmDuration(
+          fileLength, recordConfig.sampleRate, 16, recordConfig.numChannels);
+    }
   }
 
   void addShortcut() {}
+
+  void onBackClick(BuildContext context) {
+    if (currentStatus.value == RecordStatus.pending ||
+        currentStatus.value == RecordStatus.paused) {
+      Navigator.pop(context);
+    } else {
+      EAAlertDialog.show(
+        title: "是否保存当前录音?",
+        confirmText: "确定",
+        cancelText: "取消",
+        confirmOnTap: () {
+          _saveCurrentRecord();
+          EAAlertDialog.dismiss();
+        },
+        cancelOnTap: () {
+          EAAlertDialog.dismiss();
+          Navigator.pop(context);
+        },
+      );
+    }
+  }
+
+  void onActionClick() {
+    RecordStatus nextStatus = currentStatus.value.nextStatus;
+    if (nextStatus == RecordStatus.recording) {
+      _startOrContinueRecord();
+    } else {
+      _stopRecord();
+    }
+  }
+
+  void onCancelClick() {
+    if (currentStatus.value == RecordStatus.pending) {
+      return;
+    }
+    EAAlertDialog.show(
+      title: "是否删除当前录音?",
+      confirmText: "删除",
+      cancelText: "取消",
+      confirmOnTap: () {
+        _deleteCurrentRecord();
+        EAAlertDialog.dismiss();
+      },
+      cancelOnTap: () {
+        EAAlertDialog.dismiss();
+      },
+    );
+  }
+
+  void onSaveClick() {
+    if (currentStatus.value == RecordStatus.pending) {
+      return;
+    }
+    _saveCurrentRecord();
+  }
+
+  Future<void> _startOrContinueRecord() async {
+    bool hasPermission = await record.hasPermission();
+    if (!hasPermission) {
+      _onRecordPermissionDenied();
+      return;
+    }
+    File targetFile = await _getCurrentRecordFile();
+    Stream<Uint8List> recordStream = await record.startStream(recordConfig);
+    currentStatus.value = RecordStatus.recording;
+    recordStream.listen((data) async {
+      targetFile.writeAsBytesSync(data, mode: FileMode.append);
+      currentDuration.value = currentDuration.value +
+          await _getPcmDuration(data.length, recordConfig.sampleRate, 16,
+              recordConfig.numChannels);
+    }, onDone: () {
+      currentStatus.value = RecordStatus.paused;
+    }, onError: (error) {
+      currentStatus.value = RecordStatus.paused;
+    });
+  }
+
+  _onRecordPermissionDenied() {}
+
+  Future<void> _stopRecord() {
+    return record.stop().then((_) => currentStatus.value = RecordStatus.paused);
+  }
+
+  Future<File> _getCurrentRecordFile() async {
+    Directory documentDir = await getApplicationDocumentsDirectory();
+    File file = File("${documentDir.path}/.atmob/record/$lastRecordId");
+    if (!file.existsSync()) {
+      file.createSync(recursive: true);
+    }
+    return file;
+  }
+
+  Future<double> _getPcmDuration(
+      int fileSize, int sampleRate, int bitDepth, int channels) async {
+    final bytesPerSecond = sampleRate * (bitDepth / 8) * channels;
+    final durationInSeconds = fileSize / bytesPerSecond;
+    return durationInSeconds;
+  }
+
+  Future<void> _deleteCurrentRecord() async {
+    await _stopRecord();
+    _getCurrentRecordFile().then((file) {
+      if (file.existsSync()) {
+        file.deleteSync();
+      }
+    }).then((_) {
+      currentDuration.value = 0;
+      currentStatus.value = RecordStatus.pending;
+    });
+  }
+
+  Future<void> _saveCurrentRecord() async {
+    await _stopRecord();
+    talkRepository
+        .talkCreate(lastRecordId, currentDuration.value.toInt())
+        .then((talkInfo) async {
+      File pcmFile = await _getCurrentRecordFile();
+      if (pcmFile.existsSync()) {
+        File wavFile = await getRecordFile(talkInfo.id);
+        PcmWavConverter.convert(pcmFile, wavFile, recordConfig.sampleRate,
+            recordConfig.numChannels, 16);
+        pcmFile.delete();
+        Get.back();
+        TalkPage.start(talkInfo);
+      } else {
+        throw Exception("pcm file not found");
+      }
+    }).catchError((error) {
+      debugPrint(error);
+    });
+  }
+
+  static Future<File> getRecordFile(String talkId) async {
+    Directory documentDir = await getApplicationDocumentsDirectory();
+    return File("${documentDir.path}/.atmob/record/$talkId.wav");
+  }
 }

+ 59 - 27
lib/module/record/view.dart

@@ -1,4 +1,5 @@
 import 'package:electronic_assistant/base/base_page.dart';
+import 'package:electronic_assistant/module/record/constants.dart';
 import 'package:electronic_assistant/module/record/controller.dart';
 import 'package:electronic_assistant/resource/colors.gen.dart';
 import 'package:electronic_assistant/utils/expand.dart';
@@ -43,7 +44,7 @@ class RecordPage extends BasePage<RecordController> {
             icon: const Icon(Icons.arrow_back_ios_new_rounded),
             color: ColorName.white,
             onPressed: () {
-              Navigator.pop(context);
+              controller.onBackClick(context);
             },
           ),
           scrolledUnderElevation: 0,
@@ -109,7 +110,7 @@ class RecordPage extends BasePage<RecordController> {
           ),
           Obx(() {
             return Text(
-              controller.recordStatus.value,
+              controller.currentStatus.value.desc,
               style: TextStyle(color: ColorName.white, fontSize: 17.w),
             );
           }),
@@ -119,12 +120,20 @@ class RecordPage extends BasePage<RecordController> {
   }
 
   Widget _buildRecordAnim() {
-    return FrameAnimationView(
-      framePath: 'assets/anim/anim_recording.zip',
-      speed: 2,
-      width: 360.w,
-      height: 180.w,
-    );
+    return Obx(() {
+      return AnimatedOpacity(
+        opacity:
+            controller.currentStatus.value == RecordStatus.recording ? 1 : 0,
+        duration: const Duration(milliseconds: 520),
+        child: FrameAnimationView(
+          controller: controller.frameAnimationController,
+          framePath: 'assets/anim/anim_recording.zip',
+          speed: 2,
+          width: 360.w,
+          height: 180.w,
+        ),
+      );
+    });
   }
 
   Widget _buildRecordControl() {
@@ -141,33 +150,49 @@ class RecordPage extends BasePage<RecordController> {
           ),
           child: Row(
             children: [
-              Image(
-                  image: Assets.images.iconRecordCancelDisable.provider(),
-                  width: 56.w,
-                  height: 56.w),
+              GestureDetector(
+                onTap: controller.onCancelClick,
+                child: Obx(() {
+                  return Image(
+                      image: controller.currentStatus.value.cancelButtonImage,
+                      width: 56.w,
+                      height: 56.w);
+                }),
+              ),
               const Spacer(),
-              Image(
-                  image: Assets.images.iconRecordSaveDisable.provider(),
-                  width: 56.w,
-                  height: 56.w),
+              GestureDetector(
+                onTap: controller.onSaveClick,
+                child: Obx(() {
+                  return Image(
+                      image: controller.currentStatus.value.saveButtonImage,
+                      width: 56.w,
+                      height: 56.w);
+                }),
+              ),
             ],
           ),
         ),
         Column(
           children: [
-            Image(
-                image: Assets.images.iconRecordStart.provider(),
-                width: 92.w,
-                height: 92.w),
+            GestureDetector(
+                onTap: () {
+                  controller.onActionClick();
+                },
+                child: Obx(
+                  () => Image(
+                      image: controller.currentStatus.value.actionButtonImage,
+                      width: 92.w,
+                      height: 92.w),
+                )),
             Padding(
               padding: EdgeInsets.only(top: 10.w, bottom: 35.w),
-              child: Text(
-                "00:00:00",
-                style: TextStyle(
-                  color: ColorName.white,
-                  fontSize: 16.w,
-                ),
-              ),
+              child: Obx(() => Text(
+                    formatDuration(controller.currentDuration.value),
+                    style: TextStyle(
+                      color: ColorName.white,
+                      fontSize: 16.w,
+                    ),
+                  )),
             )
           ],
         )
@@ -190,4 +215,11 @@ class RecordPage extends BasePage<RecordController> {
       ),
     );
   }
+
+  String formatDuration(double value) {
+    int hour = (value / 3600).floor();
+    int minute = ((value - hour * 3600) / 60).floor();
+    int second = (value - hour * 3600 - minute * 60).floor();
+    return '${hour.toString().padLeft(2, '0')}:${minute.toString().padLeft(2, '0')}:${second.toString().padLeft(2, '0')}';
+  }
 }

+ 7 - 8
lib/module/talk/controller.dart

@@ -1,21 +1,20 @@
 import 'dart:async';
 
 import 'package:electronic_assistant/base/base_controller.dart';
-import 'package:electronic_assistant/data/bean/agenda.dart';
 import 'package:electronic_assistant/data/repositories/talk_repository.dart';
 import 'package:electronic_assistant/module/talk/summary/view.dart';
 import 'package:electronic_assistant/module/talk/todo/view.dart';
 import 'package:electronic_assistant/resource/string.gen.dart';
 import 'package:electronic_assistant/utils/toast_util.dart';
-import 'package:flutter/cupertino.dart';
 import 'package:get/get.dart';
+
 import '../../data/bean/agenda_list_all_bean.dart';
 import '../../data/bean/talks.dart';
 import '../../data/repositories/agenda_repository.dart';
 import 'original/view.dart';
 
 class TalkController extends BaseController {
-  final talkBean = TalkBean().obs;
+  final Rxn<TalkBean> talkBean = Rxn();
 
   // final isOriginalAnalysed = false.obs;
   final isShowElectricLow = false.obs;
@@ -32,7 +31,7 @@ class TalkController extends BaseController {
     StringName.talkTabOriginal.tr
   ];
 
-  StreamSubscription<TalkBean>? _talkBeanListener;
+  late StreamSubscription<TalkBean?> _talkBeanListener;
 
   final pages = [const SummaryView(), const TodoView(), const OriginalView()];
 
@@ -49,7 +48,7 @@ class TalkController extends BaseController {
     });
   }
 
-  void _dealTalkUpdate(TalkBean bean) {}
+  void _dealTalkUpdate(TalkBean? bean) {}
 
   void _getArguments() {
     if (Get.arguments is TalkBean) {
@@ -58,8 +57,8 @@ class TalkController extends BaseController {
   }
 
   void checkCanAnalyze() {
-    String? id = talkBean.value.id;
-    double? duration = talkBean.value.duration;
+    String? id = talkBean.value?.id;
+    double? duration = talkBean.value?.duration;
     if (id == null || duration == null) {
       return;
     }
@@ -85,7 +84,7 @@ class TalkController extends BaseController {
   }
 
   void refreshAgendaAllData() {
-    String? id = talkBean.value.id;
+    String? id = talkBean.value?.id;
     if (id == null || agendaAllList.isNotEmpty) {
       return;
     }

+ 2 - 2
lib/module/talk/original/controller.dart

@@ -19,7 +19,7 @@ class OriginalController extends BaseController {
   void onReady() {
     super.onReady();
     _talkBeanListener = talkController.talkBean.listen((bean) {
-      int? status = bean.status;
+      int? status = bean?.status;
       if (status == null) {
         return;
       }
@@ -34,7 +34,7 @@ class OriginalController extends BaseController {
     if (originalList.isNotEmpty) {
       return;
     }
-    talkRepository.talkOriginal(talkController.talkBean.value.id).then((value) {
+    talkRepository.talkOriginal(talkController.talkBean.value?.id).then((value) {
       originalList.value = value;
     });
   }

+ 2 - 2
lib/module/talk/summary/controller.dart

@@ -26,12 +26,12 @@ class SummaryController extends BaseController {
     _dealTalkUpdate(talkController.talkBean.value);
   }
 
-  void _dealTalkUpdate(TalkBean bean) {
+  void _dealTalkUpdate(TalkBean? bean) {
     refreshSummaryData();
   }
 
   void refreshSummaryData() {
-    String? id = talkController.talkBean.value.id;
+    String? id = talkController.talkBean.value?.id;
     if (id == null) {
       return;
     }

+ 4 - 4
lib/module/talk/todo/controller.dart

@@ -21,7 +21,7 @@ class TodoController extends BaseController {
 
   RxList<AgendaListAllBean> get agendaAllList => _talkController.agendaAllList;
 
-  Rx<TalkBean> get talkBean => _talkController.talkBean;
+  Rxn<TalkBean> get talkBean => _talkController.talkBean;
 
   @override
   void onReady() {
@@ -32,8 +32,8 @@ class TodoController extends BaseController {
     _dealTalkUpdate(_talkController.talkBean.value);
   }
 
-  void _dealTalkUpdate(TalkBean bean) {
-    int? status = bean.status;
+  void _dealTalkUpdate(TalkBean? bean) {
+    int? status = bean?.status;
     if (status == null) {
       return;
     }
@@ -44,7 +44,7 @@ class TodoController extends BaseController {
   }
 
   void requestMineTodoData() {
-    String? id = _talkController.talkBean.value.id;
+    String? id = _talkController.talkBean.value?.id;
     if (id == null) {
       return;
     }

+ 4 - 4
lib/module/talk/todo/view.dart

@@ -162,12 +162,12 @@ class TodoView extends BasePage<TodoController> {
   }
 
   Widget _buildTodoStatusView() {
-    if (controller.talkBean.value.status == TalkStatus.analysisFail) {
+    if (controller.talkBean.value?.status == TalkStatus.analysisFail) {
       return getTalkFailView();
-    } else if (controller.talkBean.value.status == TalkStatus.analysing ||
-        controller.talkBean.value.status == TalkStatus.waitAnalysis) {
+    } else if (controller.talkBean.value?.status == TalkStatus.analysing ||
+        controller.talkBean.value?.status == TalkStatus.waitAnalysis) {
       return getTalkLoadingView();
-    } else if (controller.talkBean.value.status == TalkStatus.analysisSuccess) {
+    } else if (controller.talkBean.value?.status == TalkStatus.analysisSuccess) {
       return ListView(
         padding: EdgeInsets.only(left: 12.w, right: 12.w),
         children: [_buildMineTodoList(), _buildAllTaskView()],

+ 3 - 3
lib/module/talk/view.dart

@@ -53,13 +53,13 @@ class TalkPage extends BasePage<TalkController> {
                       crossAxisAlignment: CrossAxisAlignment.start,
                       children: [
                         SizedBox(height: 8.h),
-                        Text(controller.talkBean.value.title.orEmpty,
+                        Text(controller.talkBean.value?.title ?? '',
                             style: TextStyle(
                                 fontSize: 22.sp,
                                 fontWeight: FontWeight.bold,
                                 color: ColorName.primaryTextColor)),
                         SizedBox(height: 4.h),
-                        Text(controller.talkBean.value.createTime.orEmpty,
+                        Text(controller.talkBean.value?.createTime ?? '',
                             style: TextStyle(
                                 fontSize: 12.sp,
                                 color: ColorName.secondaryTextColor)),
@@ -131,7 +131,7 @@ class TalkPage extends BasePage<TalkController> {
 
   Widget buildTalkContentView() {
     return Obx(() {
-      if (controller.talkBean.value.status == TalkStatus.notAnalysis) {
+      if (controller.talkBean.value?.status == TalkStatus.notAnalysis) {
         if (controller.isShowElectricLow.value) {
           return buildElectricLowView();
         } else {

+ 63 - 0
lib/utils/pcm_wav_converter.dart

@@ -0,0 +1,63 @@
+import 'dart:io';
+
+class PcmWavConverter {
+  static convert(File pcmFile, File wavFile, int sampleRate, int channels,
+      int bitDepth) {
+    int dataLength = pcmFile.lengthSync();
+    List<int> wavHeader = _createWavHeader(dataLength, sampleRate, channels, bitDepth);
+    wavFile.writeAsBytesSync(wavHeader, mode: FileMode.write);
+    wavFile.writeAsBytesSync(pcmFile.readAsBytesSync(), mode: FileMode.append);
+  }
+
+  static List<int> _createWavHeader(int dataLength, int sampleRate,
+      int channels, int bitDepth) {
+    int byteRate = sampleRate * channels * bitDepth ~/ 8;
+    int wavSize = dataLength + 36;
+    List<int> header = List<int>.filled(44, 0);
+    header[0] = 'R'.codeUnitAt(0);
+    header[1] = 'I'.codeUnitAt(0);
+    header[2] = 'F'.codeUnitAt(0);
+    header[3] = 'F'.codeUnitAt(0);
+    header[4] = wavSize & 0xff;
+    header[5] = ((wavSize) >> 8) & 0xff;
+    header[6] = ((wavSize) >> 16) & 0xff;
+    header[7] = ((wavSize) >> 24) & 0xff;
+    header[8] = 'W'.codeUnitAt(0);
+    header[9] = 'A'.codeUnitAt(0);
+    header[10] = 'V'.codeUnitAt(0);
+    header[11] = 'E'.codeUnitAt(0);
+    header[12] = 'f'.codeUnitAt(0);
+    header[13] = 'm'.codeUnitAt(0);
+    header[14] = 't'.codeUnitAt(0);
+    header[15] = ' '.codeUnitAt(0);
+    header[16] = 16;
+    header[17] = 0;
+    header[18] = 0;
+    header[19] = 0;
+    header[20] = 1;
+    header[21] = 0;
+    header[22] = channels & 0xff;
+    header[23] = (channels >> 8) & 0xff;
+    header[24] = sampleRate & 0xff;
+    header[25] = (sampleRate >> 8) & 0xff;
+    header[26] = (sampleRate >> 16) & 0xff;
+    header[27] = (sampleRate >> 24) & 0xff;
+    header[28] = byteRate & 0xff;
+    header[29] = (byteRate >> 8) & 0xff;
+    header[30] = (byteRate >> 16) & 0xff;
+    header[31] = (byteRate >> 24) & 0xff;
+    header[32] = (channels * bitDepth ~/ 8) & 0xff;
+    header[33] = 0;
+    header[34] = bitDepth;
+    header[35] = 0;
+    header[36] = 'd'.codeUnitAt(0);
+    header[37] = 'a'.codeUnitAt(0);
+    header[38] = 't'.codeUnitAt(0);
+    header[39] = 'a'.codeUnitAt(0);
+    header[40] = dataLength & 0xff;
+    header[41] = (dataLength >> 8) & 0xff;
+    header[42] = (dataLength >> 16) & 0xff;
+    header[43] = (dataLength >> 24) & 0xff;
+    return header;
+  }
+}

+ 36 - 38
lib/widget/frame_animation_view.dart

@@ -22,13 +22,14 @@ class FrameAnimationView extends StatefulWidget {
 
   final double? height;
 
-  const FrameAnimationView({super.key,
-    required this.framePath,
-    this.frameRate = 25,
-    this.controller,
-    this.speed,
-    this.width,
-    this.height});
+  const FrameAnimationView(
+      {super.key,
+      required this.framePath,
+      this.frameRate = 25,
+      this.controller,
+      this.speed,
+      this.width,
+      this.height});
 
   @override
   State<StatefulWidget> createState() {
@@ -37,10 +38,11 @@ class FrameAnimationView extends StatefulWidget {
 }
 
 class FrameAnimationViewState extends State<FrameAnimationView> {
-  int _currentFrame = 0;
-  Timer? _timer;
+  int _currentFrame = -1;
   List<File> imageFiles = [];
   List<ui.Image> images = [];
+  Timer? _timer;
+  StreamSubscription? _precacheStreamSubscription;
 
   @override
   void initState() {
@@ -49,10 +51,9 @@ class FrameAnimationViewState extends State<FrameAnimationView> {
     loadFrameFromAssets()
         .then((_) => initializeImageFiles())
         .then((_) => precacheImageFiles())
-        .then((_) =>
-    widget.controller == null || widget.controller!.autoPlay
-        ? startAnimation()
-        : null)
+        .then((_) => widget.controller == null || widget.controller!.autoPlay
+            ? startAnimation()
+            : null)
         .catchError((error) => debugPrint('FrameAnimationView error: $error'));
   }
 
@@ -60,6 +61,7 @@ class FrameAnimationViewState extends State<FrameAnimationView> {
   void dispose() {
     super.dispose();
     _timer?.cancel();
+    _precacheStreamSubscription?.cancel();
     imageFiles.clear();
     for (var image in images) {
       image.dispose();
@@ -69,7 +71,10 @@ class FrameAnimationViewState extends State<FrameAnimationView> {
 
   @override
   Widget build(BuildContext context) {
-    if (images.isEmpty) {
+    if (_timer == null ||
+        _currentFrame < 0 ||
+        images.isEmpty ||
+        _currentFrame >= images.length) {
       return SizedBox(width: widget.width, height: widget.height);
     }
     return RawImage(
@@ -102,12 +107,8 @@ class FrameAnimationViewState extends State<FrameAnimationView> {
     final Directory cacheDir = await createCacheDirectory();
 
     // if the cache directory is not empty and frame images count is equal to the zip file's files count
-    if (cacheDir
-        .listSync()
-        .isNotEmpty &&
-        cacheDir
-            .listSync()
-            .length == archive.length) {
+    if (cacheDir.listSync().isNotEmpty &&
+        cacheDir.listSync().length == archive.length) {
       return;
     }
 
@@ -129,7 +130,7 @@ class FrameAnimationViewState extends State<FrameAnimationView> {
   Future<void> copyToCache() async {
     // Read AssetManifest.json file
     final String manifestContent =
-    await rootBundle.loadString('AssetManifest.json');
+        await rootBundle.loadString('AssetManifest.json');
 
     // Parse JSON string into Map
     final Map<String, dynamic> manifestMap = json.decode(manifestContent);
@@ -143,12 +144,8 @@ class FrameAnimationViewState extends State<FrameAnimationView> {
     final Directory cacheDir = await createCacheDirectory();
 
     // if the cache directory is not empty and frame images count is equal to the zip file's files count
-    if (cacheDir
-        .listSync()
-        .isNotEmpty &&
-        cacheDir
-            .listSync()
-            .length == filePaths.length) {
+    if (cacheDir.listSync().isNotEmpty &&
+        cacheDir.listSync().length == filePaths.length) {
       return;
     }
 
@@ -156,9 +153,7 @@ class FrameAnimationViewState extends State<FrameAnimationView> {
     for (final String path in filePaths) {
       final ByteData data = await rootBundle.load(path);
       final List<int> bytes = data.buffer.asUint8List();
-      final File newFile = File('${cacheDir.path}/${path
-          .split('/')
-          .last}');
+      final File newFile = File('${cacheDir.path}/${path.split('/').last}');
       if (newFile.existsSync()) {
         newFile.deleteSync();
       } else {
@@ -183,8 +178,7 @@ class FrameAnimationViewState extends State<FrameAnimationView> {
 
     // create unique directory by framePath's md5
     final Directory frameDir = Directory(
-        '${cacheDir.path}/${md5.convert(utf8.encode(widget.framePath))
-            .toString()}');
+        '${cacheDir.path}/${md5.convert(utf8.encode(widget.framePath)).toString()}');
 
     // Check if the directory exists, if not, create it
     if (!await frameDir.exists()) {
@@ -207,10 +201,13 @@ class FrameAnimationViewState extends State<FrameAnimationView> {
 
     _timer = Timer.periodic(
         Duration(milliseconds: 1000 ~/ (widget.frameRate * speed)), (_) {
+      if (images.isEmpty) {
+        return;
+      }
       setState(() {
         int targetFrame = (_currentFrame + 1) % imageFiles.length;
         _currentFrame =
-        targetFrame >= images.length ? images.length - 1 : targetFrame;
+            targetFrame >= images.length ? images.length - 1 : targetFrame;
       });
     });
   }
@@ -222,10 +219,10 @@ class FrameAnimationViewState extends State<FrameAnimationView> {
         .listSync()
         .map((file) => File(file.path))
         .where((file) =>
-    file.path.endsWith('.png') ||
-        file.path.endsWith('.jpg') ||
-        file.path.endsWith('.jpeg') ||
-        file.path.endsWith('.webp'))
+            file.path.endsWith('.png') ||
+            file.path.endsWith('.jpg') ||
+            file.path.endsWith('.jpeg') ||
+            file.path.endsWith('.webp'))
         .toList();
 
     if (imageFiles.isEmpty) {
@@ -234,7 +231,8 @@ class FrameAnimationViewState extends State<FrameAnimationView> {
   }
 
   precacheImageFiles() {
-    Stream<File>.fromIterable(imageFiles).asyncMap((file) async {
+    _precacheStreamSubscription =
+        Stream<File>.fromIterable(imageFiles).asyncMap((file) async {
       final Uint8List bytes = await file.readAsBytes();
       final ui.Codec codec = await ui.instantiateImageCodec(bytes);
       final ui.FrameInfo frameInfo = await codec.getNextFrame();

+ 77 - 0
pubspec.lock

@@ -315,6 +315,11 @@ packages:
     description: flutter
     source: sdk
     version: "0.0.0"
+  flutter_web_plugins:
+    dependency: transitive
+    description: flutter
+    source: sdk
+    version: "0.0.0"
   frontend_server_client:
     dependency: transitive
     description:
@@ -691,6 +696,62 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "2.0.0"
+  record:
+    dependency: "direct main"
+    description:
+      name: record
+      sha256: "4a5cf4d083d1ee49e0878823c4397d073f8eb0a775f31215d388e2bc47a9e867"
+      url: "https://pub.dev"
+    source: hosted
+    version: "5.1.2"
+  record_android:
+    dependency: transitive
+    description:
+      name: record_android
+      sha256: d7af0b3119725a0f561817c72b5f5eca4d7a76d441deef519ae04e4824c0734c
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.2.6"
+  record_darwin:
+    dependency: transitive
+    description:
+      name: record_darwin
+      sha256: fe90d302acb1f3cee1ade5df9c150ca5cee33b48d8cdf1cf433bf577d7f00134
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.1.2"
+  record_linux:
+    dependency: transitive
+    description:
+      name: record_linux
+      sha256: "74d41a9ebb1eb498a38e9a813dd524e8f0b4fdd627270bda9756f437b110a3e3"
+      url: "https://pub.dev"
+    source: hosted
+    version: "0.7.2"
+  record_platform_interface:
+    dependency: transitive
+    description:
+      name: record_platform_interface
+      sha256: "11f8b03ea8a0e279b0e306571dbe0db0202c0b8e866495c9fa1ad2281d5e4c15"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.1.0"
+  record_web:
+    dependency: transitive
+    description:
+      name: record_web
+      sha256: "656b7a865f90651fab997c2a563364f5fd60a0b527d5dadbb915d62d84fc3867"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.1.3"
+  record_windows:
+    dependency: transitive
+    description:
+      name: record_windows
+      sha256: e653555aa3fda168aded7c34e11bd82baf0c6ac84e7624553def3c77ffefd36f
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.0.3"
   retrofit:
     dependency: "direct main"
     description:
@@ -752,6 +813,14 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "1.10.0"
+  sprintf:
+    dependency: transitive
+    description:
+      name: sprintf
+      sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23"
+      url: "https://pub.dev"
+    source: hosted
+    version: "7.0.0"
   stack_trace:
     dependency: transitive
     description:
@@ -832,6 +901,14 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "1.3.2"
+  uuid:
+    dependency: "direct main"
+    description:
+      name: uuid
+      sha256: f33d6bb662f0e4f79dcd7ada2e6170f3b3a2530c28fc41f49a411ddedd576a77
+      url: "https://pub.dev"
+    source: hosted
+    version: "4.5.0"
   vector_graphics_codec:
     dependency: transitive
     description:

+ 6 - 0
pubspec.yaml

@@ -63,6 +63,12 @@ dependencies:
   #解压
   archive: ^3.6.1
 
+  #录音
+  record: ^5.1.2
+
+  #uuid
+  uuid: ^4.5.0
+
 dev_dependencies:
   flutter_test:
     sdk: flutter