From ce2a3ae059be4de480b73ee585d36a051b303fd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BC=96=E7=A0=81=E5=B7=A5=E7=A8=8B=E5=B8=88?= Date: Thu, 20 Aug 2026 03:51:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(mobile-next):=20=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E7=BA=BF=E6=80=A7=E5=9B=BE=E6=A0=87=E4=B8=8E?= =?UTF-8?q?=20toast/=E6=96=AD=E7=BD=91=E6=A8=AA=E5=B9=85=E5=8A=A8=E6=95=88?= =?UTF-8?q?=20Token?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor Co-authored-by: multica-agent --- mobile-next/lib/company/brand/app_tokens.dart | 15 ++ mobile-next/lib/company/ui/state_views.dart | 160 +++++++++++++++++- 2 files changed, 166 insertions(+), 9 deletions(-) diff --git a/mobile-next/lib/company/brand/app_tokens.dart b/mobile-next/lib/company/brand/app_tokens.dart index 381571d..585ec89 100644 --- a/mobile-next/lib/company/brand/app_tokens.dart +++ b/mobile-next/lib/company/brand/app_tokens.dart @@ -71,7 +71,22 @@ class AppDuration { static const hover = Duration(milliseconds: 100); static const overlay = Duration(milliseconds: 150); + static const overlaySwitch = Duration(milliseconds: 100); static const route = Duration(milliseconds: 200); + static const toastOut = Duration(milliseconds: 200); + static const toastStay = Duration(seconds: 2); + static const bannerCollapse = Duration(milliseconds: 300); + static const picture = Duration(milliseconds: 200); +} + +class AppMotion { + AppMotion._(); + + static bool reduce(BuildContext context) => + MediaQuery.maybeOf(context)?.disableAnimations ?? false; + + static Duration of(BuildContext context, Duration normal) => + reduce(context) ? Duration.zero : normal; } class AppShadows { diff --git a/mobile-next/lib/company/ui/state_views.dart b/mobile-next/lib/company/ui/state_views.dart index fdaa32c..4fd5beb 100644 --- a/mobile-next/lib/company/ui/state_views.dart +++ b/mobile-next/lib/company/ui/state_views.dart @@ -16,10 +16,53 @@ class CompanyLoadingView extends StatelessWidget { const SizedBox( width: 24, height: 24, - child: CircularProgressIndicator(strokeWidth: 2, color: AppColors.primary), + child: CircularProgressIndicator( + strokeWidth: 2, color: AppColors.primary), ), const SizedBox(height: AppGap.x3), - Text(label, style: const TextStyle(fontSize: AppFont.sub, color: AppColors.textSecondary)), + Text(label, + style: const TextStyle( + fontSize: AppFont.sub, color: AppColors.textSecondary)), + ], + ), + ); + } +} + +/// 规范 3.1:空白用 64px 线性图标;失败用「对话气泡 + 感叹号」组合线性图标。 +class CompanyStatusIcon extends StatelessWidget { + const CompanyStatusIcon.chat({super.key}) + : icon = Icons.chat_bubble_outline, + fail = false; + + const CompanyStatusIcon.person({super.key}) + : icon = Icons.person_outline, + fail = false; + + const CompanyStatusIcon.fail({super.key}) + : icon = Icons.chat_bubble_outline, + fail = true; + + final IconData icon; + final bool fail; + + @override + Widget build(BuildContext context) { + if (!fail) { + return Icon(icon, size: 64, color: AppColors.divider); + } + return SizedBox( + width: 64, + height: 64, + child: Stack( + alignment: Alignment.center, + children: [ + Icon(icon, size: 64, color: AppColors.divider), + const Positioned( + right: 0, + bottom: 0, + child: Icon(Icons.error_outline, size: 22, color: AppColors.divider), + ), ], ), ); @@ -48,12 +91,15 @@ class CompanyEmptyView extends StatelessWidget { children: [ Icon(icon, size: 64, color: AppColors.divider), const SizedBox(height: AppGap.x4), - Text(title, style: const TextStyle(fontSize: AppFont.body, color: AppColors.textPrimary)), + Text(title, + style: const TextStyle( + fontSize: AppFont.body, color: AppColors.textPrimary)), const SizedBox(height: AppGap.x1), Text( subtitle, textAlign: TextAlign.center, - style: const TextStyle(fontSize: AppFont.sub, color: AppColors.textSecondary), + style: const TextStyle( + fontSize: AppFont.sub, color: AppColors.textSecondary), ), ], ), @@ -76,22 +122,27 @@ class CompanyFailView extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.chat_bubble_outline, size: 64, color: AppColors.divider), + const CompanyStatusIcon.fail(), const SizedBox(height: AppGap.x4), - Text(title, style: const TextStyle(fontSize: AppFont.body, color: AppColors.textPrimary)), + Text(title, + style: const TextStyle( + fontSize: AppFont.body, color: AppColors.textPrimary)), const SizedBox(height: AppGap.x1), const Text( '检查一下网络,再点重试', - style: TextStyle(fontSize: AppFont.sub, color: AppColors.textSecondary), + style: TextStyle( + fontSize: AppFont.sub, color: AppColors.textSecondary), ), const SizedBox(height: AppGap.x4), OutlinedButton( onPressed: onRetry, style: OutlinedButton.styleFrom( minimumSize: const Size(96, 36), - side: const BorderSide(color: AppColors.textSecondary, width: 0.5), + side: const BorderSide( + color: AppColors.textSecondary, width: 0.5), foregroundColor: AppColors.textPrimary, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppRadius.control)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.control)), ), child: const Text('重试', style: TextStyle(fontSize: AppFont.body)), ), @@ -126,3 +177,94 @@ class CompanyOfflineBanner extends StatelessWidget { ); } } + +/// 即时出现;恢复后 300ms ease-out 收起。减少动态效果时立刻切换。 +class CompanyOfflineBannerHost extends StatelessWidget { + const CompanyOfflineBannerHost({super.key, required this.visible}); + + final bool visible; + + @override + Widget build(BuildContext context) { + final reduce = AppMotion.reduce(context); + return AnimatedSize( + duration: visible || reduce + ? Duration.zero + : AppDuration.bannerCollapse, + curve: Curves.easeOut, + alignment: Alignment.topCenter, + child: visible + ? const SafeArea(bottom: false, child: CompanyOfflineBanner()) + : const SizedBox(width: double.infinity, height: 0), + ); + } +} + +/// 入场 150ms、停留 2s、退场 200ms,ease-out;减少动态效果时无动画。 +class CompanyToastBanner extends StatefulWidget { + const CompanyToastBanner({super.key, required this.message}); + + final String? message; + + @override + State createState() => _CompanyToastBannerState(); +} + +class _CompanyToastBannerState extends State { + String? _shown; + double _opacity = 0; + + @override + void didUpdateWidget(CompanyToastBanner oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.message != null) { + _shown = widget.message; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) setState(() => _opacity = 1); + }); + } else if (_shown != null) { + setState(() => _opacity = 0); + } + } + + @override + Widget build(BuildContext context) { + if (_shown == null) return const SizedBox.shrink(); + final reduce = AppMotion.reduce(context); + final duration = reduce + ? Duration.zero + : (_opacity == 0 ? AppDuration.toastOut : AppDuration.overlay); + return SafeArea( + child: Align( + alignment: Alignment.topCenter, + child: Padding( + padding: const EdgeInsets.fromLTRB(AppGap.x4, AppGap.x2, AppGap.x4, 0), + child: AnimatedOpacity( + opacity: _opacity, + duration: duration, + curve: Curves.easeOut, + onEnd: () { + if (_opacity == 0 && mounted) { + setState(() => _shown = null); + } + }, + child: Material( + color: AppColors.bannerBg, + borderRadius: BorderRadius.circular(AppRadius.control), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppGap.x4, vertical: AppGap.x3), + child: Text( + _shown!, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: AppFont.sub, color: Colors.white), + ), + ), + ), + ), + ), + ), + ); + } +}