meshcore-open/lib/services/sparse_location_logger.dart
Winston Lowe 49665fd563 Enhance location handling and UI updates
- Refactor location permission handling in SparseLocationLogger
- Add updateMyLocation method to SparseLocationLogger
- Introduce new contact handling in MeshCoreConnector
- Update map screen with a new option to refresh location
- Add localization for "Update Location" in app_en.arb
- Adjust payload types in meshcore_protocol.dart
2026-02-13 16:53:10 -08:00

244 lines
7.1 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'dart:math' as math;
import 'package:flutter/widgets.dart';
import 'package:geolocator/geolocator.dart';
import 'package:gpx/gpx.dart';
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:share_plus/share_plus.dart';
class SparseLocationLogger {
static const double distanceThresholdMiles = 0.25;
static const double distanceThresholdMeters =
distanceThresholdMiles * 1609.34;
static const double headingChangeThresholdDeg = 35.0;
static const double minSpeedForTurnKmh = 8.0;
static const double minTime = 120.0; // seconds
Position? _lastLoggedPosition;
double? _lastHeading;
DateTime? _lastLoggedTime;
StreamSubscription<Position>? _positionStream;
Timer? _timer;
Function(Position position)? _onNewLogPoint;
// GPX structures
final Gpx _gpx = Gpx();
Trkseg _currentSegment = Trkseg(); // one segment for the whole session
File? _gpxFile;
bool _isInitialized = false;
void initialize(Function(Position position) onNewLogPoint) {
_onNewLogPoint = onNewLogPoint;
}
Future<bool> getPremissons() async {
// Permissions & service check (same as before)
var status = await Permission.location.request();
if (!status.isGranted) {
debugPrint('Location permission denied');
return false;
}
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) {
debugPrint('Location services disabled');
return false;
}
return true;
}
Future<void> startLogging() async {
if (!await getPremissons()) {
return;
}
// Prepare files
final directory = await getApplicationDocumentsDirectory();
final timestamp = DateTime.now()
.toIso8601String()
.replaceAll(':', '-')
.split('.')
.first;
_gpxFile = File('${directory.path}/track_$timestamp.gpx');
// Init GPX metadata
_gpx.metadata = Metadata(
name: 'Sparse Track ${DateTime.now().toString().split(' ').first}',
desc: 'Sparse GPS log: ~every 1.5 mi or significant turns',
time: DateTime.now(),
);
// Add one track with one segment
final track = Trk(name: 'Main Track');
_currentSegment = Trkseg();
track.trksegs.add(_currentSegment);
_gpx.trks.add(track);
_isInitialized = true;
// Start location stream
_positionStream = Geolocator.getPositionStream(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.high,
distanceFilter: 152, // meters (~0.16 mi) - helps battery
),
).listen(_onPositionReceived);
// Also poll via timer as fallback
_timer = Timer.periodic(Duration(seconds: (minTime / 2).toInt()), (
_,
) async {
final position = await Geolocator.getCurrentPosition();
await _onPositionReceived(position);
});
_lastLoggedPosition = null;
_lastHeading = null;
_lastLoggedTime = null;
debugPrint('Sparse GPX logging started → ${_gpxFile?.path}');
}
Future<void> stopLogging() async {
await _positionStream?.cancel();
_positionStream = null;
_timer?.cancel();
_timer = null;
if (_isInitialized && _currentSegment.trkpts.isNotEmpty) {
// Write GPX file on stop
final xmlString = GpxWriter().asString(_gpx, pretty: true);
await _gpxFile?.writeAsString(xmlString);
await SharePlus.instance.share(
ShareParams(
text: 'Sparse GPS track',
subject: 'Sparse GPS track',
files: [XFile(_gpxFile?.path ?? '')],
),
);
await _gpxFile?.delete();
}
debugPrint('Logging stopped');
}
Future<void> updateMyLocation() async {
if (!await getPremissons()) {
return;
}
try {
final position = await Geolocator.getCurrentPosition();
_onNewLogPoint?.call(position);
} catch (e) {
debugPrint('Error updating location: $e');
}
}
Future<void> _onPositionReceived(Position position) async {
final now = DateTime.now();
final speedKmh = position.speed * 3.6;
final heading = position.heading;
bool shouldLog = false;
String reason = '';
if (_lastLoggedPosition == null) {
shouldLog = true;
reason = 'start';
} else {
final distanceMeters = Geolocator.distanceBetween(
_lastLoggedPosition!.latitude,
_lastLoggedPosition!.longitude,
position.latitude,
position.longitude,
);
if (distanceMeters >= distanceThresholdMeters) {
shouldLog = true;
reason =
'distance (${(distanceMeters / 1609.34).toStringAsFixed(2)} mi)';
} else if (speedKmh > minSpeedForTurnKmh && _lastHeading != null) {
double delta = (heading - _lastHeading!).abs();
delta = math.min(delta, 360 - delta);
if (delta > headingChangeThresholdDeg) {
shouldLog = true;
reason = 'turn (${delta.toStringAsFixed(1)}°)';
}
} else if (_lastLoggedTime != null) {
final elapsed = now.difference(_lastLoggedTime!).inSeconds;
if (elapsed >= minTime && distanceMeters >= distanceThresholdMeters) {
shouldLog = true;
reason = 'time (${elapsed}s)';
}
}
}
if (shouldLog) {
// Create GPX Waypoint (trkpt)
final pt = Wpt(
lat: position.latitude,
lon: position.longitude,
ele: position.altitude, // if available
time: now,
extensions: {
"course": ?heading.isFinite ? heading : null,
"speed": ?speedKmh > 0 ? speedKmh / 3.6 : null, // GPX speed in m/s
},
// You can add hdop, vdop, etc. from position if desired
);
_currentSegment.trkpts.add(pt);
_onNewLogPoint?.call(position);
debugPrint('Logged point: ${pt.lat}, ${pt.lon} ($reason)');
_lastLoggedPosition = position;
_lastHeading = heading;
_lastLoggedTime = now;
} else {
debugPrint('Skipped point: ${position.latitude}, ${position.longitude}');
}
}
Position snapToGridCenter({
required Position position,
required double cellSizeMeters,
}) {
Position snappedPosition = position;
// Snap latitude
final latFloor =
(position.latitude / cellSizeMeters).floor() * cellSizeMeters;
final snappedLat = latFloor + (cellSizeMeters / 2);
// Snap longitude
final lonFloor =
(position.longitude / cellSizeMeters).floor() * cellSizeMeters;
final snappedLon = lonFloor + (cellSizeMeters / 2);
snappedPosition = Position(
latitude: snappedLat,
longitude: snappedLon,
altitude: position.altitude,
accuracy: position.accuracy,
heading: position.heading,
speed: position.speed,
speedAccuracy: position.speedAccuracy,
altitudeAccuracy: position.altitudeAccuracy,
headingAccuracy: position.headingAccuracy,
timestamp: position.timestamp,
);
return snappedPosition;
}
Future<String> getGpxFilePath() async => _gpxFile?.path ?? 'Not started';
bool isLogging() => _positionStream != null;
int getPointCount() => _currentSegment.trkpts.length;
}