智慧水务管理系统 - 精河县供水工程综合管理平台

task_detail_page.dart 23KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715
  1. import 'package:flutter/material.dart';
  2. import '../../services/patrol_service.dart';
  3. import 'track_page.dart';
  4. /// 巡检任务详情页面
  5. class TaskDetailPage extends StatefulWidget {
  6. final String taskId;
  7. const TaskDetailPage({super.key, required this.taskId});
  8. @override
  9. State<TaskDetailPage> createState() => _TaskDetailPageState();
  10. }
  11. class _TaskDetailPageState extends State<TaskDetailPage> {
  12. final PatrolService _service = PatrolService.instance;
  13. PatrolTask? _task;
  14. bool _loading = true;
  15. bool _submitting = false;
  16. int _currentPointIndex = 0;
  17. @override
  18. void initState() {
  19. super.initState();
  20. _loadTask();
  21. }
  22. Future<void> _loadTask() async {
  23. setState(() => _loading = true);
  24. final task = await _service.getTaskDetail(widget.taskId);
  25. if (mounted) {
  26. setState(() {
  27. _task = task;
  28. _loading = false;
  29. // 找到第一个未完成的巡检点
  30. if (task != null && task.status == PatrolTaskStatus.ongoing) {
  31. for (int i = 0; i < task.points.length; i++) {
  32. if (task.points[i].status == null) {
  33. _currentPointIndex = i;
  34. break;
  35. }
  36. }
  37. }
  38. });
  39. }
  40. }
  41. @override
  42. Widget build(BuildContext context) {
  43. if (_loading) {
  44. return Scaffold(
  45. appBar: AppBar(title: const Text('任务详情')),
  46. body: const Center(child: CircularProgressIndicator()),
  47. );
  48. }
  49. if (_task == null) {
  50. return Scaffold(
  51. appBar: AppBar(title: const Text('任务详情')),
  52. body: const Center(child: Text('任务不存在')),
  53. );
  54. }
  55. return Scaffold(
  56. appBar: AppBar(
  57. title: Text(_task!.routeName),
  58. actions: [
  59. IconButton(
  60. icon: const Icon(Icons.map),
  61. tooltip: 'GPS轨迹',
  62. onPressed: () => _openTrackPage(),
  63. ),
  64. ],
  65. ),
  66. body: SingleChildScrollView(
  67. padding: const EdgeInsets.all(16),
  68. child: Column(
  69. crossAxisAlignment: CrossAxisAlignment.start,
  70. children: [
  71. _buildInfoCard(),
  72. const SizedBox(height: 16),
  73. _buildProgressCard(),
  74. const SizedBox(height: 16),
  75. _buildPointsSection(),
  76. if (_task!.status == PatrolTaskStatus.ongoing) ...[
  77. const SizedBox(height: 16),
  78. _buildReportSection(),
  79. ],
  80. ],
  81. ),
  82. ),
  83. );
  84. }
  85. Widget _buildInfoCard() {
  86. return Card(
  87. shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
  88. child: Padding(
  89. padding: const EdgeInsets.all(16),
  90. child: Column(
  91. crossAxisAlignment: CrossAxisAlignment.start,
  92. children: [
  93. Row(
  94. children: [
  95. const Icon(Icons.info_outline, size: 20, color: Colors.blue),
  96. const SizedBox(width: 8),
  97. Text('任务信息',
  98. style: Theme.of(context).textTheme.titleMedium),
  99. ],
  100. ),
  101. const Divider(height: 20),
  102. _InfoRow(label: '任务编号', value: _task!.id),
  103. _InfoRow(label: '巡检路线', value: _task!.routeName),
  104. _InfoRow(label: '计划日期', value: _task!.dateStr),
  105. _InfoRow(label: '执行人', value: _task!.assignee),
  106. _InfoRow(label: '巡检点数', value: '${_task!.totalPoints}个'),
  107. _InfoRow(label: '任务描述', value: _task!.description),
  108. if (_task!.status == PatrolTaskStatus.completed) ...[
  109. if (_task!.result != null)
  110. _InfoRow(
  111. label: '巡检结果',
  112. value: _task!.result!.label,
  113. valueColor: _task!.result == PatrolResult.normal
  114. ? Colors.green
  115. : Colors.orange,
  116. ),
  117. if (_task!.issues != null)
  118. _InfoRow(label: '发现问题', value: _task!.issues!),
  119. if (_task!.reportTime != null)
  120. _InfoRow(
  121. label: '上报时间',
  122. value:
  123. '${_task!.reportTime!.hour.toString().padLeft(2, '0')}:'
  124. '${_task!.reportTime!.minute.toString().padLeft(2, '0')}',
  125. ),
  126. ],
  127. ],
  128. ),
  129. ),
  130. );
  131. }
  132. Widget _buildProgressCard() {
  133. return Card(
  134. shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
  135. child: Padding(
  136. padding: const EdgeInsets.all(16),
  137. child: Column(
  138. crossAxisAlignment: CrossAxisAlignment.start,
  139. children: [
  140. Row(
  141. children: [
  142. Icon(
  143. _task!.status == PatrolTaskStatus.completed
  144. ? Icons.check_circle
  145. : _task!.status == PatrolTaskStatus.ongoing
  146. ? Icons.play_circle
  147. : Icons.schedule,
  148. size: 20,
  149. color: _task!.status == PatrolTaskStatus.completed
  150. ? Colors.green
  151. : _task!.status == PatrolTaskStatus.ongoing
  152. ? Colors.blue
  153. : Colors.orange,
  154. ),
  155. const SizedBox(width: 8),
  156. Text('巡检进度',
  157. style: Theme.of(context).textTheme.titleMedium),
  158. ],
  159. ),
  160. const SizedBox(height: 12),
  161. Row(
  162. children: [
  163. Expanded(
  164. child: ClipRRect(
  165. borderRadius: BorderRadius.circular(4),
  166. child: LinearProgressIndicator(
  167. value: _task!.progress,
  168. backgroundColor: Colors.grey.shade200,
  169. minHeight: 8,
  170. ),
  171. ),
  172. ),
  173. const SizedBox(width: 12),
  174. Text(
  175. '${(_task!.progress * 100).toInt()}%',
  176. style: const TextStyle(
  177. fontWeight: FontWeight.w600, fontSize: 16),
  178. ),
  179. ],
  180. ),
  181. const SizedBox(height: 8),
  182. Text(
  183. '已完成 ${_task!.completedPoints} / ${_task!.totalPoints} 个巡检点',
  184. style: TextStyle(fontSize: 13, color: Colors.grey.shade600),
  185. ),
  186. ],
  187. ),
  188. ),
  189. );
  190. }
  191. Widget _buildPointsSection() {
  192. return Card(
  193. shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
  194. child: Padding(
  195. padding: const EdgeInsets.all(16),
  196. child: Column(
  197. crossAxisAlignment: CrossAxisAlignment.start,
  198. children: [
  199. Row(
  200. children: [
  201. const Icon(Icons.location_on, size: 20, color: Colors.red),
  202. const SizedBox(width: 8),
  203. Text('巡检点列表',
  204. style: Theme.of(context).textTheme.titleMedium),
  205. ],
  206. ),
  207. const SizedBox(height: 12),
  208. ...List.generate(_task!.points.length, (i) {
  209. final point = _task!.points[i];
  210. final isCompleted = point.status != null;
  211. final isCurrent =
  212. _task!.status == PatrolTaskStatus.ongoing &&
  213. i == _currentPointIndex;
  214. return _PointTile(
  215. index: i + 1,
  216. point: point,
  217. isCompleted: isCompleted,
  218. isCurrent: isCurrent,
  219. onTap: _task!.status == PatrolTaskStatus.ongoing
  220. ? () => _selectPoint(i)
  221. : null,
  222. );
  223. }),
  224. ],
  225. ),
  226. ),
  227. );
  228. }
  229. Widget _buildReportSection() {
  230. final currentPoint =
  231. _currentPointIndex < _task!.points.length
  232. ? _task!.points[_currentPointIndex]
  233. : null;
  234. if (currentPoint == null || currentPoint.status != null) {
  235. return Card(
  236. shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
  237. child: Padding(
  238. padding: const EdgeInsets.all(24),
  239. child: Column(
  240. children: [
  241. const Icon(Icons.check_circle, size: 48, color: Colors.green),
  242. const SizedBox(height: 12),
  243. const Text('所有巡检点已完成上报',
  244. style: TextStyle(fontSize: 16)),
  245. const SizedBox(height: 16),
  246. FilledButton.icon(
  247. onPressed: _submitting ? null : _completeTask,
  248. icon: _submitting
  249. ? const SizedBox(
  250. width: 16,
  251. height: 16,
  252. child: CircularProgressIndicator(
  253. strokeWidth: 2, color: Colors.white))
  254. : const Icon(Icons.send),
  255. label: Text(_submitting ? '提交中...' : '完成巡检任务'),
  256. ),
  257. ],
  258. ),
  259. ),
  260. );
  261. }
  262. return _PointReportForm(
  263. point: currentPoint,
  264. pointIndex: _currentPointIndex + 1,
  265. submitting: _submitting,
  266. onSubmit: (status, remark, photos) =>
  267. _submitPointReport(currentPoint, status, remark, photos),
  268. );
  269. }
  270. void _selectPoint(int index) {
  271. setState(() => _currentPointIndex = index);
  272. }
  273. Future<void> _submitPointReport(
  274. PatrolPoint point,
  275. PatrolPointStatus status,
  276. String? remark,
  277. List<String>? photos,
  278. ) async {
  279. setState(() => _submitting = true);
  280. final success = await _service.submitPointReport(
  281. taskId: _task!.id,
  282. pointId: point.id,
  283. status: status,
  284. remark: remark,
  285. photoPaths: photos,
  286. );
  287. if (mounted) {
  288. setState(() {
  289. _submitting = false;
  290. if (success) {
  291. point.status = status;
  292. point.remark = remark;
  293. point.photos = photos;
  294. // 移到下一个未完成的点
  295. for (int i = 0; i < _task!.points.length; i++) {
  296. if (_task!.points[i].status == null) {
  297. _currentPointIndex = i;
  298. break;
  299. }
  300. }
  301. }
  302. });
  303. if (success) {
  304. ScaffoldMessenger.of(context).showSnackBar(
  305. SnackBar(
  306. content: Text('${point.name} 上报成功'),
  307. backgroundColor: Colors.green,
  308. ),
  309. );
  310. }
  311. }
  312. }
  313. Future<void> _completeTask() async {
  314. final confirmed = await showDialog<bool>(
  315. context: context,
  316. builder: (ctx) => AlertDialog(
  317. title: const Text('确认完成'),
  318. content: const Text('确定要完成本次巡检任务吗?'),
  319. actions: [
  320. TextButton(
  321. onPressed: () => Navigator.pop(ctx, false),
  322. child: const Text('取消'),
  323. ),
  324. FilledButton(
  325. onPressed: () => Navigator.pop(ctx, true),
  326. child: const Text('确认完成'),
  327. ),
  328. ],
  329. ),
  330. );
  331. if (confirmed != true) return;
  332. setState(() => _submitting = true);
  333. await _service.completeTask(
  334. taskId: _task!.id,
  335. result: PatrolResult.normal,
  336. summary: '巡检完成,所有点位正常',
  337. );
  338. if (mounted) {
  339. setState(() => _submitting = false);
  340. ScaffoldMessenger.of(context).showSnackBar(
  341. const SnackBar(
  342. content: Text('巡检任务已完成'),
  343. backgroundColor: Colors.green,
  344. ),
  345. );
  346. Navigator.pop(context);
  347. }
  348. }
  349. void _openTrackPage() {
  350. Navigator.push(
  351. context,
  352. MaterialPageRoute(
  353. builder: (_) => TrackPage(taskId: _task!.id, taskName: _task!.routeName),
  354. ),
  355. );
  356. }
  357. }
  358. class _InfoRow extends StatelessWidget {
  359. final String label;
  360. final String value;
  361. final Color? valueColor;
  362. const _InfoRow({required this.label, required this.value, this.valueColor});
  363. @override
  364. Widget build(BuildContext context) {
  365. return Padding(
  366. padding: const EdgeInsets.symmetric(vertical: 4),
  367. child: Row(
  368. crossAxisAlignment: CrossAxisAlignment.start,
  369. children: [
  370. SizedBox(
  371. width: 70,
  372. child: Text(
  373. label,
  374. style: TextStyle(fontSize: 13, color: Colors.grey.shade600),
  375. ),
  376. ),
  377. Expanded(
  378. child: Text(
  379. value,
  380. style: TextStyle(
  381. fontSize: 13,
  382. color: valueColor ?? Colors.black87,
  383. fontWeight:
  384. valueColor != null ? FontWeight.w500 : FontWeight.normal,
  385. ),
  386. ),
  387. ),
  388. ],
  389. ),
  390. );
  391. }
  392. }
  393. class _PointTile extends StatelessWidget {
  394. final int index;
  395. final PatrolPoint point;
  396. final bool isCompleted;
  397. final bool isCurrent;
  398. final VoidCallback? onTap;
  399. const _PointTile({
  400. required this.index,
  401. required this.point,
  402. required this.isCompleted,
  403. required this.isCurrent,
  404. this.onTap,
  405. });
  406. @override
  407. Widget build(BuildContext context) {
  408. Color bgColor;
  409. IconData icon;
  410. if (isCompleted) {
  411. bgColor = point.status == PatrolPointStatus.normal
  412. ? Colors.green.shade50
  413. : Colors.orange.shade50;
  414. icon = point.status == PatrolPointStatus.normal
  415. ? Icons.check_circle
  416. : Icons.warning;
  417. } else if (isCurrent) {
  418. bgColor = Colors.blue.shade50;
  419. icon = Icons.radio_button_checked;
  420. } else {
  421. bgColor = Colors.grey.shade50;
  422. icon = Icons.radio_button_unchecked;
  423. }
  424. return Container(
  425. margin: const EdgeInsets.only(bottom: 8),
  426. decoration: BoxDecoration(
  427. color: bgColor,
  428. borderRadius: BorderRadius.circular(8),
  429. border: isCurrent
  430. ? Border.all(color: Colors.blue, width: 1.5)
  431. : null,
  432. ),
  433. child: ListTile(
  434. onTap: onTap,
  435. shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
  436. leading: CircleAvatar(
  437. backgroundColor: isCompleted
  438. ? (point.status == PatrolPointStatus.normal
  439. ? Colors.green
  440. : Colors.orange)
  441. : isCurrent
  442. ? Colors.blue
  443. : Colors.grey.shade300,
  444. child: isCompleted
  445. ? Icon(icon, color: Colors.white, size: 20)
  446. : Text(
  447. '$index',
  448. style: TextStyle(
  449. color: isCurrent ? Colors.white : Colors.grey.shade700,
  450. fontWeight: FontWeight.w600,
  451. ),
  452. ),
  453. ),
  454. title: Text(
  455. point.name,
  456. style: TextStyle(
  457. fontWeight: isCurrent ? FontWeight.w600 : FontWeight.normal,
  458. fontSize: 14,
  459. ),
  460. ),
  461. subtitle: Text(
  462. isCompleted && point.remark != null
  463. ? point.remark!
  464. : point.type.label,
  465. style: TextStyle(
  466. fontSize: 12,
  467. color: Colors.grey.shade600,
  468. ),
  469. ),
  470. trailing: isCurrent
  471. ? const Icon(Icons.arrow_forward_ios, size: 16, color: Colors.blue)
  472. : null,
  473. ),
  474. );
  475. }
  476. }
  477. class _PointReportForm extends StatefulWidget {
  478. final PatrolPoint point;
  479. final int pointIndex;
  480. final bool submitting;
  481. final Future<void> Function(
  482. PatrolPointStatus status, String? remark, List<String>? photos) onSubmit;
  483. const _PointReportForm({
  484. required this.point,
  485. required this.pointIndex,
  486. required this.submitting,
  487. required this.onSubmit,
  488. });
  489. @override
  490. State<_PointReportForm> createState() => _PointReportFormState();
  491. }
  492. class _PointReportFormState extends State<_PointReportForm> {
  493. PatrolPointStatus _selectedStatus = PatrolPointStatus.normal;
  494. final _remarkController = TextEditingController();
  495. final List<String> _photos = [];
  496. @override
  497. void dispose() {
  498. _remarkController.dispose();
  499. super.dispose();
  500. }
  501. @override
  502. Widget build(BuildContext context) {
  503. return Card(
  504. shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
  505. color: Colors.blue.shade50,
  506. child: Padding(
  507. padding: const EdgeInsets.all(16),
  508. child: Column(
  509. crossAxisAlignment: CrossAxisAlignment.start,
  510. children: [
  511. Row(
  512. children: [
  513. const Icon(Icons.edit_note, size: 20, color: Colors.blue),
  514. const SizedBox(width: 8),
  515. Expanded(
  516. child: Text(
  517. '上报: ${widget.point.name} (${widget.pointIndex})',
  518. style: Theme.of(context)
  519. .textTheme
  520. .titleMedium
  521. ?.copyWith(color: Colors.blue.shade800),
  522. ),
  523. ),
  524. ],
  525. ),
  526. const SizedBox(height: 16),
  527. // 状态选择
  528. const Text('巡检状态', style: TextStyle(fontWeight: FontWeight.w500)),
  529. const SizedBox(height: 8),
  530. Row(
  531. children: PatrolPointStatus.values.map((s) {
  532. final isSelected = _selectedStatus == s;
  533. Color color;
  534. switch (s) {
  535. case PatrolPointStatus.normal:
  536. color = Colors.green;
  537. break;
  538. case PatrolPointStatus.abnormal:
  539. color = Colors.red;
  540. break;
  541. case PatrolPointStatus.maintenance:
  542. color = Colors.orange;
  543. break;
  544. }
  545. return Expanded(
  546. child: Padding(
  547. padding: const EdgeInsets.only(right: 8),
  548. child: ChoiceChip(
  549. label: Text(s.label),
  550. selected: isSelected,
  551. selectedColor: color.withAlpha(40),
  552. labelStyle: TextStyle(
  553. color: isSelected ? color : Colors.grey,
  554. ),
  555. side: BorderSide(
  556. color: isSelected ? color : Colors.grey.shade300,
  557. ),
  558. onSelected: (_) => setState(() => _selectedStatus = s),
  559. ),
  560. ),
  561. );
  562. }).toList(),
  563. ),
  564. const SizedBox(height: 16),
  565. // 备注
  566. const Text('备注', style: TextStyle(fontWeight: FontWeight.w500)),
  567. const SizedBox(height: 8),
  568. TextField(
  569. controller: _remarkController,
  570. maxLines: 3,
  571. decoration: InputDecoration(
  572. hintText: '请输入巡检备注(可选)',
  573. border: OutlineInputBorder(
  574. borderRadius: BorderRadius.circular(8),
  575. ),
  576. filled: true,
  577. fillColor: Colors.white,
  578. ),
  579. ),
  580. const SizedBox(height: 16),
  581. // 拍照上传
  582. Row(
  583. children: [
  584. const Text('拍照记录',
  585. style: TextStyle(fontWeight: FontWeight.w500)),
  586. const SizedBox(width: 8),
  587. Text(
  588. '(${_photos.length}/6)',
  589. style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
  590. ),
  591. ],
  592. ),
  593. const SizedBox(height: 8),
  594. Wrap(
  595. spacing: 8,
  596. runSpacing: 8,
  597. children: [
  598. ..._photos.asMap().entries.map(
  599. (entry) => Stack(
  600. children: [
  601. Container(
  602. width: 72,
  603. height: 72,
  604. decoration: BoxDecoration(
  605. color: Colors.grey.shade300,
  606. borderRadius: BorderRadius.circular(8),
  607. ),
  608. child: const Icon(Icons.image,
  609. color: Colors.grey, size: 32),
  610. ),
  611. Positioned(
  612. top: -4,
  613. right: -4,
  614. child: GestureDetector(
  615. onTap: () =>
  616. setState(() => _photos.removeAt(entry.key)),
  617. child: Container(
  618. width: 20,
  619. height: 20,
  620. decoration: const BoxDecoration(
  621. color: Colors.red,
  622. shape: BoxShape.circle,
  623. ),
  624. child: const Icon(Icons.close,
  625. size: 12, color: Colors.white),
  626. ),
  627. ),
  628. ),
  629. ],
  630. ),
  631. ),
  632. if (_photos.length < 6)
  633. GestureDetector(
  634. onTap: () => setState(
  635. () => _photos.add('photo_${_photos.length + 1}')),
  636. child: Container(
  637. width: 72,
  638. height: 72,
  639. decoration: BoxDecoration(
  640. border: Border.all(color: Colors.blue, width: 1.5),
  641. borderRadius: BorderRadius.circular(8),
  642. ),
  643. child: const Icon(Icons.add_a_photo,
  644. color: Colors.blue, size: 24),
  645. ),
  646. ),
  647. ],
  648. ),
  649. const SizedBox(height: 16),
  650. // 提交按钮
  651. SizedBox(
  652. width: double.infinity,
  653. child: FilledButton.icon(
  654. onPressed: widget.submitting
  655. ? null
  656. : () async {
  657. await widget.onSubmit(
  658. _selectedStatus,
  659. _remarkController.text.isEmpty
  660. ? null
  661. : _remarkController.text,
  662. _photos.isEmpty ? null : _photos,
  663. );
  664. _remarkController.clear();
  665. setState(() => _photos.clear());
  666. },
  667. icon: widget.submitting
  668. ? const SizedBox(
  669. width: 16,
  670. height: 16,
  671. child: CircularProgressIndicator(
  672. strokeWidth: 2, color: Colors.white))
  673. : const Icon(Icons.send, size: 18),
  674. label: Text(widget.submitting ? '提交中...' : '提交上报'),
  675. ),
  676. ),
  677. ],
  678. ),
  679. ),
  680. );
  681. }
  682. }