Browse Source

语音纪要等待态换成自绘动效;修 tasktype 存两张表导致重进后等待态消失

## 等待态

原来是 assets/lottie/meeting_wait.json —— 一张「JOIN」对话气泡插画,和本页在做的事
(把音频变成文稿)没有任何关系,配色也与全站的 222° 冷蓝灰不搭,深色主题下更是一块
突兀的白。改成自绘:上面一排声波表示音频正在被读,下面几条带流光的骨架行预告即将
生成的文稿形状,两者共用同一个 AnimationController。

自绘而不是再换一个 Lottie:① 不用为一个等待态再背一份 json 资源;② 颜色全部走
AppPalette,明暗主题自动跟随,插画做不到;③ 四个 tab(概览/发言/全文/思维导图)
共用同一个等待态,自绘好调。meeting_wait.json 一并删除(全仓零引用;assets/lottie/
在 pubspec 里是按目录声明的,不用改配置)。

## 重进后等待态消失 —— tasktype 在两张表里各存一份

真机上那条记录的实际状态是 meeting.tasktype=1 / meetingdetails.tasktype=0:
列表页读前者,显示「转写中」;详情页读后者,拿到 0,于是什么等待态都没有。

两张表分头写,而且中间态漏了:
- submitTask / refreshTask / readTask 只调 editMeetingTitle,**只写 meeting**
- _queryTask 里写 meetingdetails 的两次都藏在里程碑分支里(>=3、==5/6),
  1→2(等待转写→转写中)这一跳、以及失败态 10002,meetingdetails 一次都不写

首次进入之所以有动画,靠的是详情控制器里那句内存赋值 meetingDetails.value.tasktype=1;
控制器一销毁,重进读库就是 0 —— 表现为「退出重进特效就不见了」。

改法:
1. SqfliteApi.setMeetingTaskType(id, type):一个事务同时写两张表,成为写 tasktype 的
   **唯一入口**。⚠️ 别再直接 editMeetingTitle({'tasktype': x}),那只写一张。
2. 轮询里的状态推进从两个里程碑分支里提到分支之后无条件执行 —— 数据先落、状态最后推,
   详情页的 ever(tasktype) 监听读到新状态时数据已经就位。tasktype.value 同样不再只在
   分支里更新,1→2 这一跳现在也会通知 UI。
3. 详情页以 meeting 表那份为准(它是历史上唯一每次状态变化都会写的),给**存量**错位
   数据兜底:不用等下一次状态跳变就能立刻正常。

真机验证(华为 ALN-AL80):用那条 details=0/meeting=1 的错位记录进详情,等待态正常
(改前是空白页);退出重进,等待态仍在;退出后查库两张表都是 1;轮询把状态推到失败态后
两张表都是 10002(改前 details 恒为 0);终态下等待态正确消失、显示「分析失败」。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
main
Rodger-Wang 4 weeks ago
parent
commit
63634532ce
  1. 1
      apps/client/assets/lottie/meeting_wait.json
  2. 19
      apps/client/lib/data/services/db/sqflite_api.dart
  3. 35
      apps/client/lib/data/services/meeting/meeting_task_service.dart
  4. 6
      apps/client/lib/modules/meeting/controllers/meeting_details_controller.dart
  5. 37
      apps/client/lib/modules/meeting/views/meeting_details_view.dart
  6. 175
      apps/client/lib/modules/meeting/views/widgets/transcribing_placeholder.dart

1
apps/client/assets/lottie/meeting_wait.json

File diff suppressed because one or more lines are too long

19
apps/client/lib/data/services/db/sqflite_api.dart

@ -171,6 +171,25 @@ class SqfliteApi {
await db.update('meeting', meeting, where: 'id = ?', whereArgs: [id]); await db.update('meeting', meeting, where: 'id = ?', whereArgs: [id]);
} }
/// 写任务状态。**tasktype 在 `meeting` 与 `meetingdetails` 两张表里各存了一份**
/// (列表页读前者、详情页读后者),所以只能从这一个入口写,两张表一次更新。
///
/// ⚠️ 别再直接 `editMeetingTitle(id, {'tasktype': x})`:那只写 `meeting`。
/// 历史上 submitTask/refreshTask/readTask 都是这么写的,而 `meetingdetails`
/// 只在 `_queryTask` 的两个里程碑分支(>=3、==5/6)里才顺带更新一次 ——
/// 于是「等待转写中/转写中」这段 `meetingdetails.tasktype` 恒为 0,
/// 表现为**列表显示「正在转写…」,点进详情却没有等待态**(退出重进即复现,
/// 因为首次进入靠的是控制器里那次内存赋值,一离开就没了)。
static Future<void> setMeetingTaskType(int id, int tasktype) async {
final db = await DatabaseHelper().database;
await db.transaction((txn) async {
await txn.update('meeting', {'tasktype': tasktype},
where: 'id = ?', whereArgs: [id]);
await txn.update('meetingdetails', {'tasktype': tasktype},
where: 'meetingid = ?', whereArgs: [id]);
});
}
// 删除商务会议助手 // 删除商务会议助手
static Future deleteMeeting(id) async { static Future deleteMeeting(id) async {
final db = await DatabaseHelper().database; final db = await DatabaseHelper().database;

35
apps/client/lib/data/services/meeting/meeting_task_service.dart

@ -121,10 +121,7 @@ class MeetingTaskService extends GetxService {
'id': id, 'id': id,
'tasktype': 1, 'tasktype': 1,
}); });
await SqfliteApi.editMeetingTitle( await SqfliteApi.setMeetingTaskType(id, 1);
id,
{'tasktype': 1},
);
updateListState.value++; updateListState.value++;
_executeTask(); _executeTask();
} }
@ -141,20 +138,14 @@ class MeetingTaskService extends GetxService {
'id': id, 'id': id,
'tasktype': 3, 'tasktype': 3,
}); });
await SqfliteApi.editMeetingTitle( await SqfliteApi.setMeetingTaskType(id, 3);
id,
{'tasktype': 3},
);
updateListState.value++; updateListState.value++;
_executeTask(); _executeTask();
} }
Future<void> readTask(int id) async { Future<void> readTask(int id) async {
await Api.readRecordEchomeet({'id': id}); await Api.readRecordEchomeet({'id': id});
await SqfliteApi.editMeetingTitle( await SqfliteApi.setMeetingTaskType(id, 6);
id,
{'tasktype': 6},
);
updateListState.value++; updateListState.value++;
} }
@ -254,14 +245,9 @@ class MeetingTaskService extends GetxService {
} }
await SqfliteApi.deleteMeetingSpeaker(id); await SqfliteApi.deleteMeetingSpeaker(id);
await SqfliteApi.insertMeetingSpeaker(speakerList); await SqfliteApi.insertMeetingSpeaker(speakerList);
await SqfliteApi.editMeeting(
id,
{'tasktype': newTasktype},
);
if (id == meetingId) { if (id == meetingId) {
List sqlSpeakerList = await SqfliteApi.getMeetingSpeaker(id); List sqlSpeakerList = await SqfliteApi.getMeetingSpeaker(id);
textList = List.from(sqlSpeakerList); textList = List.from(sqlSpeakerList);
tasktype.value = newTasktype;
} }
} }
// ⚠️ 判据用「正好是完成/已阅」而不是 >=5:失败态 SummarizFail=10001、 // ⚠️ 判据用「正好是完成/已阅」而不是 >=5:失败态 SummarizFail=10001、
@ -271,7 +257,6 @@ class MeetingTaskService extends GetxService {
if (oldTasktype < 5 && (newTasktype == 5 || newTasktype == 6)) { if (oldTasktype < 5 && (newTasktype == 5 || newTasktype == 6)) {
// 总结完成 // 总结完成
await SqfliteApi.editMeeting(id, { await SqfliteApi.editMeeting(id, {
'tasktype': newTasktype,
'personnel': element['personnel'] ?? '', 'personnel': element['personnel'] ?? '',
'summary': element['summary'] ?? '', 'summary': element['summary'] ?? '',
'overview': element['overview'] ?? '', 'overview': element['overview'] ?? '',
@ -280,13 +265,17 @@ class MeetingTaskService extends GetxService {
personnel = element['personnel'] ?? ''; personnel = element['personnel'] ?? '';
summary = element['summary'] ?? ''; summary = element['summary'] ?? '';
overview = element['overview'] ?? ''; overview = element['overview'] ?? '';
tasktype.value = newTasktype;
} }
} }
await SqfliteApi.editMeetingTitle( // 两张表一次落地。放在上面两个里程碑分支**之后**:分支里先把
id, // speaker / summary 写好,最后这一下才把状态推进并通知 UI,
{'tasktype': newTasktype}, // 详情页的 ever(tasktype) 监听读到新状态时数据已经就位。
); await SqfliteApi.setMeetingTaskType(id, newTasktype);
// ⚠️ 这里原来也在两个分支里各写一次,于是 1→2(等待转写→转写中)
// 这一跳不通知任何人。状态本身要一直推,等待态才不会在中途变回旧值。
if (id == meetingId) {
tasktype.value = newTasktype;
}
taskData['tasktype'] = newTasktype; taskData['tasktype'] = newTasktype;
updateListState.value++; updateListState.value++;
} }

6
apps/client/lib/modules/meeting/controllers/meeting_details_controller.dart

@ -133,6 +133,12 @@ class MeetingDetailsController extends GetxController
final data = await SqfliteApi.getMeetingDetails(id); final data = await SqfliteApi.getMeetingDetails(id);
meetingData.value = MeetingModel.fromJson(data); meetingData.value = MeetingModel.fromJson(data);
meetingDetails.value = MeetingDetailsModel.fromJson(data['details']); meetingDetails.value = MeetingDetailsModel.fromJson(data['details']);
// tasktype 在 meeting / meetingdetails 两张表里各存一份
// (见 [SqfliteApi.setMeetingTaskType]),**以 meeting 表那份为准**:
// 它是历史上唯一每次状态变化都会写的,meetingdetails 那份在旧版本里
// 只有里程碑才更新,存量记录可能还停在 0。不取这一份的话,
// 一条正在转写的记录点进来会既没有等待态、也没有内容,只剩空白。
meetingDetails.value.tasktype = meetingData.value.tasktype;
_everType = meetingDetails.value.tasktype + 1; _everType = meetingDetails.value.tasktype + 1;
_meetingTaskService.tasktype.value = meetingDetails.value.tasktype; _meetingTaskService.tasktype.value = meetingDetails.value.tasktype;
if (meetingDetails.value.tasktype == 5) { if (meetingDetails.value.tasktype == 5) {

37
apps/client/lib/modules/meeting/views/meeting_details_view.dart

@ -5,7 +5,6 @@ import 'package:common_utils/common_utils.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:lottie/lottie.dart';
import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors.dart';
import '../controllers/meeting_details_controller.dart'; import '../controllers/meeting_details_controller.dart';
@ -16,6 +15,7 @@ import 'tabs/overview_tab.dart';
import 'bottomSheet/share_bottom_sheet.dart'; import 'bottomSheet/share_bottom_sheet.dart';
import 'tabs/speech_tab.dart'; import 'tabs/speech_tab.dart';
import 'tabs/summary_tab.dart'; import 'tabs/summary_tab.dart';
import 'widgets/transcribing_placeholder.dart';
///会议音频详情页面,提供会议音频播放、会议音频分享、会议音频更多操作 ///会议音频详情页面,提供会议音频播放、会议音频分享、会议音频更多操作
class MeetingDetailsView extends GetView<MeetingDetailsController> { class MeetingDetailsView extends GetView<MeetingDetailsController> {
@ -357,7 +357,7 @@ class MeetingDetailsView extends GetView<MeetingDetailsController> {
? const SizedBox() ? const SizedBox()
: controller.meetingDetails.value.tasktype > 0 && : controller.meetingDetails.value.tasktype > 0 &&
controller.meetingDetails.value.tasktype < 5 controller.meetingDetails.value.tasktype < 5
? _meetingWait( ? TranscribingPlaceholder(
controller.meetingDetails.value.tasktype > 0 && controller.meetingDetails.value.tasktype > 0 &&
controller.meetingDetails.value.tasktype < controller.meetingDetails.value.tasktype <
3 3
@ -368,13 +368,13 @@ class MeetingDetailsView extends GetView<MeetingDetailsController> {
Obx( Obx(
() => controller.meetingDetails.value.tasktype > 0 && () => controller.meetingDetails.value.tasktype > 0 &&
controller.meetingDetails.value.tasktype < 3 controller.meetingDetails.value.tasktype < 3
? _meetingWait('transcribing'.tr) // 对应中文:正在转写... ? TranscribingPlaceholder('transcribing'.tr) // 对应中文:正在转写...
: const SpeechTab(), : const SpeechTab(),
), ),
Obx( Obx(
() => controller.meetingDetails.value.tasktype > 0 && () => controller.meetingDetails.value.tasktype > 0 &&
controller.meetingDetails.value.tasktype < 5 controller.meetingDetails.value.tasktype < 5
? _meetingWait( ? TranscribingPlaceholder(
controller.meetingDetails.value.tasktype > 0 && controller.meetingDetails.value.tasktype > 0 &&
controller.meetingDetails.value.tasktype < 3 controller.meetingDetails.value.tasktype < 3
? 'transcribing'.tr // 对应中文:正在转写... ? 'transcribing'.tr // 对应中文:正在转写...
@ -385,7 +385,7 @@ class MeetingDetailsView extends GetView<MeetingDetailsController> {
? const MindMapTab() ? const MindMapTab()
: controller.meetingDetails.value.tasktype == 0 : controller.meetingDetails.value.tasktype == 0
? const SizedBox() ? const SizedBox()
: _meetingWait(controller : TranscribingPlaceholder(controller
.meetingDetails.value.tasktype > .meetingDetails.value.tasktype >
0 && 0 &&
controller.meetingDetails.value.tasktype < 3 controller.meetingDetails.value.tasktype < 3
@ -400,33 +400,6 @@ class MeetingDetailsView extends GetView<MeetingDetailsController> {
); );
} }
Widget _meetingWait(String title) {
return Builder(
builder: (context) {
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
return Stack(
alignment: Alignment.topCenter,
children: [
Lottie.asset(
'assets/lottie/meeting_wait.json',
),
Positioned(
top: 320.w,
child: Text(
title,
style: TextStyle(
fontSize: 18.sp,
color: isDarkMode ? Colors.white : Colors.black87,
fontWeight: FontWeight.w500,
),
),
),
],
);
},
);
}
Widget _soundWave(bool isDarkMode) { Widget _soundWave(bool isDarkMode) {
return Obx( return Obx(
() => Opacity( () => Opacity(

175
apps/client/lib/modules/meeting/views/widgets/transcribing_placeholder.dart

@ -0,0 +1,175 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import '../../../../core/theme/app_palette.dart';
/// 转写 / 分析进行中的等待态。
///
/// 取代原来的 `assets/lottie/meeting_wait.json`(一张「JOIN」对话气泡插画)——
/// 那张图和本页在做的事(把音频变成文稿)没有任何关系,风格也与全站的
/// 222° 冷蓝灰不搭。现在改成**自绘**:上面一排声波表示音频正在被读,
/// 下面几条带流光的骨架行预告即将生成的文稿形状,两者共用同一个
/// [AnimationController]。
///
/// 自绘而不是再换一个 Lottie,是因为:① 不用为一个等待态再背一份 json 资源;
/// ② 颜色全部走 [AppPalette],明暗主题自动跟随,插画做不到;
/// ③ 四个 tab(概览/发言/全文/思维导图)共用同一个等待态,自绘好调。
class TranscribingPlaceholder extends StatefulWidget {
const TranscribingPlaceholder(this.title, {super.key});
/// 状态文案,已含省略号(`transcribing` / `analyzing`),这里不要再补点。
final String title;
@override
State<TranscribingPlaceholder> createState() =>
_TranscribingPlaceholderState();
}
class _TranscribingPlaceholderState extends State<TranscribingPlaceholder>
with SingleTickerProviderStateMixin {
late final AnimationController _c;
/// 骨架行的宽度占比,模拟一段自然段落的长短参差。
static const List<double> _lineWidths = <double>[1.0, 0.94, 0.82, 0.62];
/// 声波柱数。奇数,让中间那根成为视觉锚点。
static const int _barCount = 7;
@override
void initState() {
super.initState();
_c = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1600),
)..repeat();
}
@override
void dispose() {
_c.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final bool isDark = Theme.of(context).brightness == Brightness.dark;
return Center(
child: Padding(
// 往上抬一点:等待态居中偏上比死居中更像「内容马上要从这里长出来」
padding: EdgeInsets.only(bottom: 64.h, left: 24.w, right: 24.w),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_waveform(isDark),
SizedBox(height: 36.h),
_skeleton(isDark),
SizedBox(height: 32.h),
Text(
widget.title,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w500,
color: AppPalette.textSecondary(isDark),
),
),
],
),
),
);
}
/// 声波:每根柱子给一个固定相位差,整排看起来像一道行进的波而不是随机跳动。
Widget _waveform(bool isDark) {
return SizedBox(
height: 40.h,
child: AnimatedBuilder(
animation: _c,
builder: (_, __) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: List<Widget>.generate(_barCount, (int i) {
final double phase = i / _barCount;
// 0..1 的正弦,取绝对值让波谷也留一点高度,柱子不会缩到看不见
final double v =
(math.sin((_c.value + phase) * 2 * math.pi) + 1) / 2;
return Padding(
padding: EdgeInsets.symmetric(horizontal: 3.w),
child: Container(
width: 4.w,
height: 10.h + 26.h * v,
decoration: BoxDecoration(
color: AppPalette.accent.withValues(alpha: 0.35 + 0.5 * v),
borderRadius: BorderRadius.circular(2.r),
),
),
);
}),
);
},
),
);
}
/// 骨架行 + 流光。流光用 [ShaderMask] 叠在实心色块上,
/// `srcATop` 保证亮带只出现在色块内部、不会溢到行与行之间的空隙。
Widget _skeleton(bool isDark) {
final Color base = AppPalette.border(isDark);
final Color highlight = isDark
? AppPalette.brand500.withValues(alpha: 0.85)
: Colors.white.withValues(alpha: 0.9);
return AnimatedBuilder(
animation: _c,
builder: (_, __) {
return ShaderMask(
blendMode: BlendMode.srcATop,
shaderCallback: (Rect rect) => LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: <Color>[
highlight.withValues(alpha: 0),
highlight,
highlight.withValues(alpha: 0),
],
stops: const <double>[0.30, 0.50, 0.70],
transform: _SlideGradient(_c.value * 2 - 1),
).createShader(rect),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
for (int i = 0; i < _lineWidths.length; i++) ...<Widget>[
if (i > 0) SizedBox(height: 14.h),
FractionallySizedBox(
widthFactor: _lineWidths[i],
child: Container(
height: 12.h,
decoration: BoxDecoration(
color: base,
borderRadius: BorderRadius.circular(6.r),
),
),
),
],
],
),
);
},
);
}
}
/// 把渐变横向平移 [dx](以自身宽度为单位)。
///
/// Flutter 只内置了 [GradientRotation],要让亮带扫过去只能自己写一个。
class _SlideGradient extends GradientTransform {
const _SlideGradient(this.dx);
final double dx;
@override
Matrix4 transform(Rect bounds, {TextDirection? textDirection}) =>
Matrix4.translationValues(bounds.width * dx, 0, 0);
}
Loading…
Cancel
Save