app_skeletons.dart 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080
  1. import 'package:flutter/material.dart';
  2. import 'package:skeletonizer/skeletonizer.dart';
  3. import '../../core/theme/app_colors_extension.dart';
  4. /// 骨架占位卡片容器(列表加载态)
  5. class SkeletonLoadingList extends StatelessWidget {
  6. final int cardCount;
  7. final Widget Function() cardBuilder;
  8. final EdgeInsetsGeometry padding;
  9. const SkeletonLoadingList({
  10. super.key,
  11. this.cardCount = 5,
  12. this.cardBuilder = _defaultBuilder,
  13. this.padding = const EdgeInsets.fromLTRB(16, 16, 16, 24),
  14. });
  15. static Widget _defaultBuilder() => const SkeletonListCard();
  16. @override
  17. Widget build(BuildContext context) {
  18. return SingleChildScrollView(
  19. padding: padding,
  20. child: Skeletonizer(
  21. enabled: true,
  22. child: Column(
  23. children: List.generate(
  24. cardCount,
  25. (_) => Padding(
  26. padding: const EdgeInsets.only(bottom: 16),
  27. child: cardBuilder(),
  28. ),
  29. ),
  30. ),
  31. ),
  32. );
  33. }
  34. }
  35. /// 匹配 ListCard 的骨架占位
  36. class SkeletonListCard extends StatelessWidget {
  37. const SkeletonListCard({super.key});
  38. @override
  39. Widget build(BuildContext context) {
  40. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  41. return Skeletonizer(
  42. enabled: true,
  43. child: Container(
  44. padding: const EdgeInsets.all(12),
  45. decoration: BoxDecoration(
  46. color: colors.bgCard,
  47. borderRadius: BorderRadius.circular(8),
  48. ),
  49. child: Column(
  50. crossAxisAlignment: CrossAxisAlignment.start,
  51. children: [
  52. Row(
  53. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  54. children: [Bone.text(width: 120), Bone.text(width: 80)],
  55. ),
  56. SizedBox(height: 6),
  57. Bone.text(width: 140),
  58. SizedBox(height: 6),
  59. Bone.text(),
  60. SizedBox(height: 8),
  61. Row(
  62. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  63. children: [
  64. Bone.text(width: 100),
  65. Bone(
  66. width: 48,
  67. height: 20,
  68. borderRadius: BorderRadius.circular(4),
  69. ),
  70. ],
  71. ),
  72. ],
  73. ),
  74. ),
  75. );
  76. }
  77. }
  78. /// 匹配车辆列表卡片的骨架占位
  79. class SkeletonVehicleCard extends StatelessWidget {
  80. const SkeletonVehicleCard({super.key});
  81. @override
  82. Widget build(BuildContext context) {
  83. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  84. return Skeletonizer(
  85. enabled: true,
  86. child: Container(
  87. padding: const EdgeInsets.all(12),
  88. decoration: BoxDecoration(
  89. color: colors.bgCard,
  90. borderRadius: BorderRadius.circular(8),
  91. ),
  92. child: Column(
  93. crossAxisAlignment: CrossAxisAlignment.start,
  94. children: [
  95. Row(
  96. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  97. children: [
  98. Bone.text(width: 100),
  99. Bone(
  100. width: 48,
  101. height: 20,
  102. borderRadius: BorderRadius.circular(4),
  103. ),
  104. ],
  105. ),
  106. SizedBox(height: 6),
  107. Row(
  108. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  109. children: [
  110. Bone.text(width: 140),
  111. Bone(
  112. width: 40,
  113. height: 16,
  114. borderRadius: BorderRadius.circular(4),
  115. ),
  116. ],
  117. ),
  118. SizedBox(height: 6),
  119. Row(
  120. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  121. children: [
  122. Expanded(child: Bone.text()),
  123. const SizedBox(width: 8),
  124. Bone.text(width: 120),
  125. ],
  126. ),
  127. ],
  128. ),
  129. ),
  130. );
  131. }
  132. }
  133. /// 匹配外勤日志卡片的骨架占位
  134. class SkeletonOutingLogCard extends StatelessWidget {
  135. const SkeletonOutingLogCard({super.key});
  136. @override
  137. Widget build(BuildContext context) {
  138. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  139. return Skeletonizer(
  140. enabled: true,
  141. child: Container(
  142. padding: const EdgeInsets.all(12),
  143. decoration: BoxDecoration(
  144. color: colors.bgCard,
  145. borderRadius: BorderRadius.circular(8),
  146. ),
  147. child: Column(
  148. crossAxisAlignment: CrossAxisAlignment.start,
  149. children: [
  150. Bone.text(width: 100),
  151. SizedBox(height: 4),
  152. Row(
  153. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  154. children: [
  155. Expanded(flex: 3, child: Bone.text()),
  156. const SizedBox(width: 8),
  157. Bone(
  158. width: 48,
  159. height: 20,
  160. borderRadius: BorderRadius.circular(4),
  161. ),
  162. ],
  163. ),
  164. SizedBox(height: 4),
  165. Bone.text(),
  166. SizedBox(height: 4),
  167. Row(
  168. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  169. children: [
  170. Expanded(flex: 2, child: Bone.text()),
  171. const SizedBox(width: 8),
  172. Bone.text(width: 80),
  173. ],
  174. ),
  175. ],
  176. ),
  177. ),
  178. );
  179. }
  180. }
  181. /// 匹配公告卡片的骨架占位
  182. class SkeletonAnnouncementCard extends StatelessWidget {
  183. const SkeletonAnnouncementCard({super.key});
  184. @override
  185. Widget build(BuildContext context) {
  186. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  187. return Skeletonizer(
  188. enabled: true,
  189. child: Container(
  190. padding: const EdgeInsets.all(12),
  191. decoration: BoxDecoration(
  192. color: colors.bgCard,
  193. borderRadius: BorderRadius.circular(8),
  194. ),
  195. child: Column(
  196. crossAxisAlignment: CrossAxisAlignment.start,
  197. children: [
  198. Bone.text(),
  199. SizedBox(height: 8),
  200. Row(
  201. children: [
  202. Bone(
  203. width: 56,
  204. height: 20,
  205. borderRadius: BorderRadius.circular(4),
  206. ),
  207. const SizedBox(width: 8),
  208. Bone.text(width: 60),
  209. const Spacer(),
  210. Bone.text(width: 120),
  211. ],
  212. ),
  213. ],
  214. ),
  215. ),
  216. );
  217. }
  218. }
  219. /// 匹配费用申请导入列表卡片的骨架占位
  220. class SkeletonImportCard extends StatelessWidget {
  221. const SkeletonImportCard({super.key});
  222. @override
  223. Widget build(BuildContext context) {
  224. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  225. return Skeletonizer(
  226. enabled: true,
  227. child: Container(
  228. padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
  229. decoration: BoxDecoration(
  230. color: colors.bgCard,
  231. borderRadius: BorderRadius.circular(12),
  232. ),
  233. child: Column(
  234. crossAxisAlignment: CrossAxisAlignment.start,
  235. children: [
  236. Row(
  237. children: [
  238. Bone.square(size: 22, borderRadius: BorderRadius.circular(4)),
  239. const SizedBox(width: 8),
  240. Expanded(flex: 3, child: Bone.text()),
  241. const Spacer(),
  242. Bone.text(width: 80),
  243. ],
  244. ),
  245. SizedBox(height: 8),
  246. Row(children: [const SizedBox(width: 30), Bone.text(width: 140)]),
  247. SizedBox(height: 8),
  248. Row(
  249. children: [
  250. Bone.square(size: 18, borderRadius: BorderRadius.circular(4)),
  251. const SizedBox(width: 4),
  252. Bone.text(width: 24),
  253. const SizedBox(width: 8),
  254. Expanded(flex: 3, child: Bone.text()),
  255. const Spacer(),
  256. Bone.text(width: 64),
  257. ],
  258. ),
  259. SizedBox(height: 8),
  260. Row(
  261. children: [
  262. const SizedBox(width: 54),
  263. Expanded(flex: 2, child: Bone.text()),
  264. ],
  265. ),
  266. ],
  267. ),
  268. ),
  269. );
  270. }
  271. }
  272. /// 匹配消息卡片的骨架占位
  273. class SkeletonMessageCard extends StatelessWidget {
  274. const SkeletonMessageCard({super.key});
  275. @override
  276. Widget build(BuildContext context) {
  277. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  278. return Skeletonizer(
  279. enabled: true,
  280. child: Container(
  281. height: 88,
  282. padding: const EdgeInsets.all(12),
  283. decoration: BoxDecoration(
  284. color: colors.bgCard,
  285. borderRadius: BorderRadius.circular(8),
  286. ),
  287. child: Row(
  288. children: [
  289. Bone.circle(size: 40),
  290. SizedBox(width: 12),
  291. Expanded(
  292. child: Column(
  293. crossAxisAlignment: CrossAxisAlignment.start,
  294. children: [
  295. Row(
  296. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  297. children: [
  298. Expanded(flex: 3, child: Bone.text()),
  299. const Spacer(),
  300. Bone.text(width: 80),
  301. ],
  302. ),
  303. SizedBox(height: 2),
  304. Bone.text(width: 80),
  305. SizedBox(height: 2),
  306. Expanded(flex: 2, child: Bone.text()),
  307. ],
  308. ),
  309. ),
  310. SizedBox(width: 12),
  311. Bone(width: 8, height: 8, borderRadius: BorderRadius.circular(4)),
  312. ],
  313. ),
  314. ),
  315. );
  316. }
  317. }
  318. /// 详情页骨架 — 匹配 FormSection 卡片布局。
  319. ///
  320. /// [sectionRows] 每个 FormSection 的占位行数,默认值对应 ExpenseDetailPage 布局。
  321. class SkeletonDetailPage extends StatelessWidget {
  322. final List<int> sectionRows;
  323. final bool showPageFooter;
  324. const SkeletonDetailPage({
  325. super.key,
  326. this.sectionRows = const [7, 2, 3],
  327. this.showPageFooter = true,
  328. });
  329. @override
  330. Widget build(BuildContext context) {
  331. return SingleChildScrollView(
  332. padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
  333. child: Skeletonizer(
  334. enabled: true,
  335. child: Column(
  336. children: [
  337. for (var i = 0; i < sectionRows.length; i++) ...[
  338. _FormCard(rows: sectionRows[i]),
  339. if (i < sectionRows.length - 1) const SizedBox(height: 16),
  340. ],
  341. if (showPageFooter) ...[
  342. const SizedBox(height: 24),
  343. const Center(child: Bone(width: 120, height: 14)),
  344. ],
  345. ],
  346. ),
  347. ),
  348. );
  349. }
  350. }
  351. /// 表单页骨架 — 匹配 FormSection + 输入框 + 底部操作栏布局。
  352. ///
  353. /// [sectionRows] 每个 FormSection 的占位行数,例如 `[4, 6, 4]` 表示 3 个卡片分别为 4、6、4 行。
  354. /// [bottomButtonCount] 底部操作栏按钮数(1 或 2),默认 2。
  355. /// [showImportLink] 是否显示导入申请单入口骨架。
  356. class SkeletonFormPage extends StatelessWidget {
  357. final List<int> sectionRows;
  358. final int bottomButtonCount;
  359. final bool showImportLink;
  360. final bool showPageFooter;
  361. const SkeletonFormPage({
  362. super.key,
  363. this.sectionRows = const [5, 2, 3],
  364. this.bottomButtonCount = 2,
  365. this.showImportLink = false,
  366. this.showPageFooter = true,
  367. });
  368. @override
  369. Widget build(BuildContext context) {
  370. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  371. final bottomPadding = MediaQuery.of(context).padding.bottom;
  372. return Stack(
  373. children: [
  374. SingleChildScrollView(
  375. padding: EdgeInsets.fromLTRB(16, 16, 16, 80 + bottomPadding),
  376. child: Skeletonizer(
  377. enabled: true,
  378. child: Column(
  379. children: [
  380. if (showImportLink) ...[
  381. // 导入申请单按钮骨架
  382. Container(
  383. padding: const EdgeInsets.symmetric(
  384. horizontal: 12,
  385. vertical: 10,
  386. ),
  387. decoration: BoxDecoration(
  388. color: colors.bgCard,
  389. borderRadius: BorderRadius.circular(12),
  390. ),
  391. child: Row(
  392. children: [
  393. Bone.square(
  394. size: 20,
  395. borderRadius: BorderRadius.circular(4),
  396. ),
  397. const SizedBox(width: 8),
  398. Bone(width: 160, height: 14),
  399. ],
  400. ),
  401. ),
  402. const SizedBox(height: 16),
  403. ],
  404. for (var i = 0; i < sectionRows.length; i++) ...[
  405. _FormCard(rows: sectionRows[i]),
  406. if (i < sectionRows.length - 1) const SizedBox(height: 16),
  407. ],
  408. if (showPageFooter) ...[
  409. const SizedBox(height: 24),
  410. const Center(child: Bone(width: 120, height: 14)),
  411. ],
  412. ],
  413. ),
  414. ),
  415. ),
  416. // 底部操作栏骨架,贴底并适配安全区
  417. Positioned(
  418. left: 0,
  419. right: 0,
  420. bottom: 0,
  421. child: Skeletonizer(
  422. enabled: true,
  423. child: Container(
  424. padding: EdgeInsets.fromLTRB(16, 12, 16, 12 + bottomPadding),
  425. decoration: BoxDecoration(
  426. color: colors.bgCard,
  427. border: Border(top: BorderSide(color: colors.border)),
  428. ),
  429. child: Row(
  430. children: [
  431. if (bottomButtonCount == 1) ...[
  432. Expanded(
  433. child: Bone(
  434. height: 44,
  435. borderRadius: BorderRadius.circular(8),
  436. ),
  437. ),
  438. ] else ...[
  439. Expanded(
  440. child: Bone(
  441. height: 44,
  442. borderRadius: BorderRadius.circular(8),
  443. ),
  444. ),
  445. const SizedBox(width: 12),
  446. Expanded(
  447. child: Bone(
  448. height: 44,
  449. borderRadius: BorderRadius.circular(8),
  450. ),
  451. ),
  452. ],
  453. ],
  454. ),
  455. ),
  456. ),
  457. ),
  458. ],
  459. );
  460. }
  461. }
  462. class _FormCard extends StatelessWidget {
  463. final int rows;
  464. const _FormCard({required this.rows});
  465. @override
  466. Widget build(BuildContext context) {
  467. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  468. return Container(
  469. padding: const EdgeInsets.all(16),
  470. decoration: BoxDecoration(
  471. color: colors.bgCard,
  472. borderRadius: BorderRadius.circular(12),
  473. ),
  474. child: Column(
  475. crossAxisAlignment: CrossAxisAlignment.start,
  476. children: [
  477. Row(
  478. children: [
  479. Bone.square(size: 20, borderRadius: BorderRadius.circular(4)),
  480. const SizedBox(width: 8),
  481. Bone(width: 80, height: 16),
  482. const Spacer(),
  483. Bone(width: 40, height: 16),
  484. ],
  485. ),
  486. const SizedBox(height: 20),
  487. ...List.generate(
  488. rows,
  489. (i) => Padding(
  490. padding: EdgeInsets.only(bottom: i < rows - 1 ? 16 : 0),
  491. child: Row(
  492. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  493. children: [
  494. Bone(width: 64, height: 14),
  495. Bone(width: 140 + (i % 3) * 20, height: 14),
  496. ],
  497. ),
  498. ),
  499. ),
  500. ],
  501. ),
  502. );
  503. }
  504. }
  505. // ═══ 报表页骨架 — 各页面按实际布局独立定义 ═══
  506. /// 事前申请明细报表骨架(ExpenseApplyDetailReportPage)
  507. /// 布局:筛选栏 + 统计卡片(1行2个) + 折线图(单线) + 明细列表
  508. class SkeletonExpenseApplyDetailReportPage extends StatelessWidget {
  509. const SkeletonExpenseApplyDetailReportPage({super.key});
  510. @override
  511. Widget build(BuildContext context) {
  512. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  513. return Column(
  514. children: [
  515. _dateFilterChip(colors),
  516. Expanded(
  517. child: SingleChildScrollView(
  518. child: Skeletonizer(
  519. enabled: true,
  520. child: Column(
  521. children: [
  522. // 统计卡片 — 1行2列
  523. Padding(
  524. padding: const EdgeInsets.fromLTRB(12, 12, 12, 0),
  525. child: Row(
  526. children: [
  527. Expanded(
  528. child: Bone(
  529. height: 80,
  530. borderRadius: BorderRadius.circular(8),
  531. ),
  532. ),
  533. const SizedBox(width: 8),
  534. Expanded(
  535. child: Bone(
  536. height: 80,
  537. borderRadius: BorderRadius.circular(8),
  538. ),
  539. ),
  540. ],
  541. ),
  542. ),
  543. const SizedBox(height: 16),
  544. // 折线图 — 标题 + 1个图例 + 图表区
  545. Padding(
  546. padding: const EdgeInsets.symmetric(horizontal: 12),
  547. child: Container(
  548. padding: const EdgeInsets.all(16),
  549. decoration: BoxDecoration(
  550. color: colors.bgCard,
  551. borderRadius: BorderRadius.circular(8),
  552. ),
  553. child: Column(
  554. crossAxisAlignment: CrossAxisAlignment.start,
  555. children: [
  556. Bone(width: 120, height: 16),
  557. const SizedBox(height: 8),
  558. Row(
  559. children: [
  560. Bone.square(
  561. size: 8,
  562. borderRadius: BorderRadius.circular(4),
  563. ),
  564. const SizedBox(width: 4),
  565. Bone(width: 60, height: 12),
  566. ],
  567. ),
  568. const SizedBox(height: 16),
  569. Row(
  570. children: [
  571. Expanded(
  572. child: Bone(
  573. height: 200,
  574. borderRadius: BorderRadius.circular(8),
  575. ),
  576. ),
  577. ],
  578. ),
  579. ],
  580. ),
  581. ),
  582. ),
  583. const SizedBox(height: 16),
  584. // 明细列表
  585. _detailListSkeleton(colors),
  586. const SizedBox(height: 80),
  587. ],
  588. ),
  589. ),
  590. ),
  591. ),
  592. ],
  593. );
  594. }
  595. }
  596. /// 费用报销明细报表骨架(ExpenseDetailReportPage)
  597. /// 布局:筛选栏 + 统计卡片(2+1) + 双折线图 + 柱形图 + 明细列表
  598. class SkeletonExpenseDetailReportPage extends StatelessWidget {
  599. const SkeletonExpenseDetailReportPage({super.key});
  600. @override
  601. Widget build(BuildContext context) {
  602. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  603. return Column(
  604. children: [
  605. _dateFilterChip(colors),
  606. Expanded(
  607. child: SingleChildScrollView(
  608. child: Skeletonizer(
  609. enabled: true,
  610. child: Column(
  611. children: [
  612. // 统计卡片 — 1行2列 + 1个全宽
  613. Padding(
  614. padding: const EdgeInsets.fromLTRB(12, 12, 12, 0),
  615. child: Row(
  616. children: [
  617. Expanded(
  618. child: Bone(
  619. height: 80,
  620. borderRadius: BorderRadius.circular(8),
  621. ),
  622. ),
  623. const SizedBox(width: 8),
  624. Expanded(
  625. child: Bone(
  626. height: 80,
  627. borderRadius: BorderRadius.circular(8),
  628. ),
  629. ),
  630. ],
  631. ),
  632. ),
  633. const SizedBox(height: 8),
  634. Padding(
  635. padding: const EdgeInsets.symmetric(horizontal: 12),
  636. child: Row(
  637. children: [
  638. Expanded(
  639. child: Bone(
  640. height: 80,
  641. borderRadius: BorderRadius.circular(8),
  642. ),
  643. ),
  644. ],
  645. ),
  646. ),
  647. const SizedBox(height: 16),
  648. // 双折线图 — 标题 + 2个图例 + 图表区
  649. Padding(
  650. padding: const EdgeInsets.symmetric(horizontal: 12),
  651. child: Container(
  652. padding: const EdgeInsets.all(16),
  653. decoration: BoxDecoration(
  654. color: colors.bgCard,
  655. borderRadius: BorderRadius.circular(8),
  656. ),
  657. child: Column(
  658. crossAxisAlignment: CrossAxisAlignment.start,
  659. children: [
  660. Bone(width: 120, height: 16),
  661. const SizedBox(height: 8),
  662. Row(
  663. children: [
  664. Bone.square(
  665. size: 8,
  666. borderRadius: BorderRadius.circular(4),
  667. ),
  668. const SizedBox(width: 4),
  669. Bone(width: 60, height: 12),
  670. const SizedBox(width: 16),
  671. Bone.square(
  672. size: 8,
  673. borderRadius: BorderRadius.circular(4),
  674. ),
  675. const SizedBox(width: 4),
  676. Bone(width: 60, height: 12),
  677. ],
  678. ),
  679. const SizedBox(height: 16),
  680. Row(
  681. children: [
  682. Expanded(
  683. child: Bone(
  684. height: 200,
  685. borderRadius: BorderRadius.circular(8),
  686. ),
  687. ),
  688. ],
  689. ),
  690. ],
  691. ),
  692. ),
  693. ),
  694. const SizedBox(height: 16),
  695. // 柱形图 — 标题 + 图表区
  696. Padding(
  697. padding: const EdgeInsets.symmetric(horizontal: 12),
  698. child: Container(
  699. padding: const EdgeInsets.all(16),
  700. decoration: BoxDecoration(
  701. color: colors.bgCard,
  702. borderRadius: BorderRadius.circular(8),
  703. ),
  704. child: Column(
  705. crossAxisAlignment: CrossAxisAlignment.start,
  706. children: [
  707. Bone(width: 160, height: 16),
  708. const SizedBox(height: 16),
  709. Row(
  710. children: [
  711. Expanded(
  712. child: Bone(
  713. height: 220,
  714. borderRadius: BorderRadius.circular(8),
  715. ),
  716. ),
  717. ],
  718. ),
  719. ],
  720. ),
  721. ),
  722. ),
  723. const SizedBox(height: 12),
  724. // 明细列表
  725. _detailListSkeleton(colors),
  726. const SizedBox(height: 80),
  727. ],
  728. ),
  729. ),
  730. ),
  731. ),
  732. ],
  733. );
  734. }
  735. }
  736. /// 用车明细报表骨架(VehicleDetailReportPage)
  737. /// 布局:筛选栏 + 统计卡片(2+1) + 双折线图 + 柱形图 + 明细列表
  738. class SkeletonVehicleDetailReportPage extends StatelessWidget {
  739. const SkeletonVehicleDetailReportPage({super.key});
  740. @override
  741. Widget build(BuildContext context) {
  742. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  743. return Column(
  744. children: [
  745. _dateFilterChip(colors),
  746. Expanded(
  747. child: SingleChildScrollView(
  748. child: Skeletonizer(
  749. enabled: true,
  750. child: Column(
  751. children: [
  752. // 统计卡片 — 1行2列 + 1个全宽
  753. Padding(
  754. padding: const EdgeInsets.fromLTRB(12, 12, 12, 0),
  755. child: Row(
  756. children: [
  757. Expanded(
  758. child: Bone(
  759. height: 80,
  760. borderRadius: BorderRadius.circular(8),
  761. ),
  762. ),
  763. const SizedBox(width: 8),
  764. Expanded(
  765. child: Bone(
  766. height: 80,
  767. borderRadius: BorderRadius.circular(8),
  768. ),
  769. ),
  770. ],
  771. ),
  772. ),
  773. const SizedBox(height: 8),
  774. Padding(
  775. padding: const EdgeInsets.symmetric(horizontal: 12),
  776. child: Row(
  777. children: [
  778. Expanded(
  779. child: Bone(
  780. height: 80,
  781. borderRadius: BorderRadius.circular(8),
  782. ),
  783. ),
  784. ],
  785. ),
  786. ),
  787. const SizedBox(height: 16),
  788. // 折线图 — 标题 + 图例 + 图表区
  789. Padding(
  790. padding: const EdgeInsets.symmetric(horizontal: 12),
  791. child: Container(
  792. padding: const EdgeInsets.all(16),
  793. decoration: BoxDecoration(
  794. color: colors.bgCard,
  795. borderRadius: BorderRadius.circular(8),
  796. ),
  797. child: Column(
  798. crossAxisAlignment: CrossAxisAlignment.start,
  799. children: [
  800. Bone(width: 120, height: 16),
  801. const SizedBox(height: 8),
  802. Row(
  803. children: [
  804. Bone.square(size: 8, borderRadius: BorderRadius.circular(4)),
  805. const SizedBox(width: 4),
  806. Bone(width: 60, height: 12),
  807. ],
  808. ),
  809. const SizedBox(height: 16),
  810. Row(
  811. children: [
  812. Expanded(
  813. child: Bone(height: 200, borderRadius: BorderRadius.circular(8)),
  814. ),
  815. ],
  816. ),
  817. ],
  818. ),
  819. ),
  820. ),
  821. const SizedBox(height: 16),
  822. // 柱形图 — 标题 + 图表区
  823. Padding(
  824. padding: const EdgeInsets.symmetric(horizontal: 12),
  825. child: Container(
  826. padding: const EdgeInsets.all(16),
  827. decoration: BoxDecoration(
  828. color: colors.bgCard,
  829. borderRadius: BorderRadius.circular(8),
  830. ),
  831. child: Column(
  832. crossAxisAlignment: CrossAxisAlignment.start,
  833. children: [
  834. Bone(width: 160, height: 16),
  835. const SizedBox(height: 16),
  836. Row(
  837. children: [
  838. Expanded(
  839. child: Bone(height: 220, borderRadius: BorderRadius.circular(8)),
  840. ),
  841. ],
  842. ),
  843. ],
  844. ),
  845. ),
  846. ),
  847. const SizedBox(height: 12),
  848. _detailListSkeleton(colors),
  849. const SizedBox(height: 80),
  850. ],
  851. ),
  852. ),
  853. ),
  854. ),
  855. ],
  856. );
  857. }
  858. }
  859. // ── 日期筛选 chip 占位 ──
  860. Widget _dateFilterChip(AppColorsExtension colors) => Container(
  861. padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
  862. color: colors.bgCard,
  863. child: Container(
  864. height: 40,
  865. decoration: BoxDecoration(
  866. color: colors.bgSecondaryContainer,
  867. borderRadius: BorderRadius.circular(20),
  868. ),
  869. ),
  870. );
  871. // ── 明细列表骨架 ──
  872. Widget _detailListSkeleton(AppColorsExtension colors) => Padding(
  873. padding: const EdgeInsets.symmetric(horizontal: 12),
  874. child: Container(
  875. width: double.infinity,
  876. decoration: BoxDecoration(
  877. color: colors.bgCard,
  878. borderRadius: BorderRadius.circular(8),
  879. ),
  880. child: Column(
  881. crossAxisAlignment: CrossAxisAlignment.start,
  882. children: [
  883. Padding(
  884. padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
  885. child: Row(
  886. children: [
  887. Bone(width: 80, height: 16),
  888. const Spacer(),
  889. Bone(width: 100, height: 14),
  890. ],
  891. ),
  892. ),
  893. const Divider(height: 1),
  894. ...List.generate(4, (i) {
  895. return Column(
  896. children: [
  897. Padding(
  898. padding: const EdgeInsets.symmetric(
  899. horizontal: 16,
  900. vertical: 12,
  901. ),
  902. child: Column(
  903. crossAxisAlignment: CrossAxisAlignment.start,
  904. children: [
  905. Row(
  906. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  907. children: [
  908. Bone(width: 120 + (i % 3) * 40, height: 14),
  909. Bone(width: 80, height: 16),
  910. ],
  911. ),
  912. const SizedBox(height: 6),
  913. Row(
  914. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  915. children: [
  916. Bone(width: 90, height: 12),
  917. Bone(width: 80, height: 16),
  918. ],
  919. ),
  920. if (i == 0) ...[
  921. const SizedBox(height: 6),
  922. Bone(width: 200, height: 14),
  923. ],
  924. ],
  925. ),
  926. ),
  927. if (i < 3) const Divider(height: 1),
  928. ],
  929. );
  930. }),
  931. ],
  932. ),
  933. ),
  934. );
  935. /// 加班明细报表骨架(OvertimeDetailReportPage)
  936. /// 布局:筛选栏 + 统计卡片(2x2) + 折线图(单线) + 明细列表
  937. class SkeletonOTDetailReportPage extends StatelessWidget {
  938. const SkeletonOTDetailReportPage({super.key});
  939. @override
  940. Widget build(BuildContext context) {
  941. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  942. return Column(
  943. children: [
  944. _dateFilterChip(colors),
  945. Expanded(
  946. child: SingleChildScrollView(
  947. child: Skeletonizer(
  948. enabled: true,
  949. child: Column(
  950. children: [
  951. // 统计卡片 — 2行2列
  952. Padding(
  953. padding: const EdgeInsets.fromLTRB(12, 12, 12, 0),
  954. child: Row(
  955. children: [
  956. Expanded(
  957. child: Bone(
  958. height: 80,
  959. borderRadius: BorderRadius.circular(8),
  960. ),
  961. ),
  962. const SizedBox(width: 8),
  963. Expanded(
  964. child: Bone(
  965. height: 80,
  966. borderRadius: BorderRadius.circular(8),
  967. ),
  968. ),
  969. ],
  970. ),
  971. ),
  972. const SizedBox(height: 8),
  973. Padding(
  974. padding: const EdgeInsets.symmetric(horizontal: 12),
  975. child: Row(
  976. children: [
  977. Expanded(
  978. child: Bone(
  979. height: 80,
  980. borderRadius: BorderRadius.circular(8),
  981. ),
  982. ),
  983. const SizedBox(width: 8),
  984. Expanded(
  985. child: Bone(
  986. height: 80,
  987. borderRadius: BorderRadius.circular(8),
  988. ),
  989. ),
  990. ],
  991. ),
  992. ),
  993. const SizedBox(height: 16),
  994. // 折线图 — 标题 + 1个图例 + 图表区
  995. Padding(
  996. padding: const EdgeInsets.symmetric(horizontal: 12),
  997. child: Container(
  998. padding: const EdgeInsets.all(16),
  999. decoration: BoxDecoration(
  1000. color: colors.bgCard,
  1001. borderRadius: BorderRadius.circular(8),
  1002. ),
  1003. child: Column(
  1004. crossAxisAlignment: CrossAxisAlignment.start,
  1005. children: [
  1006. Bone(width: 120, height: 16),
  1007. const SizedBox(height: 8),
  1008. Row(
  1009. children: [
  1010. Bone.square(
  1011. size: 8,
  1012. borderRadius: BorderRadius.circular(4),
  1013. ),
  1014. const SizedBox(width: 4),
  1015. Bone(width: 60, height: 12),
  1016. ],
  1017. ),
  1018. const SizedBox(height: 16),
  1019. Row(
  1020. children: [
  1021. Expanded(
  1022. child: Bone(
  1023. height: 200,
  1024. borderRadius: BorderRadius.circular(8),
  1025. ),
  1026. ),
  1027. ],
  1028. ),
  1029. ],
  1030. ),
  1031. ),
  1032. ),
  1033. const SizedBox(height: 16),
  1034. // 明细列表
  1035. _detailListSkeleton(colors),
  1036. const SizedBox(height: 80),
  1037. ],
  1038. ),
  1039. ),
  1040. ),
  1041. ),
  1042. ],
  1043. );
  1044. }
  1045. }