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

track_page.dart 15KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512
  1. import 'package:flutter/material.dart';
  2. import '../../services/patrol_service.dart';
  3. /// GPS 轨迹记录页面
  4. class TrackPage extends StatefulWidget {
  5. final String taskId;
  6. final String taskName;
  7. const TrackPage({super.key, required this.taskId, required this.taskName});
  8. @override
  9. State<TrackPage> createState() => _TrackPageState();
  10. }
  11. class _TrackPageState extends State<TrackPage> {
  12. final PatrolService _service = PatrolService.instance;
  13. List<TrackPoint> _trackPoints = [];
  14. bool _loading = true;
  15. bool _recording = false;
  16. @override
  17. void initState() {
  18. super.initState();
  19. _loadTrackPoints();
  20. }
  21. Future<void> _loadTrackPoints() async {
  22. setState(() => _loading = true);
  23. final points = await _service.getTrackPoints(widget.taskId);
  24. if (mounted) {
  25. setState(() {
  26. _trackPoints = points;
  27. _loading = false;
  28. });
  29. }
  30. }
  31. @override
  32. Widget build(BuildContext context) {
  33. return Scaffold(
  34. appBar: AppBar(
  35. title: Text('GPS轨迹 - ${widget.taskName}'),
  36. actions: [
  37. IconButton(
  38. icon: Icon(
  39. _recording ? Icons.stop : Icons.play_arrow,
  40. color: _recording ? Colors.red : null,
  41. ),
  42. tooltip: _recording ? '停止记录' : '开始记录',
  43. onPressed: _toggleRecording,
  44. ),
  45. ],
  46. ),
  47. body: _loading
  48. ? const Center(child: CircularProgressIndicator())
  49. : Column(
  50. children: [
  51. _buildStatsBar(),
  52. Expanded(child: _buildTrackMap()),
  53. _buildPointList(),
  54. ],
  55. ),
  56. );
  57. }
  58. Widget _buildStatsBar() {
  59. final totalDistance = _calculateDistance();
  60. final duration = _trackPoints.isNotEmpty
  61. ? _trackPoints.last.timestamp.difference(_trackPoints.first.timestamp)
  62. : Duration.zero;
  63. return Container(
  64. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
  65. color: Theme.of(context).colorScheme.surfaceContainerHighest,
  66. child: Row(
  67. mainAxisAlignment: MainAxisAlignment.spaceAround,
  68. children: [
  69. _StatItem(
  70. icon: Icons.straighten,
  71. label: '总距离',
  72. value: '${totalDistance.toStringAsFixed(1)} km',
  73. ),
  74. _StatItem(
  75. icon: Icons.access_time,
  76. label: '用时',
  77. value:
  78. '${duration.inHours}h ${duration.inMinutes.remainder(60)}min',
  79. ),
  80. _StatItem(
  81. icon: Icons.location_on,
  82. label: '轨迹点',
  83. value: '${_trackPoints.length}',
  84. ),
  85. _StatItem(
  86. icon: Icons.speed,
  87. label: '平均速度',
  88. value: duration.inMinutes > 0
  89. ? '${(totalDistance / (duration.inMinutes / 60)).toStringAsFixed(1)} km/h'
  90. : '-',
  91. ),
  92. ],
  93. ),
  94. );
  95. }
  96. Widget _buildTrackMap() {
  97. // 使用简化的轨迹可视化(实际项目可集成高德/百度地图)
  98. return Card(
  99. margin: const EdgeInsets.all(12),
  100. shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
  101. child: Column(
  102. children: [
  103. Padding(
  104. padding: const EdgeInsets.all(12),
  105. child: Row(
  106. children: [
  107. const Icon(Icons.map, size: 20, color: Colors.blue),
  108. const SizedBox(width: 8),
  109. Text('轨迹地图', style: Theme.of(context).textTheme.titleMedium),
  110. const Spacer(),
  111. if (_recording)
  112. Container(
  113. padding: const EdgeInsets.symmetric(
  114. horizontal: 8, vertical: 2),
  115. decoration: BoxDecoration(
  116. color: Colors.red.shade100,
  117. borderRadius: BorderRadius.circular(4),
  118. ),
  119. child: const Row(
  120. mainAxisSize: MainAxisSize.min,
  121. children: [
  122. Icon(Icons.fiber_manual_record,
  123. size: 8, color: Colors.red),
  124. SizedBox(width: 4),
  125. Text('记录中',
  126. style: TextStyle(fontSize: 11, color: Colors.red)),
  127. ],
  128. ),
  129. ),
  130. ],
  131. ),
  132. ),
  133. Expanded(
  134. child: Container(
  135. margin: const EdgeInsets.fromLTRB(12, 0, 12, 12),
  136. decoration: BoxDecoration(
  137. color: Colors.grey.shade100,
  138. borderRadius: BorderRadius.circular(8),
  139. ),
  140. child: CustomPaint(
  141. size: Size.infinite,
  142. painter: _TrackPainter(
  143. points: _trackPoints,
  144. color: Theme.of(context).colorScheme.primary,
  145. ),
  146. child: Stack(
  147. children: [
  148. // 起点标记
  149. if (_trackPoints.isNotEmpty)
  150. Positioned(
  151. left: 12,
  152. top: 12,
  153. child: Container(
  154. padding: const EdgeInsets.symmetric(
  155. horizontal: 8, vertical: 4),
  156. decoration: BoxDecoration(
  157. color: Colors.green,
  158. borderRadius: BorderRadius.circular(12),
  159. ),
  160. child: const Text(
  161. '起点',
  162. style: TextStyle(
  163. color: Colors.white,
  164. fontSize: 11,
  165. fontWeight: FontWeight.w600),
  166. ),
  167. ),
  168. ),
  169. // 终点标记
  170. if (_trackPoints.isNotEmpty)
  171. Positioned(
  172. right: 12,
  173. bottom: 12,
  174. child: Container(
  175. padding: const EdgeInsets.symmetric(
  176. horizontal: 8, vertical: 4),
  177. decoration: BoxDecoration(
  178. color: Colors.red,
  179. borderRadius: BorderRadius.circular(12),
  180. ),
  181. child: const Text(
  182. '终点',
  183. style: TextStyle(
  184. color: Colors.white,
  185. fontSize: 11,
  186. fontWeight: FontWeight.w600),
  187. ),
  188. ),
  189. ),
  190. ],
  191. ),
  192. ),
  193. ),
  194. ),
  195. ],
  196. ),
  197. );
  198. }
  199. Widget _buildPointList() {
  200. return Container(
  201. height: 200,
  202. decoration: BoxDecoration(
  203. color: Theme.of(context).colorScheme.surface,
  204. border: Border(
  205. top: BorderSide(color: Colors.grey.shade300),
  206. ),
  207. ),
  208. child: Column(
  209. crossAxisAlignment: CrossAxisAlignment.start,
  210. children: [
  211. Padding(
  212. padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
  213. child: Text(
  214. '轨迹点详情',
  215. style: Theme.of(context).textTheme.titleSmall,
  216. ),
  217. ),
  218. Expanded(
  219. child: ListView.builder(
  220. padding: const EdgeInsets.symmetric(horizontal: 12),
  221. itemCount: _trackPoints.length,
  222. itemBuilder: (ctx, i) {
  223. final point = _trackPoints[i];
  224. return _TrackPointTile(point: point, index: i + 1);
  225. },
  226. ),
  227. ),
  228. ],
  229. ),
  230. );
  231. }
  232. double _calculateDistance() {
  233. if (_trackPoints.length < 2) return 0;
  234. double total = 0;
  235. for (int i = 1; i < _trackPoints.length; i++) {
  236. total += _haversine(
  237. _trackPoints[i - 1].lat,
  238. _trackPoints[i - 1].lng,
  239. _trackPoints[i].lat,
  240. _trackPoints[i].lng,
  241. );
  242. }
  243. return total;
  244. }
  245. double _haversine(double lat1, double lng1, double lat2, double lng2) {
  246. const r = 6371.0; // Earth radius in km
  247. final dLat = (lat2 - lat1) * 3.14159265 / 180;
  248. final dLng = (lng2 - lng1) * 3.14159265 / 180;
  249. final a = (dLat / 2).sin() * (dLat / 2).sin() +
  250. (lat1 * 3.14159265 / 180).cos() *
  251. (lat2 * 3.14159265 / 180).cos() *
  252. (dLng / 2).sin() *
  253. (dLng / 2).sin();
  254. return 2 * r * a.sqrt().asin();
  255. }
  256. void _toggleRecording() {
  257. setState(() => _recording = !_recording);
  258. ScaffoldMessenger.of(context).showSnackBar(
  259. SnackBar(
  260. content: Text(_recording ? 'GPS轨迹记录已开始' : 'GPS轨迹记录已停止'),
  261. duration: const Duration(seconds: 2),
  262. ),
  263. );
  264. }
  265. }
  266. // 扩展方法用于三角函数计算
  267. extension _MathExt on double {
  268. double sin() => _sin(this);
  269. double cos() => _cos(this);
  270. double sqrt() => _sqrt(this);
  271. double asin() => _asin(this);
  272. static double _sin(double x) {
  273. // Taylor approximation
  274. double result = x;
  275. double term = x;
  276. for (int i = 1; i <= 10; i++) {
  277. term *= -x * x / ((2 * i + 1) * (2 * i + 2));
  278. result += term / (2 * i + 1);
  279. }
  280. return result;
  281. }
  282. static double _cos(double x) {
  283. double result = 1;
  284. double term = 1;
  285. for (int i = 1; i <= 10; i++) {
  286. term *= -x * x / ((2 * i - 1) * (2 * i));
  287. result += term;
  288. }
  289. return result;
  290. }
  291. static double _sqrt(double x) {
  292. if (x < 0) return double.nan;
  293. double guess = x / 2;
  294. for (int i = 0; i < 20; i++) {
  295. guess = (guess + x / guess) / 2;
  296. }
  297. return guess;
  298. }
  299. static double _asin(double x) {
  300. if (x < -1 || x > 1) return double.nan;
  301. // Use Taylor series: asin(x) ≈ x + x³/6 + 3x⁵/40 + ...
  302. // Better approximation using atan
  303. return _atan(x / _sqrt(1 - x * x));
  304. }
  305. static double _atan(double x) {
  306. // atan approximation
  307. if (x.abs() > 1) {
  308. return 3.14159265 / 2 * (x > 0 ? 1 : -1) - _atanSmall(1 / x);
  309. }
  310. return _atanSmall(x);
  311. }
  312. static double _atanSmall(double x) {
  313. // Polynomial approximation for |x| <= 1
  314. final x2 = x * x;
  315. return x * (1 - x2 / 3 + x2 * x2 / 5 - x2 * x2 * x2 / 7 +
  316. x2 * x2 * x2 * x2 / 9);
  317. }
  318. }
  319. class _TrackPainter extends CustomPainter {
  320. final List<TrackPoint> points;
  321. final Color color;
  322. _TrackPainter({required this.points, required this.color});
  323. @override
  324. void paint(Canvas canvas, Size size) {
  325. if (points.length < 2) return;
  326. // Find bounds
  327. double minLat = points.first.lat;
  328. double maxLat = points.first.lat;
  329. double minLng = points.first.lng;
  330. double maxLng = points.first.lng;
  331. for (final p in points) {
  332. if (p.lat < minLat) minLat = p.lat;
  333. if (p.lat > maxLat) maxLat = p.lat;
  334. if (p.lng < minLng) minLng = p.lng;
  335. if (p.lng > maxLng) maxLng = p.lng;
  336. }
  337. final latRange = maxLat - minLat;
  338. final lngRange = maxLng - minLng;
  339. final padding = 20.0;
  340. final drawWidth = size.width - padding * 2;
  341. final drawHeight = size.height - padding * 2;
  342. Offset toScreen(TrackPoint p) {
  343. final x = lngRange > 0
  344. ? padding + (p.lng - minLng) / lngRange * drawWidth
  345. : size.width / 2;
  346. final y = latRange > 0
  347. ? padding + (1 - (p.lat - minLat) / latRange) * drawHeight
  348. : size.height / 2;
  349. return Offset(x, y);
  350. }
  351. // Draw track line
  352. final paint = Paint()
  353. ..color = color
  354. ..strokeWidth = 3
  355. ..style = PaintingStyle.stroke
  356. ..strokeCap = StrokeCap.round;
  357. final path = Path();
  358. final first = toScreen(points.first);
  359. path.moveTo(first.dx, first.dy);
  360. for (int i = 1; i < points.length; i++) {
  361. final p = toScreen(points[i]);
  362. path.lineTo(p.dx, p.dy);
  363. }
  364. canvas.drawPath(path, paint);
  365. // Draw points
  366. final pointPaint = Paint()..style = PaintingStyle.fill;
  367. for (int i = 0; i < points.length; i++) {
  368. final p = toScreen(points[i]);
  369. pointPaint.color = i == 0
  370. ? Colors.green
  371. : i == points.length - 1
  372. ? Colors.red
  373. : color;
  374. canvas.drawCircle(p, i == 0 || i == points.length - 1 ? 6 : 4, pointPaint);
  375. canvas.drawCircle(
  376. p, i == 0 || i == points.length - 1 ? 8 : 5,
  377. Paint()
  378. ..color = Colors.white
  379. ..style = PaintingStyle.stroke
  380. ..strokeWidth = 1.5);
  381. }
  382. }
  383. @override
  384. bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
  385. }
  386. class _StatItem extends StatelessWidget {
  387. final IconData icon;
  388. final String label;
  389. final String value;
  390. const _StatItem({
  391. required this.icon,
  392. required this.label,
  393. required this.value,
  394. });
  395. @override
  396. Widget build(BuildContext context) {
  397. return Column(
  398. mainAxisSize: MainAxisSize.min,
  399. children: [
  400. Icon(icon, size: 18, color: Theme.of(context).colorScheme.primary),
  401. const SizedBox(height: 4),
  402. Text(value,
  403. style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13)),
  404. const SizedBox(height: 2),
  405. Text(label, style: TextStyle(fontSize: 11, color: Colors.grey.shade600)),
  406. ],
  407. );
  408. }
  409. }
  410. class _TrackPointTile extends StatelessWidget {
  411. final TrackPoint point;
  412. final int index;
  413. const _TrackPointTile({required this.point, required this.index});
  414. @override
  415. Widget build(BuildContext context) {
  416. return Container(
  417. padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8),
  418. margin: const EdgeInsets.only(bottom: 4),
  419. decoration: BoxDecoration(
  420. color: index == 1
  421. ? Colors.green.shade50
  422. : Colors.grey.shade50,
  423. borderRadius: BorderRadius.circular(6),
  424. ),
  425. child: Row(
  426. children: [
  427. CircleAvatar(
  428. radius: 14,
  429. backgroundColor:
  430. index == 1 ? Colors.green : Colors.grey.shade300,
  431. child: Text(
  432. '$index',
  433. style: TextStyle(
  434. fontSize: 11,
  435. color: index == 1 ? Colors.white : Colors.grey.shade700,
  436. ),
  437. ),
  438. ),
  439. const SizedBox(width: 8),
  440. Expanded(
  441. child: Column(
  442. crossAxisAlignment: CrossAxisAlignment.start,
  443. children: [
  444. Text(
  445. point.address,
  446. style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500),
  447. ),
  448. Text(
  449. '${point.lat.toStringAsFixed(4)}, ${point.lng.toStringAsFixed(4)} | 海拔 ${point.altitude.toStringAsFixed(1)}m',
  450. style: TextStyle(fontSize: 11, color: Colors.grey.shade600),
  451. ),
  452. ],
  453. ),
  454. ),
  455. Column(
  456. crossAxisAlignment: CrossAxisAlignment.end,
  457. children: [
  458. Text(
  459. '${point.timestamp.hour.toString().padLeft(2, '0')}:'
  460. '${point.timestamp.minute.toString().padLeft(2, '0')}',
  461. style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500),
  462. ),
  463. if (point.speed > 0)
  464. Text(
  465. '${point.speed.toStringAsFixed(1)} km/h',
  466. style: TextStyle(fontSize: 11, color: Colors.grey.shade600),
  467. ),
  468. ],
  469. ),
  470. ],
  471. ),
  472. );
  473. }
  474. }