feat(mobile-next): 补齐状态线性图标与 toast/断网横幅动效 Token

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
编码工程师
2026-08-20 03:51:26 +08:00
co-authored by Cursor multica-agent
parent fb58da670b
commit ce2a3ae059
2 changed files with 166 additions and 9 deletions
@@ -71,7 +71,22 @@ class AppDuration {
static const hover = Duration(milliseconds: 100); static const hover = Duration(milliseconds: 100);
static const overlay = Duration(milliseconds: 150); static const overlay = Duration(milliseconds: 150);
static const overlaySwitch = Duration(milliseconds: 100);
static const route = Duration(milliseconds: 200); 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 { class AppShadows {
+151 -9
View File
@@ -16,10 +16,53 @@ class CompanyLoadingView extends StatelessWidget {
const SizedBox( const SizedBox(
width: 24, width: 24,
height: 24, height: 24,
child: CircularProgressIndicator(strokeWidth: 2, color: AppColors.primary), child: CircularProgressIndicator(
strokeWidth: 2, color: AppColors.primary),
), ),
const SizedBox(height: AppGap.x3), 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: [ children: [
Icon(icon, size: 64, color: AppColors.divider), Icon(icon, size: 64, color: AppColors.divider),
const SizedBox(height: AppGap.x4), 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 SizedBox(height: AppGap.x1),
Text( Text(
subtitle, subtitle,
textAlign: TextAlign.center, 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( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Icon(Icons.chat_bubble_outline, size: 64, color: AppColors.divider), const CompanyStatusIcon.fail(),
const SizedBox(height: AppGap.x4), 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 SizedBox(height: AppGap.x1),
const Text( const Text(
'检查一下网络,再点重试', '检查一下网络,再点重试',
style: TextStyle(fontSize: AppFont.sub, color: AppColors.textSecondary), style: TextStyle(
fontSize: AppFont.sub, color: AppColors.textSecondary),
), ),
const SizedBox(height: AppGap.x4), const SizedBox(height: AppGap.x4),
OutlinedButton( OutlinedButton(
onPressed: onRetry, onPressed: onRetry,
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
minimumSize: const Size(96, 36), 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, 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)), 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、退场 200msease-out;减少动态效果时无动画。
class CompanyToastBanner extends StatefulWidget {
const CompanyToastBanner({super.key, required this.message});
final String? message;
@override
State<CompanyToastBanner> createState() => _CompanyToastBannerState();
}
class _CompanyToastBannerState extends State<CompanyToastBanner> {
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),
),
),
),
),
),
),
);
}
}