Location - Continuous Updates and Geofencing
- Last year’s recording
- Objectives
- Introduction
- Starting the location stream
- Pausing and disposing of the location stream
- Geofencing
- Conclusion
Last year’s recording
I recorded the wrong screen this semester. Sorry.
Ending code will have slightly different variable and method names.
Objectives
- To continuously stream a user’s location
- To implement rudimentary geofencing
- You will need the files from previous on-demand Location lab in a project that must be named
location_sampler
:pubspec.yaml
lib/
main.dart
lib/
on_demand.dart
- (Android only)
android/app/
build.gradle
- removebuild.gradle.kts
if present. - (Android only)
android/app/src/main/
AndroidManifest.xml
- (iOS only)
ios/Runner/
Info.plist
- (iOS only)
ios/
Podfile
- Download
streaming_loc.dart
and replace the old one inlib/
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.
Selecting the “Streaming” button on the home screen will take you to the following “Continuous Location Streaming” page:
This screen looks a lot like the On Demand Location screen:
- We will accumulate the user’s coordinates in a
positions
state variable. We display a Text widget with “No positions yet” ifpositions
has no elements, or use aListView.builder()
to display a list of the latitude, longitude, and accuracy data of each position in the list. - there is an
error
state variable that we will use to display problems, like if location services is turned off or the user does not grant location privileges. - there is a “clear” button to empty the list of positions.
- There are new “Start” and “Stop” buttons that we will use to toggle location streaming on or off. These buttons currently do nothing.
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:
- We create a
LocationSettings
object which tells thegeolocator
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. - Streams can be expensive to set up. If the Stream is already set up but paused because we have “stopped listening”, simply resume listening.
- 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, aStreamSubscription
, that let’s us pause, resume, or cancel the stream. Any time the Stream produces a new Position, we add it to thepositions
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();
}
Geofencing
Geofencing is determining if a user’s location falls within an geographic area. Geofencing is used to inform the user if they are near a point of interest, spawning monsters in a location-aware game, and targeted advertising on maps.
Geofencing comes in two flavors:
- Point-based geofencing: Determine if a user is within a specified distance, d, of a latitude and longitude, i.e., is the user within a circle with radius d of the point.
- Polygon geofencing. Create of polygon using a series of coordinates, and use ray-casting or point-in-polygon algorithms to determine if the user’s coordinate are inside the polygon.
Checking if a user is within a geofence is a matter of iterating through the programmed list of geofences and checking each one. Advanced strategies are possible and necessary when the number of geofences grows large in order to preserve battery.
A Geofencer module
Create a new file named geofencer.dart
and paste in the following code:
import 'dart:math';
class PointOfInterest {
final String name;
final double latitude;
final double longitude;
PointOfInterest({
required this.name,
required this.latitude,
required this.longitude,
});
}
var CLOSE = 0.001;
class Geofencer {
List<PointOfInterest> pointsOfInterest = [
PointOfInterest(
name: "UNCW Clocktower",
latitude: 34.227159,
longitude: -77.872979,
),
PointOfInterest(name: "Dobo Hall", latitude: 34.2257, longitude: -77.8683),
PointOfInterest(
name: "Congdon Hall",
latitude: 34.2261,
longitude: -77.8718,
),
];
/// latitude and longitude are passed as the user's current location
/// If they're close, return the point of interest
String? getNearbyPOI(double latitude, double longitude) {
String? result;
for (var poi in pointsOfInterest) {
// 0.001 is within 100 meters
if (sqrt(
pow(poi.latitude - latitude, 2) + pow(poi.longitude - longitude, 2),
) <
CLOSE) {
return poi.name;
}
}
}
}
- The
pointsOfinterest
are hardcoded, but could be drawn from the Firestore. - the
getNearbyPOI()
function returns a string if the Euclidian distance between the user’s coordinates and a point of interest’s coordinates are less than the constantCLOSE
. Note this function only returns the first point of interest in the list – it’s possible the user is within range of multiple! - If the user is not with
CLOSE
range of a POI, thennull
is returned by default.
Updating the display
Go back to streaming_loc.dart
and do the following:
- Instantiate the class variable
Geofencer geofencer = Geofencer()
in your State class so we can access the geofencing functionality. - Also add the class variable
String? mostRecentPOI
in your State class to keep track of which POI we are near, if any. - Finally, change the
positionStream
in_startPositionStreaming()
to the following:
positionStream ??= Geolocator.getPositionStream(
locationSettings: locationSettings,
).listen((Position? position) {
if (position != null) {
setState(() => positions.add(position));
// Check for nearby points of interest
// If it's a new one, show a snackbar
String? poi = geofencer.getNearbyPOI(
position.latitude,
position.longitude,
);
if (poi != null && poi != mostRecentPOI) {
mostRecentPOI = poi;
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text("Near: $mostRecentPOI")));
}
}
}
});
Now, every time the Position stream produces a new value, we check to see if that Position is in one of our Geofences. If so, return the name of the point of interest and display it in a Snackbar.
It’s possible that the user moves around within range of a POI for a while. We use the mostRecentPOI
to see if the POI has changed so we only show the Snackbar if we’re somewhere new.
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 coode: