import 'dart:async'; import 'package:common_utils/common_utils.dart'; import 'package:fleather/fleather.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; import 'package:parchment/codecs.dart'; import '../../../../data/services/db/sqflite_api.dart'; import '../../../../data/services/network/api.dart'; import '../../controllers/meeting_details_controller.dart'; import '../../utils/meeting_ui_utils.dart'; import '../bottomSheet/ai_bottom_sheet.dart'; import '../bottomSheet/generate_bottom_sheet.dart'; class SummaryTab extends StatefulWidget { const SummaryTab({super.key}); @override State createState() => _SummaryTabState(); } class _SummaryTabState extends State { final _controller = Get.find(); late final Worker _personnelListener; late final Worker _summaryListener; /// 上一次装进编辑器的总结原文;服务端刷新回来的内容和它一样就不重建编辑器。 String _lastLoadedSummary = ''; final String _addressPrefix = '${'location'.tr}:'; // 对应中文:地点 String _addressText = ''; final String _personnelPrefix = '${'attendees'.tr}:'; // 对应中文:与会人员 String _personnelText = ''; final TextEditingController _addressController = TextEditingController(); final TextEditingController _personnelController = TextEditingController(); final FocusNode _focusNode = FocusNode(); final GlobalKey _editorKey = GlobalKey(); FleatherController? _fleatherController; bool _hasFocus = false; @override void initState() { super.initState(); _initController(); _addressController.addListener(_handleAddressChanged); _personnelController.addListener(_handlePersonnelChanged); _focusNode.addListener(() { setState(() { _hasFocus = _focusNode.hasFocus; }); }); _personnelListener = ever(_controller.personnelNumberModifications, (value) { _initController(); }); // 总结内容变了(服务端刷新 / 总结重跑 / 别的设备改过)就重装编辑器。 // 原来只听与会人员那个信号,summaryNumberModifications 全项目只有思维导图在听, // 于是第一次进详情、服务端把总结拉回来之后,本页还是空的(2026-09-20 修)。 // 用户正在编辑(_summaryDirty)时不动它,别把没保存的改动冲掉。 _summaryListener = ever(_controller.summaryNumberModifications, (value) { if (_summaryDirty) return; if (_controller.meetingDetails.value.summary == _lastLoadedSummary) return; _initController(); }); } void _initController() { _addressController.text = _addressPrefix + (_controller.meetingDetails.value.address.isNotEmpty ? _controller.meetingDetails.value.address : '[${'inputLocation'.tr}]'); // 对应中文:输入地点 _personnelController.text = _personnelPrefix + (_controller.meetingDetails.value.personnel.isNotEmpty ? _controller.meetingDetails.value.personnel : '[${'inputAttendees'.tr}]'); // 对应中文:输入与会人员 _lastLoadedSummary = _controller.meetingDetails.value.summary; final markdown = const ParchmentMarkdownCodec() .decode(_controller.meetingDetails.value.summary); final fc = FleatherController(document: markdown); // 只有用户真的动过编辑器才允许回写。理由见 _saveSummary 的注释。 _summaryDirty = false; _summaryChangeSub?.cancel(); _summaryChangeSub = fc.document.changes.listen((_) => _summaryDirty = true); setState(() { _fleatherController = fc; }); } /// 用户是否真的编辑过纪要正文。 /// /// ⚠️ 不能只靠「编码结果和原文不一样」来判断:**空文档编码出来是 `"\n\n"`**, /// 与空字符串永远不相等,于是只要进过一次详情页、退出时就会把服务端的纪要 /// 覆盖成 `"\n\n"`。2026-09-06 真机丢过一次总结就是这么来的: /// 客户端轮询死了没拿到总结 → 详情页里是空的 → 用户退出 → dispose 回写空值。 bool _summaryDirty = false; StreamSubscription? _summaryChangeSub; void _handleAddressChanged() { String currentText = _addressController.text; TextSelection currentSelection = _addressController.selection; if (!currentText.startsWith(_addressPrefix)) { _addressController.value = TextEditingValue( text: _addressPrefix + _addressText, selection: TextSelection.collapsed( offset: _addressPrefix.length + _addressText.length, ), ); return; } else { _addressText = currentText.substring(_addressPrefix.length); } if (currentSelection.start < _addressPrefix.length || currentSelection.end < _addressPrefix.length) { int newStart = currentSelection.start < _addressPrefix.length ? _addressPrefix.length : currentSelection.start; int newEnd = currentSelection.end < _addressPrefix.length ? _addressPrefix.length : currentSelection.end; _addressController.selection = currentSelection.copyWith( baseOffset: newStart, extentOffset: newEnd, ); } } void _handlePersonnelChanged() { String currentText = _personnelController.text; TextSelection currentSelection = _personnelController.selection; if (!currentText.startsWith(_personnelPrefix)) { _personnelController.value = TextEditingValue( text: _personnelPrefix + _personnelText, selection: TextSelection.collapsed( offset: _personnelPrefix.length + _personnelText.length, ), ); return; } else { _personnelText = currentText.substring(_personnelPrefix.length); } if (currentSelection.start < _personnelPrefix.length || currentSelection.end < _personnelPrefix.length) { int newStart = currentSelection.start < _personnelPrefix.length ? _personnelPrefix.length : currentSelection.start; int newEnd = currentSelection.end < _personnelPrefix.length ? _personnelPrefix.length : currentSelection.end; _personnelController.selection = currentSelection.copyWith( baseOffset: newStart, extentOffset: newEnd, ); } } void _startEditSummary() { final fc = _fleatherController; if (fc == null) return; final end = fc.document.length - 1; fc.updateSelection( TextSelection.collapsed(offset: end < 0 ? 0 : end), source: ChangeSource.local, ); _focusNode.requestFocus(); } void _saveSummary() async { if (_fleatherController == null) return; final markdown = const ParchmentMarkdownCodec().encode(_fleatherController!.document); final current = _controller.meetingDetails.value.summary; // 三道闸,缺一不可: // 1. 用户没动过编辑器就绝不回写——dispose 是无条件调用的,页面只是被看了 // 一眼也会走到这里; // 2. 两边都是空白(含只有换行)时视为没变化——空文档编码是 "\n\n", // 直接和 "" 比永远不等; // 3. 比较前 trim,避免尾部空行造成的假变化。 if (!_summaryDirty) return; if (markdown.trim().isEmpty && current.trim().isEmpty) return; if (markdown.trim() == current.trim()) return; // 先落本地再等服务端确认:详情页每次进都会用服务端整条覆盖本地, // 这里不 await 的话「改完立刻退出再进」会拿到旧值把刚改的冲掉。 SqfliteApi.editMeeting( _controller.id, {'summary': markdown}, ); await Api.modifyRecordEchomeet({ 'id': _controller.id, 'summary': markdown, }); _controller.meetingDetails.value.summary = markdown; _controller.summaryNumberModifications.value++; _summaryDirty = false; } @override void dispose() { _personnelListener.dispose(); _summaryListener.dispose(); _saveSummary(); _summaryChangeSub?.cancel(); _fleatherController?.dispose(); _focusNode.dispose(); _addressController.dispose(); _personnelController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final isDarkMode = Theme.of(context).brightness == Brightness.dark; final TextStyle textStyle = TextStyle( color: isDarkMode ? Colors.grey[400] : Colors.grey[700], fontSize: 16.sp, letterSpacing: 0, height: 1.5, ); return Stack( clipBehavior: Clip.none, 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( _formatDateSeconds(_controller.meetingData.value.seconds), 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.withValues(alpha: 0.1) : Colors.grey[300]!, ), ), ), child: Focus( onFocusChange: (bool hasFocus) { if (!hasFocus) { bool isEdit = false; if (_addressText.isNotEmpty && _addressText != _controller.meetingDetails.value.address) { _controller.meetingDetails.value.address = _addressText; isEdit = true; } if (_personnelText.isNotEmpty && _personnelText != _controller.meetingDetails.value.personnel) { _controller.meetingDetails.value.personnel = _personnelText; isEdit = true; } if (isEdit) { Api.modifyRecordEchomeet({ 'id': _controller.id, 'address': _controller.meetingDetails.value.address, 'personnel': _controller.meetingDetails.value.personnel, }); SqfliteApi.editMeeting( _controller.id, { 'address': _controller.meetingDetails.value.address, 'personnel': _controller.meetingDetails.value.personnel, }, ); } } }, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '${'dateAndTime'.tr}:${DateUtil.formatDateMs( _controller.meetingData.value.creationtime, format: DateFormats.y_mo_d_h_m, )}', style: textStyle, ), TextField( controller: _addressController, style: textStyle, minLines: 1, maxLines: 100, decoration: const InputDecoration( isDense: true, fillColor: Colors.transparent, border: InputBorder.none, contentPadding: EdgeInsets.zero, ), ), TextField( controller: _personnelController, style: textStyle, minLines: 1, maxLines: 100, decoration: const InputDecoration( isDense: true, fillColor: Colors.transparent, border: InputBorder.none, contentPadding: EdgeInsets.zero, ), ), ], ), ), ), Padding( padding: EdgeInsets.only(top: 10.w), child: _controller.meetingDetails.value.tasktype < 10000 ? FleatherEditor( controller: _fleatherController!, focusNode: _focusNode, editorKey: _editorKey, ) : Center( child: Text( 'summaryFailed'.tr, // 总结失败,请稍后重试 style: TextStyle( color: MeetingUIUtils.getSecondaryTextColor( isDarkMode), fontSize: 14.sp, ), ), ), ), ], ), ), ), if (!_hasFocus) Obx( () => Positioned( bottom: MediaQuery.of(context).padding.bottom + 20.w, left: 0, right: 0, child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ if (_controller.meetingDetails.value.tasktype >= 3 && _controller.meetingDetails.value.tasktype < 10000) _button( Icons.edit, 'editSpeaker'.tr, // 对应中文:编辑 _startEditSummary, ), if (_controller.meetingDetails.value.tasktype >= 3 && _controller.meetingDetails.value.tasktype < 10000) _button( Icons.auto_awesome, 'Ask AI', () { Get.bottomSheet( const AIBottomSheet(), isScrollControlled: true, backgroundColor: isDarkMode ? Colors.grey[800] : Colors.white, shape: RoundedRectangleBorder( borderRadius: BorderRadius.vertical( top: Radius.circular(12.r), ), ), ); }, ), if (_controller.meetingDetails.value.tasktype == 0) SizedBox( width: 150.w, child: _button( Icons.auto_awesome, 'generate'.tr, // 对应中文:生成 () { Get.bottomSheet( const GenerateBottomSheet(), isScrollControlled: true, backgroundColor: isDarkMode ? Colors.grey[800] : Colors.grey[100], shape: RoundedRectangleBorder( borderRadius: BorderRadius.vertical( top: Radius.circular(12.r), ), ), ); }, ), ), if (_controller.meetingDetails.value.tasktype == 10002) SizedBox( width: 150.w, child: _button( Icons.auto_awesome, 'regenerate'.tr, // 重新生成 () { Get.bottomSheet( const GenerateBottomSheet(), isScrollControlled: true, backgroundColor: isDarkMode ? Colors.grey[800] : Colors.grey[100], shape: RoundedRectangleBorder( borderRadius: BorderRadius.vertical( top: Radius.circular(12.r), ), ), ); }, ), ), if (_controller.meetingDetails.value.tasktype == 10001) SizedBox( width: 150.w, child: _button( Icons.auto_awesome, 'reSummarize'.tr, // 重新总结 () { Get.bottomSheet( const GenerateBottomSheet( isSummarizeAgain: true, ), isScrollControlled: true, backgroundColor: isDarkMode ? Colors.grey[800] : Colors.grey[100], shape: RoundedRectangleBorder( borderRadius: BorderRadius.vertical( top: Radius.circular(12.r), ), ), ); }, ), ), ], ), ), ), if (_hasFocus) Positioned( bottom: 0, left: -12.w, right: -12.w, child: SafeArea( top: false, child: Container( height: 40, color: isDarkMode ? Colors.grey[800] : Colors.white, alignment: Alignment.center, child: FleatherToolbar.basic( controller: _fleatherController!, editorKey: _editorKey, ), ), ), ), ], ); } Widget _button(IconData icon, String title, Function() onTap) { return Builder( builder: (context) { final isDarkMode = Theme.of(context).brightness == Brightness.dark; return GestureDetector( onTap: onTap, child: Container( height: 42.w, padding: EdgeInsets.symmetric(horizontal: 15.w), margin: EdgeInsets.symmetric(horizontal: 4.w), decoration: BoxDecoration( color: isDarkMode ? Colors.white : Colors.black, borderRadius: BorderRadius.circular(42.w), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( icon, color: isDarkMode ? Colors.black : Colors.white, size: 20.sp, ), 5.horizontalSpace, Text( title, style: TextStyle( color: isDarkMode ? Colors.black : Colors.white, fontSize: 14.sp, ), ), ], ), ), ); }, ); } String _formatDateSeconds(int seconds) { if (seconds < 60) { return '${seconds}s'; } else if (seconds < 3600) { int minutes = seconds ~/ 60; int remainingSeconds = seconds % 60; return '${minutes}m ${remainingSeconds}s'; } else { int hours = seconds ~/ 3600; int remainingMinutes = (seconds % 3600) ~/ 60; int remainingSeconds = seconds % 60; return '${hours}h ${remainingMinutes}m ${remainingSeconds}s'; } } }