view.dart 17 KB

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