Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: multica-agent <github@multica.ai>
271 lines
7.8 KiB
Dart
271 lines
7.8 KiB
Dart
import 'package:flutter/material.dart';
|
||
|
||
import '../brand/app_tokens.dart';
|
||
|
||
class CompanyLoadingView extends StatelessWidget {
|
||
const CompanyLoadingView({super.key, this.label = '正在加载'});
|
||
|
||
final String label;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Center(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const SizedBox(
|
||
width: 24,
|
||
height: 24,
|
||
child: CircularProgressIndicator(
|
||
strokeWidth: 2, color: AppColors.primary),
|
||
),
|
||
const SizedBox(height: AppGap.x3),
|
||
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),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class CompanyEmptyView extends StatelessWidget {
|
||
const CompanyEmptyView({
|
||
super.key,
|
||
required this.icon,
|
||
required this.title,
|
||
required this.subtitle,
|
||
});
|
||
|
||
final IconData icon;
|
||
final String title;
|
||
final String subtitle;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Center(
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: AppGap.x8),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(icon, size: 64, color: AppColors.divider),
|
||
const SizedBox(height: AppGap.x4),
|
||
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),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class CompanyFailView extends StatelessWidget {
|
||
const CompanyFailView({super.key, required this.onRetry, this.title = '加载失败'});
|
||
|
||
final VoidCallback onRetry;
|
||
final String title;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Center(
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: AppGap.x8),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const CompanyStatusIcon.fail(),
|
||
const SizedBox(height: AppGap.x4),
|
||
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),
|
||
),
|
||
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),
|
||
foregroundColor: AppColors.textPrimary,
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(AppRadius.control)),
|
||
),
|
||
child: const Text('重试', style: TextStyle(fontSize: AppFont.body)),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class CompanyOfflineBanner extends StatelessWidget {
|
||
const CompanyOfflineBanner({super.key});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Container(
|
||
height: 36,
|
||
color: AppColors.bannerBg,
|
||
padding: const EdgeInsets.symmetric(horizontal: AppGap.x4),
|
||
child: const Row(
|
||
children: [
|
||
Icon(Icons.info_outline, size: 16, color: Colors.white),
|
||
SizedBox(width: AppGap.x2),
|
||
Expanded(
|
||
child: Text(
|
||
'当前网络不可用,请检查网络连接',
|
||
style: TextStyle(fontSize: AppFont.sub, color: Colors.white),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 即时出现;恢复后 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),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|