Browse Source
本次提交对会议模块进行了重构,主要包括以下内容: 1. 新增文件上传功能,支持通过腾讯云COS上传文件。 2. 新增会议详情视图,包括会议概览、思维导图、总结和发言内容等标签页。 3. 优化数据库结构,新增会议发言者表,并调整了会议和会议详情表的结构。 4. 移除不再使用的会议编辑和会议记录功能,简化了路由和页面结构。 5. 新增会议发言者命名功能,支持对发言者进行命名和编辑。weicu
23 changed files with 1768 additions and 1717 deletions
@ -0,0 +1,88 @@ |
|||||
|
import 'dart:async'; |
||||
|
import 'dart:math'; |
||||
|
|
||||
|
import 'package:common_utils/common_utils.dart'; |
||||
|
import 'package:dio/dio.dart'; |
||||
|
import 'package:tencentcloud_cos_sdk_plugin/cos.dart'; |
||||
|
import 'package:tencentcloud_cos_sdk_plugin/cos_transfer_manger.dart'; |
||||
|
import 'package:tencentcloud_cos_sdk_plugin/pigeon.dart'; |
||||
|
|
||||
|
class UploadOss { |
||||
|
static final UploadOss _shared = UploadOss._internal(); |
||||
|
factory UploadOss() => _shared; |
||||
|
|
||||
|
static const bucket = 'deepsound-1253517901'; |
||||
|
static const region = 'ap-guangzhou'; |
||||
|
|
||||
|
static CosTransferManger? _cosTransferManger; |
||||
|
|
||||
|
UploadOss._internal(); |
||||
|
|
||||
|
static Future<CosTransferManger> getTransferManger() async { |
||||
|
if (_cosTransferManger == null) { |
||||
|
await Cos().initWithPlainSecret('AKID9F8qNiqlTzIMFwQ9rQxNcgYCdsukNmzA', |
||||
|
'OYVGo8HEzWpvpfHgtfHfqpcDbnlY7Tdc'); |
||||
|
if (Cos().hasTransferManger(region)) { |
||||
|
_cosTransferManger = Cos().getTransferManger(region); |
||||
|
} else { |
||||
|
CosXmlServiceConfig serviceConfig = CosXmlServiceConfig( |
||||
|
region: region, |
||||
|
isHttps: true, |
||||
|
); |
||||
|
_cosTransferManger = await Cos().registerTransferManger( |
||||
|
region, serviceConfig..region = region, TransferConfig()); |
||||
|
} |
||||
|
} |
||||
|
return _cosTransferManger!; |
||||
|
} |
||||
|
|
||||
|
static Future<String> upload({ |
||||
|
String? filepath, |
||||
|
String rootDir = 'file', |
||||
|
String? fileType, |
||||
|
Function? callback, |
||||
|
ProgressCallback? onSendProgress, |
||||
|
}) async { |
||||
|
String pathName = |
||||
|
'$rootDir/${getDate()}/${getRandom(12)}.${fileType ?? getFileType(filepath!)}'; |
||||
|
CosTransferManger cosTransferManger = await getTransferManger(); |
||||
|
final completer = Completer<String>(); |
||||
|
await cosTransferManger.upload( |
||||
|
bucket, |
||||
|
pathName, |
||||
|
filePath: filepath, |
||||
|
resultListener: ResultListener( |
||||
|
(Map<String?, String?>? header, CosXmlResult? result) { |
||||
|
final url = result?.accessUrl ?? |
||||
|
'https://$bucket.cos.$region.myqcloud.com/$pathName'; |
||||
|
completer.complete(url); |
||||
|
}, |
||||
|
(clientException, serviceException) { |
||||
|
completer.completeError(clientException ?? |
||||
|
serviceException ?? |
||||
|
Exception('Unknown error')); |
||||
|
}, |
||||
|
), |
||||
|
); |
||||
|
return completer.future; |
||||
|
} |
||||
|
|
||||
|
static String getDate() { |
||||
|
DateTime now = DateTime.now(); |
||||
|
return DateUtil.formatDate(now, format: 'yyyy/MM/dd'); |
||||
|
} |
||||
|
|
||||
|
static String getRandom(int num) { |
||||
|
String alphabet = 'qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM'; |
||||
|
String left = ''; |
||||
|
for (var i = 0; i < num; i++) { |
||||
|
left = left + alphabet[Random().nextInt(alphabet.length)]; |
||||
|
} |
||||
|
return left; |
||||
|
} |
||||
|
|
||||
|
static String getFileType(String path) { |
||||
|
List<String> array = path.split('.'); |
||||
|
return array[array.length - 1]; |
||||
|
} |
||||
|
} |
||||
@ -1,11 +0,0 @@ |
|||||
import 'package:get/get.dart'; |
|
||||
|
|
||||
import '../controllers/meeting_edit_controller.dart'; |
|
||||
|
|
||||
class MeetingEditBinding extends Bindings { |
|
||||
@override |
|
||||
void dependencies() { |
|
||||
// 注册控制器 |
|
||||
Get.lazyPut<MeetingEditController>(() => MeetingEditController()); |
|
||||
} |
|
||||
} |
|
||||
@ -1,10 +0,0 @@ |
|||||
import 'package:get/get.dart'; |
|
||||
import '../controllers/meeting_record_controller.dart'; |
|
||||
|
|
||||
class MeetingRecordBinding extends Bindings { |
|
||||
@override |
|
||||
void dependencies() { |
|
||||
// 注册控制器 |
|
||||
Get.lazyPut<MeetingRecordController>(() => MeetingRecordController()); |
|
||||
} |
|
||||
} |
|
||||
@ -1,67 +1,374 @@ |
|||||
|
import 'dart:async'; |
||||
|
import 'dart:convert'; |
||||
|
|
||||
|
import 'package:audio_waveforms/audio_waveforms.dart'; |
||||
import 'package:flutter/material.dart'; |
import 'package:flutter/material.dart'; |
||||
|
import 'package:flutter_easyloading/flutter_easyloading.dart'; |
||||
import 'package:get/get.dart'; |
import 'package:get/get.dart'; |
||||
import 'package:just_audio/just_audio.dart'; |
import 'package:get_storage/get_storage.dart'; |
||||
|
import 'package:http/http.dart' as http; |
||||
|
import 'package:uuid/uuid.dart'; |
||||
|
|
||||
|
import '../../../core/utils/logger.dart'; |
||||
|
import '../../../core/utils/upload_oss.dart'; |
||||
|
import '../../../data/models/appconfig_model.dart'; |
||||
|
import '../../../data/services/ai_service.dart'; |
||||
import '../../../data/services/db/sqflite_api.dart'; |
import '../../../data/services/db/sqflite_api.dart'; |
||||
|
import '../../../data/services/open_ai_service_adapter.dart'; |
||||
import '../model/meeting_details_model.dart'; |
import '../model/meeting_details_model.dart'; |
||||
import '../model/meeting_model.dart'; |
import '../model/meeting_model.dart'; |
||||
|
|
||||
class MeetingDetailsController extends GetxController |
class MeetingDetailsController extends GetxController |
||||
with GetSingleTickerProviderStateMixin { |
with GetSingleTickerProviderStateMixin { |
||||
late AnimationController lottieController; |
late ScrollController scrollController; |
||||
final AudioPlayer audioPlayer = AudioPlayer(); |
late TabController tabController; |
||||
|
final RxInt currentIndex = 0.obs; |
||||
|
|
||||
|
final PlayerController playerController = PlayerController(); |
||||
|
RxBool isTop = false.obs; |
||||
|
RxInt durationMax = 0.obs; |
||||
|
|
||||
int id = 0; |
int id = 0; |
||||
|
|
||||
RxBool isLoading = true.obs; |
RxBool isLoading = true.obs; |
||||
Rx<MeetingModel> meetingData = MeetingModel().obs; |
Rx<MeetingModel> meetingData = MeetingModel().obs; |
||||
Rx<MeetingDetailsModel> dataDetails = MeetingDetailsModel().obs; |
Rx<MeetingDetailsModel> meetingDetails = MeetingDetailsModel().obs; |
||||
|
RxList textList = [].obs; |
||||
|
String allText = ''; |
||||
|
RxBool isSpeakerText = false.obs; |
||||
|
List editSpeakerTextList = []; |
||||
|
RxInt editSpeakerTextId = 0.obs; |
||||
|
String editSpeakerTextChange = ''; |
||||
|
|
||||
|
final AiService _aiService = Get.find<OpenAIServiceAdapter>(); |
||||
|
// Agent 信息 |
||||
|
late final DBAgent _agent; |
||||
|
String _userId = ''; |
||||
|
StreamSubscription? _aiResponseSubscription; |
||||
|
|
||||
int seconds = 0; |
bool _isDisposed = false; |
||||
|
|
||||
RxBool isPlay = false.obs; |
final String _appid = '2316081933'; |
||||
|
final String _token = 'mcJTh6OelBqU_L78lgGd77Nyrc_-5-En'; |
||||
|
final String _cluster = 'volc_auc_meeting_flash'; |
||||
|
final String _serviceUrl = 'https://openspeech.bytedance.com/api/v1/auc'; |
||||
|
Map<String, String> _headers = {}; |
||||
|
|
||||
@override |
@override |
||||
void onInit() { |
void onInit() { |
||||
super.onInit(); |
super.onInit(); |
||||
lottieController = AnimationController(vsync: this); |
scrollController = ScrollController(initialScrollOffset: 0.0); |
||||
|
scrollController.addListener(_onScroll); |
||||
|
tabController = TabController(length: 4, vsync: this); |
||||
|
tabController.addListener(() { |
||||
|
currentIndex.value = tabController.index; |
||||
|
}); |
||||
final Map<String, dynamic> args = Get.arguments ?? {}; |
final Map<String, dynamic> args = Get.arguments ?? {}; |
||||
id = args['id']; |
id = args['id']; |
||||
|
_headers = { |
||||
|
'Authorization': 'Bearer; $_token', |
||||
|
'Content-Type': 'application/json', |
||||
|
}; |
||||
|
_getAgent(); |
||||
_getDataDetails(); |
_getDataDetails(); |
||||
} |
} |
||||
|
|
||||
@override |
@override |
||||
void onClose() { |
void onClose() { |
||||
lottieController.dispose(); |
scrollController.dispose(); |
||||
audioPlayer.dispose(); |
tabController.dispose(); |
||||
|
playerController.dispose(); |
||||
|
_aiResponseSubscription?.cancel(); |
||||
|
_isDisposed = true; |
||||
super.onClose(); |
super.onClose(); |
||||
} |
} |
||||
|
|
||||
|
void _onScroll() async { |
||||
|
double pixels = scrollController.position.pixels; |
||||
|
double maxScrollExtent = scrollController.position.maxScrollExtent; |
||||
|
if (!isTop.value && pixels > 5) { |
||||
|
await scrollController.animateTo( |
||||
|
maxScrollExtent, |
||||
|
duration: const Duration(milliseconds: 100), |
||||
|
curve: Curves.easeOut, |
||||
|
); |
||||
|
if (pixels > maxScrollExtent - 2) { |
||||
|
isTop.value = true; |
||||
|
} |
||||
|
} |
||||
|
if (isTop.value && pixels < maxScrollExtent - 5) { |
||||
|
await scrollController.animateTo( |
||||
|
0, |
||||
|
duration: const Duration(milliseconds: 100), |
||||
|
curve: Curves.easeOut, |
||||
|
); |
||||
|
if (pixels < 2) { |
||||
|
isTop.value = false; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
void _getAgent() async { |
||||
|
final GetStorage storage = GetStorage(); |
||||
|
final List<DBAgent> agents = storage.read("AGENTS"); |
||||
|
Map userInfo = storage.read("user_info"); |
||||
|
_userId = userInfo['user']['uid']; |
||||
|
_agent = agents.firstWhere((agent) => agent.id == 'meet_assistant'); |
||||
|
} |
||||
|
|
||||
void _getDataDetails() async { |
void _getDataDetails() async { |
||||
final data = await SqfliteApi.getMeetingDetails(id); |
final data = await SqfliteApi.getMeetingDetails(id); |
||||
meetingData.value = MeetingModel.fromJson(data); |
meetingData.value = MeetingModel.fromJson(data); |
||||
dataDetails.value = MeetingDetailsModel.fromJson(data['details']); |
meetingDetails.value = MeetingDetailsModel.fromJson(data['details']); |
||||
audioPlayer.setAudioSource(AudioSource.file(dataDetails.value.audio)); |
await playerController.preparePlayer( |
||||
audioPlayer.playerStateStream.listen((state) async { |
path: meetingData.value.filepath, |
||||
if (state.processingState == ProcessingState.completed) { |
noOfSamples: 1200, |
||||
await audioPlayer.seek(Duration.zero); |
); |
||||
audioPlayer.pause(); |
durationMax.value = await playerController.getDuration(DurationType.max); |
||||
lottieController.stop(); |
playerController.setFinishMode(finishMode: FinishMode.pause); |
||||
isPlay.value = false; |
if (meetingDetails.value.tasktype == 1) { |
||||
|
List speakerList = await SqfliteApi.getMeetingSpeaker(id); |
||||
|
textList.value = List.from(speakerList); |
||||
} |
} |
||||
}); |
|
||||
isLoading.value = false; |
isLoading.value = false; |
||||
} |
} |
||||
|
|
||||
void start() async { |
void editSpeakerText() async { |
||||
if (isPlay.value) { |
if (editSpeakerTextId.value != 0 && editSpeakerTextChange.isNotEmpty) { |
||||
lottieController.stop(); |
Map? foundElement = editSpeakerTextList.firstWhere( |
||||
audioPlayer.pause(); |
(element) => element['id'] == editSpeakerTextId.value, |
||||
isPlay.value = false; |
orElse: () => null, |
||||
|
); |
||||
|
if (foundElement != null) { |
||||
|
foundElement['content'] = editSpeakerTextChange; |
||||
|
} else { |
||||
|
editSpeakerTextList.add({ |
||||
|
'id': editSpeakerTextId.value, |
||||
|
'content': editSpeakerTextChange, |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
if (editSpeakerTextList.isNotEmpty) { |
||||
|
EasyLoading.show(); |
||||
|
var result = await SqfliteApi.editMeetingSpeaker(editSpeakerTextList); |
||||
|
if (result != null) { |
||||
|
for (var element in editSpeakerTextList) { |
||||
|
int index = |
||||
|
textList.indexWhere((item) => item['id'] == element['id']); |
||||
|
if (index != -1) { |
||||
|
final dataMap = Map<String, dynamic>.from(textList[index]); |
||||
|
dataMap['content'] = element['content']; |
||||
|
textList[index] = dataMap; |
||||
|
} |
||||
|
} |
||||
|
isSpeakerText.value = false; |
||||
|
editSpeakerTextId.value = 0; |
||||
|
editSpeakerTextList = []; |
||||
|
} |
||||
|
EasyLoading.dismiss(); |
||||
} else { |
} else { |
||||
lottieController.repeat(); |
isSpeakerText.value = false; |
||||
audioPlayer.play(); |
editSpeakerTextId.value = 0; |
||||
isPlay.value = true; |
editSpeakerTextList = []; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
void editSpeakerName() {} |
||||
|
|
||||
|
void speakerCreate() async { |
||||
|
EasyLoading.show(); |
||||
|
await _audioSpeaker(); |
||||
|
EasyLoading.dismiss(); |
||||
|
} |
||||
|
|
||||
|
void summaryCreate() async { |
||||
|
EasyLoading.show(); |
||||
|
if (meetingDetails.value.tasktype != 1) { |
||||
|
await _audioSpeaker(); |
||||
|
} |
||||
|
if (allText.isEmpty) { |
||||
|
String text = ''; |
||||
|
for (var element in textList) { |
||||
|
if (element['speaker'].isNotEmpty) { |
||||
|
text += '${element['speaker']}:'; |
||||
|
} |
||||
|
text += '${element['content']}\n'; |
||||
|
} |
||||
|
allText = text; |
||||
|
} |
||||
|
_aiServiceListen(); |
||||
|
// const uuid = Uuid(); |
||||
|
// _aiService.send( |
||||
|
// sid: uuid.v4(), |
||||
|
// messages: [ |
||||
|
// {'role': 'user', 'content': allText}, |
||||
|
// ], |
||||
|
// systemPrompt: _agent.systemPrompt, |
||||
|
// ); |
||||
|
} |
||||
|
|
||||
|
void _aiServiceListen() { |
||||
|
String summaryText = ''; |
||||
|
// _aiResponseSubscription = _aiService.start().listen((AIStreamEvent event) { |
||||
|
// summaryText += event.content ?? ''; |
||||
|
// if (event.type == StreamEventType.complete) { |
||||
|
// SqfliteApi.editMeeting(id, {'summary': summaryText}); |
||||
|
// meetingDetails.value.summary = summaryText; |
||||
|
// meetingDetails.refresh(); |
||||
|
// EasyLoading.dismiss(); |
||||
|
// } |
||||
|
// }); |
||||
|
} |
||||
|
|
||||
|
Future<void> _audioSpeaker() async { |
||||
|
String audiourl = meetingData.value.audiourl; |
||||
|
if (audiourl.isEmpty) { |
||||
|
audiourl = await UploadOss.upload( |
||||
|
filepath: meetingData.value.filepath, |
||||
|
rootDir: 'audio', |
||||
|
); |
||||
|
meetingData.value.audiourl = audiourl; |
||||
|
} |
||||
|
String taskId = meetingDetails.value.taskid; |
||||
|
if (taskId.isEmpty) { |
||||
|
taskId = await _submitTask(audiourl); |
||||
|
meetingDetails.value.taskid = taskId; |
||||
|
await SqfliteApi.editMeeting(id, {'taskid': taskId}); |
||||
|
} |
||||
|
await _fileRecognize(taskId); |
||||
|
} |
||||
|
|
||||
|
Future<String> _submitTask(String audiourl) async { |
||||
|
String format = _getFileType(audiourl); |
||||
|
|
||||
|
final requestBody = jsonEncode({ |
||||
|
"app": { |
||||
|
"appid": _appid, |
||||
|
"token": _token, |
||||
|
"cluster": _cluster, |
||||
|
}, |
||||
|
"user": { |
||||
|
"uid": _userId, |
||||
|
}, |
||||
|
"audio": { |
||||
|
"format": format, |
||||
|
"url": audiourl, |
||||
|
}, |
||||
|
"additions": { |
||||
|
"with_speaker_info": "True", |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
try { |
||||
|
final response = await http.post( |
||||
|
Uri.parse('$_serviceUrl/submit'), |
||||
|
headers: _headers, |
||||
|
body: requestBody, |
||||
|
); |
||||
|
|
||||
|
if (response.statusCode != 200) { |
||||
|
throw Exception('Submit failed with status: ${response.statusCode}'); |
||||
|
} |
||||
|
|
||||
|
final respDic = jsonDecode(response.body); |
||||
|
final taskId = respDic['resp']['id'] as String; |
||||
|
Logger.debug('taskId: $taskId'); |
||||
|
return taskId; |
||||
|
} catch (e) { |
||||
|
Logger.error('Submit error: $e'); |
||||
|
rethrow; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
String _getFileType(String path) { |
||||
|
List<String> array = path.split('.'); |
||||
|
return array[array.length - 1]; |
||||
|
} |
||||
|
|
||||
|
Future<Map<String, dynamic>> _queryTask(String taskId) async { |
||||
|
final queryBody = jsonEncode({ |
||||
|
"appid": _appid, |
||||
|
"token": _token, |
||||
|
"id": taskId, |
||||
|
"cluster": _cluster, |
||||
|
}); |
||||
|
|
||||
|
try { |
||||
|
final response = await http.post( |
||||
|
Uri.parse('$_serviceUrl/query'), |
||||
|
headers: _headers, |
||||
|
body: queryBody, |
||||
|
); |
||||
|
|
||||
|
if (response.statusCode != 200) { |
||||
|
throw Exception('Query failed with status: ${response.statusCode}'); |
||||
|
} |
||||
|
|
||||
|
final respDic = jsonDecode(response.body); |
||||
|
return respDic; |
||||
|
} catch (e) { |
||||
|
Logger.error('Query error: $e'); |
||||
|
rethrow; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
Future<void> _fileRecognize(String taskId) async { |
||||
|
try { |
||||
|
final startTime = DateTime.now().millisecondsSinceEpoch; |
||||
|
|
||||
|
while (!_isDisposed) { |
||||
|
await Future.delayed(const Duration(seconds: 2)); |
||||
|
|
||||
|
if (_isDisposed) return; |
||||
|
|
||||
|
final respDic = await _queryTask(taskId); |
||||
|
final code = respDic['resp']['code'] as int; |
||||
|
|
||||
|
if (code == 1000) { |
||||
|
await _addTextList(respDic['resp']['utterances']); |
||||
|
return; |
||||
|
} else if (code < 2000) { |
||||
|
Logger.debug("FileRecognize failed"); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
final currentTime = DateTime.now().millisecondsSinceEpoch; |
||||
|
if (currentTime - startTime > 300 * 1000) { |
||||
|
Logger.debug('wait time exceeds 300s'); |
||||
|
return; |
||||
|
} |
||||
|
} |
||||
|
} catch (e) { |
||||
|
Logger.error('Error occurred: $e'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
Future<void> _addTextList(List utterances) async { |
||||
|
String text = ''; |
||||
|
List<Map<String, Object?>> list = []; |
||||
|
for (var element in utterances) { |
||||
|
String speaker = ''; |
||||
|
if (element['additions'].isNotEmpty) { |
||||
|
speaker = 'Speaker ${element['additions']['speaker']}'; |
||||
|
text += '$speaker:'; |
||||
|
} |
||||
|
list.add({ |
||||
|
'meetingid': id, |
||||
|
'content': element['text'], |
||||
|
'starttime': element['start_time'], |
||||
|
'endtime': element['end_time'], |
||||
|
'speaker': speaker, |
||||
|
}); |
||||
|
text += '${element['text']}\n'; |
||||
|
} |
||||
|
var result = await SqfliteApi.insertMeetingSpeaker(list); |
||||
|
if (result != null) { |
||||
|
await SqfliteApi.editMeeting(id, {'tasktype': 1}); |
||||
|
List speakerList = await SqfliteApi.getMeetingSpeaker(id); |
||||
|
textList.value = List.from(speakerList); |
||||
|
meetingDetails.value.tasktype = 1; |
||||
|
meetingDetails.refresh(); |
||||
} |
} |
||||
|
allText = text; |
||||
} |
} |
||||
} |
} |
||||
|
|||||
@ -1,143 +0,0 @@ |
|||||
import 'package:flutter/material.dart'; |
|
||||
import 'package:flutter_easyloading/flutter_easyloading.dart'; |
|
||||
import 'package:get/get.dart'; |
|
||||
import 'package:just_audio/just_audio.dart'; |
|
||||
|
|
||||
import '../../../data/services/ai_service.dart'; |
|
||||
import '../../../data/services/db/sqflite_api.dart'; |
|
||||
import '../../../data/services/volcano_ai_service.dart'; |
|
||||
import 'meeting_controller.dart'; |
|
||||
import 'meeting_details_controller.dart'; |
|
||||
|
|
||||
class MeetingEditController extends GetxController |
|
||||
with GetSingleTickerProviderStateMixin { |
|
||||
late AiService _aiService; |
|
||||
|
|
||||
final MeetingController meetingController = Get.find<MeetingController>(); |
|
||||
final MeetingDetailsController meetingDetailsController = |
|
||||
Get.find<MeetingDetailsController>(); |
|
||||
|
|
||||
late AnimationController lottieController; |
|
||||
|
|
||||
late TextEditingController titleController; |
|
||||
late TextEditingController contentController; |
|
||||
|
|
||||
final AudioPlayer audioPlayer = AudioPlayer(); |
|
||||
|
|
||||
int creationTime = 0; |
|
||||
|
|
||||
RxString address = ''.obs; |
|
||||
String _longitude = ''; |
|
||||
String _latitude = ''; |
|
||||
|
|
||||
int id = 0; |
|
||||
|
|
||||
RxBool isPlay = false.obs; |
|
||||
|
|
||||
@override |
|
||||
void onInit() { |
|
||||
super.onInit(); |
|
||||
lottieController = AnimationController(vsync: this); |
|
||||
final Map<String, dynamic> args = Get.arguments ?? {}; |
|
||||
id = args['id']; |
|
||||
_getDataDetails(); |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
void onClose() { |
|
||||
lottieController.dispose(); |
|
||||
titleController.dispose(); |
|
||||
contentController.dispose(); |
|
||||
audioPlayer.dispose(); |
|
||||
super.onClose(); |
|
||||
} |
|
||||
|
|
||||
void _getDataDetails() async { |
|
||||
titleController = TextEditingController( |
|
||||
text: meetingDetailsController.meetingData.value.title, |
|
||||
); |
|
||||
contentController = TextEditingController( |
|
||||
text: meetingDetailsController.dataDetails.value.content, |
|
||||
); |
|
||||
creationTime = meetingDetailsController.meetingData.value.creationtime; |
|
||||
address.value = meetingDetailsController.meetingData.value.address; |
|
||||
_longitude = meetingDetailsController.meetingData.value.longitude; |
|
||||
_latitude = meetingDetailsController.meetingData.value.latitude; |
|
||||
audioPlayer.setAudioSource( |
|
||||
AudioSource.file(meetingDetailsController.dataDetails.value.audio)); |
|
||||
audioPlayer.playerStateStream.listen((state) async { |
|
||||
if (state.processingState == ProcessingState.completed) { |
|
||||
await audioPlayer.seek(Duration.zero); |
|
||||
audioPlayer.pause(); |
|
||||
lottieController.stop(); |
|
||||
isPlay.value = false; |
|
||||
} |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
void start() async { |
|
||||
if (isPlay.value) { |
|
||||
lottieController.stop(); |
|
||||
audioPlayer.pause(); |
|
||||
isPlay.value = false; |
|
||||
} else { |
|
||||
lottieController.repeat(); |
|
||||
audioPlayer.play(); |
|
||||
isPlay.value = true; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
void confirm() async { |
|
||||
EasyLoading.show(); |
|
||||
_aiService = Get.find<VolcanoAIService>(); |
|
||||
String aiSummary = await _aiService.sendMessage( |
|
||||
systemPrompt: '', |
|
||||
messages: [ |
|
||||
{'role': 'user', 'content': contentController.text}, |
|
||||
{'role': 'user', 'content': '帮我总结一下这次会议'}, |
|
||||
], |
|
||||
); |
|
||||
String content = contentController.text; |
|
||||
String firstContent = |
|
||||
content.length >= 30 ? content.substring(0, 30) : content; |
|
||||
String title = titleController.text.isNotEmpty |
|
||||
? titleController.text |
|
||||
: meetingDetailsController.meetingData.value.title; |
|
||||
Map<String, Object?> data = { |
|
||||
'title': title, |
|
||||
'firstContent': firstContent.replaceAll("\n", ""), |
|
||||
'seconds': meetingDetailsController.seconds, |
|
||||
'content': contentController.text, |
|
||||
'aisummary': aiSummary, |
|
||||
'address': address.value, |
|
||||
'longitude': _longitude, |
|
||||
'latitude': _latitude, |
|
||||
}; |
|
||||
final result = await SqfliteApi.editMeeting(id, data); |
|
||||
if (result != null) { |
|
||||
int index = meetingController.dataList.indexWhere( |
|
||||
(item) => item['id'] == id, |
|
||||
); |
|
||||
if (index != -1) { |
|
||||
final dataMap = |
|
||||
Map<String, dynamic>.from(meetingController.dataList[index]); |
|
||||
dataMap['title'] = title; |
|
||||
dataMap['content'] = firstContent.replaceAll("\n", ""); |
|
||||
dataMap['seconds'] = meetingDetailsController.seconds; |
|
||||
dataMap['address'] = address.value; |
|
||||
meetingController.dataList[index] = dataMap; |
|
||||
} |
|
||||
meetingDetailsController.meetingData.value.title = title; |
|
||||
meetingDetailsController.meetingData.value.address = address.value; |
|
||||
meetingDetailsController.meetingData.value.longitude = _longitude; |
|
||||
meetingDetailsController.meetingData.value.latitude = _latitude; |
|
||||
meetingDetailsController.dataDetails.value.content = |
|
||||
contentController.text; |
|
||||
meetingDetailsController.dataDetails.value.aisummary = aiSummary; |
|
||||
meetingDetailsController.meetingData.refresh(); |
|
||||
meetingDetailsController.dataDetails.refresh(); |
|
||||
Get.back(); |
|
||||
} |
|
||||
EasyLoading.dismiss(); |
|
||||
} |
|
||||
} |
|
||||
@ -1,324 +0,0 @@ |
|||||
import 'dart:async'; |
|
||||
import 'dart:io'; |
|
||||
import 'dart:typed_data'; |
|
||||
|
|
||||
import 'package:amap_flutter/amap_flutter.dart'; |
|
||||
import 'package:flutter/material.dart'; |
|
||||
import 'package:flutter_easyloading/flutter_easyloading.dart'; |
|
||||
import 'package:get/get.dart'; |
|
||||
import 'package:path_provider/path_provider.dart'; |
|
||||
import 'package:permission_handler/permission_handler.dart'; |
|
||||
|
|
||||
import '../../../core/utils/logger.dart'; |
|
||||
import '../../../data/services/ai_service.dart'; |
|
||||
import '../../../data/services/asr_service.dart'; |
|
||||
import '../../../data/services/db/sqflite_api.dart'; |
|
||||
import '../../../data/services/speech_impl/xunfei_asr_service.dart'; |
|
||||
import '../../../data/services/volcano_ai_service.dart'; |
|
||||
import 'meeting_controller.dart'; |
|
||||
|
|
||||
class MeetingRecordController extends GetxController |
|
||||
with GetSingleTickerProviderStateMixin { |
|
||||
late AiService _aiService; |
|
||||
|
|
||||
final MeetingController meetingController = Get.find<MeetingController>(); |
|
||||
|
|
||||
late AnimationController lottieController; |
|
||||
late AsrService _asrService; |
|
||||
|
|
||||
final _amapFlutter = AmapFlutter(); |
|
||||
StreamSubscription? _locationStream; |
|
||||
|
|
||||
Timer? timer; |
|
||||
|
|
||||
RxBool isRecording = false.obs; //是否录音中 |
|
||||
RxInt seconds = 0.obs; |
|
||||
|
|
||||
String title = ''; |
|
||||
int creationTime = 0; |
|
||||
|
|
||||
RxString address = '正在定位'.obs; |
|
||||
String _longitude = ''; |
|
||||
String _latitude = ''; |
|
||||
|
|
||||
RxString intermediateContent = ''.obs; |
|
||||
RxString finalContent = ''.obs; |
|
||||
|
|
||||
StreamSubscription? _recognitionSubscription; |
|
||||
|
|
||||
BytesBuilder _originaBytes = BytesBuilder(); //原始音频 |
|
||||
|
|
||||
@override |
|
||||
void onInit() { |
|
||||
super.onInit(); |
|
||||
Permission.storage.request(); |
|
||||
lottieController = AnimationController(vsync: this); |
|
||||
Get.put(XunfeiAsrService()); |
|
||||
_asrService = Get.find<XunfeiAsrService>(); |
|
||||
final Map<String, dynamic> args = Get.arguments ?? {}; |
|
||||
title = args['title']; |
|
||||
creationTime = DateTime.now().millisecondsSinceEpoch; |
|
||||
_amapInit(); |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
void onClose() { |
|
||||
lottieController.dispose(); |
|
||||
timer?.cancel(); |
|
||||
_asrService.dispose(); |
|
||||
_recognitionSubscription?.cancel(); |
|
||||
_originaBytes = BytesBuilder(); |
|
||||
_amapFlutter.dispose(); |
|
||||
_locationStream?.cancel(); |
|
||||
super.onClose(); |
|
||||
} |
|
||||
|
|
||||
void _amapInit() async { |
|
||||
await _amapFlutter.initLocation(); |
|
||||
_locationStream = _amapFlutter.locationStream().listen((data) { |
|
||||
address.value = data['address']; |
|
||||
_longitude = data['longitude'].toString(); |
|
||||
_latitude = data['latitude'].toString(); |
|
||||
_amapFlutter.stopLocation(); |
|
||||
}); |
|
||||
_amapFlutter.startLocation(); |
|
||||
} |
|
||||
|
|
||||
// 点击开始、暂停按钮 |
|
||||
void recorderHandle() { |
|
||||
if (isRecording.value) { |
|
||||
_pause(); |
|
||||
} else { |
|
||||
_start(); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 开始 |
|
||||
void _start() async { |
|
||||
await _startRecorder(); |
|
||||
lottieController.repeat(); |
|
||||
isRecording.value = true; |
|
||||
timer = Timer.periodic(const Duration(seconds: 1), (_) => seconds++); |
|
||||
} |
|
||||
|
|
||||
// 暂停 |
|
||||
Future<void> _pause() async { |
|
||||
await _stopRecorder(); |
|
||||
lottieController.stop(); |
|
||||
isRecording.value = false; |
|
||||
timer?.cancel(); |
|
||||
} |
|
||||
|
|
||||
//开始录音 |
|
||||
Future<void> _startRecorder() async { |
|
||||
final recognitionStream = |
|
||||
await _asrService.startContinuousRecognition(false); |
|
||||
_recognitionSubscription = recognitionStream.listen( |
|
||||
_handleRecognitionEvent, |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
//结束录音 |
|
||||
Future<void> _stopRecorder() async { |
|
||||
await _asrService.stopContinuousRecognition(); |
|
||||
} |
|
||||
|
|
||||
// 处理语音识别事件 |
|
||||
void _handleRecognitionEvent(RecognitionEvent event) { |
|
||||
switch (event.type) { |
|
||||
case RecognitionEventType.intermediateResult: |
|
||||
intermediateContent.value = event.text; |
|
||||
break; |
|
||||
case RecognitionEventType.finalResult: |
|
||||
finalContent.value += _spliceText(event); |
|
||||
intermediateContent.value = ''; |
|
||||
_originaBytes.add(event.audio!); |
|
||||
break; |
|
||||
default: |
|
||||
// 不做任何处理 |
|
||||
break; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
String _spliceText(RecognitionEvent event) { |
|
||||
String eventText = event.text; |
|
||||
String text = ''; |
|
||||
if (finalContent.value.isNotEmpty) { |
|
||||
if (event.role != '') { |
|
||||
final punctuations = { |
|
||||
',', |
|
||||
'。', |
|
||||
'!', |
|
||||
'?', |
|
||||
';', |
|
||||
':', |
|
||||
',', |
|
||||
'.', |
|
||||
':', |
|
||||
';', |
|
||||
'!', |
|
||||
'?' |
|
||||
}; |
|
||||
final firstChar = eventText[0]; |
|
||||
if (punctuations.contains(firstChar)) { |
|
||||
text += '$firstChar\n'; |
|
||||
eventText = eventText.substring(1); |
|
||||
} else { |
|
||||
text += '\n'; |
|
||||
} |
|
||||
text += '说话人${event.role}:'; |
|
||||
} |
|
||||
} else { |
|
||||
text += '说话人${event.role}:'; |
|
||||
} |
|
||||
text += eventText; |
|
||||
return text; |
|
||||
} |
|
||||
|
|
||||
// 完成 |
|
||||
void complete() async { |
|
||||
EasyLoading.show(); |
|
||||
await _pause(); |
|
||||
_aiSummary(); |
|
||||
} |
|
||||
|
|
||||
void _aiSummary() async { |
|
||||
_aiService = Get.find<VolcanoAIService>(); |
|
||||
String aiSummary = await _aiService.sendMessage( |
|
||||
systemPrompt: '', |
|
||||
messages: [ |
|
||||
{'role': 'user', 'content': finalContent.value}, |
|
||||
{'role': 'user', 'content': '帮我总结一下这次会议'}, |
|
||||
], |
|
||||
); |
|
||||
_save(aiSummary); |
|
||||
} |
|
||||
|
|
||||
void _save(String aiSummary) async { |
|
||||
String firstContent = finalContent.value.length >= 30 |
|
||||
? finalContent.value.substring(0, 30) |
|
||||
: finalContent.value; |
|
||||
Uint8List audio = _originaBytes.toBytes(); |
|
||||
Uint8List wavHeader = generateWavHeader( |
|
||||
sampleRate: 16000, |
|
||||
numberOfChannels: 1, |
|
||||
bitsPerSample: 16, |
|
||||
dataLength: audio.length, |
|
||||
); |
|
||||
Uint8List wavData = Uint8List.fromList([...wavHeader, ...audio]); |
|
||||
String audiopPath = |
|
||||
await _saveAudioToFile(wavData, 'meeting_$creationTime.wav'); |
|
||||
if (audiopPath.isNotEmpty) { |
|
||||
Map<String, Object?> data = { |
|
||||
'title': title, |
|
||||
'firstContent': firstContent.replaceAll("\n", ""), |
|
||||
'seconds': seconds.value, |
|
||||
'creationTime': creationTime, |
|
||||
'audio': audiopPath, |
|
||||
'content': finalContent.value, |
|
||||
'aisummary': aiSummary, |
|
||||
'address': address.value, |
|
||||
'longitude': _longitude, |
|
||||
'latitude': _latitude, |
|
||||
}; |
|
||||
final result = await SqfliteApi.insertMeeting(data); |
|
||||
if (result != null) { |
|
||||
meetingController.dataList.insert(0, { |
|
||||
'id': result, |
|
||||
'title': title, |
|
||||
'content': firstContent.replaceAll("\n", ""), |
|
||||
'seconds': seconds.value, |
|
||||
'address': address.value, |
|
||||
'longitude': _longitude, |
|
||||
'latitude': _latitude, |
|
||||
'creationtime': creationTime, |
|
||||
}); |
|
||||
Get.back(); |
|
||||
} |
|
||||
} |
|
||||
EasyLoading.dismiss(); |
|
||||
} |
|
||||
|
|
||||
Future<String> _saveAudioToFile(Uint8List audioData, String fileName) async { |
|
||||
try { |
|
||||
final directory = await getApplicationDocumentsDirectory(); |
|
||||
final file = File('${directory.path}/$fileName'); |
|
||||
|
|
||||
// 写入文件 |
|
||||
await file.writeAsBytes(audioData); |
|
||||
return file.path; |
|
||||
} catch (e) { |
|
||||
Logger.error('保存失败: $e'); |
|
||||
return ''; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
Uint8List generateWavHeader({ |
|
||||
required int sampleRate, |
|
||||
required int numberOfChannels, |
|
||||
required int bitsPerSample, |
|
||||
required int dataLength, |
|
||||
}) { |
|
||||
final byteRate = sampleRate * numberOfChannels * (bitsPerSample ~/ 8); |
|
||||
final blockAlign = numberOfChannels * (bitsPerSample ~/ 8); |
|
||||
final riffSize = 36 + dataLength; |
|
||||
final header = Uint8List(44); |
|
||||
var pos = 0; |
|
||||
// RIFF 块 |
|
||||
header.setRange(pos, pos + 4, [0x52, 0x49, 0x46, 0x46]); // 'RIFF' |
|
||||
pos += 4; |
|
||||
// 写入 RIFF 大小(小端) |
|
||||
header.setRange(pos, pos + 4, [ |
|
||||
riffSize & 0xff, |
|
||||
(riffSize >> 8) & 0xff, |
|
||||
(riffSize >> 16) & 0xff, |
|
||||
(riffSize >> 24) & 0xff, |
|
||||
]); |
|
||||
pos += 4; |
|
||||
// WAVE 标识 |
|
||||
header.setRange(pos, pos + 4, [0x57, 0x41, 0x56, 0x45]); // 'WAVE' |
|
||||
pos += 4; |
|
||||
// fmt 子块 |
|
||||
header.setRange(pos, pos + 4, [0x66, 0x6d, 0x74, 0x20]); // 'fmt ' |
|
||||
pos += 4; |
|
||||
header.setRange(pos, pos + 4, [16, 0, 0, 0]); // 块大小 |
|
||||
pos += 4; |
|
||||
header.setRange(pos, pos + 2, [1, 0]); // PCM 格式 |
|
||||
pos += 2; |
|
||||
header.setRange(pos, pos + 2, [numberOfChannels, 0]); // 声道数 |
|
||||
pos += 2; |
|
||||
// 采样率(小端) |
|
||||
header.setRange(pos, pos + 4, [ |
|
||||
sampleRate & 0xff, |
|
||||
(sampleRate >> 8) & 0xff, |
|
||||
(sampleRate >> 16) & 0xff, |
|
||||
(sampleRate >> 24) & 0xff, |
|
||||
]); |
|
||||
pos += 4; |
|
||||
// 字节率(小端) |
|
||||
header.setRange(pos, pos + 4, [ |
|
||||
byteRate & 0xff, |
|
||||
(byteRate >> 8) & 0xff, |
|
||||
(byteRate >> 16) & 0xff, |
|
||||
(byteRate >> 24) & 0xff, |
|
||||
]); |
|
||||
pos += 4; |
|
||||
// 块对齐 |
|
||||
header.setRange(pos, pos + 2, [blockAlign, 0]); |
|
||||
pos += 2; |
|
||||
// 位深度 |
|
||||
header.setRange(pos, pos + 2, [bitsPerSample, 0]); |
|
||||
pos += 2; |
|
||||
// data 子块 |
|
||||
header.setRange(pos, pos + 4, [0x64, 0x61, 0x74, 0x61]); // 'data' |
|
||||
pos += 4; |
|
||||
// 数据大小(小端) |
|
||||
header.setRange(pos, pos + 4, [ |
|
||||
dataLength & 0xff, |
|
||||
(dataLength >> 8) & 0xff, |
|
||||
(dataLength >> 16) & 0xff, |
|
||||
(dataLength >> 24) & 0xff, |
|
||||
]); |
|
||||
return header; |
|
||||
} |
|
||||
} |
|
||||
@ -1,301 +0,0 @@ |
|||||
import 'package:common_utils/common_utils.dart'; |
|
||||
import 'package:flutter/material.dart'; |
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|
||||
import 'package:get/get.dart'; |
|
||||
import 'package:lottie/lottie.dart'; |
|
||||
|
|
||||
import '../../../core/theme/app_colors.dart'; |
|
||||
import '../controllers/meeting_edit_controller.dart'; |
|
||||
|
|
||||
class MeetingEditView extends GetView<MeetingEditController> { |
|
||||
const MeetingEditView({super.key}); |
|
||||
|
|
||||
@override |
|
||||
Widget build(BuildContext context) { |
|
||||
final isDarkMode = Theme.of(context).brightness == Brightness.dark; |
|
||||
|
|
||||
return Scaffold( |
|
||||
backgroundColor: isDarkMode ? Colors.black : Colors.grey[50], |
|
||||
appBar: _buildAppBar(isDarkMode), |
|
||||
body: _buildBody(isDarkMode), |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
// 构建应用栏 |
|
||||
PreferredSizeWidget _buildAppBar(bool isDarkMode) { |
|
||||
return AppBar( |
|
||||
backgroundColor: isDarkMode ? Colors.black : Colors.white, |
|
||||
elevation: 0.5, |
|
||||
leading: IconButton( |
|
||||
icon: Icon( |
|
||||
Icons.arrow_back_ios, |
|
||||
color: isDarkMode ? Colors.white : Colors.black, |
|
||||
size: 20.sp, |
|
||||
), |
|
||||
onPressed: () => Get.back(), |
|
||||
), |
|
||||
actions: [ |
|
||||
GestureDetector( |
|
||||
onTap: controller.confirm, |
|
||||
child: Padding( |
|
||||
padding: EdgeInsets.symmetric( |
|
||||
horizontal: 12.w, |
|
||||
vertical: 6.w, |
|
||||
), |
|
||||
child: Text( |
|
||||
'save'.tr, |
|
||||
style: TextStyle( |
|
||||
color: isDarkMode ? AppColors.primary : Colors.blue, |
|
||||
fontSize: 18.sp, |
|
||||
fontWeight: FontWeight.bold, |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
], |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
// 构建主体内容 |
|
||||
Widget _buildBody(bool isDarkMode) { |
|
||||
return Padding( |
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 10.w), |
|
||||
child: Column( |
|
||||
crossAxisAlignment: CrossAxisAlignment.start, |
|
||||
children: [ |
|
||||
Container( |
|
||||
height: 48.w, |
|
||||
alignment: Alignment.center, |
|
||||
decoration: BoxDecoration( |
|
||||
color: isDarkMode ? Colors.white.withOpacity(0.05) : Colors.white, |
|
||||
borderRadius: BorderRadius.circular(12.w), |
|
||||
boxShadow: [ |
|
||||
BoxShadow( |
|
||||
color: isDarkMode |
|
||||
? Colors.black.withOpacity(0.2) |
|
||||
: Colors.black.withOpacity(0.05), |
|
||||
blurRadius: 10, |
|
||||
offset: const Offset(0, 2), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
child: TextField( |
|
||||
controller: controller.titleController, |
|
||||
style: TextStyle( |
|
||||
color: isDarkMode ? Colors.white : Colors.black87, |
|
||||
fontSize: 16.sp, |
|
||||
), |
|
||||
decoration: InputDecoration( |
|
||||
hintText: 'maxCharacters'.tr, |
|
||||
hintStyle: TextStyle( |
|
||||
color: isDarkMode ? Colors.white70 : Colors.black54, |
|
||||
fontSize: 16.sp, |
|
||||
), |
|
||||
fillColor: Colors.transparent, |
|
||||
border: InputBorder.none, |
|
||||
contentPadding: EdgeInsets.symmetric( |
|
||||
horizontal: 10.w, |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
Padding( |
|
||||
padding: EdgeInsets.symmetric(vertical: 5.w), |
|
||||
child: Text( |
|
||||
DateUtil.formatDateMs( |
|
||||
controller.creationTime, |
|
||||
format: DateFormats.y_mo_d_h_m, |
|
||||
), |
|
||||
style: TextStyle( |
|
||||
color: isDarkMode ? Colors.grey[400] : Colors.grey[500], |
|
||||
fontSize: 14.sp, |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
Row( |
|
||||
children: [ |
|
||||
Icon( |
|
||||
Icons.location_on, |
|
||||
size: 16.w, |
|
||||
color: isDarkMode ? Colors.grey[400] : Colors.grey[500], |
|
||||
), |
|
||||
Expanded( |
|
||||
child: Obx( |
|
||||
() => Text( |
|
||||
controller.address.value, |
|
||||
style: TextStyle( |
|
||||
color: isDarkMode ? Colors.grey[400] : Colors.grey[500], |
|
||||
fontSize: 14.sp, |
|
||||
), |
|
||||
overflow: TextOverflow.ellipsis, |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
_audioWave(isDarkMode), |
|
||||
_recordContent(isDarkMode), |
|
||||
], |
|
||||
), |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
// 音波 |
|
||||
Widget _audioWave(bool isDarkMode) { |
|
||||
return Container( |
|
||||
padding: EdgeInsets.only(bottom: 10.w), |
|
||||
margin: EdgeInsets.symmetric(vertical: 5.w), |
|
||||
decoration: BoxDecoration( |
|
||||
color: isDarkMode ? Colors.white.withOpacity(0.05) : Colors.white, |
|
||||
borderRadius: BorderRadius.circular(12.w), |
|
||||
boxShadow: [ |
|
||||
BoxShadow( |
|
||||
color: isDarkMode |
|
||||
? Colors.black.withOpacity(0.2) |
|
||||
: Colors.black.withOpacity(0.05), |
|
||||
blurRadius: 10, |
|
||||
offset: const Offset(0, 2), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
child: Column( |
|
||||
children: [ |
|
||||
SizedBox( |
|
||||
height: 70.w, |
|
||||
child: Row( |
|
||||
children: [ |
|
||||
Expanded( |
|
||||
child: Lottie.asset( |
|
||||
'assets/lottie/audio_wave.json', |
|
||||
controller: controller.lottieController, |
|
||||
fit: BoxFit.fill, |
|
||||
onLoaded: (composition) { |
|
||||
controller.lottieController.duration = |
|
||||
composition.duration; |
|
||||
}, |
|
||||
), |
|
||||
), |
|
||||
Expanded( |
|
||||
child: Lottie.asset( |
|
||||
'assets/lottie/audio_wave.json', |
|
||||
controller: controller.lottieController, |
|
||||
fit: BoxFit.fill, |
|
||||
), |
|
||||
), |
|
||||
Expanded( |
|
||||
child: Lottie.asset( |
|
||||
'assets/lottie/audio_wave.json', |
|
||||
controller: controller.lottieController, |
|
||||
fit: BoxFit.fill, |
|
||||
), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
), |
|
||||
Row( |
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround, |
|
||||
children: [ |
|
||||
StreamBuilder<Duration>( |
|
||||
stream: controller.audioPlayer.positionStream, |
|
||||
builder: (context, snapshot) { |
|
||||
final position = snapshot.data ?? Duration.zero; |
|
||||
return Text( |
|
||||
DateUtil.formatDateMs( |
|
||||
position.inMilliseconds, |
|
||||
format: 'HH:mm:ss', |
|
||||
isUtc: true, |
|
||||
), |
|
||||
style: TextStyle( |
|
||||
color: isDarkMode ? Colors.grey[400] : Colors.grey[500], |
|
||||
fontSize: 12.sp, |
|
||||
), |
|
||||
); |
|
||||
}, |
|
||||
), |
|
||||
GestureDetector( |
|
||||
onTap: controller.start, |
|
||||
child: Container( |
|
||||
width: 30.w, |
|
||||
height: 30.w, |
|
||||
decoration: BoxDecoration( |
|
||||
color: isDarkMode ? AppColors.primary : Colors.blue, |
|
||||
borderRadius: BorderRadius.circular( |
|
||||
12.w, |
|
||||
), |
|
||||
), |
|
||||
child: Obx( |
|
||||
() => Icon( |
|
||||
controller.isPlay.value ? Icons.pause : Icons.play_arrow, |
|
||||
color: Colors.white, |
|
||||
size: 25.w, |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
StreamBuilder<Duration?>( |
|
||||
stream: controller.audioPlayer.durationStream, |
|
||||
builder: (context, snapshot) { |
|
||||
final duration = snapshot.data ?? Duration.zero; |
|
||||
return Text( |
|
||||
DateUtil.formatDateMs( |
|
||||
duration.inMilliseconds, |
|
||||
format: 'HH:mm:ss', |
|
||||
isUtc: true, |
|
||||
), |
|
||||
style: TextStyle( |
|
||||
color: isDarkMode ? Colors.grey[400] : Colors.grey[500], |
|
||||
fontSize: 12.sp, |
|
||||
), |
|
||||
); |
|
||||
}, |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
// 记录内容 |
|
||||
Widget _recordContent(bool isDarkMode) { |
|
||||
return Expanded( |
|
||||
child: Container( |
|
||||
width: double.infinity, |
|
||||
padding: EdgeInsets.all(10.w), |
|
||||
margin: EdgeInsets.symmetric(vertical: 5.w), |
|
||||
decoration: BoxDecoration( |
|
||||
color: isDarkMode ? Colors.white.withOpacity(0.05) : Colors.white, |
|
||||
borderRadius: BorderRadius.circular(12.w), |
|
||||
boxShadow: [ |
|
||||
BoxShadow( |
|
||||
color: isDarkMode |
|
||||
? Colors.black.withOpacity(0.2) |
|
||||
: Colors.black.withOpacity(0.05), |
|
||||
blurRadius: 10, |
|
||||
offset: const Offset(0, 2), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
child: TextField( |
|
||||
controller: controller.contentController, |
|
||||
style: TextStyle( |
|
||||
color: isDarkMode ? Colors.white : Colors.black87, |
|
||||
fontSize: 14.sp, |
|
||||
), |
|
||||
maxLines: 50, |
|
||||
decoration: InputDecoration( |
|
||||
hintText: '编辑记录内容', |
|
||||
hintStyle: TextStyle( |
|
||||
color: isDarkMode ? Colors.white70 : Colors.black54, |
|
||||
fontSize: 14.sp, |
|
||||
), |
|
||||
fillColor: Colors.transparent, |
|
||||
border: InputBorder.none, |
|
||||
focusedBorder: InputBorder.none, |
|
||||
contentPadding: EdgeInsets.zero, |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
); |
|
||||
} |
|
||||
} |
|
||||
@ -1,363 +0,0 @@ |
|||||
import 'package:common_utils/common_utils.dart'; |
|
||||
import 'package:flutter/material.dart'; |
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|
||||
import 'package:get/get.dart'; |
|
||||
import 'package:lottie/lottie.dart'; |
|
||||
|
|
||||
import '../../../core/theme/app_colors.dart'; |
|
||||
import '../../../core/widgets/select_location_map.dart'; |
|
||||
import '../controllers/meeting_record_controller.dart'; |
|
||||
|
|
||||
class MeetingRecordView extends GetView<MeetingRecordController> { |
|
||||
const MeetingRecordView({super.key}); |
|
||||
|
|
||||
@override |
|
||||
Widget build(BuildContext context) { |
|
||||
final isDarkMode = Theme.of(context).brightness == Brightness.dark; |
|
||||
|
|
||||
return PopScope( |
|
||||
canPop: false, |
|
||||
onPopInvokedWithResult: (didPop, result) { |
|
||||
if (didPop) return; |
|
||||
_showAlertDialog(isDarkMode); |
|
||||
}, |
|
||||
child: Scaffold( |
|
||||
backgroundColor: isDarkMode ? Colors.black : Colors.grey[50], |
|
||||
appBar: _buildAppBar(isDarkMode), |
|
||||
body: _buildBody(isDarkMode), |
|
||||
), |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
// 构建应用栏 |
|
||||
PreferredSizeWidget _buildAppBar(bool isDarkMode) { |
|
||||
return AppBar( |
|
||||
backgroundColor: isDarkMode ? Colors.black : Colors.white, |
|
||||
elevation: 0.5, |
|
||||
leading: IconButton( |
|
||||
icon: Icon( |
|
||||
Icons.arrow_back_ios, |
|
||||
color: isDarkMode ? Colors.white : Colors.black, |
|
||||
size: 20.sp, |
|
||||
), |
|
||||
onPressed: () { |
|
||||
_showAlertDialog(isDarkMode); |
|
||||
}, |
|
||||
), |
|
||||
title: Padding( |
|
||||
padding: const EdgeInsets.only(right: 56), |
|
||||
child: Row( |
|
||||
mainAxisAlignment: MainAxisAlignment.center, |
|
||||
children: [ |
|
||||
Obx( |
|
||||
() => Icon( |
|
||||
Icons.keyboard_voice, |
|
||||
color: isDarkMode |
|
||||
? controller.seconds.value > 0 |
|
||||
? AppColors.primary |
|
||||
: Colors.white |
|
||||
: controller.seconds.value > 0 |
|
||||
? Colors.blue |
|
||||
: Colors.black, |
|
||||
), |
|
||||
), |
|
||||
5.horizontalSpace, |
|
||||
Obx( |
|
||||
() => Text( |
|
||||
DateUtil.formatDateMs( |
|
||||
controller.seconds.value * 1000, |
|
||||
format: 'HH:mm:ss', |
|
||||
isUtc: true, |
|
||||
), |
|
||||
style: TextStyle( |
|
||||
fontSize: 17.sp, |
|
||||
fontWeight: FontWeight.w600, |
|
||||
color: isDarkMode |
|
||||
? controller.seconds.value > 0 |
|
||||
? AppColors.primary |
|
||||
: Colors.white |
|
||||
: controller.seconds.value > 0 |
|
||||
? Colors.blue |
|
||||
: Colors.black, |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
), |
|
||||
centerTitle: true, |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
// 构建主体内容 |
|
||||
Widget _buildBody(bool isDarkMode) { |
|
||||
return Column( |
|
||||
crossAxisAlignment: CrossAxisAlignment.start, |
|
||||
children: [ |
|
||||
10.verticalSpace, |
|
||||
Padding( |
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w), |
|
||||
child: Row( |
|
||||
children: [ |
|
||||
Expanded( |
|
||||
child: Text( |
|
||||
controller.title, |
|
||||
style: TextStyle( |
|
||||
color: isDarkMode ? Colors.white : Colors.black87, |
|
||||
fontSize: 17.sp, |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
10.horizontalSpace, |
|
||||
Text( |
|
||||
DateUtil.formatDateMs( |
|
||||
controller.creationTime, |
|
||||
format: DateFormats.y_mo_d_h_m, |
|
||||
), |
|
||||
style: TextStyle( |
|
||||
color: isDarkMode ? Colors.grey[400] : Colors.grey[500], |
|
||||
fontSize: 14.sp, |
|
||||
), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
), |
|
||||
GestureDetector( |
|
||||
onTap: () { |
|
||||
Get.to(() => const SelectLocationMap()); |
|
||||
}, |
|
||||
child: Padding( |
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 5.w), |
|
||||
child: Row( |
|
||||
children: [ |
|
||||
Icon( |
|
||||
Icons.location_on, |
|
||||
size: 16.w, |
|
||||
color: isDarkMode ? Colors.grey[400] : Colors.grey[500], |
|
||||
), |
|
||||
Expanded( |
|
||||
child: Obx( |
|
||||
() => Text( |
|
||||
controller.address.value, |
|
||||
style: TextStyle( |
|
||||
color: isDarkMode ? Colors.grey[400] : Colors.grey[500], |
|
||||
fontSize: 14.sp, |
|
||||
), |
|
||||
overflow: TextOverflow.ellipsis, |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
_recordContent(isDarkMode), |
|
||||
_bottomButton(isDarkMode), |
|
||||
], |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
// 记录内容 |
|
||||
Widget _recordContent(bool isDarkMode) { |
|
||||
return Expanded( |
|
||||
child: Container( |
|
||||
width: double.infinity, |
|
||||
padding: EdgeInsets.all(10.w), |
|
||||
margin: EdgeInsets.symmetric(horizontal: 12.w, vertical: 5.w), |
|
||||
decoration: BoxDecoration( |
|
||||
color: isDarkMode ? Colors.white.withOpacity(0.05) : Colors.white, |
|
||||
borderRadius: BorderRadius.circular(12.w), |
|
||||
boxShadow: [ |
|
||||
BoxShadow( |
|
||||
color: isDarkMode |
|
||||
? Colors.black.withOpacity(0.2) |
|
||||
: Colors.black.withOpacity(0.05), |
|
||||
blurRadius: 10, |
|
||||
offset: const Offset(0, 2), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
child: Align( |
|
||||
alignment: Alignment.topLeft, |
|
||||
child: SingleChildScrollView( |
|
||||
reverse: true, |
|
||||
child: Obx( |
|
||||
() => Text( |
|
||||
'${controller.finalContent.value}${controller.intermediateContent.value}', |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
// 底部按钮 |
|
||||
Widget _bottomButton(bool isDarkMode) { |
|
||||
return Container( |
|
||||
margin: EdgeInsets.only(top: 10.h), |
|
||||
decoration: BoxDecoration( |
|
||||
color: isDarkMode ? AppColors.cardBackground : Colors.white, |
|
||||
boxShadow: [ |
|
||||
BoxShadow( |
|
||||
color: Colors.black.withOpacity(0.05), |
|
||||
blurRadius: 3, |
|
||||
offset: const Offset(0, -1), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
child: SafeArea( |
|
||||
child: SizedBox( |
|
||||
height: 80.h, |
|
||||
child: Padding( |
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w), |
|
||||
child: Row( |
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
|
||||
crossAxisAlignment: CrossAxisAlignment.center, |
|
||||
children: [ |
|
||||
// 语音录制按钮 |
|
||||
Container( |
|
||||
width: 60.r, |
|
||||
height: 60.r, |
|
||||
decoration: BoxDecoration( |
|
||||
color: isDarkMode |
|
||||
? Colors.white.withOpacity(0.1) |
|
||||
: Colors.white, |
|
||||
shape: BoxShape.circle, |
|
||||
boxShadow: [ |
|
||||
BoxShadow( |
|
||||
color: Colors.black.withOpacity(0.1), |
|
||||
blurRadius: 8, |
|
||||
spreadRadius: 1, |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
child: Center( |
|
||||
child: Obx( |
|
||||
() => IconButton( |
|
||||
icon: Icon( |
|
||||
controller.isRecording.value |
|
||||
? Icons.mic |
|
||||
: Icons.mic_none, |
|
||||
), |
|
||||
color: controller.isRecording.value |
|
||||
? AppColors.primary |
|
||||
: Colors.grey.shade400, |
|
||||
iconSize: 28.r, |
|
||||
padding: EdgeInsets.zero, |
|
||||
onPressed: controller.recorderHandle, |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
10.horizontalSpace, |
|
||||
Expanded( |
|
||||
child: Lottie.asset( |
|
||||
'assets/lottie/audio_wave.json', |
|
||||
controller: controller.lottieController, |
|
||||
fit: BoxFit.fill, |
|
||||
onLoaded: (composition) { |
|
||||
controller.lottieController.duration = |
|
||||
composition.duration; |
|
||||
}, |
|
||||
), |
|
||||
), |
|
||||
Expanded( |
|
||||
child: Lottie.asset( |
|
||||
'assets/lottie/audio_wave.json', |
|
||||
controller: controller.lottieController, |
|
||||
fit: BoxFit.fill, |
|
||||
onLoaded: (composition) { |
|
||||
controller.lottieController.duration = |
|
||||
composition.duration; |
|
||||
}, |
|
||||
), |
|
||||
), |
|
||||
10.horizontalSpace, |
|
||||
// 关闭按钮 |
|
||||
Container( |
|
||||
width: 60.r, |
|
||||
height: 60.r, |
|
||||
decoration: BoxDecoration( |
|
||||
color: isDarkMode |
|
||||
? Colors.white.withOpacity(0.1) |
|
||||
: Colors.white, |
|
||||
shape: BoxShape.circle, |
|
||||
boxShadow: [ |
|
||||
BoxShadow( |
|
||||
color: Colors.black.withOpacity(0.1), |
|
||||
blurRadius: 8, |
|
||||
spreadRadius: 1, |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
child: Center( |
|
||||
child: IconButton( |
|
||||
icon: Icon( |
|
||||
Icons.close, |
|
||||
color: isDarkMode ? Colors.white : Colors.grey.shade600, |
|
||||
size: 28.r, |
|
||||
), |
|
||||
padding: EdgeInsets.zero, |
|
||||
onPressed: () { |
|
||||
_showAlertDialog(isDarkMode); |
|
||||
}, |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
// 显示对话框 |
|
||||
void _showAlertDialog(bool isDarkMode) { |
|
||||
if (controller.seconds.value > 0) { |
|
||||
Get.dialog( |
|
||||
AlertDialog( |
|
||||
backgroundColor: isDarkMode ? Colors.grey[900] : Colors.white, |
|
||||
title: Text( |
|
||||
'注意', |
|
||||
style: TextStyle( |
|
||||
color: isDarkMode ? Colors.white : Colors.black87, |
|
||||
), |
|
||||
), |
|
||||
content: Text( |
|
||||
'你想结束录音吗?', |
|
||||
style: TextStyle( |
|
||||
color: isDarkMode ? Colors.white70 : Colors.black54, |
|
||||
), |
|
||||
), |
|
||||
actions: [ |
|
||||
TextButton( |
|
||||
onPressed: () => Get.back(), |
|
||||
child: Text( |
|
||||
'cancel'.tr, // 取消 |
|
||||
style: TextStyle( |
|
||||
color: isDarkMode ? Colors.white70 : Colors.black54, |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
TextButton( |
|
||||
onPressed: () { |
|
||||
Get.back(); |
|
||||
controller.complete(); |
|
||||
}, |
|
||||
child: Text( |
|
||||
'confirm'.tr, // 确定 |
|
||||
style: TextStyle( |
|
||||
color: isDarkMode ? Colors.red[300] : Colors.red[600], |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
); |
|
||||
} else { |
|
||||
Get.back(); |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
@ -0,0 +1,12 @@ |
|||||
|
import 'package:flutter/material.dart'; |
||||
|
|
||||
|
class MindMapTab extends StatelessWidget { |
||||
|
const MindMapTab({super.key}); |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
return SingleChildScrollView( |
||||
|
child: Text(''), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,12 @@ |
|||||
|
import 'package:flutter/material.dart'; |
||||
|
|
||||
|
class OverviewTab extends StatelessWidget { |
||||
|
const OverviewTab({super.key}); |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
return SingleChildScrollView( |
||||
|
child: Text(''), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,202 @@ |
|||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
|
||||
|
class SpeakerBottomSheet extends StatelessWidget { |
||||
|
const SpeakerBottomSheet({super.key}); |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
return Padding( |
||||
|
padding: EdgeInsets.symmetric(horizontal: 12.w), |
||||
|
child: Column( |
||||
|
mainAxisSize: MainAxisSize.min, |
||||
|
children: [ |
||||
|
Container( |
||||
|
padding: EdgeInsets.symmetric(vertical: 10.w), |
||||
|
child: Row( |
||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
||||
|
children: [ |
||||
|
Text( |
||||
|
'命名当前发言者', |
||||
|
style: TextStyle( |
||||
|
fontSize: 16.sp, |
||||
|
color: Colors.black87, |
||||
|
fontWeight: FontWeight.bold, |
||||
|
), |
||||
|
), |
||||
|
GestureDetector( |
||||
|
onTap: () => Get.back(), |
||||
|
child: Container( |
||||
|
width: 26.w, |
||||
|
height: 26.w, |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.white, |
||||
|
borderRadius: BorderRadius.circular(26.r), |
||||
|
), |
||||
|
child: Icon( |
||||
|
Icons.close, |
||||
|
size: 18.w, |
||||
|
color: Colors.grey[400], |
||||
|
), |
||||
|
), |
||||
|
) |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
Divider( |
||||
|
height: 1, |
||||
|
color: Colors.grey[300], |
||||
|
), |
||||
|
Container( |
||||
|
height: 46.w, |
||||
|
margin: EdgeInsets.symmetric(vertical: 15.w), |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.white, |
||||
|
borderRadius: BorderRadius.circular(12.r), |
||||
|
), |
||||
|
), |
||||
|
Container( |
||||
|
constraints: BoxConstraints(maxHeight: 1.sh - 390.w), |
||||
|
child: ListView.builder( |
||||
|
itemCount: 30, |
||||
|
shrinkWrap: true, |
||||
|
itemBuilder: (BuildContext context, int index) { |
||||
|
return Container( |
||||
|
padding: EdgeInsets.only(left: 10.w), |
||||
|
decoration: BoxDecoration( |
||||
|
border: Border( |
||||
|
bottom: BorderSide( |
||||
|
width: 1, |
||||
|
color: Colors.grey[300]!, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
if (index == 0) |
||||
|
Text( |
||||
|
'最近使用过的名称', |
||||
|
style: TextStyle( |
||||
|
fontSize: 12.sp, |
||||
|
color: Colors.grey[600]!, |
||||
|
), |
||||
|
), |
||||
|
SizedBox( |
||||
|
height: 42.w, |
||||
|
child: Align( |
||||
|
alignment: Alignment.centerLeft, |
||||
|
child: Text( |
||||
|
'龙哥', |
||||
|
style: TextStyle( |
||||
|
fontSize: 14.sp, |
||||
|
color: Colors.black87, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
}, |
||||
|
), |
||||
|
), |
||||
|
_row('应用到当前段落', false), |
||||
|
_row('应用到这个发言者的所有段落', true, description: '总结将根据模版自动更新'), |
||||
|
Padding( |
||||
|
padding: EdgeInsets.only(top: 10.w, bottom: 20.w), |
||||
|
child: GestureDetector( |
||||
|
onTap: () {}, |
||||
|
child: Container( |
||||
|
height: 48.w, |
||||
|
alignment: Alignment.center, |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.black, |
||||
|
borderRadius: BorderRadius.circular(12.r), |
||||
|
), |
||||
|
child: Text( |
||||
|
'保存', |
||||
|
style: TextStyle( |
||||
|
fontSize: 16.sp, |
||||
|
color: Colors.white, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _row(String name, bool isActive, {String? description}) { |
||||
|
return Padding( |
||||
|
padding: EdgeInsets.symmetric(vertical: 10.w), |
||||
|
child: Row( |
||||
|
children: [ |
||||
|
isActive ? _round() : _circle(), |
||||
|
Expanded( |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Text( |
||||
|
name, |
||||
|
style: TextStyle( |
||||
|
fontSize: 14.sp, |
||||
|
color: Colors.black87, |
||||
|
), |
||||
|
), |
||||
|
if (description != null) |
||||
|
Text( |
||||
|
description, |
||||
|
style: TextStyle( |
||||
|
fontSize: 10.sp, |
||||
|
color: Colors.grey[500]!, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _circle() { |
||||
|
return Container( |
||||
|
width: 18.w, |
||||
|
height: 18.w, |
||||
|
margin: EdgeInsets.only(right: 6.w), |
||||
|
decoration: BoxDecoration( |
||||
|
borderRadius: BorderRadius.circular(18.r), |
||||
|
border: Border.all( |
||||
|
width: 1.5, |
||||
|
color: Colors.grey[300]!, |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _round() { |
||||
|
return Container( |
||||
|
width: 18.w, |
||||
|
height: 18.w, |
||||
|
padding: EdgeInsets.all(2.w), |
||||
|
margin: EdgeInsets.only(right: 6.w), |
||||
|
decoration: BoxDecoration( |
||||
|
borderRadius: BorderRadius.circular(18.r), |
||||
|
border: Border.all( |
||||
|
width: 1.5, |
||||
|
color: Colors.black87, |
||||
|
), |
||||
|
), |
||||
|
child: Container( |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.black87, |
||||
|
borderRadius: BorderRadius.circular(18.r), |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,256 @@ |
|||||
|
import 'package:common_utils/common_utils.dart'; |
||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
|
||||
|
import '../controllers/meeting_details_controller.dart'; |
||||
|
import 'speaker_bottom_sheet.dart'; |
||||
|
|
||||
|
class SpeechTab extends GetView<MeetingDetailsController> { |
||||
|
const SpeechTab({super.key}); |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
return Stack( |
||||
|
children: [ |
||||
|
Obx( |
||||
|
() => ListView.builder( |
||||
|
itemCount: controller.textList.length, |
||||
|
padding: EdgeInsets.only(bottom: 80.w), |
||||
|
itemBuilder: (BuildContext context, int index) { |
||||
|
Map item = controller.textList[index]; |
||||
|
TextStyle textStyle = TextStyle( |
||||
|
fontSize: 16.sp, |
||||
|
color: Colors.black87, |
||||
|
letterSpacing: 0, |
||||
|
height: 1.4, |
||||
|
); |
||||
|
return Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Padding( |
||||
|
padding: EdgeInsets.only(top: 10.w), |
||||
|
child: Row( |
||||
|
children: [ |
||||
|
if (item['speaker'].isNotEmpty) |
||||
|
Container( |
||||
|
width: 12.w, |
||||
|
height: 12.w, |
||||
|
margin: EdgeInsets.only(right: 2.w), |
||||
|
decoration: BoxDecoration( |
||||
|
borderRadius: BorderRadius.circular(28.r), |
||||
|
border: Border.all( |
||||
|
color: const Color(0xFFFFC107), |
||||
|
width: 3.w, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
if (item['speaker'].isNotEmpty) |
||||
|
GestureDetector( |
||||
|
onTap: () { |
||||
|
if (controller.isSpeakerText.value) return; |
||||
|
showModalBottomSheet( |
||||
|
context: context, |
||||
|
isScrollControlled: true, |
||||
|
backgroundColor: Colors.grey[100], |
||||
|
shape: RoundedRectangleBorder( |
||||
|
borderRadius: BorderRadius.vertical( |
||||
|
top: Radius.circular(12.r), |
||||
|
), |
||||
|
), |
||||
|
builder: (BuildContext context) { |
||||
|
return const SpeakerBottomSheet(); |
||||
|
}, |
||||
|
); |
||||
|
}, |
||||
|
child: Padding( |
||||
|
padding: EdgeInsets.only(right: 5.w), |
||||
|
child: Text( |
||||
|
item['speaker'], |
||||
|
style: TextStyle( |
||||
|
color: const Color(0xFFFFC107), |
||||
|
fontSize: 14.sp, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
Text( |
||||
|
DateUtil.formatDateMs( |
||||
|
item['starttime'], |
||||
|
format: 'HH:mm:ss', |
||||
|
isUtc: true, |
||||
|
), |
||||
|
style: TextStyle( |
||||
|
color: Colors.grey, |
||||
|
fontSize: 14.sp, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
Obx(() { |
||||
|
if (controller.isSpeakerText.value) { |
||||
|
TextEditingController speakerTextController = |
||||
|
TextEditingController(text: item['content']); |
||||
|
return Obx( |
||||
|
() => Container( |
||||
|
padding: EdgeInsets.symmetric( |
||||
|
horizontal: 14.w, |
||||
|
vertical: 5.w, |
||||
|
), |
||||
|
decoration: BoxDecoration( |
||||
|
color: |
||||
|
controller.editSpeakerTextId.value == item['id'] |
||||
|
? Colors.blue[100] |
||||
|
: null, |
||||
|
borderRadius: BorderRadius.circular(8.w), |
||||
|
), |
||||
|
child: FocusScope( |
||||
|
onFocusChange: (bool hasFocus) { |
||||
|
if (hasFocus) { |
||||
|
controller.editSpeakerTextId.value = item['id']; |
||||
|
controller.editSpeakerTextChange = ''; |
||||
|
} else { |
||||
|
if (speakerTextController.text != |
||||
|
item['content']) { |
||||
|
Map? foundElement = |
||||
|
controller.editSpeakerTextList.firstWhere( |
||||
|
(element) => element['id'] == item['id'], |
||||
|
orElse: () => null, |
||||
|
); |
||||
|
if (foundElement != null) { |
||||
|
foundElement['content'] = |
||||
|
speakerTextController.text; |
||||
|
} else { |
||||
|
controller.editSpeakerTextList.add({ |
||||
|
'id': item['id'], |
||||
|
'content': speakerTextController.text, |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
}, |
||||
|
child: TextField( |
||||
|
controller: speakerTextController, |
||||
|
style: textStyle, |
||||
|
minLines: 1, |
||||
|
maxLines: 100, |
||||
|
decoration: const InputDecoration( |
||||
|
isDense: true, |
||||
|
fillColor: Colors.transparent, |
||||
|
border: InputBorder.none, |
||||
|
contentPadding: EdgeInsets.zero, |
||||
|
), |
||||
|
onChanged: (String value) { |
||||
|
controller.editSpeakerTextChange = value; |
||||
|
}, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
} else { |
||||
|
return Container( |
||||
|
padding: EdgeInsets.symmetric( |
||||
|
horizontal: 14.w, |
||||
|
vertical: 5.w, |
||||
|
), |
||||
|
child: Text( |
||||
|
item['content'], |
||||
|
style: textStyle, |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
}), |
||||
|
], |
||||
|
); |
||||
|
}, |
||||
|
), |
||||
|
), |
||||
|
Obx( |
||||
|
() => Positioned( |
||||
|
bottom: 20.w, |
||||
|
left: 0, |
||||
|
right: 0, |
||||
|
child: Row( |
||||
|
mainAxisAlignment: MainAxisAlignment.center, |
||||
|
children: [ |
||||
|
if (controller.meetingDetails.value.tasktype == 1 && |
||||
|
!controller.isSpeakerText.value) |
||||
|
_button( |
||||
|
Icons.edit, |
||||
|
'编辑', |
||||
|
() { |
||||
|
controller.isSpeakerText.value = true; |
||||
|
}, |
||||
|
), |
||||
|
if (controller.meetingDetails.value.tasktype == 1 && |
||||
|
!controller.isSpeakerText.value) |
||||
|
_button( |
||||
|
Icons.supervisor_account, |
||||
|
'命名发言者', |
||||
|
() { |
||||
|
showModalBottomSheet( |
||||
|
context: context, |
||||
|
isScrollControlled: true, |
||||
|
backgroundColor: Colors.grey[100], |
||||
|
shape: RoundedRectangleBorder( |
||||
|
borderRadius: BorderRadius.vertical( |
||||
|
top: Radius.circular(12.r), |
||||
|
), |
||||
|
), |
||||
|
builder: (BuildContext context) { |
||||
|
return const SpeakerBottomSheet(); |
||||
|
}, |
||||
|
); |
||||
|
}, |
||||
|
), |
||||
|
if (controller.meetingDetails.value.tasktype != 1) |
||||
|
SizedBox( |
||||
|
width: 150.w, |
||||
|
child: _button( |
||||
|
Icons.auto_awesome, |
||||
|
'生成', |
||||
|
controller.speakerCreate, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _button(IconData icon, String title, Function() onTap) { |
||||
|
return GestureDetector( |
||||
|
onTap: onTap, |
||||
|
child: Container( |
||||
|
height: 42.w, |
||||
|
padding: EdgeInsets.symmetric(horizontal: 15.w), |
||||
|
margin: EdgeInsets.symmetric(horizontal: 4.w), |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.black, |
||||
|
borderRadius: BorderRadius.circular(42.w), |
||||
|
), |
||||
|
child: Row( |
||||
|
mainAxisAlignment: MainAxisAlignment.center, |
||||
|
children: [ |
||||
|
Icon( |
||||
|
icon, |
||||
|
color: Colors.white, |
||||
|
size: 20.sp, |
||||
|
), |
||||
|
5.horizontalSpace, |
||||
|
Text( |
||||
|
title, |
||||
|
style: TextStyle( |
||||
|
color: Colors.white, |
||||
|
fontSize: 14.sp, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,226 @@ |
|||||
|
import 'package:common_utils/common_utils.dart'; |
||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:flutter_markdown/flutter_markdown.dart'; |
||||
|
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
|
||||
|
import '../controllers/meeting_details_controller.dart'; |
||||
|
import 'speaker_bottom_sheet.dart'; |
||||
|
|
||||
|
class SummaryTab extends GetView<MeetingDetailsController> { |
||||
|
const SummaryTab({super.key}); |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
final isDarkMode = Theme.of(context).brightness == Brightness.dark; |
||||
|
|
||||
|
return Stack( |
||||
|
children: [ |
||||
|
SingleChildScrollView( |
||||
|
padding: EdgeInsets.only(bottom: 80.w), |
||||
|
child: Obx( |
||||
|
() => Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Padding( |
||||
|
padding: EdgeInsets.symmetric(vertical: 10.w), |
||||
|
child: Text( |
||||
|
controller.meetingData.value.title, |
||||
|
style: TextStyle( |
||||
|
fontSize: 20.sp, |
||||
|
fontWeight: FontWeight.bold, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
Row( |
||||
|
children: [ |
||||
|
Container( |
||||
|
padding: |
||||
|
EdgeInsets.symmetric(horizontal: 5.w, vertical: 2.w), |
||||
|
margin: EdgeInsets.only(right: 10.w), |
||||
|
decoration: BoxDecoration( |
||||
|
color: isDarkMode ? Colors.grey[900] : Colors.grey[200], |
||||
|
borderRadius: BorderRadius.circular(4.w), |
||||
|
), |
||||
|
child: Text( |
||||
|
controller.meetingData.value.type, |
||||
|
style: TextStyle( |
||||
|
color: |
||||
|
isDarkMode ? Colors.grey[400] : Colors.grey[500], |
||||
|
fontSize: 10.sp, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
Icon( |
||||
|
Icons.calendar_month, |
||||
|
size: 14.w, |
||||
|
color: isDarkMode ? Colors.grey[400] : Colors.grey[500], |
||||
|
), |
||||
|
2.horizontalSpace, |
||||
|
Text( |
||||
|
DateUtil.formatDateMs( |
||||
|
controller.meetingData.value.creationtime, |
||||
|
format: DateFormats.y_mo_d_h_m, |
||||
|
), |
||||
|
style: TextStyle( |
||||
|
color: isDarkMode ? Colors.grey[400] : Colors.grey[500], |
||||
|
fontSize: 12.sp, |
||||
|
), |
||||
|
), |
||||
|
10.horizontalSpace, |
||||
|
Icon( |
||||
|
Icons.access_time, |
||||
|
size: 14.w, |
||||
|
color: isDarkMode ? Colors.grey[400] : Colors.grey[500], |
||||
|
), |
||||
|
2.horizontalSpace, |
||||
|
Text( |
||||
|
'${controller.meetingData.value.seconds}s', |
||||
|
style: TextStyle( |
||||
|
color: isDarkMode ? Colors.grey[400] : Colors.grey[500], |
||||
|
fontSize: 12.sp, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
Padding( |
||||
|
padding: EdgeInsets.symmetric(vertical: 10.w), |
||||
|
child: Divider( |
||||
|
height: 1, |
||||
|
color: isDarkMode ? Colors.grey[900] : Colors.grey[300], |
||||
|
), |
||||
|
), |
||||
|
Container( |
||||
|
padding: EdgeInsets.only(left: 20.w), |
||||
|
margin: EdgeInsets.symmetric(vertical: 10.w), |
||||
|
decoration: BoxDecoration( |
||||
|
border: Border( |
||||
|
left: BorderSide( |
||||
|
width: 2, |
||||
|
color: isDarkMode |
||||
|
? Colors.white.withOpacity(0.1) |
||||
|
: Colors.grey[300]!, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Text( |
||||
|
'日期和时间:${DateUtil.formatDateMs( |
||||
|
controller.meetingData.value.creationtime, |
||||
|
format: DateFormats.y_mo_d_h_m, |
||||
|
)}', |
||||
|
style: TextStyle( |
||||
|
color: |
||||
|
isDarkMode ? Colors.grey[400] : Colors.grey[700], |
||||
|
fontSize: 16.sp, |
||||
|
), |
||||
|
), |
||||
|
Text( |
||||
|
'地点:${controller.meetingDetails.value.address.isNotEmpty ? controller.meetingDetails.value.address : '[输入地点]'}', |
||||
|
style: TextStyle( |
||||
|
color: |
||||
|
isDarkMode ? Colors.grey[400] : Colors.grey[700], |
||||
|
fontSize: 16.sp, |
||||
|
), |
||||
|
), |
||||
|
Text( |
||||
|
'与会人员:${controller.meetingDetails.value.address}', |
||||
|
style: TextStyle( |
||||
|
color: |
||||
|
isDarkMode ? Colors.grey[400] : Colors.grey[700], |
||||
|
fontSize: 16.sp, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
Padding( |
||||
|
padding: EdgeInsets.only(top: 10.w), |
||||
|
child: MarkdownBody( |
||||
|
data: controller.meetingDetails.value.summary, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
Obx( |
||||
|
() => Positioned( |
||||
|
bottom: 20.w, |
||||
|
left: 0, |
||||
|
right: 0, |
||||
|
child: Row( |
||||
|
mainAxisAlignment: MainAxisAlignment.center, |
||||
|
children: [ |
||||
|
if (controller.meetingDetails.value.summary.isNotEmpty) |
||||
|
_button( |
||||
|
Icons.supervisor_account, |
||||
|
'编辑发言者', |
||||
|
() { |
||||
|
showModalBottomSheet( |
||||
|
context: context, |
||||
|
isScrollControlled: true, |
||||
|
backgroundColor: Colors.grey[100], |
||||
|
shape: RoundedRectangleBorder( |
||||
|
borderRadius: BorderRadius.vertical( |
||||
|
top: Radius.circular(12.r), |
||||
|
), |
||||
|
), |
||||
|
builder: (BuildContext context) { |
||||
|
return const SpeakerBottomSheet(); |
||||
|
}, |
||||
|
); |
||||
|
}, |
||||
|
), |
||||
|
if (controller.meetingDetails.value.summary.isEmpty) |
||||
|
SizedBox( |
||||
|
width: 150.w, |
||||
|
child: _button( |
||||
|
Icons.auto_awesome, |
||||
|
'生成', |
||||
|
controller.summaryCreate, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _button(IconData icon, String title, Function() onTap) { |
||||
|
return GestureDetector( |
||||
|
onTap: onTap, |
||||
|
child: Container( |
||||
|
height: 42.w, |
||||
|
padding: EdgeInsets.symmetric(horizontal: 15.w), |
||||
|
margin: EdgeInsets.symmetric(horizontal: 4.w), |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.black, |
||||
|
borderRadius: BorderRadius.circular(42.w), |
||||
|
), |
||||
|
child: Row( |
||||
|
mainAxisAlignment: MainAxisAlignment.center, |
||||
|
children: [ |
||||
|
Icon( |
||||
|
icon, |
||||
|
color: Colors.white, |
||||
|
size: 20.sp, |
||||
|
), |
||||
|
5.horizontalSpace, |
||||
|
Text( |
||||
|
title, |
||||
|
style: TextStyle( |
||||
|
color: Colors.white, |
||||
|
fontSize: 14.sp, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue