Location - Continuous Updates

  1. Objectives
  2. Code along
  3. Introduction
  4. Boilerplate
  5. Starting the location stream
  6. Pausing and disposing of the location stream
  7. Conclusion

Objectives

Code along

https://youtu.be/ghRZLrlqGdQ

Introduction

We demonstrated in the previous lab how to obtain a device’s location on-demand, i.e., in response to a single event like a button press.

Location information can also be continuously streamed. Android and iOS have facilities to detect when a device’s position has changed, and can then notify interested applications that a change has occurred. This is useful for navigation applications and applications that pop-up information when the user approaches a specific place (a concept called geofencing).

Continuously polling for the device’s location involves a tradeoff between location accuracy and battery life. The more often you ask for location, the quicker the device battery will drain. Consequently, most location provider code will allow the caller to specify a strategy that allows the programmer to say something like, “I only need a new location reading every 5 minutes” or “I only need a new location reading when you’re sure the device has moved a few meters”.

We will add continuous location polling to our location_sampler. We will again use the geolocator library from the previous lab to do the heavy lifting for us.

Boilerplate

You will need to starter code from the previous lab on On Demand Location. Then, paste the following over streaming_loc.dart:

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:geolocator/geolocator.dart';

class StreamingLocationScreen extends StatefulWidget {
  const StreamingLocationScreen({super.key});

  @override
  State<StreamingLocationScreen> createState() =>
      _StreamingLocationScreenState();
}

class _StreamingLocationScreenState extends State<StreamingLocationScreen> {
  String? error;
  List<Position> positions = [];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text("Continuous Location Streaming")),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          children: [
            if (error != null)
              Text(
                error!,
                style: TextStyle(
                    color: Colors.red[900],
                    fontSize: 18,
                    fontWeight: FontWeight.bold),
              ),
            if (positions.isEmpty)
              const Expanded(child: Text("No positions yet.")),
            if (positions.isNotEmpty)
              Expanded(
                  child: ListView.builder(
                itemCount: positions.length,
                itemBuilder: (context, index) => Padding(
                  padding: const EdgeInsets.all(8),
                  child: Text(
                      "${positions[index].latitude} ${positions[index].longitude} ${positions[index].accuracy}"),
                ),
              )),
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceAround,
              children: [
                ElevatedButton(onPressed: () {}, child: const Text("Start")),
                ElevatedButton(onPressed: () {}, child: const Text("Stop")),
                ElevatedButton(
                    onPressed: () => setState(() => positions.clear()),
                    child: const Text("Clear"))
              ],
            )
          ],
        ),
      ),
    );
  }
}

Selecting the “Streaming” button on the home screen will take you to the following “Continuous Location Streaming” page:

Starting point for continuous location streaming

This screen looks a lot like the On Demand Location screen:

Starting the location stream

The Dart/Flutter construct for a data source that yields new data asynchronously and periodically is a Stream. We encountered streams when reading data from the Firebase Firestore.

The geolocator library provides us with a PositionStream that can provide us with a continuous update of location data based.

First, add a new state variable: StreamSubscription<Position>? positionStream;

Second, create a new async method called _startPositionStreaming() with the following code:

  /// Attempt to start the geolocator position streaming.
  ///
  /// We must also check for permission and that location services are enabled.
  /// The 'error' state variable set to the error string if there is a problem.
  _startPositionStreaming() async {
    error = null;

    // Test if location services are enabled.
    bool 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.
      error = 'Location services are disabled.';
    }

    LocationPermission 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.
        //
        // Your App should show an explanatory UI now.
        error = 'Location permissions are denied';
      }
    }

    if (permission == LocationPermission.deniedForever) {
      // Permissions are denied forever, handle appropriately.
      error =
          'Location permissions are permanently denied, we cannot request permissions.';
    }

    // Check that everything okay, then access the position of the device.
    if (error == null) {
      // LocationSettings tell the streaming location provider how to behave
      // w.r.t. to the tradeoff between accuracy and power.
      // See https://pub.dev/documentation/geolocator_platform_interface/latest/geolocator_platform_interface/LocationSettings-class.html
      // See https://pub.dev/packages/geolocator#location-accuracy
      LocationSettings locationSettings = const LocationSettings(
        accuracy: LocationAccuracy.high,
        distanceFilter: 5,
      );

      // Starts the streaming.
      // Only create the new stream if positionStream is null, otherwise, you'll
      // have multiple listeners and duplicate data!
      positionStream ??=
          Geolocator.getPositionStream(locationSettings: locationSettings)
              .listen((Position? position) {
        if (position != null) {
          setState(() => positions.add(position));
        }
      });

      setState(() {});
    }
  }

The logic for checking if Location Services are enabled and handling the permission request is the same as in the On Demand lab lab. The big difference here is at the end:

  1. We create a LocationSettings object which tells the geolocator how to behave. Have a look at the LocationSettings API to see what the arguments do. The accuracy is of particular interest. Again, you are telling Geolocator how to trade off positioning accuracy vs. power consumption.
  2. Streams can be expensive to set up. If the Stream is already set up but paused because we have “stopped listening”, simply resume listening.
  3. Finally, if the Stream has not been set up, make the call to Geolocator.getPositionStream(...) to start the location streaming. The .listen() method says what to do when we get an update AND gives us a controller to the Stream, a StreamSubscription, that let’s us pause, resume, or cancel the stream. Any time the Stream produces a new Position, we add it to the positions list and trigger a rebuild of the UI.

Finally, update the “Start” ElevatedButton as follows to call the _startPositionStreaming() method:

  ElevatedButton(
      onPressed: () => _startPositionStreaming(),
      child: const Text("Start")),

The video below demonstrates the functionality. I am using the Android Emulator’s extended controls to simulate walking between Congdon Hall and Veteran’s Hall at UNCW. You can see the list of positions updating and the values subtly changing as the Geolocator streams the data.

Pausing and disposing of the location stream

The positionStream variable acts as a controller for the stream. If we need to temporarily pause the delivery of data from any Stream, we can call positionStream.pause(). This will usually result in data buffering that is delivered again once positionStream.resume() is called.

It is usually better to .cancel() a stream when you no longer need it. For example, when the user navigates away from the page. Location streaming is expensive, so we should turn it off when we don’t need it.

Change our “Stop” button to the following:

  ElevatedButton(
      onPressed: () => positionStream
          ?.cancel()
          .then((_) => positionStream = null),
      child: const Text("Stop")),

This will stop the stream. Clicking “Start” will restart the stream.

We should also invoke positionStream.cancel() the position stream whenever the Screen is destroyed, or when we navigate away. We don’t have any navigation on this screen right now. To make sure the stream is canceled when the Screen is destroyed, add the following to your State:

  @override
  void dispose() {
    positionStream?.cancel();
    super.dispose();
  }

Conclusion

The Geolocator library once again does the heavy lifting for us, allowing us to continuously stream Position updates. We need to take care to cancel the stream when appropriate.

Here is the completed streaming_loc.dart