view.dart 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667
  1. import 'package:flutter/material.dart';
  2. import 'package:get/get.dart';
  3. import 'package:intl/intl.dart';
  4. import 'package:vitalapp/architecture/utils/advance_debounce.dart';
  5. import 'package:vitalapp/architecture/utils/datetime.dart';
  6. import 'package:vitalapp/architecture/utils/prompt_box.dart';
  7. import 'package:vitalapp/architecture/utils/sensitive.dart';
  8. import 'package:vitalapp/architecture/values/features.dart';
  9. import 'package:vitalapp/components/alert_dialog.dart';
  10. import 'package:vitalapp/components/button.dart';
  11. import 'package:vitalapp/components/dialog_date.dart';
  12. import 'package:vitalapp/components/dynamic_drawer.dart';
  13. import 'package:vitalapp/components/input.dart';
  14. import 'package:vitalapp/components/search_input.dart';
  15. import 'package:vitalapp/consts/rpc_enum_labels.dart';
  16. import 'package:vitalapp/consts/styles.dart';
  17. import 'package:vitalapp/global.dart';
  18. import 'package:vitalapp/managers/contract/index.dart';
  19. import 'package:vitalapp/managers/interfaces/models/patient_model_dto.dart';
  20. import 'package:vitalapp/pages/home/controller.dart';
  21. import 'package:vitalapp/pages/patient/create/controller.dart';
  22. import 'package:vitalapp/pages/patient/list/widgets/status.dart';
  23. import 'package:vitalapp/store/store.dart';
  24. import 'controller.dart';
  25. class PatientListPage extends GetView<PatientListController> {
  26. const PatientListPage({super.key});
  27. @override
  28. Widget build(BuildContext context) {
  29. return GetBuilder(
  30. init: PatientListController(),
  31. id: "PatientListPage",
  32. builder: (_) {
  33. return Container(
  34. margin: const EdgeInsets.all(16),
  35. child: Column(
  36. children: [
  37. _HeaderWidget(
  38. onFilterPressed: () {
  39. VDynamicDrawerWrapper.show(
  40. scaffoldKey: Get.find<HomeController>().homeScaffoldKey,
  41. builder: (_) => _filterdrawer(context),
  42. );
  43. // scaffoldKey.currentState?.openEndDrawer();
  44. },
  45. ),
  46. const SizedBox(height: 20),
  47. Expanded(child: _buildListView()),
  48. ],
  49. ),
  50. );
  51. });
  52. }
  53. VDrawer _filterdrawer(BuildContext context) {
  54. final scrollController = ScrollController();
  55. controller.crowdLabelsController.onReady();
  56. return VDrawer(
  57. width: 600,
  58. title: "筛选",
  59. scaffoldKey: Get.find<HomeController>().homeScaffoldKey,
  60. onConfirm: () {
  61. var state = controller.state;
  62. var startTime = state.startTime.value;
  63. var endTime = state.endTime.value;
  64. if (startTime != null &&
  65. endTime != null &&
  66. endTime.difference(startTime).inSeconds < 0) {
  67. PromptBox.toast('起始时间不能晚于结束时间');
  68. return;
  69. }
  70. controller.reloadList(isFilter: true);
  71. // Get.back();
  72. VDynamicDrawerWrapper.hide(
  73. scaffoldKey: Get.find<HomeController>().homeScaffoldKey,
  74. );
  75. },
  76. onCancel: () {
  77. // Get.back();
  78. VDynamicDrawerWrapper.hide(
  79. scaffoldKey: Get.find<HomeController>().homeScaffoldKey,
  80. );
  81. },
  82. child: Scrollbar(
  83. controller: scrollController,
  84. thumbVisibility: true,
  85. child: SingleChildScrollView(
  86. controller: scrollController,
  87. child: Padding(
  88. padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 20),
  89. child: Column(
  90. crossAxisAlignment: CrossAxisAlignment.start,
  91. children: [
  92. const Text(
  93. '居民创建时间:',
  94. style: TextStyle(fontSize: 20),
  95. ),
  96. const SizedBox(
  97. height: 20,
  98. ),
  99. Row(
  100. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  101. mainAxisSize: MainAxisSize.max,
  102. children: [
  103. Expanded(
  104. child: Obx(
  105. () => VInput(
  106. readOnly: true,
  107. controller: TextEditingController(
  108. text: DateFormat('yyyy-MM-dd').format(
  109. controller.state.startTime.value!.toLocal(),
  110. ),
  111. ),
  112. radius: 4,
  113. onTap: () async {
  114. final result = await VDialogDate(
  115. maxValue: controller.state.endTime.value,
  116. title: '起始时间',
  117. initialValue: controller.state.startTime.value,
  118. ).show();
  119. controller.state.startTime.value = result;
  120. },
  121. ),
  122. ),
  123. ),
  124. Container(
  125. margin: const EdgeInsets.symmetric(horizontal: 16),
  126. child: const Text('一')),
  127. Expanded(
  128. child: Obx(
  129. () => VInput(
  130. readOnly: true,
  131. controller: TextEditingController(
  132. text: DateFormat('yyyy-MM-dd').format(
  133. controller.state.endTime.value!.toLocal(),
  134. ),
  135. ),
  136. radius: 4,
  137. onTap: () async {
  138. final result = await VDialogDate(
  139. title: '结束时间',
  140. initialValue: controller.state.endTime.value,
  141. ).show();
  142. controller.state.endTime.value = result;
  143. },
  144. ),
  145. ),
  146. )
  147. ],
  148. ),
  149. // const SizedBox(
  150. // height: 20,
  151. // ),
  152. // const Text(
  153. // '人群分类:',
  154. // style: TextStyle(fontSize: 20),
  155. // ),
  156. // const SizedBox(
  157. // height: 20,
  158. // ),
  159. // CrowdSelectLabelView(
  160. // controller: controller.crowdLabelsController,
  161. // ),
  162. ],
  163. ),
  164. ),
  165. ),
  166. ),
  167. );
  168. }
  169. Widget _buildListView() {
  170. final scrollController = ScrollController();
  171. scrollController.addListener(
  172. () {
  173. // 如果滑动到底部
  174. try {
  175. if (scrollController.position.atEdge) {
  176. if (scrollController.position.pixels != 0) {
  177. if (controller.state.hasNextPage) {
  178. controller.loadNextPageList();
  179. }
  180. }
  181. }
  182. } catch (e) {
  183. // logger.e("listViewScrollController exception:", e);
  184. }
  185. },
  186. );
  187. return RefreshIndicator(
  188. onRefresh: () async {
  189. controller.reloadList();
  190. },
  191. child: Obx(
  192. () {
  193. final list = controller.state.dataList;
  194. final children = <Widget>[];
  195. for (var i = 0; i < list.length; i++) {
  196. children.add(_PatientCard(dto: list[i]));
  197. }
  198. return children.isEmpty
  199. ? Container(
  200. margin: const EdgeInsets.only(top: 80),
  201. child: Column(
  202. children: [
  203. Center(
  204. child: Image.asset(
  205. "assets/images/no_data.png",
  206. width: 300,
  207. height: 300,
  208. fit: BoxFit.cover,
  209. ),
  210. ),
  211. const Text(
  212. "暂无数据,先看看别的吧",
  213. style: TextStyle(fontSize: 18),
  214. ),
  215. ],
  216. ),
  217. )
  218. : Scrollbar(
  219. trackVisibility: true,
  220. controller: scrollController,
  221. child: GridView(
  222. shrinkWrap: true,
  223. controller: scrollController,
  224. gridDelegate:
  225. const SliverGridDelegateWithFixedCrossAxisCount(
  226. crossAxisCount: 3,
  227. mainAxisSpacing: 16,
  228. crossAxisSpacing: 20,
  229. childAspectRatio: 360 / 180,
  230. ),
  231. children: children,
  232. ),
  233. );
  234. },
  235. ),
  236. );
  237. }
  238. }
  239. class _HeaderWidget extends GetView<PatientListController> {
  240. final searchTextEditingController = TextEditingController();
  241. final VoidCallback onFilterPressed;
  242. _HeaderWidget({
  243. required this.onFilterPressed,
  244. });
  245. final createPatientController = Get.find<CreatePatientController>();
  246. Widget _buildIconButton(
  247. IconData iconData,
  248. String textString,
  249. VoidCallback voidCallback,
  250. ) {
  251. return Material(
  252. child: InkWell(
  253. onTap: () => voidCallback.call(),
  254. child: Container(
  255. margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 20),
  256. child: Column(
  257. children: [
  258. Icon(
  259. iconData,
  260. size: 38,
  261. ),
  262. Text(textString),
  263. ],
  264. ),
  265. ),
  266. ),
  267. );
  268. }
  269. // VSideNavMenuItem _buildCreateManualRecord() {
  270. // return VSideNavMenuItem(
  271. // title: "手动建档",
  272. // icon: Icon(Icons.edit_document, color: Colors.grey.shade700),
  273. // pageBuilder: (_) => _buildInterval(
  274. // PatientInfo(),
  275. // ),
  276. // );
  277. // }
  278. // VSideNavMenuItem _buildIDReaderRecord() {
  279. // return VSideNavMenuItem(
  280. // title: "读卡建档",
  281. // icon: Icon(Icons.chrome_reader_mode, color: Colors.grey.shade700),
  282. // onTap: () {
  283. // controller.onReadCardClicked();
  284. // },
  285. // );
  286. // }
  287. // VSideNavMenuItem _buildScanIdCardRecord() {
  288. // return VSideNavMenuItem(
  289. // title: "身份证识别建档",
  290. // icon: Icon(Icons.perm_contact_cal_rounded, color: Colors.grey.shade700),
  291. // onTap: () {
  292. // if (!kIsOnline) {
  293. // PromptBox.toast("当前为离线模式,不支持此功能");
  294. // return;
  295. // }
  296. // controller.onIdCardScanClicked();
  297. // },
  298. // );
  299. // }
  300. @override
  301. Widget build(BuildContext context) {
  302. return SizedBox(
  303. height: 76,
  304. child: Row(
  305. children: [
  306. _PatientStatisticWidget(),
  307. if (Store.user.hasFeature(FeatureKeys.FaceRecognition))
  308. _buildIconButton(Icons.sensor_occupied, '人脸识别', () {
  309. if (!kIsOnline) {
  310. PromptBox.toast("当前为离线模式,不支持此功能");
  311. return;
  312. }
  313. advanceDebounce(
  314. createPatientController.onFaceIdLoginClicked,
  315. "PatientList.OnFaceIdLoginClicked",
  316. 1500,
  317. );
  318. }),
  319. if (Store.user.hasFeature(FeatureKeys.IdCardPhotoOCR))
  320. _buildIconButton(Icons.perm_contact_cal_rounded, '拍照识别', () {
  321. if (!Store.user
  322. .hasFeature(FeatureKeys.IdCardOfflineRecognition)) {
  323. if (!kIsOnline) {
  324. PromptBox.toast("当前为离线模式,不支持此功能");
  325. return;
  326. }
  327. }
  328. advanceDebounce(
  329. createPatientController.onIdCardScanClickedToDetail,
  330. "PatientList.OnIdCardScanClickedToDetail",
  331. 1500,
  332. );
  333. }),
  334. if (Store.user.hasFeature(FeatureKeys.IDCardReader))
  335. _buildIconButton(Icons.chrome_reader_mode, '读卡识别', () {
  336. advanceDebounce(
  337. createPatientController.onReadCardClickedToDetail,
  338. "PatientList.OnReadCardClickedToDetail",
  339. 1500,
  340. );
  341. }),
  342. Expanded(
  343. child: SizedBox(
  344. height: 70,
  345. child: Obx(
  346. () => VSearchInput(
  347. textEditingController: searchTextEditingController,
  348. placeholder:
  349. "身份证号码/姓名${controller.state.isOnline ? '/手机号' : ''}",
  350. clearable: true,
  351. onClear: () {},
  352. onSearch: (value) {
  353. controller.state.searchString = value;
  354. controller.reloadList();
  355. },
  356. ),
  357. ),
  358. ),
  359. ),
  360. const SizedBox(width: 8),
  361. SizedBox(
  362. width: 180,
  363. height: 70,
  364. child: VButton(
  365. onTap: onFilterPressed,
  366. child: Row(
  367. mainAxisAlignment: MainAxisAlignment.center,
  368. children: const [
  369. Icon(Icons.filter_alt, size: 24),
  370. Text("筛选", style: TextStyle(fontSize: 20)),
  371. ],
  372. ),
  373. ),
  374. ),
  375. ],
  376. ),
  377. );
  378. }
  379. }
  380. class _PatientCard extends StatelessWidget {
  381. final PatientModelDTO dto;
  382. const _PatientCard({required this.dto});
  383. @override
  384. Widget build(BuildContext context) {
  385. final body = Stack(
  386. children: [
  387. Container(
  388. padding: const EdgeInsets.symmetric(
  389. horizontal: 16,
  390. vertical: 12,
  391. ),
  392. child: Column(
  393. crossAxisAlignment: CrossAxisAlignment.start,
  394. children: [
  395. const SizedBox(height: 8),
  396. SizedBox(
  397. child: Text(
  398. dto.patientName!,
  399. overflow: TextOverflow.ellipsis,
  400. style: const TextStyle(
  401. color: Colors.black,
  402. fontSize: 26,
  403. fontWeight: FontWeight.bold,
  404. ),
  405. ),
  406. ),
  407. const SizedBox(height: 8),
  408. LayoutBuilder(
  409. builder: (context, c) {
  410. final width = c.maxWidth - 80;
  411. // 不和状态标签重叠,并保持一定距离
  412. return SizedBox(width: width, child: _buildBaseInfoRow());
  413. },
  414. ),
  415. const SizedBox(height: 8),
  416. // Expanded(child: _buildClassTags()),
  417. // _buildClassTags(),
  418. _buildPhone(),
  419. const SizedBox(height: 4),
  420. _buildCardNo(),
  421. ],
  422. ),
  423. ),
  424. Positioned(
  425. top: 0,
  426. right: 0,
  427. child: _PatientSignStatusTag(
  428. dto: dto,
  429. ),
  430. ),
  431. Positioned(
  432. right: 0,
  433. bottom: 0,
  434. child: _PatientRemoveMarkButton(dto: dto),
  435. ),
  436. ],
  437. );
  438. return Material(
  439. borderRadius: GlobalStyles.borderRadius,
  440. child: Ink(
  441. decoration: BoxDecoration(
  442. color: Colors.white,
  443. borderRadius: GlobalStyles.borderRadius,
  444. border: Border.all(color: Colors.grey.shade400),
  445. ),
  446. child: InkWell(
  447. borderRadius: GlobalStyles.borderRadius,
  448. onTap: () {
  449. // Get.back();
  450. Get.find<PatientListController>().patientListGotoDetail(dto);
  451. },
  452. child: body,
  453. ),
  454. ),
  455. );
  456. }
  457. Widget _buildBaseInfoRow() {
  458. final birthday = dto.birthday!.toLocal();
  459. final age = DataTimeUtils.calculateAge(birthday);
  460. return Row(
  461. mainAxisSize: MainAxisSize.max,
  462. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  463. children: [
  464. Expanded(
  465. child: Row(
  466. mainAxisSize: MainAxisSize.max,
  467. mainAxisAlignment: MainAxisAlignment.start,
  468. children: [
  469. Expanded(
  470. child: Text(
  471. RpcEnumLabels.gender[dto.patientGender] ?? "未知",
  472. style: const TextStyle(
  473. color: Colors.grey,
  474. fontSize: 20,
  475. fontWeight: FontWeight.bold,
  476. ),
  477. ),
  478. ),
  479. Expanded(
  480. child: Text(
  481. "$age岁",
  482. style: const TextStyle(
  483. color: Colors.grey,
  484. fontSize: 20,
  485. ),
  486. ),
  487. ),
  488. ],
  489. ),
  490. ),
  491. ],
  492. );
  493. }
  494. Widget _buildClassTags() {
  495. return Column(
  496. children: [
  497. ConstrainedBox(
  498. constraints: const BoxConstraints(
  499. minWidth: double.infinity,
  500. maxHeight: 50,
  501. ),
  502. child: Text(
  503. dto.labelNames?.join(' ') ?? '',
  504. style: const TextStyle(color: Colors.grey, fontSize: 18),
  505. maxLines: 2,
  506. overflow: TextOverflow.ellipsis,
  507. ),
  508. ),
  509. ],
  510. );
  511. }
  512. Widget _buildPhone() {
  513. if (dto.phone != null && dto.phone!.isNotEmpty) {
  514. String phone = dto.phone!;
  515. if (Store.app.enableEncryptSensitiveInfo) {
  516. phone = SensitiveUtils.desensitizeMobilePhone(phone);
  517. }
  518. return Text(
  519. '手机号:$phone',
  520. style: const TextStyle(color: Colors.grey, fontSize: 18),
  521. );
  522. } else {
  523. return const SizedBox();
  524. }
  525. }
  526. Widget _buildCardNo() {
  527. if (dto.cardNo != null && dto.cardNo!.isNotEmpty) {
  528. String cardNo = dto.cardNo!;
  529. if (Store.app.enableEncryptSensitiveInfo) {
  530. cardNo = SensitiveUtils.desensitizeIdCard(cardNo);
  531. }
  532. return Text(
  533. '证件号码:$cardNo',
  534. style: const TextStyle(color: Colors.grey, fontSize: 18),
  535. );
  536. } else {
  537. return const SizedBox();
  538. }
  539. }
  540. }
  541. class _PatientSignStatusTag extends StatelessWidget {
  542. final PatientModelDTO dto;
  543. _PatientSignStatusTag({required this.dto});
  544. final ContractUtils _contractUtils = ContractUtils();
  545. @override
  546. Widget build(BuildContext context) {
  547. return Container(
  548. alignment: Alignment.centerRight,
  549. width: 120,
  550. padding: const EdgeInsets.only(top: 18),
  551. child: StatusLabel(
  552. title: _contractUtils.dataOfflineStatus(dto.isExistLocalData!),
  553. color: _contractUtils.dataOfflineColor(dto.isExistLocalData!),
  554. ),
  555. );
  556. }
  557. }
  558. class _PatientRemoveMarkButton extends StatelessWidget {
  559. final PatientModelDTO dto;
  560. const _PatientRemoveMarkButton({super.key, required this.dto});
  561. @override
  562. Widget build(BuildContext context) {
  563. return GestureDetector(
  564. onTap: () {
  565. Get.dialog(
  566. VAlertDialog(
  567. title: "提示",
  568. width: 260,
  569. content: Container(
  570. height: 32,
  571. padding: const EdgeInsets.symmetric(horizontal: 24),
  572. alignment: Alignment.center,
  573. child: Text(
  574. "确定删除 ${dto.patientName}?",
  575. style: TextStyle(fontSize: 20),
  576. ),
  577. ),
  578. onConfirm: () async {
  579. Get.find<PatientListController>().removePatient(dto.code!);
  580. Get.back();
  581. },
  582. ),
  583. barrierDismissible: false,
  584. barrierColor: Colors.black.withOpacity(.4),
  585. );
  586. },
  587. child: Container(
  588. padding: EdgeInsets.only(top: 10, left: 10, right: 2, bottom: 2),
  589. decoration: BoxDecoration(
  590. color: Colors.red,
  591. borderRadius: BorderRadius.only(
  592. topLeft: Radius.circular(32),
  593. bottomRight: Radius.circular(4),
  594. ),
  595. ),
  596. child: Icon(
  597. Icons.delete_forever,
  598. size: 26,
  599. color: Colors.white,
  600. ),
  601. ),
  602. );
  603. }
  604. }
  605. class _PatientStatisticWidget extends StatelessWidget {
  606. @override
  607. Widget build(BuildContext context) {
  608. final controller = Get.find<PatientListController>();
  609. final state = controller.state;
  610. return Container(
  611. width: 120,
  612. alignment: Alignment.centerLeft,
  613. child: Obx(() {
  614. return Column(
  615. mainAxisAlignment: MainAxisAlignment.center,
  616. crossAxisAlignment: CrossAxisAlignment.start,
  617. children: [
  618. _buildItem(context, "当日数量: ", state.statisticTodayCount),
  619. const SizedBox(height: 8),
  620. _buildItem(context, "总共数量: ", state.statisticTotalCount),
  621. ],
  622. );
  623. }),
  624. );
  625. }
  626. Widget _buildItem(BuildContext context, String label, int count) {
  627. return RichText(
  628. text: TextSpan(
  629. style: TextStyle(fontSize: 16, color: Colors.black),
  630. children: [
  631. TextSpan(text: label),
  632. TextSpan(text: " "),
  633. TextSpan(
  634. text: "${count}",
  635. style:
  636. TextStyle(fontSize: 18, color: Theme.of(context).primaryColor),
  637. ),
  638. ],
  639. ),
  640. );
  641. }
  642. }