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:
co-authored by
Cursor
multica-agent
parent
fb58da670b
commit
ce2a3ae059
@@ -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 {
|
||||||
|
|||||||
@@ -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、退场 200ms,ease-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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user