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

meter_reading_page.dart 15KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444
  1. import 'dart:async';
  2. import 'package:flutter/material.dart';
  3. import '../../services/revenue_service.dart';
  4. /// 抄表录入页面
  5. class MeterReadingPage extends StatefulWidget {
  6. const MeterReadingPage({super.key});
  7. @override
  8. State<MeterReadingPage> createState() => _MeterReadingPageState();
  9. }
  10. class _MeterReadingPageState extends State<MeterReadingPage> {
  11. final RevenueService _service = RevenueService.instance;
  12. final _searchController = TextEditingController();
  13. Timer? _debounce;
  14. List<CustomerInfo> _customers = [];
  15. bool _searching = false;
  16. CustomerInfo? _selectedCustomer;
  17. // 录入表单
  18. final _readingController = TextEditingController();
  19. final _remarkController = TextEditingController();
  20. final List<String> _photos = [];
  21. bool _submitting = false;
  22. @override
  23. void initState() {
  24. super.initState();
  25. _loadInitialCustomers();
  26. }
  27. @override
  28. void dispose() {
  29. _searchController.dispose();
  30. _readingController.dispose();
  31. _remarkController.dispose();
  32. _debounce?.cancel();
  33. super.dispose();
  34. }
  35. Future<void> _loadInitialCustomers() async {
  36. setState(() => _searching = true);
  37. final customers = await _service.searchCustomers('');
  38. if (mounted) {
  39. setState(() {
  40. _customers = customers;
  41. _searching = false;
  42. });
  43. }
  44. }
  45. void _onSearchChanged(String query) {
  46. _debounce?.cancel();
  47. _debounce = Timer(const Duration(milliseconds: 500), () async {
  48. setState(() => _searching = true);
  49. final results = await _service.searchCustomers(query);
  50. if (mounted) {
  51. setState(() {
  52. _customers = results;
  53. _searching = false;
  54. });
  55. }
  56. });
  57. }
  58. @override
  59. Widget build(BuildContext context) {
  60. return Scaffold(
  61. appBar: AppBar(
  62. title: const Text('抄表录入'),
  63. ),
  64. body: _selectedCustomer != null
  65. ? _buildReadingForm()
  66. : _buildCustomerList(),
  67. );
  68. }
  69. Widget _buildCustomerList() {
  70. return Column(
  71. children: [
  72. // 搜索栏
  73. Padding(
  74. padding: const EdgeInsets.all(16),
  75. child: TextField(
  76. controller: _searchController,
  77. decoration: InputDecoration(
  78. hintText: '搜索用户姓名、表号或地址',
  79. prefixIcon: const Icon(Icons.search),
  80. suffixIcon: _searchController.text.isNotEmpty
  81. ? IconButton(
  82. icon: const Icon(Icons.clear),
  83. onPressed: () {
  84. _searchController.clear();
  85. _loadInitialCustomers();
  86. },
  87. )
  88. : null,
  89. border: OutlineInputBorder(
  90. borderRadius: BorderRadius.circular(12),
  91. ),
  92. filled: true,
  93. ),
  94. onChanged: _onSearchChanged,
  95. ),
  96. ),
  97. // 用户列表
  98. Expanded(
  99. child: _searching
  100. ? const Center(child: CircularProgressIndicator())
  101. : _customers.isEmpty
  102. ? Center(
  103. child: Column(
  104. mainAxisSize: MainAxisSize.min,
  105. children: [
  106. Icon(Icons.search_off,
  107. size: 64, color: Colors.grey.shade300),
  108. const SizedBox(height: 16),
  109. const Text('未找到匹配用户',
  110. style: TextStyle(color: Colors.grey)),
  111. ],
  112. ),
  113. )
  114. : ListView.builder(
  115. padding: const EdgeInsets.symmetric(horizontal: 12),
  116. itemCount: _customers.length,
  117. itemBuilder: (ctx, i) => _CustomerTile(
  118. customer: _customers[i],
  119. onTap: () => setState(
  120. () => _selectedCustomer = _customers[i]),
  121. ),
  122. ),
  123. ),
  124. ],
  125. );
  126. }
  127. Widget _buildReadingForm() {
  128. final customer = _selectedCustomer!;
  129. return SingleChildScrollView(
  130. padding: const EdgeInsets.all(16),
  131. child: Column(
  132. crossAxisAlignment: CrossAxisAlignment.start,
  133. children: [
  134. // 用户信息卡
  135. Card(
  136. shape: RoundedRectangleBorder(
  137. borderRadius: BorderRadius.circular(12)),
  138. color: Colors.blue.shade50,
  139. child: Padding(
  140. padding: const EdgeInsets.all(16),
  141. child: Column(
  142. crossAxisAlignment: CrossAxisAlignment.start,
  143. children: [
  144. Row(
  145. children: [
  146. const Icon(Icons.person, color: Colors.blue),
  147. const SizedBox(width: 8),
  148. Expanded(
  149. child: Text(
  150. customer.name,
  151. style: const TextStyle(
  152. fontSize: 16, fontWeight: FontWeight.w600),
  153. ),
  154. ),
  155. TextButton.icon(
  156. onPressed: () => setState(() {
  157. _selectedCustomer = null;
  158. _readingController.clear();
  159. _remarkController.clear();
  160. _photos.clear();
  161. }),
  162. icon: const Icon(Icons.arrow_back, size: 16),
  163. label: const Text('切换用户'),
  164. ),
  165. ],
  166. ),
  167. const Divider(),
  168. _FormInfoRow(label: '地址', value: customer.address),
  169. _FormInfoRow(label: '水表号', value: customer.meterId),
  170. _FormInfoRow(label: '水表型号', value: customer.meterModel),
  171. _FormInfoRow(label: '联系电话', value: customer.phone),
  172. ],
  173. ),
  174. ),
  175. ),
  176. const SizedBox(height: 16),
  177. // 读数录入
  178. Card(
  179. shape: RoundedRectangleBorder(
  180. borderRadius: BorderRadius.circular(12)),
  181. child: Padding(
  182. padding: const EdgeInsets.all(16),
  183. child: Column(
  184. crossAxisAlignment: CrossAxisAlignment.start,
  185. children: [
  186. Row(
  187. children: [
  188. const Icon(Icons.speed,
  189. size: 20, color: Colors.orange),
  190. const SizedBox(width: 8),
  191. Text('表计读数',
  192. style: Theme.of(context).textTheme.titleMedium),
  193. ],
  194. ),
  195. const SizedBox(height: 16),
  196. TextField(
  197. controller: _readingController,
  198. keyboardType:
  199. const TextInputType.numberWithOptions(decimal: true),
  200. decoration: InputDecoration(
  201. labelText: '当前读数 (m³)',
  202. hintText: '请输入水表当前读数',
  203. border: OutlineInputBorder(
  204. borderRadius: BorderRadius.circular(8),
  205. ),
  206. prefixIcon: const Icon(Icons.numbers),
  207. ),
  208. ),
  209. const SizedBox(height: 16),
  210. TextField(
  211. controller: _remarkController,
  212. maxLines: 2,
  213. decoration: InputDecoration(
  214. labelText: '备注(可选)',
  215. hintText: '如水表异常、更换水表等',
  216. border: OutlineInputBorder(
  217. borderRadius: BorderRadius.circular(8),
  218. ),
  219. ),
  220. ),
  221. ],
  222. ),
  223. ),
  224. ),
  225. const SizedBox(height: 16),
  226. // 拍照记录
  227. Card(
  228. shape: RoundedRectangleBorder(
  229. borderRadius: BorderRadius.circular(12)),
  230. child: Padding(
  231. padding: const EdgeInsets.all(16),
  232. child: Column(
  233. crossAxisAlignment: CrossAxisAlignment.start,
  234. children: [
  235. Row(
  236. children: [
  237. const Icon(Icons.camera_alt,
  238. size: 20, color: Colors.purple),
  239. const SizedBox(width: 8),
  240. Text('拍照记录',
  241. style: Theme.of(context).textTheme.titleMedium),
  242. const SizedBox(width: 8),
  243. Text(
  244. '(${_photos.length}/3)',
  245. style: TextStyle(
  246. fontSize: 12, color: Colors.grey.shade600),
  247. ),
  248. ],
  249. ),
  250. const SizedBox(height: 12),
  251. Wrap(
  252. spacing: 8,
  253. runSpacing: 8,
  254. children: [
  255. ..._photos.asMap().entries.map(
  256. (entry) => Stack(
  257. children: [
  258. Container(
  259. width: 80,
  260. height: 80,
  261. decoration: BoxDecoration(
  262. color: Colors.grey.shade200,
  263. borderRadius: BorderRadius.circular(8),
  264. ),
  265. child: const Icon(Icons.image,
  266. color: Colors.grey, size: 36),
  267. ),
  268. Positioned(
  269. top: -4,
  270. right: -4,
  271. child: GestureDetector(
  272. onTap: () => setState(
  273. () => _photos.removeAt(entry.key)),
  274. child: Container(
  275. width: 22,
  276. height: 22,
  277. decoration: const BoxDecoration(
  278. color: Colors.red,
  279. shape: BoxShape.circle,
  280. ),
  281. child: const Icon(Icons.close,
  282. size: 14, color: Colors.white),
  283. ),
  284. ),
  285. ),
  286. ],
  287. ),
  288. ),
  289. if (_photos.length < 3)
  290. GestureDetector(
  291. onTap: () => setState(() =>
  292. _photos.add('meter_photo_${_photos.length + 1}')),
  293. child: Container(
  294. width: 80,
  295. height: 80,
  296. decoration: BoxDecoration(
  297. border: Border.all(
  298. color: Colors.purple, width: 1.5),
  299. borderRadius: BorderRadius.circular(8),
  300. ),
  301. child: const Icon(Icons.add_a_photo,
  302. color: Colors.purple, size: 28),
  303. ),
  304. ),
  305. ],
  306. ),
  307. ],
  308. ),
  309. ),
  310. ),
  311. const SizedBox(height: 24),
  312. // 提交按钮
  313. SizedBox(
  314. width: double.infinity,
  315. child: FilledButton.icon(
  316. onPressed: _submitting ? null : _submitReading,
  317. icon: _submitting
  318. ? const SizedBox(
  319. width: 16,
  320. height: 16,
  321. child: CircularProgressIndicator(
  322. strokeWidth: 2, color: Colors.white))
  323. : const Icon(Icons.check, size: 18),
  324. label: Text(_submitting ? '提交中...' : '提交读数'),
  325. ),
  326. ),
  327. ],
  328. ),
  329. );
  330. }
  331. Future<void> _submitReading() async {
  332. final reading = double.tryParse(_readingController.text);
  333. if (reading == null || reading <= 0) {
  334. ScaffoldMessenger.of(context).showSnackBar(
  335. const SnackBar(
  336. content: Text('请输入有效的表计读数'),
  337. backgroundColor: Colors.red,
  338. ),
  339. );
  340. return;
  341. }
  342. setState(() => _submitting = true);
  343. final success = await _service.submitMeterReading(
  344. meterId: _selectedCustomer!.meterId,
  345. reading: reading,
  346. photoPath: _photos.isEmpty ? null : _photos.first,
  347. remark: _remarkController.text.isEmpty ? null : _remarkController.text,
  348. );
  349. if (mounted) {
  350. setState(() => _submitting = false);
  351. if (success) {
  352. ScaffoldMessenger.of(context).showSnackBar(
  353. SnackBar(
  354. content: Text('${_selectedCustomer!.name} 的读数已提交: $reading m³'),
  355. backgroundColor: Colors.green,
  356. ),
  357. );
  358. setState(() {
  359. _selectedCustomer = null;
  360. _readingController.clear();
  361. _remarkController.clear();
  362. _photos.clear();
  363. });
  364. }
  365. }
  366. }
  367. }
  368. class _CustomerTile extends StatelessWidget {
  369. final CustomerInfo customer;
  370. final VoidCallback onTap;
  371. const _CustomerTile({required this.customer, required this.onTap});
  372. @override
  373. Widget build(BuildContext context) {
  374. return Card(
  375. margin: const EdgeInsets.only(bottom: 8),
  376. shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
  377. child: ListTile(
  378. onTap: onTap,
  379. shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
  380. leading: CircleAvatar(
  381. backgroundColor: Colors.blue.shade50,
  382. child: Text(customer.name[0],
  383. style: TextStyle(color: Colors.blue.shade700)),
  384. ),
  385. title: Text(customer.name,
  386. style: const TextStyle(fontWeight: FontWeight.w500)),
  387. subtitle: Column(
  388. crossAxisAlignment: CrossAxisAlignment.start,
  389. children: [
  390. Text('${customer.address} | ${customer.meterId}',
  391. style: const TextStyle(fontSize: 12)),
  392. ],
  393. ),
  394. trailing: const Icon(Icons.chevron_right, color: Colors.grey),
  395. ),
  396. );
  397. }
  398. }
  399. class _FormInfoRow extends StatelessWidget {
  400. final String label;
  401. final String value;
  402. const _FormInfoRow({required this.label, required this.value});
  403. @override
  404. Widget build(BuildContext context) {
  405. return Padding(
  406. padding: const EdgeInsets.symmetric(vertical: 3),
  407. child: Row(
  408. children: [
  409. SizedBox(
  410. width: 70,
  411. child: Text(label,
  412. style: TextStyle(fontSize: 13, color: Colors.grey.shade600)),
  413. ),
  414. Expanded(
  415. child: Text(value,
  416. style: const TextStyle(fontSize: 13)),
  417. ),
  418. ],
  419. ),
  420. );
  421. }
  422. }