import 'package:flutter/material.dart'; import 'package:skeletonizer/skeletonizer.dart'; import '../../core/theme/app_colors_extension.dart'; /// 骨架占位卡片容器(列表加载态) class SkeletonLoadingList extends StatelessWidget { final int cardCount; final Widget Function() cardBuilder; final EdgeInsetsGeometry padding; const SkeletonLoadingList({ super.key, this.cardCount = 5, this.cardBuilder = _defaultBuilder, this.padding = const EdgeInsets.fromLTRB(16, 16, 16, 24), }); static Widget _defaultBuilder() => const SkeletonListCard(); @override Widget build(BuildContext context) { return SingleChildScrollView( padding: padding, child: Skeletonizer( enabled: true, child: Column( children: List.generate( cardCount, (_) => Padding( padding: const EdgeInsets.only(bottom: 16), child: cardBuilder(), ), ), ), ), ); } } /// 匹配 ListCard 的骨架占位 class SkeletonListCard extends StatelessWidget { const SkeletonListCard({super.key}); @override Widget build(BuildContext context) { final colors = Theme.of(context).extension()!; return Skeletonizer( enabled: true, child: Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: colors.bgCard, borderRadius: BorderRadius.circular(8), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [Bone.text(width: 120), Bone.text(width: 80)], ), SizedBox(height: 6), Bone.text(width: 140), SizedBox(height: 6), Bone.text(), SizedBox(height: 8), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Bone.text(width: 100), Bone( width: 48, height: 20, borderRadius: BorderRadius.circular(4), ), ], ), ], ), ), ); } } /// 匹配车辆列表卡片的骨架占位 class SkeletonVehicleCard extends StatelessWidget { const SkeletonVehicleCard({super.key}); @override Widget build(BuildContext context) { final colors = Theme.of(context).extension()!; return Skeletonizer( enabled: true, child: Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: colors.bgCard, borderRadius: BorderRadius.circular(8), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Bone.text(width: 100), Bone( width: 48, height: 20, borderRadius: BorderRadius.circular(4), ), ], ), SizedBox(height: 6), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Bone.text(width: 140), Bone( width: 40, height: 16, borderRadius: BorderRadius.circular(4), ), ], ), SizedBox(height: 6), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded(child: Bone.text()), const SizedBox(width: 8), Bone.text(width: 120), ], ), ], ), ), ); } } /// 匹配外勤日志卡片的骨架占位 class SkeletonOutingLogCard extends StatelessWidget { const SkeletonOutingLogCard({super.key}); @override Widget build(BuildContext context) { final colors = Theme.of(context).extension()!; return Skeletonizer( enabled: true, child: Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: colors.bgCard, borderRadius: BorderRadius.circular(8), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Bone.text(width: 100), SizedBox(height: 4), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded(flex: 3, child: Bone.text()), const SizedBox(width: 8), Bone( width: 48, height: 20, borderRadius: BorderRadius.circular(4), ), ], ), SizedBox(height: 4), Bone.text(), SizedBox(height: 4), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded(flex: 2, child: Bone.text()), const SizedBox(width: 8), Bone.text(width: 80), ], ), ], ), ), ); } } /// 匹配公告卡片的骨架占位 class SkeletonAnnouncementCard extends StatelessWidget { const SkeletonAnnouncementCard({super.key}); @override Widget build(BuildContext context) { final colors = Theme.of(context).extension()!; return Skeletonizer( enabled: true, child: Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: colors.bgCard, borderRadius: BorderRadius.circular(8), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Bone.text(), SizedBox(height: 8), Row( children: [ Bone( width: 56, height: 20, borderRadius: BorderRadius.circular(4), ), const SizedBox(width: 8), Bone.text(width: 60), const Spacer(), Bone.text(width: 120), ], ), ], ), ), ); } } /// 匹配费用申请导入列表卡片的骨架占位 class SkeletonImportCard extends StatelessWidget { const SkeletonImportCard({super.key}); @override Widget build(BuildContext context) { final colors = Theme.of(context).extension()!; return Skeletonizer( enabled: true, child: Container( padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), decoration: BoxDecoration( color: colors.bgCard, borderRadius: BorderRadius.circular(12), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Bone.square(size: 22, borderRadius: BorderRadius.circular(4)), const SizedBox(width: 8), Expanded(flex: 3, child: Bone.text()), const Spacer(), Bone.text(width: 80), ], ), SizedBox(height: 8), Row(children: [const SizedBox(width: 30), Bone.text(width: 140)]), SizedBox(height: 8), Row( children: [ Bone.square(size: 18, borderRadius: BorderRadius.circular(4)), const SizedBox(width: 4), Bone.text(width: 24), const SizedBox(width: 8), Expanded(flex: 3, child: Bone.text()), const Spacer(), Bone.text(width: 64), ], ), SizedBox(height: 8), Row( children: [ const SizedBox(width: 54), Expanded(flex: 2, child: Bone.text()), ], ), ], ), ), ); } } /// 匹配消息卡片的骨架占位 class SkeletonMessageCard extends StatelessWidget { const SkeletonMessageCard({super.key}); @override Widget build(BuildContext context) { final colors = Theme.of(context).extension()!; return Skeletonizer( enabled: true, child: Container( height: 88, padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: colors.bgCard, borderRadius: BorderRadius.circular(8), ), child: Row( children: [ Bone.circle(size: 40), SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded(flex: 3, child: Bone.text()), const Spacer(), Bone.text(width: 80), ], ), SizedBox(height: 2), Bone.text(width: 80), SizedBox(height: 2), Expanded(flex: 2, child: Bone.text()), ], ), ), SizedBox(width: 12), Bone(width: 8, height: 8, borderRadius: BorderRadius.circular(4)), ], ), ), ); } } /// 详情页骨架(ExpenseDetailPage / ExpenseApplyDetailPage) /// 匹配 FormSection 卡片布局:基本信息 + 明细 + 附件 class SkeletonDetailPage extends StatelessWidget { const SkeletonDetailPage({super.key}); @override Widget build(BuildContext context) { return SingleChildScrollView( padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), child: Skeletonizer( enabled: true, child: Column( children: [ _FormCard(rows: 7), const SizedBox(height: 16), _FormCard(rows: 2), const SizedBox(height: 16), _FormCard(rows: 3), const SizedBox(height: 24), const Center(child: Bone(width: 120, height: 14)), ], ), ), ); } } /// 表单页骨架(ExpenseApplyCreatePage / ExpenseCreatePage) /// 匹配 FormSection + 输入框 + 底部操作栏布局 class SkeletonFormPage extends StatelessWidget { final bool showImportLink; const SkeletonFormPage({super.key, this.showImportLink = false}); @override Widget build(BuildContext context) { final colors = Theme.of(context).extension()!; final bottomPadding = MediaQuery.of(context).padding.bottom; return Stack( children: [ SingleChildScrollView( padding: EdgeInsets.fromLTRB(16, 16, 16, 80 + bottomPadding), child: Skeletonizer( enabled: true, child: Column( children: [ if (showImportLink) ...[ // 导入申请单按钮骨架 Container( padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 10, ), decoration: BoxDecoration( color: colors.bgCard, borderRadius: BorderRadius.circular(12), ), child: Row( children: [ Bone.square( size: 20, borderRadius: BorderRadius.circular(4), ), const SizedBox(width: 8), Bone(width: 160, height: 14), ], ), ), const SizedBox(height: 16), ], _FormCard(rows: 5), const SizedBox(height: 16), _FormCard(rows: 2), const SizedBox(height: 16), _FormCard(rows: 3), const SizedBox(height: 24), const Center(child: Bone(width: 120, height: 14)), ], ), ), ), // 底部操作栏骨架,贴底并适配安全区 Positioned( left: 0, right: 0, bottom: 0, child: Skeletonizer( enabled: true, child: Container( padding: EdgeInsets.fromLTRB(16, 12, 16, 12 + bottomPadding), decoration: BoxDecoration( color: colors.bgCard, border: Border(top: BorderSide(color: colors.border)), ), child: Row( children: [ Expanded( child: Bone( height: 44, borderRadius: BorderRadius.circular(8), ), ), const SizedBox(width: 12), Expanded( child: Bone( height: 44, borderRadius: BorderRadius.circular(8), ), ), ], ), ), ), ), ], ); } } class _FormCard extends StatelessWidget { final int rows; const _FormCard({required this.rows}); @override Widget build(BuildContext context) { final colors = Theme.of(context).extension()!; return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: colors.bgCard, borderRadius: BorderRadius.circular(12), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Bone.square(size: 20, borderRadius: BorderRadius.circular(4)), const SizedBox(width: 8), Bone(width: 80, height: 16), const Spacer(), Bone(width: 40, height: 16), ], ), const SizedBox(height: 20), ...List.generate( rows, (i) => Padding( padding: EdgeInsets.only(bottom: i < rows - 1 ? 16 : 0), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Bone(width: 64, height: 14), Bone(width: 140 + (i % 3) * 20, height: 14), ], ), ), ), ], ), ); } } // ═══ 报表页骨架 — 各页面按实际布局独立定义 ═══ /// 事前申请明细报表骨架(ExpenseApplyDetailReportPage) /// 布局:筛选栏 + 统计卡片(1行2个) + 折线图(单线) + 明细列表 class SkeletonExpenseApplyDetailReportPage extends StatelessWidget { const SkeletonExpenseApplyDetailReportPage({super.key}); @override Widget build(BuildContext context) { final colors = Theme.of(context).extension()!; return Column( children: [ _dateFilterChip(colors), Expanded( child: SingleChildScrollView( child: Skeletonizer( enabled: true, child: Column( children: [ // 统计卡片 — 1行2列 Padding( padding: const EdgeInsets.fromLTRB(12, 12, 12, 0), child: Row( children: [ Expanded( child: Bone( height: 80, borderRadius: BorderRadius.circular(8), ), ), const SizedBox(width: 8), Expanded( child: Bone( height: 80, borderRadius: BorderRadius.circular(8), ), ), ], ), ), const SizedBox(height: 16), // 折线图 — 标题 + 1个图例 + 图表区 Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: colors.bgCard, borderRadius: BorderRadius.circular(8), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Bone(width: 120, height: 16), const SizedBox(height: 8), Row( children: [ Bone.square( size: 8, borderRadius: BorderRadius.circular(4), ), const SizedBox(width: 4), Bone(width: 60, height: 12), ], ), const SizedBox(height: 16), Row( children: [ Expanded( child: Bone( height: 200, borderRadius: BorderRadius.circular(8), ), ), ], ), ], ), ), ), const SizedBox(height: 16), // 明细列表 _detailListSkeleton(colors), const SizedBox(height: 80), ], ), ), ), ), ], ); } } /// 费用报销明细报表骨架(ExpenseDetailReportPage) /// 布局:筛选栏 + 统计卡片(2+1) + 双折线图 + 柱形图 + 明细列表 class SkeletonExpenseDetailReportPage extends StatelessWidget { const SkeletonExpenseDetailReportPage({super.key}); @override Widget build(BuildContext context) { final colors = Theme.of(context).extension()!; return Column( children: [ _dateFilterChip(colors), Expanded( child: SingleChildScrollView( child: Skeletonizer( enabled: true, child: Column( children: [ // 统计卡片 — 1行2列 + 1个全宽 Padding( padding: const EdgeInsets.fromLTRB(12, 12, 12, 0), child: Row( children: [ Expanded( child: Bone( height: 80, borderRadius: BorderRadius.circular(8), ), ), const SizedBox(width: 8), Expanded( child: Bone( height: 80, borderRadius: BorderRadius.circular(8), ), ), ], ), ), const SizedBox(height: 8), Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: Row( children: [ Expanded( child: Bone( height: 80, borderRadius: BorderRadius.circular(8), ), ), ], ), ), const SizedBox(height: 16), // 双折线图 — 标题 + 2个图例 + 图表区 Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: colors.bgCard, borderRadius: BorderRadius.circular(8), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Bone(width: 120, height: 16), const SizedBox(height: 8), Row( children: [ Bone.square( size: 8, borderRadius: BorderRadius.circular(4), ), const SizedBox(width: 4), Bone(width: 60, height: 12), const SizedBox(width: 16), Bone.square( size: 8, borderRadius: BorderRadius.circular(4), ), const SizedBox(width: 4), Bone(width: 60, height: 12), ], ), const SizedBox(height: 16), Row( children: [ Expanded( child: Bone( height: 200, borderRadius: BorderRadius.circular(8), ), ), ], ), ], ), ), ), const SizedBox(height: 16), // 柱形图 — 标题 + 图表区 Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: colors.bgCard, borderRadius: BorderRadius.circular(8), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Bone(width: 160, height: 16), const SizedBox(height: 16), Row( children: [ Expanded( child: Bone( height: 220, borderRadius: BorderRadius.circular(8), ), ), ], ), ], ), ), ), const SizedBox(height: 12), // 明细列表 _detailListSkeleton(colors), const SizedBox(height: 80), ], ), ), ), ), ], ); } } // ── 日期筛选 chip 占位 ── Widget _dateFilterChip(AppColorsExtension colors) => Container( padding: const EdgeInsets.fromLTRB(12, 8, 12, 12), color: colors.bgCard, child: Container( height: 40, decoration: BoxDecoration( color: colors.bgSecondaryContainer, borderRadius: BorderRadius.circular(20), ), ), ); // ── 明细列表骨架 ── Widget _detailListSkeleton(AppColorsExtension colors) => Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: Container( width: double.infinity, decoration: BoxDecoration( color: colors.bgCard, borderRadius: BorderRadius.circular(8), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), child: Row( children: [ Bone(width: 80, height: 16), const Spacer(), Bone(width: 100, height: 14), ], ), ), const Divider(height: 1), ...List.generate(4, (i) { return Column( children: [ Padding( padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 12, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Bone(width: 120 + (i % 3) * 40, height: 14), Bone(width: 80, height: 16), ], ), const SizedBox(height: 6), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Bone(width: 90, height: 12), Bone(width: 80, height: 16), ], ), if (i == 0) ...[ const SizedBox(height: 6), Bone(width: 200, height: 14), ], ], ), ), if (i < 3) const Divider(height: 1), ], ); }), ], ), ), );