| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512 |
- import 'package:flutter/material.dart';
- import '../../services/patrol_service.dart';
-
- /// GPS 轨迹记录页面
- class TrackPage extends StatefulWidget {
- final String taskId;
- final String taskName;
-
- const TrackPage({super.key, required this.taskId, required this.taskName});
-
- @override
- State<TrackPage> createState() => _TrackPageState();
- }
-
- class _TrackPageState extends State<TrackPage> {
- final PatrolService _service = PatrolService.instance;
- List<TrackPoint> _trackPoints = [];
- bool _loading = true;
- bool _recording = false;
-
- @override
- void initState() {
- super.initState();
- _loadTrackPoints();
- }
-
- Future<void> _loadTrackPoints() async {
- setState(() => _loading = true);
- final points = await _service.getTrackPoints(widget.taskId);
- if (mounted) {
- setState(() {
- _trackPoints = points;
- _loading = false;
- });
- }
- }
-
- @override
- Widget build(BuildContext context) {
- return Scaffold(
- appBar: AppBar(
- title: Text('GPS轨迹 - ${widget.taskName}'),
- actions: [
- IconButton(
- icon: Icon(
- _recording ? Icons.stop : Icons.play_arrow,
- color: _recording ? Colors.red : null,
- ),
- tooltip: _recording ? '停止记录' : '开始记录',
- onPressed: _toggleRecording,
- ),
- ],
- ),
- body: _loading
- ? const Center(child: CircularProgressIndicator())
- : Column(
- children: [
- _buildStatsBar(),
- Expanded(child: _buildTrackMap()),
- _buildPointList(),
- ],
- ),
- );
- }
-
- Widget _buildStatsBar() {
- final totalDistance = _calculateDistance();
- final duration = _trackPoints.isNotEmpty
- ? _trackPoints.last.timestamp.difference(_trackPoints.first.timestamp)
- : Duration.zero;
-
- return Container(
- padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
- color: Theme.of(context).colorScheme.surfaceContainerHighest,
- child: Row(
- mainAxisAlignment: MainAxisAlignment.spaceAround,
- children: [
- _StatItem(
- icon: Icons.straighten,
- label: '总距离',
- value: '${totalDistance.toStringAsFixed(1)} km',
- ),
- _StatItem(
- icon: Icons.access_time,
- label: '用时',
- value:
- '${duration.inHours}h ${duration.inMinutes.remainder(60)}min',
- ),
- _StatItem(
- icon: Icons.location_on,
- label: '轨迹点',
- value: '${_trackPoints.length}',
- ),
- _StatItem(
- icon: Icons.speed,
- label: '平均速度',
- value: duration.inMinutes > 0
- ? '${(totalDistance / (duration.inMinutes / 60)).toStringAsFixed(1)} km/h'
- : '-',
- ),
- ],
- ),
- );
- }
-
- Widget _buildTrackMap() {
- // 使用简化的轨迹可视化(实际项目可集成高德/百度地图)
- return Card(
- margin: const EdgeInsets.all(12),
- shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
- child: Column(
- children: [
- Padding(
- padding: const EdgeInsets.all(12),
- child: Row(
- children: [
- const Icon(Icons.map, size: 20, color: Colors.blue),
- const SizedBox(width: 8),
- Text('轨迹地图', style: Theme.of(context).textTheme.titleMedium),
- const Spacer(),
- if (_recording)
- Container(
- padding: const EdgeInsets.symmetric(
- horizontal: 8, vertical: 2),
- decoration: BoxDecoration(
- color: Colors.red.shade100,
- borderRadius: BorderRadius.circular(4),
- ),
- child: const Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- Icon(Icons.fiber_manual_record,
- size: 8, color: Colors.red),
- SizedBox(width: 4),
- Text('记录中',
- style: TextStyle(fontSize: 11, color: Colors.red)),
- ],
- ),
- ),
- ],
- ),
- ),
- Expanded(
- child: Container(
- margin: const EdgeInsets.fromLTRB(12, 0, 12, 12),
- decoration: BoxDecoration(
- color: Colors.grey.shade100,
- borderRadius: BorderRadius.circular(8),
- ),
- child: CustomPaint(
- size: Size.infinite,
- painter: _TrackPainter(
- points: _trackPoints,
- color: Theme.of(context).colorScheme.primary,
- ),
- child: Stack(
- children: [
- // 起点标记
- if (_trackPoints.isNotEmpty)
- Positioned(
- left: 12,
- top: 12,
- child: Container(
- padding: const EdgeInsets.symmetric(
- horizontal: 8, vertical: 4),
- decoration: BoxDecoration(
- color: Colors.green,
- borderRadius: BorderRadius.circular(12),
- ),
- child: const Text(
- '起点',
- style: TextStyle(
- color: Colors.white,
- fontSize: 11,
- fontWeight: FontWeight.w600),
- ),
- ),
- ),
- // 终点标记
- if (_trackPoints.isNotEmpty)
- Positioned(
- right: 12,
- bottom: 12,
- child: Container(
- padding: const EdgeInsets.symmetric(
- horizontal: 8, vertical: 4),
- decoration: BoxDecoration(
- color: Colors.red,
- borderRadius: BorderRadius.circular(12),
- ),
- child: const Text(
- '终点',
- style: TextStyle(
- color: Colors.white,
- fontSize: 11,
- fontWeight: FontWeight.w600),
- ),
- ),
- ),
- ],
- ),
- ),
- ),
- ),
- ],
- ),
- );
- }
-
- Widget _buildPointList() {
- return Container(
- height: 200,
- decoration: BoxDecoration(
- color: Theme.of(context).colorScheme.surface,
- border: Border(
- top: BorderSide(color: Colors.grey.shade300),
- ),
- ),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Padding(
- padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
- child: Text(
- '轨迹点详情',
- style: Theme.of(context).textTheme.titleSmall,
- ),
- ),
- Expanded(
- child: ListView.builder(
- padding: const EdgeInsets.symmetric(horizontal: 12),
- itemCount: _trackPoints.length,
- itemBuilder: (ctx, i) {
- final point = _trackPoints[i];
- return _TrackPointTile(point: point, index: i + 1);
- },
- ),
- ),
- ],
- ),
- );
- }
-
- double _calculateDistance() {
- if (_trackPoints.length < 2) return 0;
- double total = 0;
- for (int i = 1; i < _trackPoints.length; i++) {
- total += _haversine(
- _trackPoints[i - 1].lat,
- _trackPoints[i - 1].lng,
- _trackPoints[i].lat,
- _trackPoints[i].lng,
- );
- }
- return total;
- }
-
- double _haversine(double lat1, double lng1, double lat2, double lng2) {
- const r = 6371.0; // Earth radius in km
- final dLat = (lat2 - lat1) * 3.14159265 / 180;
- final dLng = (lng2 - lng1) * 3.14159265 / 180;
- final a = (dLat / 2).sin() * (dLat / 2).sin() +
- (lat1 * 3.14159265 / 180).cos() *
- (lat2 * 3.14159265 / 180).cos() *
- (dLng / 2).sin() *
- (dLng / 2).sin();
- return 2 * r * a.sqrt().asin();
- }
-
- void _toggleRecording() {
- setState(() => _recording = !_recording);
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(
- content: Text(_recording ? 'GPS轨迹记录已开始' : 'GPS轨迹记录已停止'),
- duration: const Duration(seconds: 2),
- ),
- );
- }
- }
-
- // 扩展方法用于三角函数计算
- extension _MathExt on double {
- double sin() => _sin(this);
- double cos() => _cos(this);
- double sqrt() => _sqrt(this);
- double asin() => _asin(this);
-
- static double _sin(double x) {
- // Taylor approximation
- double result = x;
- double term = x;
- for (int i = 1; i <= 10; i++) {
- term *= -x * x / ((2 * i + 1) * (2 * i + 2));
- result += term / (2 * i + 1);
- }
- return result;
- }
-
- static double _cos(double x) {
- double result = 1;
- double term = 1;
- for (int i = 1; i <= 10; i++) {
- term *= -x * x / ((2 * i - 1) * (2 * i));
- result += term;
- }
- return result;
- }
-
- static double _sqrt(double x) {
- if (x < 0) return double.nan;
- double guess = x / 2;
- for (int i = 0; i < 20; i++) {
- guess = (guess + x / guess) / 2;
- }
- return guess;
- }
-
- static double _asin(double x) {
- if (x < -1 || x > 1) return double.nan;
- // Use Taylor series: asin(x) ≈ x + x³/6 + 3x⁵/40 + ...
- // Better approximation using atan
- return _atan(x / _sqrt(1 - x * x));
- }
-
- static double _atan(double x) {
- // atan approximation
- if (x.abs() > 1) {
- return 3.14159265 / 2 * (x > 0 ? 1 : -1) - _atanSmall(1 / x);
- }
- return _atanSmall(x);
- }
-
- static double _atanSmall(double x) {
- // Polynomial approximation for |x| <= 1
- final x2 = x * x;
- return x * (1 - x2 / 3 + x2 * x2 / 5 - x2 * x2 * x2 / 7 +
- x2 * x2 * x2 * x2 / 9);
- }
- }
-
- class _TrackPainter extends CustomPainter {
- final List<TrackPoint> points;
- final Color color;
-
- _TrackPainter({required this.points, required this.color});
-
- @override
- void paint(Canvas canvas, Size size) {
- if (points.length < 2) return;
-
- // Find bounds
- double minLat = points.first.lat;
- double maxLat = points.first.lat;
- double minLng = points.first.lng;
- double maxLng = points.first.lng;
-
- for (final p in points) {
- if (p.lat < minLat) minLat = p.lat;
- if (p.lat > maxLat) maxLat = p.lat;
- if (p.lng < minLng) minLng = p.lng;
- if (p.lng > maxLng) maxLng = p.lng;
- }
-
- final latRange = maxLat - minLat;
- final lngRange = maxLng - minLng;
- final padding = 20.0;
- final drawWidth = size.width - padding * 2;
- final drawHeight = size.height - padding * 2;
-
- Offset toScreen(TrackPoint p) {
- final x = lngRange > 0
- ? padding + (p.lng - minLng) / lngRange * drawWidth
- : size.width / 2;
- final y = latRange > 0
- ? padding + (1 - (p.lat - minLat) / latRange) * drawHeight
- : size.height / 2;
- return Offset(x, y);
- }
-
- // Draw track line
- final paint = Paint()
- ..color = color
- ..strokeWidth = 3
- ..style = PaintingStyle.stroke
- ..strokeCap = StrokeCap.round;
-
- final path = Path();
- final first = toScreen(points.first);
- path.moveTo(first.dx, first.dy);
- for (int i = 1; i < points.length; i++) {
- final p = toScreen(points[i]);
- path.lineTo(p.dx, p.dy);
- }
- canvas.drawPath(path, paint);
-
- // Draw points
- final pointPaint = Paint()..style = PaintingStyle.fill;
- for (int i = 0; i < points.length; i++) {
- final p = toScreen(points[i]);
- pointPaint.color = i == 0
- ? Colors.green
- : i == points.length - 1
- ? Colors.red
- : color;
- canvas.drawCircle(p, i == 0 || i == points.length - 1 ? 6 : 4, pointPaint);
- canvas.drawCircle(
- p, i == 0 || i == points.length - 1 ? 8 : 5,
- Paint()
- ..color = Colors.white
- ..style = PaintingStyle.stroke
- ..strokeWidth = 1.5);
- }
- }
-
- @override
- bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
- }
-
- class _StatItem extends StatelessWidget {
- final IconData icon;
- final String label;
- final String value;
-
- const _StatItem({
- required this.icon,
- required this.label,
- required this.value,
- });
-
- @override
- Widget build(BuildContext context) {
- return Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- Icon(icon, size: 18, color: Theme.of(context).colorScheme.primary),
- const SizedBox(height: 4),
- Text(value,
- style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13)),
- const SizedBox(height: 2),
- Text(label, style: TextStyle(fontSize: 11, color: Colors.grey.shade600)),
- ],
- );
- }
- }
-
- class _TrackPointTile extends StatelessWidget {
- final TrackPoint point;
- final int index;
-
- const _TrackPointTile({required this.point, required this.index});
-
- @override
- Widget build(BuildContext context) {
- return Container(
- padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8),
- margin: const EdgeInsets.only(bottom: 4),
- decoration: BoxDecoration(
- color: index == 1
- ? Colors.green.shade50
- : Colors.grey.shade50,
- borderRadius: BorderRadius.circular(6),
- ),
- child: Row(
- children: [
- CircleAvatar(
- radius: 14,
- backgroundColor:
- index == 1 ? Colors.green : Colors.grey.shade300,
- child: Text(
- '$index',
- style: TextStyle(
- fontSize: 11,
- color: index == 1 ? Colors.white : Colors.grey.shade700,
- ),
- ),
- ),
- const SizedBox(width: 8),
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(
- point.address,
- style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500),
- ),
- Text(
- '${point.lat.toStringAsFixed(4)}, ${point.lng.toStringAsFixed(4)} | 海拔 ${point.altitude.toStringAsFixed(1)}m',
- style: TextStyle(fontSize: 11, color: Colors.grey.shade600),
- ),
- ],
- ),
- ),
- Column(
- crossAxisAlignment: CrossAxisAlignment.end,
- children: [
- Text(
- '${point.timestamp.hour.toString().padLeft(2, '0')}:'
- '${point.timestamp.minute.toString().padLeft(2, '0')}',
- style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500),
- ),
- if (point.speed > 0)
- Text(
- '${point.speed.toStringAsFixed(1)} km/h',
- style: TextStyle(fontSize: 11, color: Colors.grey.shade600),
- ),
- ],
- ),
- ],
- ),
- );
- }
- }
|