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

alert_page.dart 16KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506
  1. import 'package:flutter/material.dart';
  2. import 'package:intl/intl.dart';
  3. import '../../services/water_service.dart';
  4. /// 报警推送列表页面
  5. class AlertPage extends StatefulWidget {
  6. const AlertPage({super.key});
  7. @override
  8. State<AlertPage> createState() => _AlertPageState();
  9. }
  10. class _AlertPageState extends State<AlertPage> {
  11. final WaterService _service = WaterService.instance;
  12. List<AlertItem> _alerts = [];
  13. bool _isLoading = true;
  14. String? _error;
  15. bool _unreadOnly = false;
  16. @override
  17. void initState() {
  18. super.initState();
  19. _loadData();
  20. }
  21. Future<void> _loadData() async {
  22. setState(() {
  23. _isLoading = true;
  24. _error = null;
  25. });
  26. try {
  27. final data = await _service.getAlertList(unreadOnly: _unreadOnly);
  28. if (mounted) {
  29. setState(() {
  30. _alerts = data;
  31. _isLoading = false;
  32. });
  33. }
  34. } catch (e) {
  35. if (mounted) {
  36. setState(() {
  37. _error = e.toString();
  38. _isLoading = false;
  39. });
  40. }
  41. }
  42. }
  43. int get _unreadCount => _alerts.where((a) => !a.isRead).length;
  44. Future<void> _markRead(AlertItem alert) async {
  45. if (alert.isRead) return;
  46. await _service.markAlertRead(alert.id);
  47. if (mounted) {
  48. setState(() => alert.isRead = true);
  49. }
  50. }
  51. Future<void> _markAllRead() async {
  52. await _service.markAllAlertsRead();
  53. if (mounted) {
  54. setState(() {
  55. for (final a in _alerts) {
  56. a.isRead = true;
  57. }
  58. });
  59. ScaffoldMessenger.of(context).showSnackBar(
  60. const SnackBar(content: Text('已全部标记为已读')),
  61. );
  62. }
  63. }
  64. @override
  65. Widget build(BuildContext context) {
  66. final theme = Theme.of(context);
  67. return Scaffold(
  68. appBar: AppBar(
  69. title: const Text('报警推送'),
  70. centerTitle: true,
  71. actions: [
  72. if (_unreadCount > 0)
  73. TextButton(
  74. onPressed: _markAllRead,
  75. child: Text('全部已读 ($_unreadCount)'),
  76. ),
  77. IconButton(
  78. icon: const Icon(Icons.refresh),
  79. onPressed: _isLoading ? null : _loadData,
  80. ),
  81. ],
  82. ),
  83. body: Column(
  84. children: [
  85. // 筛选栏
  86. _buildFilterBar(theme),
  87. // 列表
  88. Expanded(child: _buildBody(theme)),
  89. ],
  90. ),
  91. );
  92. }
  93. Widget _buildFilterBar(ThemeData theme) {
  94. return Container(
  95. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
  96. child: Row(
  97. children: [
  98. _FilterTab(label: '全部', selected: !_unreadOnly, onTap: () {
  99. if (_unreadOnly) {
  100. setState(() => _unreadOnly = false);
  101. _loadData();
  102. }
  103. }),
  104. const SizedBox(width: 12),
  105. _FilterTab(
  106. label: '未读',
  107. selected: _unreadOnly,
  108. badge: _unreadCount,
  109. onTap: () {
  110. if (!_unreadOnly) {
  111. setState(() => _unreadOnly = true);
  112. _loadData();
  113. }
  114. },
  115. ),
  116. ],
  117. ),
  118. );
  119. }
  120. Widget _buildBody(ThemeData theme) {
  121. if (_isLoading) {
  122. return const Center(child: CircularProgressIndicator());
  123. }
  124. if (_error != null) {
  125. return Center(
  126. child: Column(
  127. mainAxisSize: MainAxisSize.min,
  128. children: [
  129. Icon(Icons.error_outline, size: 48, color: theme.colorScheme.error),
  130. const SizedBox(height: 16),
  131. Text('加载失败', style: theme.textTheme.titleMedium),
  132. const SizedBox(height: 16),
  133. FilledButton.icon(
  134. onPressed: _loadData,
  135. icon: const Icon(Icons.refresh),
  136. label: const Text('重试'),
  137. ),
  138. ],
  139. ),
  140. );
  141. }
  142. if (_alerts.isEmpty) {
  143. return Center(
  144. child: Column(
  145. mainAxisSize: MainAxisSize.min,
  146. children: [
  147. Icon(Icons.notifications_none, size: 48, color: Colors.grey.shade400),
  148. const SizedBox(height: 16),
  149. Text(
  150. _unreadOnly ? '没有未读报警' : '暂无报警信息',
  151. style: theme.textTheme.titleMedium,
  152. ),
  153. ],
  154. ),
  155. );
  156. }
  157. return RefreshIndicator(
  158. onRefresh: _loadData,
  159. child: ListView.builder(
  160. padding: const EdgeInsets.all(12),
  161. itemCount: _alerts.length,
  162. itemBuilder: (context, index) {
  163. final alert = _alerts[index];
  164. return _AlertCard(
  165. alert: alert,
  166. onTap: () => _showAlertDetail(context, alert),
  167. );
  168. },
  169. ),
  170. );
  171. }
  172. void _showAlertDetail(BuildContext context, AlertItem alert) {
  173. _markRead(alert);
  174. showModalBottomSheet(
  175. context: context,
  176. isScrollControlled: true,
  177. shape: const RoundedRectangleBorder(
  178. borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
  179. ),
  180. builder: (context) => _AlertDetailSheet(alert: alert),
  181. );
  182. }
  183. }
  184. /// 报警卡片
  185. class _AlertCard extends StatelessWidget {
  186. final AlertItem alert;
  187. final VoidCallback onTap;
  188. const _AlertCard({required this.alert, required this.onTap});
  189. @override
  190. Widget build(BuildContext context) {
  191. final theme = Theme.of(context);
  192. final levelColor = Color(alert.level.color);
  193. final dateFormat = DateFormat('MM-dd HH:mm');
  194. return Card(
  195. margin: const EdgeInsets.only(bottom: 8),
  196. elevation: 0,
  197. shape: RoundedRectangleBorder(
  198. borderRadius: BorderRadius.circular(12),
  199. side: BorderSide(
  200. color: alert.isRead ? Colors.grey.shade200 : levelColor.withAlpha(100),
  201. ),
  202. ),
  203. color: alert.isRead ? null : levelColor.withAlpha(8),
  204. child: InkWell(
  205. onTap: onTap,
  206. borderRadius: BorderRadius.circular(12),
  207. child: Padding(
  208. padding: const EdgeInsets.all(14),
  209. child: Row(
  210. crossAxisAlignment: CrossAxisAlignment.start,
  211. children: [
  212. // 级别图标
  213. Container(
  214. width: 40,
  215. height: 40,
  216. decoration: BoxDecoration(
  217. color: levelColor.withAlpha(30),
  218. shape: BoxShape.circle,
  219. ),
  220. child: Icon(
  221. alert.level == AlertLevel.critical
  222. ? Icons.error
  223. : alert.level == AlertLevel.warning
  224. ? Icons.warning_amber
  225. : Icons.info_outline,
  226. color: levelColor,
  227. size: 22,
  228. ),
  229. ),
  230. const SizedBox(width: 12),
  231. // 内容
  232. Expanded(
  233. child: Column(
  234. crossAxisAlignment: CrossAxisAlignment.start,
  235. children: [
  236. Row(
  237. children: [
  238. Container(
  239. padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
  240. decoration: BoxDecoration(
  241. color: levelColor.withAlpha(30),
  242. borderRadius: BorderRadius.circular(4),
  243. ),
  244. child: Text(
  245. alert.level.label,
  246. style: TextStyle(fontSize: 10, color: levelColor, fontWeight: FontWeight.w600),
  247. ),
  248. ),
  249. const SizedBox(width: 8),
  250. Expanded(
  251. child: Text(
  252. alert.title,
  253. style: theme.textTheme.titleSmall?.copyWith(
  254. fontWeight: alert.isRead ? FontWeight.normal : FontWeight.w600,
  255. ),
  256. maxLines: 1,
  257. overflow: TextOverflow.ellipsis,
  258. ),
  259. ),
  260. ],
  261. ),
  262. const SizedBox(height: 6),
  263. Text(
  264. alert.content,
  265. style: TextStyle(
  266. fontSize: 12,
  267. color: Colors.grey.shade600,
  268. ),
  269. maxLines: 2,
  270. overflow: TextOverflow.ellipsis,
  271. ),
  272. const SizedBox(height: 8),
  273. Row(
  274. children: [
  275. Icon(Icons.location_on_outlined, size: 12, color: Colors.grey.shade500),
  276. const SizedBox(width: 2),
  277. Text(alert.location, style: TextStyle(fontSize: 11, color: Colors.grey.shade500)),
  278. const SizedBox(width: 12),
  279. Icon(Icons.access_time, size: 12, color: Colors.grey.shade500),
  280. const SizedBox(width: 2),
  281. Text(
  282. dateFormat.format(alert.createTime),
  283. style: TextStyle(fontSize: 11, color: Colors.grey.shade500),
  284. ),
  285. ],
  286. ),
  287. ],
  288. ),
  289. ),
  290. // 未读标记
  291. if (!alert.isRead)
  292. Container(
  293. width: 8,
  294. height: 8,
  295. margin: const EdgeInsets.only(top: 4),
  296. decoration: BoxDecoration(
  297. shape: BoxShape.circle,
  298. color: levelColor,
  299. ),
  300. ),
  301. ],
  302. ),
  303. ),
  304. ),
  305. );
  306. }
  307. }
  308. /// 报警详情底部弹窗
  309. class _AlertDetailSheet extends StatelessWidget {
  310. final AlertItem alert;
  311. const _AlertDetailSheet({required this.alert});
  312. @override
  313. Widget build(BuildContext context) {
  314. final theme = Theme.of(context);
  315. final levelColor = Color(alert.level.color);
  316. final dateFormat = DateFormat('yyyy-MM-dd HH:mm:ss');
  317. return DraggableScrollableSheet(
  318. initialChildSize: 0.65,
  319. minChildSize: 0.4,
  320. maxChildSize: 0.85,
  321. expand: false,
  322. builder: (context, scrollController) {
  323. return SingleChildScrollView(
  324. controller: scrollController,
  325. padding: const EdgeInsets.all(20),
  326. child: Column(
  327. crossAxisAlignment: CrossAxisAlignment.start,
  328. children: [
  329. // 拖拽指示器
  330. Center(
  331. child: Container(
  332. width: 40,
  333. height: 4,
  334. decoration: BoxDecoration(
  335. color: Colors.grey.shade300,
  336. borderRadius: BorderRadius.circular(2),
  337. ),
  338. ),
  339. ),
  340. const SizedBox(height: 20),
  341. // 级别标签
  342. Container(
  343. padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
  344. decoration: BoxDecoration(
  345. color: levelColor.withAlpha(30),
  346. borderRadius: BorderRadius.circular(6),
  347. ),
  348. child: Text(
  349. '${alert.level.label}报警',
  350. style: TextStyle(color: levelColor, fontWeight: FontWeight.w600),
  351. ),
  352. ),
  353. const SizedBox(height: 12),
  354. // 标题
  355. Text(
  356. alert.title,
  357. style: theme.textTheme.titleLarge,
  358. ),
  359. const SizedBox(height: 16),
  360. // 详情信息
  361. _DetailRow(icon: Icons.location_on, label: '位置', value: alert.location),
  362. _DetailRow(icon: Icons.devices, label: '设备', value: alert.deviceName),
  363. _DetailRow(icon: Icons.access_time, label: '时间', value: dateFormat.format(alert.createTime)),
  364. _DetailRow(icon: Icons.person, label: '处理人', value: alert.handlerName),
  365. const Divider(height: 24),
  366. // 内容
  367. Text('详细描述', style: theme.textTheme.titleSmall),
  368. const SizedBox(height: 8),
  369. Text(
  370. alert.content,
  371. style: theme.textTheme.bodyMedium?.copyWith(height: 1.6),
  372. ),
  373. const SizedBox(height: 24),
  374. // 操作按钮
  375. Row(
  376. children: [
  377. Expanded(
  378. child: OutlinedButton.icon(
  379. onPressed: () => Navigator.pop(context),
  380. icon: const Icon(Icons.phone),
  381. label: const Text('联系处理人'),
  382. ),
  383. ),
  384. const SizedBox(width: 12),
  385. Expanded(
  386. child: FilledButton.icon(
  387. onPressed: () {
  388. Navigator.pop(context);
  389. ScaffoldMessenger.of(context).showSnackBar(
  390. const SnackBar(content: Text('已派单处理')),
  391. );
  392. },
  393. icon: const Icon(Icons.assignment),
  394. label: const Text('派单处理'),
  395. ),
  396. ),
  397. ],
  398. ),
  399. const SizedBox(height: 20),
  400. ],
  401. ),
  402. );
  403. },
  404. );
  405. }
  406. }
  407. class _DetailRow extends StatelessWidget {
  408. final IconData icon;
  409. final String label;
  410. final String value;
  411. const _DetailRow({required this.icon, required this.label, required this.value});
  412. @override
  413. Widget build(BuildContext context) {
  414. return Padding(
  415. padding: const EdgeInsets.only(bottom: 10),
  416. child: Row(
  417. children: [
  418. Icon(icon, size: 16, color: Colors.grey.shade600),
  419. const SizedBox(width: 8),
  420. SizedBox(
  421. width: 60,
  422. child: Text(label, style: TextStyle(fontSize: 13, color: Colors.grey.shade600)),
  423. ),
  424. Expanded(
  425. child: Text(value, style: const TextStyle(fontSize: 13)),
  426. ),
  427. ],
  428. ),
  429. );
  430. }
  431. }
  432. class _FilterTab extends StatelessWidget {
  433. final String label;
  434. final bool selected;
  435. final VoidCallback onTap;
  436. final int? badge;
  437. const _FilterTab({required this.label, required this.selected, required this.onTap, this.badge});
  438. @override
  439. Widget build(BuildContext context) {
  440. final theme = Theme.of(context);
  441. return InkWell(
  442. onTap: onTap,
  443. borderRadius: BorderRadius.circular(8),
  444. child: Container(
  445. padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
  446. decoration: BoxDecoration(
  447. color: selected ? theme.colorScheme.primaryContainer : Colors.transparent,
  448. borderRadius: BorderRadius.circular(8),
  449. ),
  450. child: Row(
  451. mainAxisSize: MainAxisSize.min,
  452. children: [
  453. Text(
  454. label,
  455. style: TextStyle(
  456. fontSize: 13,
  457. color: selected ? theme.colorScheme.primary : Colors.grey.shade700,
  458. fontWeight: selected ? FontWeight.w600 : FontWeight.normal,
  459. ),
  460. ),
  461. if (badge != null && badge! > 0) ...[
  462. const SizedBox(width: 4),
  463. Container(
  464. padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
  465. decoration: BoxDecoration(
  466. color: Colors.red,
  467. borderRadius: BorderRadius.circular(8),
  468. ),
  469. child: Text(
  470. '$badge',
  471. style: const TextStyle(fontSize: 10, color: Colors.white),
  472. ),
  473. ),
  474. ],
  475. ],
  476. ),
  477. ),
  478. );
  479. }
  480. }