You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
208 lines
7.1 KiB
208 lines
7.1 KiB
import 'dart:math' as math;
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
|
import 'package:get/get.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, this.onRefresh});
|
|
|
|
/// 状态文案,已含省略号(`transcribing` / `analyzing`),这里不要再补点。
|
|
final String title;
|
|
|
|
/// 手动刷新。给用户一个不退出页面也能自救的出口——全局轮询死了、
|
|
/// 进程被冻结过,都不用再「退出重进」。null 时不显示按钮。
|
|
final Future<void> Function()? onRefresh;
|
|
|
|
@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),
|
|
),
|
|
),
|
|
if (widget.onRefresh != null) ...<Widget>[
|
|
SizedBox(height: 16.h),
|
|
TextButton.icon(
|
|
onPressed: _refreshing ? null : _onRefreshTap,
|
|
icon: _refreshing
|
|
? SizedBox(
|
|
width: 14.w,
|
|
height: 14.w,
|
|
child: const CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: Icon(Icons.refresh, size: 18.sp),
|
|
label: Text('meetingRefreshNow'.tr, // 刷新
|
|
style: TextStyle(fontSize: 13.sp)),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
bool _refreshing = false;
|
|
|
|
Future<void> _onRefreshTap() async {
|
|
final cb = widget.onRefresh;
|
|
if (cb == null) return;
|
|
setState(() => _refreshing = true);
|
|
try {
|
|
await cb();
|
|
} finally {
|
|
if (mounted) setState(() => _refreshing = false);
|
|
}
|
|
}
|
|
|
|
/// 声波:每根柱子给一个固定相位差,整排看起来像一道行进的波而不是随机跳动。
|
|
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);
|
|
}
|
|
|