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_easyloading/flutter_easyloading.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/open_ai_service_adapter.dart'; |
|||
import '../model/meeting_details_model.dart'; |
|||
import '../model/meeting_model.dart'; |
|||
|
|||
class MeetingDetailsController extends GetxController |
|||
with GetSingleTickerProviderStateMixin { |
|||
late AnimationController lottieController; |
|||
final AudioPlayer audioPlayer = AudioPlayer(); |
|||
late ScrollController scrollController; |
|||
late TabController tabController; |
|||
final RxInt currentIndex = 0.obs; |
|||
|
|||
final PlayerController playerController = PlayerController(); |
|||
RxBool isTop = false.obs; |
|||
RxInt durationMax = 0.obs; |
|||
|
|||
int id = 0; |
|||
|
|||
RxBool isLoading = true.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 |
|||
void 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 ?? {}; |
|||
id = args['id']; |
|||
_headers = { |
|||
'Authorization': 'Bearer; $_token', |
|||
'Content-Type': 'application/json', |
|||
}; |
|||
_getAgent(); |
|||
_getDataDetails(); |
|||
} |
|||
|
|||
@override |
|||
void onClose() { |
|||
lottieController.dispose(); |
|||
audioPlayer.dispose(); |
|||
scrollController.dispose(); |
|||
tabController.dispose(); |
|||
playerController.dispose(); |
|||
_aiResponseSubscription?.cancel(); |
|||
_isDisposed = true; |
|||
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 { |
|||
final data = await SqfliteApi.getMeetingDetails(id); |
|||
meetingData.value = MeetingModel.fromJson(data); |
|||
dataDetails.value = MeetingDetailsModel.fromJson(data['details']); |
|||
audioPlayer.setAudioSource(AudioSource.file(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; |
|||
meetingDetails.value = MeetingDetailsModel.fromJson(data['details']); |
|||
await playerController.preparePlayer( |
|||
path: meetingData.value.filepath, |
|||
noOfSamples: 1200, |
|||
); |
|||
durationMax.value = await playerController.getDuration(DurationType.max); |
|||
playerController.setFinishMode(finishMode: FinishMode.pause); |
|||
if (meetingDetails.value.tasktype == 1) { |
|||
List speakerList = await SqfliteApi.getMeetingSpeaker(id); |
|||
textList.value = List.from(speakerList); |
|||
} |
|||
}); |
|||
isLoading.value = false; |
|||
} |
|||
|
|||
void start() async { |
|||
if (isPlay.value) { |
|||
lottieController.stop(); |
|||
audioPlayer.pause(); |
|||
isPlay.value = false; |
|||
void editSpeakerText() async { |
|||
if (editSpeakerTextId.value != 0 && editSpeakerTextChange.isNotEmpty) { |
|||
Map? foundElement = editSpeakerTextList.firstWhere( |
|||
(element) => element['id'] == editSpeakerTextId.value, |
|||
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 { |
|||
lottieController.repeat(); |
|||
audioPlayer.play(); |
|||
isPlay.value = true; |
|||
isSpeakerText.value = false; |
|||
editSpeakerTextId.value = 0; |
|||
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