From 1241505a947ff658ba344f5b6200c5a1125d87b4 Mon Sep 17 00:00:00 2001 From: Janez T Date: Mon, 13 Oct 2025 22:43:19 +0200 Subject: [PATCH] Add compass functionality and shared preferences support to map tab --- lib/screens/map_tab.dart | 217 +++++++++++++++++- macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 66 +++++- pubspec.yaml | 4 + 4 files changed, 285 insertions(+), 4 deletions(-) diff --git a/lib/screens/map_tab.dart b/lib/screens/map_tab.dart index d12b6f9..d2a0a30 100644 --- a/lib/screens/map_tab.dart +++ b/lib/screens/map_tab.dart @@ -1,9 +1,12 @@ import 'dart:async'; +import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; import 'package:provider/provider.dart'; import 'package:geolocator/geolocator.dart'; +import 'package:flutter_compass/flutter_compass.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import '../providers/contacts_provider.dart'; import '../providers/messages_provider.dart'; import '../providers/map_provider.dart'; @@ -28,9 +31,12 @@ class _MapTabState extends State { bool _isInitialized = false; MapLayer _currentLayer = MapLayer.openStreetMap; Position? _currentPosition; - bool _showLegend = true; + double? _compassHeading; // Compass sensor heading + bool _rotateMarkerWithHeading = false; // Toggle for rotation + bool _showLegend = false; double _gpsUpdateDistance = 3.0; // meters StreamSubscription? _positionStreamSubscription; + StreamSubscription? _compassStreamSubscription; // Default center point (will be updated based on markers) static const LatLng _defaultCenter = LatLng(46.0569, 14.5058); // Ljubljana, Slovenia @@ -39,8 +45,10 @@ class _MapTabState extends State { @override void initState() { super.initState(); + _loadSettings(); _initializeTileCache(); _requestLocationPermission(); + _startCompassTracking(); // Listen to map provider for navigation requests WidgetsBinding.instance.addPostFrameCallback((_) { @@ -49,6 +57,41 @@ class _MapTabState extends State { }); } + void _startCompassTracking() { + // Start listening to compass events + _compassStreamSubscription = FlutterCompass.events?.listen((CompassEvent event) { + if (mounted && event.heading != null) { + setState(() { + _compassHeading = event.heading; + }); + + // Rotate map if rotation mode is enabled and we have compass heading + if (_rotateMarkerWithHeading && event.heading != null) { + debugPrint('Rotating map to compass heading: ${event.heading}'); + _mapController.rotate(-event.heading!); + } + } + }); + } + + Future _loadSettings() async { + final prefs = await SharedPreferences.getInstance(); + if (mounted) { + setState(() { + _showLegend = prefs.getBool('map_show_legend') ?? false; + _rotateMarkerWithHeading = prefs.getBool('map_rotate_with_heading') ?? false; + _gpsUpdateDistance = prefs.getDouble('map_gps_update_distance') ?? 3.0; + }); + } + } + + Future _saveSettings() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('map_show_legend', _showLegend); + await prefs.setBool('map_rotate_with_heading', _rotateMarkerWithHeading); + await prefs.setDouble('map_gps_update_distance', _gpsUpdateDistance); + } + Future _requestLocationPermission() async { bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); if (!serviceEnabled) { @@ -92,9 +135,18 @@ class _MapTabState extends State { ), ).listen((Position position) { if (mounted) { + debugPrint('Position update - Heading: ${position.heading}, Speed: ${position.speed}'); + setState(() { _currentPosition = position; }); + + // Rotate map if rotation mode is enabled and heading is available + // Heading of -1.0 means heading is unavailable + if (_rotateMarkerWithHeading && position.heading != null && position.heading >= 0) { + debugPrint('Rotating map to heading: ${position.heading}'); + _mapController.rotate(-position.heading); + } } }); } @@ -134,11 +186,25 @@ class _MapTabState extends State { final mapProvider = context.read(); mapProvider.removeListener(_handleMapNavigation); _positionStreamSubscription?.cancel(); + _compassStreamSubscription?.cancel(); _mapController.dispose(); _tileCache.dispose(); super.dispose(); } + // Get the current heading from compass or GPS + double? get _currentHeading { + // Prefer compass heading as it works when stationary + if (_compassHeading != null) { + return _compassHeading; + } + // Fall back to GPS heading when moving + if (_currentPosition?.heading != null && _currentPosition!.heading >= 0) { + return _currentPosition!.heading; + } + return null; + } + LatLng _calculateCenter(List contacts, List sarMarkers) { final allPoints = []; @@ -272,6 +338,29 @@ class _MapTabState extends State { _showLegend = value; }); setModalState(() {}); + _saveSettings(); + }, + ), + const Divider(), + // Compass rotation toggle + SwitchListTile( + secondary: const Icon(Icons.explore), + title: const Text('Rotate Map with Heading'), + subtitle: const Text('Map follows your direction when moving'), + value: _rotateMarkerWithHeading, + onChanged: (value) { + setState(() { + _rotateMarkerWithHeading = value; + // Reset map rotation when disabling + if (!_rotateMarkerWithHeading) { + _mapController.rotate(0); + } else if (_currentHeading != null) { + // Apply current heading rotation when enabling (if heading is valid) + _mapController.rotate(-_currentHeading!); + } + }); + setModalState(() {}); + _saveSettings(); }, ), const Divider(), @@ -302,6 +391,7 @@ class _MapTabState extends State { }); // Restart location stream with new distance _restartLocationStream(); + _saveSettings(); }, ), Padding( @@ -345,6 +435,12 @@ class _MapTabState extends State { setState(() { _currentPosition = position; }); + + // Rotate map if rotation mode is enabled and heading is available + // Heading of -1.0 means heading is unavailable + if (_rotateMarkerWithHeading && position.heading != null && position.heading >= 0) { + _mapController.rotate(-position.heading); + } } }); } @@ -398,6 +494,7 @@ class _MapTabState extends State { ), width: 40, height: 40, + rotate: false, // Don't rotate with map child: Container( decoration: BoxDecoration( color: Colors.blue.withOpacity(0.3), @@ -416,7 +513,7 @@ class _MapTabState extends State { ], ), child: const Icon( - Icons.navigation, + Icons.my_location, color: Colors.white, size: 16, ), @@ -440,10 +537,20 @@ class _MapTabState extends State { ], ), ), + // Compass widget - top right + if (_rotateMarkerWithHeading) + Positioned( + top: 16, + right: 16, + child: _CompassWidget( + heading: _currentHeading ?? 0, + hasHeading: _currentHeading != null, + ), + ), // Map legend overlay if (_showLegend) Positioned( - top: 16, + top: _rotateMarkerWithHeading ? 80 : 16, right: 16, child: _MapLegend( teamMemberCount: contactsWithLocation.length, @@ -624,3 +731,107 @@ class _LegendItem extends StatelessWidget { } } +class _CompassWidget extends StatelessWidget { + final double heading; + final bool hasHeading; + + const _CompassWidget({ + required this.heading, + required this.hasHeading, + }); + + @override + Widget build(BuildContext context) { + return Card( + child: Container( + width: 56, + height: 56, + padding: const EdgeInsets.all(8), + child: Stack( + alignment: Alignment.center, + children: [ + // Compass rose background - rotates to show true north at top + Transform.rotate( + angle: heading * pi / 180, + child: CustomPaint( + size: const Size(40, 40), + painter: _CompassRosePainter(), + ), + ), + // Fixed needle pointing up (since map rotates) + Icon( + Icons.navigation, + color: hasHeading ? Colors.red : Colors.grey, + size: 28, + ), + // Heading text + Positioned( + bottom: 0, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.7), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + hasHeading ? '${heading.round()}°' : '--', + style: const TextStyle( + color: Colors.white, + fontSize: 9, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +class _CompassRosePainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = Colors.grey.withOpacity(0.3) + ..style = PaintingStyle.stroke + ..strokeWidth = 1; + + final center = Offset(size.width / 2, size.height / 2); + final radius = size.width / 2; + + // Draw circle + canvas.drawCircle(center, radius, paint); + + // Draw cardinal direction markers + final textPainter = TextPainter( + textDirection: TextDirection.ltr, + ); + + final directions = ['N', 'E', 'S', 'W']; + for (int i = 0; i < 4; i++) { + final angle = i * pi / 2 - pi / 2; // Start from North (top) + final x = center.dx + radius * 0.7 * cos(angle); + final y = center.dy + radius * 0.7 * sin(angle); + + textPainter.text = TextSpan( + text: directions[i], + style: TextStyle( + color: Colors.grey.shade700, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ); + textPainter.layout(); + textPainter.paint( + canvas, + Offset(x - textPainter.width / 2, y - textPainter.height / 2), + ); + } + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} + diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 2229ad5..0baa484 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -12,6 +12,7 @@ import objectbox_flutter_libs import package_info_plus import path_provider_foundation import share_plus +import shared_preferences_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) @@ -21,4 +22,5 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 9371390..a62ff6a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -206,6 +206,14 @@ packages: url: "https://pub.dev" source: hosted version: "8.0.0" + flutter_compass: + dependency: "direct main" + description: + name: flutter_compass + sha256: "1b4d7e6c95a675ec8482b5c9c9ccf1ebf0ced3dbec59dce28ad609da953de850" + url: "https://pub.dev" + source: hosted + version: "0.8.1" flutter_lints: dependency: "direct dev" description: @@ -648,6 +656,62 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.2" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + url: "https://pub.dev" + source: hosted + version: "2.5.3" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "34266009473bf71d748912da4bf62d439185226c03e01e2d9687bc65bbfcb713" + url: "https://pub.dev" + source: hosted + version: "2.4.15" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" sky_engine: dependency: transitive description: flutter @@ -823,4 +887,4 @@ packages: version: "6.6.1" sdks: dart: ">=3.9.2 <4.0.0" - flutter: ">=3.29.0" + flutter: ">=3.35.0" diff --git a/pubspec.yaml b/pubspec.yaml index 8fd5323..570d738 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -53,6 +53,7 @@ dependencies: # Location services geolocator: ^14.0.2 + flutter_compass: ^0.8.0 # Utilities intl: ^0.20.2 @@ -63,6 +64,9 @@ dependencies: share_plus: ^10.1.3 path_provider: ^2.1.5 + # Persistent storage + shared_preferences: ^2.3.3 + dev_dependencies: flutter_test: sdk: flutter