Explorar el Código

feat: navigation map

Léo Salé hace 3 años
padre
commit
c9eef437c0

+ 1 - 1
app/android/app/build.gradle

@@ -44,7 +44,7 @@ android {
     defaultConfig {
         // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
         applicationId "com.example.physigo"
-        minSdkVersion flutter.minSdkVersion
+        minSdkVersion 23
         targetSdkVersion flutter.targetSdkVersion
         versionCode flutterVersionCode.toInteger()
         versionName flutterVersionName

+ 3 - 0
app/android/app/src/main/AndroidManifest.xml

@@ -1,5 +1,8 @@
 <manifest xmlns:android="http://schemas.android.com/apk/res/android"
     package="com.example.physigo">
+   <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
+   <uses-permission android:name="android.permission.INTERNET" />
+   <uses-permission android:name="android.permission.VIBRATE"/>
    <application
         android:label="PhysiGo"
         android:name="${applicationName}"

+ 2 - 0
app/ios/Runner/Info.plist

@@ -24,6 +24,8 @@
 	<string>$(FLUTTER_BUILD_NUMBER)</string>
 	<key>LSRequiresIPhoneOS</key>
 	<true/>
+	<key>NSLocationWhenInUseUsageDescription</key>
+	<string>Physigo needs access to location when open.</string>
 	<key>UILaunchStoryboardName</key>
 	<string>LaunchScreen</string>
 	<key>UIMainStoryboardFile</key>

+ 19 - 4
app/lib/main.dart

@@ -1,6 +1,9 @@
-import 'package:flutter/material.dart';
 import 'package:firebase_core/firebase_core.dart';
-import 'package:physigo/firebase_options.dart';
+import 'package:flutter/material.dart';
+import 'package:latlong2/latlong.dart';
+import 'navigation/navigation_page.dart';
+
+import 'firebase_options.dart';
 
 void main() async {
   WidgetsFlutterBinding.ensureInitialized();
@@ -29,9 +32,21 @@ class HomePage extends StatelessWidget {
 
   @override
   Widget build(BuildContext context) {
-    return const Scaffold(
+    return Scaffold(
       body: Center(
-        child: Text("Home"),
+        child: TextButton(
+            onPressed: () {
+              Navigator.push(
+                context,
+                MaterialPageRoute(
+                  // Example on how to use NavigationPage
+                  builder: (context) => NavigationPage(
+                    destination: LatLng(51.78036111980833, 19.451262207821234),
+                  ),
+                ),
+              );
+            },
+            child: const Text('Navigation')),
       ),
     );
   }

+ 59 - 0
app/lib/navigation/models/directions.dart

@@ -0,0 +1,59 @@
+import 'package:latlong2/latlong.dart';
+
+class Directions {
+  final List<DirectionSegment> segments;
+  final List<LatLng> waypointsCoordinates;
+
+  const Directions({required this.segments, required this.waypointsCoordinates});
+
+  factory Directions.fromJson(Map<String, dynamic> json) {
+    return Directions(
+      segments: _getSegmentsFromJson(json),
+      waypointsCoordinates: _getWaypointsCoordinatesFromJson(json),
+    );
+  }
+
+  static List<DirectionSegment> _getSegmentsFromJson(Map<String, dynamic> json) {
+    final segmentsJson = List<Map<String, dynamic>>.from(json['features'][0]['properties']['segments'][0]['steps']);
+    final List<DirectionSegment> segments = [];
+    for (final segmentJson in segmentsJson) {
+      segments.add(DirectionSegment.fromJson(segmentJson));
+    }
+    return segments;
+  }
+
+  static List<LatLng> _getWaypointsCoordinatesFromJson(Map<String, dynamic> json) {
+    final coordinatesJson = List.from(json['features'][0]['geometry']['coordinates']);
+    final List<LatLng> waypointsCoordinates = [];
+    for (final coordinateJson in coordinatesJson) {
+      waypointsCoordinates.add(LatLng(coordinateJson[1], coordinateJson[0]));
+    }
+    return waypointsCoordinates;
+  }
+}
+
+class DirectionSegment {
+  final num distance;
+  final num duration;
+  final int type;
+  final String instruction;
+  final List<int> waypoints;
+
+  const DirectionSegment({
+    required this.distance,
+    required this.duration,
+    required this.type,
+    required this.instruction,
+    required this.waypoints,
+  });
+
+  factory DirectionSegment.fromJson(Map<String, dynamic> json) {
+    return DirectionSegment(
+      distance: json['distance'],
+      duration: json['duration'],
+      type: json['type'],
+      instruction: json['instruction'],
+      waypoints: List<int>.from(json['way_points']),
+    );
+  }
+}

+ 30 - 0
app/lib/navigation/navigation_page.dart

@@ -0,0 +1,30 @@
+import 'package:flutter/material.dart';
+import 'package:latlong2/latlong.dart';
+import 'package:physigo/navigation/utils/permissions_utils.dart';
+import 'package:physigo/navigation/widgets/navigation.dart';
+
+class NavigationPage extends StatelessWidget {
+  final LatLng destination;
+
+  const NavigationPage({required this.destination, Key? key}) : super(key: key);
+
+  @override
+  Widget build(BuildContext context) {
+    return Scaffold(
+      body: FutureBuilder<void>(
+        future: PermissionsUtils.determineLocationPermission(),
+        builder: (context, snapshot) {
+          if (snapshot.connectionState == ConnectionState.waiting) {
+            return const Center(child: CircularProgressIndicator());
+          }
+
+          if (snapshot.hasError) {
+            return Text(snapshot.error.toString());
+          }
+
+          return Navigation(destination: destination);
+        },
+      ),
+    );
+  }
+}

+ 15 - 0
app/lib/navigation/services/directions_service.dart

@@ -0,0 +1,15 @@
+import 'dart:convert';
+
+import 'package:http/http.dart' as http;
+import 'package:physigo/navigation/models/directions.dart';
+import 'package:latlong2/latlong.dart';
+
+class DirectionsService {
+  static const _url = "https://physigo.vercel.app/api/directions-wrapper";
+
+  static Future<Directions> getDirections(LatLng currentPosition, LatLng destination) async {
+    var res = await http.get(Uri.parse(
+        '$_url?start=${currentPosition.longitude},${currentPosition.latitude}&end=${destination.longitude},${destination.latitude}'));
+    return Directions.fromJson(jsonDecode(res.body));
+  }
+}

+ 105 - 0
app/lib/navigation/utils/geometry_utils.dart

@@ -0,0 +1,105 @@
+import 'dart:math';
+import 'package:latlong2/latlong.dart';
+
+class Point3D {
+  final num x;
+  final num y;
+  final num z;
+  static const _earthRadiusInMeter = 6371 * 1000;
+
+  const Point3D({required this.x, required this.y, required this.z});
+
+  // From https://stackoverflow.com/questions/1185408/converting-from-longitude-latitude-to-cartesian-coordinates
+  // (because I forgot basic geometry...)
+  factory Point3D.fromLatLng(LatLng point) {
+    final latInRadian = point.latitude * pi / 180;
+    final longInRadian = point.longitude * pi / 180;
+    return Point3D(
+      x: _earthRadiusInMeter * cos(latInRadian) * cos(longInRadian),
+      y: _earthRadiusInMeter * cos(latInRadian) * sin(longInRadian),
+      z: _earthRadiusInMeter * sin(latInRadian),
+    );
+  }
+
+  Point3D operator +(Point3D other) {
+    return Point3D(x: x + other.x, y: y + other.y, z: z + other.z);
+  }
+
+  Point3D operator -(Point3D other) {
+    return Point3D(x: x - other.x, y: y - other.y, z: z - other.z);
+  }
+
+  Point3D operator *(num value) {
+    return Point3D(x: x * value, y: y * value, z: z * value);
+  }
+}
+
+class Segment {
+  final LatLng start;
+  final LatLng end;
+
+  const Segment({required this.start, required this.end});
+}
+
+// Implementation found from StackOverflow answer https://stackoverflow.com/questions/849211/shortest-distance-between-a-point-and-a-line-segment
+class DistanceUtils {
+  /// Distance from [point] to [segment] in meter
+  static num distToSegment(LatLng point, Segment segment) {
+    final p = Point3D.fromLatLng(point);
+    final v = Point3D.fromLatLng(segment.start);
+    final w = Point3D.fromLatLng(segment.end);
+    final segmentLength = _sqrDistBetweenPoints(v, w);
+
+    // Case v == w
+    if (segmentLength == 0) {
+      return sqrt(_sqrDistBetweenPoints(p, v));
+    }
+
+    // If dotProduct is between 0 and 1, then the projected point is on the segment
+    // and we take the distance between p and the projected point
+    // Else, we take the distance between p and the nearer end
+    final t = max(0, min(1, _dotProduct(p - v, w - v) / segmentLength));
+
+    final projection = v + (w - v) * t;
+    return sqrt(_sqrDistBetweenPoints(p, projection));
+  }
+
+  /// Distance between [point1] and [point2] in meter
+  static num distBetweenTwoPoints(LatLng point1, LatLng point2) {
+    final v = Point3D.fromLatLng(point1);
+    final w = Point3D.fromLatLng(point2);
+
+    return sqrt(_sqrDistBetweenPoints(v, w));
+  }
+
+  /// If parameter is between 0 and 1, the projected point is on the segment
+  ///
+  /// If parameter is greater than 1, the projected point is after the end of the segment
+  ///
+  /// If parameter is lesser than 0, the projected point is before the start of the segment
+  static num getParameterFromProjection(LatLng point, Segment segment) {
+    final p = Point3D.fromLatLng(point);
+    final v = Point3D.fromLatLng(segment.start);
+    final w = Point3D.fromLatLng(segment.end);
+    final segmentLength = _sqrDistBetweenPoints(v, w);
+
+    // Case v == w
+    if (segmentLength == 0) {
+      return 0;
+    }
+
+    return _dotProduct(p - v, w - v) / segmentLength;
+  }
+
+  static num _sqr(num x) {
+    return x * x;
+  }
+
+  static num _sqrDistBetweenPoints(Point3D v, Point3D w) {
+    return _sqr(v.x - w.x) + _sqr(v.y - w.y) + _sqr(v.z - w.z);
+  }
+
+  static num _dotProduct(Point3D v, Point3D w) {
+    return (v.x * w.x) + (v.y * w.y) + (v.z * w.z);
+  }
+}

+ 41 - 0
app/lib/navigation/utils/permissions_utils.dart

@@ -0,0 +1,41 @@
+
+import 'package:geolocator/geolocator.dart';
+
+class PermissionsUtils {
+
+  /// Get Location permissions of the device
+  ///
+  /// Returns a [Future.error] if Location permission is denied
+  static Future<void> determineLocationPermission() async {
+    bool serviceEnabled;
+    LocationPermission permission;
+
+    // Test if location services are enabled.
+    serviceEnabled = await Geolocator.isLocationServiceEnabled();
+    if (!serviceEnabled) {
+      // Location services are not enabled don't continue
+      // accessing the position and request users of the
+      // App to enable the location services.
+      return Future.error('Location services are disabled.');
+    }
+
+    permission = await Geolocator.checkPermission();
+    if (permission == LocationPermission.denied) {
+      permission = await Geolocator.requestPermission();
+      if (permission == LocationPermission.denied) {
+        // Permissions are denied, next time you could try
+        // requesting permissions again (this is also where
+        // Android's shouldShowRequestPermissionRationale
+        // returned true. According to Android guidelines
+        // your App should show an explanatory UI now.
+        return Future.error('Location permissions are denied');
+      }
+    }
+
+    if (permission == LocationPermission.deniedForever) {
+      // Permissions are denied forever, handle appropriately.
+      return Future.error('Location permissions are permanently denied, we cannot request permissions.');
+    }
+  }
+
+}

+ 144 - 0
app/lib/navigation/widgets/directions_instruction.dart

@@ -0,0 +1,144 @@
+import 'dart:async';
+import 'dart:math';
+
+import 'package:flutter/material.dart';
+import 'package:latlong2/latlong.dart';
+
+import '../models/directions.dart';
+import '../utils/geometry_utils.dart';
+
+/// Maximum distance the user can be from the line before recalculating the directions
+const maxDistanceFromLine = 100;
+
+class DirectionsInstruction extends StatefulWidget {
+  final Directions directions;
+  final Stream<LatLng> currentPositionStream;
+  final StreamController<int> currentWaypointIndexController;
+  final bool Function(LatLng) recalculateDirections;
+
+  const DirectionsInstruction({
+    required this.directions,
+    required this.currentPositionStream,
+    required this.currentWaypointIndexController,
+    required this.recalculateDirections,
+    Key? key,
+  }) : super(key: key);
+
+  @override
+  State<DirectionsInstruction> createState() => _DirectionsInstructionState();
+}
+
+class _DirectionsInstructionState extends State<DirectionsInstruction> {
+  /// The current step the user is in the directions
+  int _currentSegmentIndex = 0;
+  /// Next waypoint the user has to pass
+  int _nextWaypointIndex = 0;
+  late StreamSubscription<LatLng> _currentPositionSubscription;
+
+  /// Line the user is currently following, defined by the previous and next waypoints
+  Segment get _currentLine {
+    final startLine = widget.directions.waypointsCoordinates[max(0, _nextWaypointIndex - 1)];
+    final endLine = widget.directions.waypointsCoordinates[_nextWaypointIndex];
+    return Segment(start: startLine, end: endLine);
+  }
+
+  @override
+  initState() {
+    super.initState();
+    _currentPositionSubscription = widget.currentPositionStream.listen(_handleCurrentPositionUpdates);
+  }
+
+  @override
+  void dispose() {
+    _currentPositionSubscription.cancel();
+    super.dispose();
+  }
+
+  void _handleCurrentPositionUpdates(currentPosition) {
+    _updateDirectionsFromCurrentPosition(currentPosition);
+    _updateWaypointFromCurrentPosition(currentPosition);
+  }
+
+  void _updateWaypointFromCurrentPosition(LatLng currentPosition) {
+    final nextWaypointIndex = _getNextWaypointIndex(currentPosition);
+    widget.currentWaypointIndexController.add(nextWaypointIndex);
+    setState(() {
+      _nextWaypointIndex = nextWaypointIndex;
+      _currentSegmentIndex = _getNewSegmentIndex(nextWaypointIndex);
+    });
+  }
+
+  /// Get new directions if the user is too far from the current segment
+  void _updateDirectionsFromCurrentPosition(LatLng currentPosition) {
+    var distanceFromCurrentSegment = DistanceUtils.distToSegment(currentPosition, _currentLine);
+    if (distanceFromCurrentSegment > maxDistanceFromLine) {
+      // If we could recalculate the directions, we reset the segment and waypoint to zero
+      if (widget.recalculateDirections(currentPosition)) {
+        setState(() {
+          _currentSegmentIndex = 0;
+          _nextWaypointIndex = 0;
+        });
+      }
+    }
+  }
+
+  num _getDistanceFromWaypoint(LatLng currentPosition, int waypointIndex) {
+    final currentWaypoint = widget.directions.waypointsCoordinates[waypointIndex];
+    return DistanceUtils.distBetweenTwoPoints(currentPosition, currentWaypoint);
+  }
+
+  int _getNewSegmentIndex(int nextWaypointIndex) {
+    final currentSegment = widget.directions.segments[_currentSegmentIndex];
+    var newSegmentIndex = _currentSegmentIndex;
+    if (nextWaypointIndex > currentSegment.waypoints[1]) {
+      newSegmentIndex++;
+    } else if (nextWaypointIndex <= currentSegment.waypoints[0]) {
+      newSegmentIndex--;
+    }
+    return max(0, min(newSegmentIndex, widget.directions.segments.length - 1));
+  }
+
+  int _getNextWaypointIndex(LatLng currentPosition) {
+    final parameterProjection = DistanceUtils.getParameterFromProjection(currentPosition, _currentLine);
+    final distanceFromNextWaypoint = _getDistanceFromWaypoint(currentPosition, _nextWaypointIndex);
+    final distanceFromNextNextWaypoint = _nextWaypointIndex < widget.directions.waypointsCoordinates.length - 1
+        ? _getDistanceFromWaypoint(currentPosition, _nextWaypointIndex + 1)
+        : double.infinity;
+
+    final isProjectedPointOnCurrentSegment = parameterProjection > 0 && parameterProjection < 1;
+
+    // Approaching the next waypoint while following the line
+    // We want to detect it before passing the waypoint
+    if (distanceFromNextWaypoint < 3 && isProjectedPointOnCurrentSegment) {
+      return _nextWaypointIndex + 1;
+    }
+    // Went back past previous waypoint
+    if (parameterProjection < 0) {
+      return max(0, _nextWaypointIndex - 1);
+    }
+    // Not closely following the line, but still went past next waypoint
+    if (distanceFromNextWaypoint > distanceFromNextNextWaypoint || parameterProjection > 1) {
+      return _nextWaypointIndex + 1;
+    }
+    return _nextWaypointIndex;
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final segments = widget.directions.segments;
+    return Column(
+      children: [
+        SizedBox(height: MediaQuery.of(context).size.height * 0.05),
+        Text(
+          "${segments[_currentSegmentIndex + 1].instruction} in ${segments[_currentSegmentIndex].distance.round()}m",
+          style: const TextStyle(fontSize: 25),
+        ),
+        if (_currentSegmentIndex < segments.length - 2)
+          Text(
+            "Next: ${segments[_currentSegmentIndex + 2].instruction}",
+            style: const TextStyle(fontSize: 20),
+          ),
+      ],
+    );
+  }
+}

+ 98 - 0
app/lib/navigation/widgets/navigation.dart

@@ -0,0 +1,98 @@
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+import 'package:geolocator/geolocator.dart';
+import 'package:latlong2/latlong.dart';
+import 'package:physigo/navigation/models/directions.dart';
+
+import '../services/directions_service.dart';
+import 'directions_instruction.dart';
+import 'navigation_map.dart';
+
+/// Time in seconds before two consecutive calls to the api
+const throttleCallApis = 30;
+
+/// Displays map with directions instruction to go from current position
+/// to [destination]
+class Navigation extends StatefulWidget {
+  final LatLng destination;
+  const Navigation({required this.destination, Key? key}) : super(key: key);
+
+  @override
+  State<Navigation> createState() => _NavigationState();
+}
+
+class _NavigationState extends State<Navigation> {
+  final StreamController<int> _currentWaypointIndexController = StreamController.broadcast();
+  final StreamController<Directions> _directionsController = StreamController.broadcast();
+  late DateTime _lastDirectionsCalculation;
+
+  Stream<LatLng> get _currentPositionStream {
+    final currentPositionStream = Geolocator.getPositionStream();
+    return currentPositionStream.map((position) => LatLng(position.latitude, position.longitude));
+  }
+
+  @override
+  void initState() {
+    _lastDirectionsCalculation = DateTime.now();
+    Geolocator.getCurrentPosition().then((position) {
+      final currentPosition = LatLng(position.latitude, position.longitude);
+      _getDirections(currentPosition);
+    });
+    super.initState();
+  }
+
+  /// Applies a throttle before getting new directions from [currentPosition] to [widget.destination]
+  ///  to prevent spam
+  ///
+  /// If new directions are calculated, returns true
+  bool _recalculateDirections(LatLng currentPosition) {
+    if (DateTime.now().difference(_lastDirectionsCalculation).inSeconds > throttleCallApis) {
+      _getDirections(currentPosition);
+      setState(() {
+        _lastDirectionsCalculation = DateTime.now();
+      });
+      return true;
+    }
+    return false;
+  }
+
+  Future<void> _getDirections(LatLng currentPosition) async {
+    final directions = await DirectionsService.getDirections(currentPosition, widget.destination);
+    _directionsController.add(directions);
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return Scaffold(
+      body: Column(
+        children: [
+          NavigationMap(
+            waypointsCoordinates: _directionsController.stream.map((directions) => directions.waypointsCoordinates),
+            currentWaypointIndexStream: _currentWaypointIndexController.stream,
+          ),
+          StreamBuilder<Directions>(
+              stream: _directionsController.stream,
+              builder: (context, snapshot) {
+                if (snapshot.hasData) {
+                  return DirectionsInstruction(
+                    directions: snapshot.data!,
+                    currentPositionStream: _currentPositionStream,
+                    currentWaypointIndexController: _currentWaypointIndexController,
+                    recalculateDirections: _recalculateDirections,
+                  );
+                }
+                return const CircularProgressIndicator();
+              }),
+        ],
+      ),
+    );
+  }
+
+  @override
+  void dispose() {
+    _currentWaypointIndexController.close();
+    _directionsController.close();
+    super.dispose();
+  }
+}

+ 123 - 0
app/lib/navigation/widgets/navigation_map.dart

@@ -0,0 +1,123 @@
+import 'dart:async';
+import 'dart:math';
+
+import 'package:flutter/material.dart';
+import 'package:flutter_map/flutter_map.dart';
+import 'package:flutter_map_location_marker/flutter_map_location_marker.dart';
+import 'package:geolocator/geolocator.dart';
+import 'package:latlong2/latlong.dart';
+
+class NavigationMap extends StatefulWidget {
+  final Stream<List<LatLng>> waypointsCoordinates;
+  final Stream<int> currentWaypointIndexStream;
+  const NavigationMap({
+    required this.waypointsCoordinates,
+    required this.currentWaypointIndexStream,
+    Key? key,
+  }) : super(key: key);
+
+  @override
+  State<NavigationMap> createState() => _NavigationMapState();
+}
+
+class _NavigationMapState extends State<NavigationMap> {
+  /// Lines drawn on the map to indicate the path
+  List<LatLng> _polylines = [];
+  /// Used to differentiate the path before and after the user
+  int _currentWaypointIndex = 0;
+  final List<StreamSubscription<dynamic>> _streamsSubscription = [];
+  late CenterOnLocationUpdate _centerOnLocationUpdate;
+  late StreamController<double> _centerCurrentLocationStreamController;
+
+  @override
+  void initState() {
+    super.initState();
+    _centerOnLocationUpdate = CenterOnLocationUpdate.always;
+    _centerCurrentLocationStreamController = StreamController<double>();
+    _streamsSubscription.add(widget.waypointsCoordinates.listen(_updatePolylines));
+    _streamsSubscription.add(widget.currentWaypointIndexStream.listen(_updateWaypointIndex));
+  }
+
+  void _updatePolylines(List<LatLng> waypointsCoordinates) {
+    setState(() {
+      _polylines = waypointsCoordinates.map((waypoint) => LatLng(waypoint.latitude, waypoint.longitude)).toList();
+    });
+  }
+
+  void _updateWaypointIndex(int newWaypointIndex) {
+    setState(() {
+      _currentWaypointIndex = newWaypointIndex;
+    });
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return SizedBox(
+      height: MediaQuery.of(context).size.height * 0.8,
+      child: FlutterMap(
+        options: MapOptions(
+          center: LatLng(0, 0),
+          zoom: 15,
+          minZoom: 10,
+          maxZoom: 18,
+          onPositionChanged: (MapPosition position, bool hasGesture) {
+            if (hasGesture) {
+              setState(() => _centerOnLocationUpdate = CenterOnLocationUpdate.never);
+            }
+          },
+        ),
+        children: [
+          TileLayerWidget(
+            options: TileLayerOptions(
+              urlTemplate: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
+              subdomains: ['a', 'b', 'c'],
+            ),
+          ),
+          LocationMarkerLayerWidget(
+            plugin: LocationMarkerPlugin(
+              centerOnLocationUpdate: _centerOnLocationUpdate,
+              centerCurrentLocationStream: _centerCurrentLocationStreamController.stream,
+            ),
+            options: LocationMarkerLayerOptions(
+              marker: const DefaultLocationMarker(
+                color: Colors.blue,
+              ),
+              positionStream: const LocationMarkerDataStreamFactory().geolocatorPositionStream(
+                stream: Geolocator.getPositionStream(
+                  locationSettings: const LocationSettings(
+                    accuracy: LocationAccuracy.bestForNavigation,
+                    distanceFilter: 1,
+                  ),
+                ),
+              ),
+            ),
+          ),
+          PolylineLayerWidget(
+            options: PolylineLayerOptions(
+              polylines: [
+                Polyline(
+                  points: _polylines.sublist(max(0, _currentWaypointIndex - 1)),
+                  color: Colors.red,
+                  strokeWidth: 10,
+                ),
+                Polyline(
+                  points: _polylines.sublist(0, max(0, _currentWaypointIndex)),
+                  color: Colors.green,
+                  strokeWidth: 10,
+                ),
+              ],
+            ),
+          ),
+        ],
+      ),
+    );
+  }
+
+  @override
+  void dispose() {
+    for (final streamSubscription in _streamsSubscription) {
+      streamSubscription.cancel();
+    }
+    super.dispose();
+  }
+}

+ 155 - 1
app/pubspec.lock

@@ -83,6 +83,13 @@ packages:
     description: flutter
     source: sdk
     version: "0.0.0"
+  flutter_compass:
+    dependency: transitive
+    description:
+      name: flutter_compass
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.7.0"
   flutter_lints:
     dependency: "direct dev"
     description:
@@ -90,6 +97,20 @@ packages:
       url: "https://pub.dartlang.org"
     source: hosted
     version: "1.0.4"
+  flutter_map:
+    dependency: "direct main"
+    description:
+      name: flutter_map
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.14.0"
+  flutter_map_location_marker:
+    dependency: "direct main"
+    description:
+      name: flutter_map_location_marker
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "3.1.0"
   flutter_test:
     dependency: "direct dev"
     description: flutter
@@ -100,6 +121,69 @@ packages:
     description: flutter
     source: sdk
     version: "0.0.0"
+  geolocator:
+    dependency: transitive
+    description:
+      name: geolocator
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "8.2.0"
+  geolocator_android:
+    dependency: transitive
+    description:
+      name: geolocator_android
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "3.1.6"
+  geolocator_apple:
+    dependency: transitive
+    description:
+      name: geolocator_apple
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "2.1.2"
+  geolocator_platform_interface:
+    dependency: transitive
+    description:
+      name: geolocator_platform_interface
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "4.0.4"
+  geolocator_web:
+    dependency: transitive
+    description:
+      name: geolocator_web
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "2.1.4"
+  geolocator_windows:
+    dependency: transitive
+    description:
+      name: geolocator_windows
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.1.0"
+  http:
+    dependency: "direct main"
+    description:
+      name: http
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.13.4"
+  http_parser:
+    dependency: transitive
+    description:
+      name: http_parser
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "4.0.0"
+  intl:
+    dependency: transitive
+    description:
+      name: intl
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.17.0"
   js:
     dependency: transitive
     description:
@@ -107,6 +191,13 @@ packages:
       url: "https://pub.dartlang.org"
     source: hosted
     version: "0.6.3"
+  latlong2:
+    dependency: transitive
+    description:
+      name: latlong2
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.8.1"
   lints:
     dependency: transitive
     description:
@@ -114,6 +205,13 @@ packages:
       url: "https://pub.dartlang.org"
     source: hosted
     version: "1.0.1"
+  lists:
+    dependency: transitive
+    description:
+      name: lists
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.0.1"
   matcher:
     dependency: transitive
     description:
@@ -135,6 +233,13 @@ packages:
       url: "https://pub.dartlang.org"
     source: hosted
     version: "1.7.0"
+  mgrs_dart:
+    dependency: transitive
+    description:
+      name: mgrs_dart
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "2.0.0"
   path:
     dependency: transitive
     description:
@@ -149,6 +254,27 @@ packages:
       url: "https://pub.dartlang.org"
     source: hosted
     version: "2.1.2"
+  positioned_tap_detector_2:
+    dependency: transitive
+    description:
+      name: positioned_tap_detector_2
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.0.4"
+  proj4dart:
+    dependency: transitive
+    description:
+      name: proj4dart
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "2.0.0"
+  quiver:
+    dependency: transitive
+    description:
+      name: quiver
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "3.0.1+1"
   sky_engine:
     dependency: transitive
     description: flutter
@@ -196,6 +322,20 @@ packages:
       url: "https://pub.dartlang.org"
     source: hosted
     version: "0.4.8"
+  transparent_image:
+    dependency: transitive
+    description:
+      name: transparent_image
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "2.0.0"
+  tuple:
+    dependency: transitive
+    description:
+      name: tuple
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "2.0.0"
   typed_data:
     dependency: transitive
     description:
@@ -203,6 +343,13 @@ packages:
       url: "https://pub.dartlang.org"
     source: hosted
     version: "1.3.0"
+  unicode:
+    dependency: transitive
+    description:
+      name: unicode
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.3.1"
   vector_math:
     dependency: transitive
     description:
@@ -210,6 +357,13 @@ packages:
       url: "https://pub.dartlang.org"
     source: hosted
     version: "2.1.1"
+  wkt_parser:
+    dependency: transitive
+    description:
+      name: wkt_parser
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "2.0.0"
 sdks:
   dart: ">=2.16.2 <3.0.0"
-  flutter: ">=1.12.13+hotfix.5"
+  flutter: ">=2.8.0"

+ 3 - 0
app/pubspec.yaml

@@ -35,6 +35,9 @@ dependencies:
   # Use with the CupertinoIcons class for iOS style icons.
   cupertino_icons: ^1.0.2
   firebase_core: ^1.15.0
+  http: ^0.13.4
+  flutter_map: ^0.14.0
+  flutter_map_location_marker: ^3.1.0
 
 dev_dependencies:
   flutter_test: