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 createState() => _TranscribingPlaceholderState(); } class _TranscribingPlaceholderState extends State with SingleTickerProviderStateMixin { late final AnimationController _c; /// 骨架行的宽度占比,模拟一段自然段落的长短参差。 static const List _lineWidths = [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.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: [ highlight.withValues(alpha: 0), highlight, highlight.withValues(alpha: 0), ], stops: const [0.30, 0.50, 0.70], transform: _SlideGradient(_c.value * 2 - 1), ).createShader(rect), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ for (int i = 0; i < _lineWidths.length; i++) ...[ 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); }