app_skeletons.dart 36 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097
  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. // 双折线图 — 标题 + 2个图例 + 图表区
  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(
  805. size: 8,
  806. borderRadius: BorderRadius.circular(4),
  807. ),
  808. const SizedBox(width: 4),
  809. Bone(width: 60, height: 12),
  810. const SizedBox(width: 16),
  811. Bone.square(
  812. size: 8,
  813. borderRadius: BorderRadius.circular(4),
  814. ),
  815. const SizedBox(width: 4),
  816. Bone(width: 60, height: 12),
  817. ],
  818. ),
  819. const SizedBox(height: 16),
  820. Row(
  821. children: [
  822. Expanded(
  823. child: Bone(
  824. height: 200,
  825. borderRadius: BorderRadius.circular(8),
  826. ),
  827. ),
  828. ],
  829. ),
  830. ],
  831. ),
  832. ),
  833. ),
  834. const SizedBox(height: 16),
  835. // 柱形图 — 标题 + 图表区
  836. Padding(
  837. padding: const EdgeInsets.symmetric(horizontal: 12),
  838. child: Container(
  839. padding: const EdgeInsets.all(16),
  840. decoration: BoxDecoration(
  841. color: colors.bgCard,
  842. borderRadius: BorderRadius.circular(8),
  843. ),
  844. child: Column(
  845. crossAxisAlignment: CrossAxisAlignment.start,
  846. children: [
  847. Bone(width: 160, height: 16),
  848. const SizedBox(height: 16),
  849. Row(
  850. children: [
  851. Expanded(
  852. child: Bone(
  853. height: 220,
  854. borderRadius: BorderRadius.circular(8),
  855. ),
  856. ),
  857. ],
  858. ),
  859. ],
  860. ),
  861. ),
  862. ),
  863. const SizedBox(height: 12),
  864. // 明细列表
  865. _detailListSkeleton(colors),
  866. const SizedBox(height: 80),
  867. ],
  868. ),
  869. ),
  870. ),
  871. ),
  872. ],
  873. );
  874. }
  875. }
  876. // ── 日期筛选 chip 占位 ──
  877. Widget _dateFilterChip(AppColorsExtension colors) => Container(
  878. padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
  879. color: colors.bgCard,
  880. child: Container(
  881. height: 40,
  882. decoration: BoxDecoration(
  883. color: colors.bgSecondaryContainer,
  884. borderRadius: BorderRadius.circular(20),
  885. ),
  886. ),
  887. );
  888. // ── 明细列表骨架 ──
  889. Widget _detailListSkeleton(AppColorsExtension colors) => Padding(
  890. padding: const EdgeInsets.symmetric(horizontal: 12),
  891. child: Container(
  892. width: double.infinity,
  893. decoration: BoxDecoration(
  894. color: colors.bgCard,
  895. borderRadius: BorderRadius.circular(8),
  896. ),
  897. child: Column(
  898. crossAxisAlignment: CrossAxisAlignment.start,
  899. children: [
  900. Padding(
  901. padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
  902. child: Row(
  903. children: [
  904. Bone(width: 80, height: 16),
  905. const Spacer(),
  906. Bone(width: 100, height: 14),
  907. ],
  908. ),
  909. ),
  910. const Divider(height: 1),
  911. ...List.generate(4, (i) {
  912. return Column(
  913. children: [
  914. Padding(
  915. padding: const EdgeInsets.symmetric(
  916. horizontal: 16,
  917. vertical: 12,
  918. ),
  919. child: Column(
  920. crossAxisAlignment: CrossAxisAlignment.start,
  921. children: [
  922. Row(
  923. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  924. children: [
  925. Bone(width: 120 + (i % 3) * 40, height: 14),
  926. Bone(width: 80, height: 16),
  927. ],
  928. ),
  929. const SizedBox(height: 6),
  930. Row(
  931. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  932. children: [
  933. Bone(width: 90, height: 12),
  934. Bone(width: 80, height: 16),
  935. ],
  936. ),
  937. if (i == 0) ...[
  938. const SizedBox(height: 6),
  939. Bone(width: 200, height: 14),
  940. ],
  941. ],
  942. ),
  943. ),
  944. if (i < 3) const Divider(height: 1),
  945. ],
  946. );
  947. }),
  948. ],
  949. ),
  950. ),
  951. );
  952. /// 加班明细报表骨架(OvertimeDetailReportPage)
  953. /// 布局:筛选栏 + 统计卡片(2x2) + 折线图(单线) + 明细列表
  954. class SkeletonOTDetailReportPage extends StatelessWidget {
  955. const SkeletonOTDetailReportPage({super.key});
  956. @override
  957. Widget build(BuildContext context) {
  958. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  959. return Column(
  960. children: [
  961. _dateFilterChip(colors),
  962. Expanded(
  963. child: SingleChildScrollView(
  964. child: Skeletonizer(
  965. enabled: true,
  966. child: Column(
  967. children: [
  968. // 统计卡片 — 2行2列
  969. Padding(
  970. padding: const EdgeInsets.fromLTRB(12, 12, 12, 0),
  971. child: Row(
  972. children: [
  973. Expanded(
  974. child: Bone(
  975. height: 80,
  976. borderRadius: BorderRadius.circular(8),
  977. ),
  978. ),
  979. const SizedBox(width: 8),
  980. Expanded(
  981. child: Bone(
  982. height: 80,
  983. borderRadius: BorderRadius.circular(8),
  984. ),
  985. ),
  986. ],
  987. ),
  988. ),
  989. const SizedBox(height: 8),
  990. Padding(
  991. padding: const EdgeInsets.symmetric(horizontal: 12),
  992. child: Row(
  993. children: [
  994. Expanded(
  995. child: Bone(
  996. height: 80,
  997. borderRadius: BorderRadius.circular(8),
  998. ),
  999. ),
  1000. const SizedBox(width: 8),
  1001. Expanded(
  1002. child: Bone(
  1003. height: 80,
  1004. borderRadius: BorderRadius.circular(8),
  1005. ),
  1006. ),
  1007. ],
  1008. ),
  1009. ),
  1010. const SizedBox(height: 16),
  1011. // 折线图 — 标题 + 1个图例 + 图表区
  1012. Padding(
  1013. padding: const EdgeInsets.symmetric(horizontal: 12),
  1014. child: Container(
  1015. padding: const EdgeInsets.all(16),
  1016. decoration: BoxDecoration(
  1017. color: colors.bgCard,
  1018. borderRadius: BorderRadius.circular(8),
  1019. ),
  1020. child: Column(
  1021. crossAxisAlignment: CrossAxisAlignment.start,
  1022. children: [
  1023. Bone(width: 120, height: 16),
  1024. const SizedBox(height: 8),
  1025. Row(
  1026. children: [
  1027. Bone.square(
  1028. size: 8,
  1029. borderRadius: BorderRadius.circular(4),
  1030. ),
  1031. const SizedBox(width: 4),
  1032. Bone(width: 60, height: 12),
  1033. ],
  1034. ),
  1035. const SizedBox(height: 16),
  1036. Row(
  1037. children: [
  1038. Expanded(
  1039. child: Bone(
  1040. height: 200,
  1041. borderRadius: BorderRadius.circular(8),
  1042. ),
  1043. ),
  1044. ],
  1045. ),
  1046. ],
  1047. ),
  1048. ),
  1049. ),
  1050. const SizedBox(height: 16),
  1051. // 明细列表
  1052. _detailListSkeleton(colors),
  1053. const SizedBox(height: 80),
  1054. ],
  1055. ),
  1056. ),
  1057. ),
  1058. ),
  1059. ],
  1060. );
  1061. }
  1062. }