diff --git a/packages/geolocator_watchos/CHANGELOG.md b/packages/geolocator_watchos/CHANGELOG.md index 22d9875..38d87ea 100644 --- a/packages/geolocator_watchos/CHANGELOG.md +++ b/packages/geolocator_watchos/CHANGELOG.md @@ -51,6 +51,13 @@ filter, as geolocator_apple's does: a reduced-accuracy fix can legitimately be old. +**Example** + +* `geolocator`'s own example app, the Baseflow demo, replaces a hand-written + demo (`AUTHORING.md` §3). Its code is unchanged; the example adds + `url_launcher_watchos`, so the template's links open on the watch, and + scales its content by 0.6 so the phone layout fits the watch screen. + **Docs** * README and PORTING_REPORT: the three members move out of "Not supported on diff --git a/packages/geolocator_watchos/PORTING_REPORT.md b/packages/geolocator_watchos/PORTING_REPORT.md index 33f1c61..7571f30 100644 --- a/packages/geolocator_watchos/PORTING_REPORT.md +++ b/packages/geolocator_watchos/PORTING_REPORT.md @@ -7,7 +7,7 @@ | Implementation | ✅ Working (CoreLocation FFI) | | watchOS capability | Full — query, permission, accuracy, service status, and position stream. Accuracy, service status and the `AppleSettings` fields are checked by host tests; the temporary full-accuracy prompt and background updates are not yet checked on a physical watch | | Host unit tests (`flutter-watchos test`) | ✅ pass | -| Upstream integration test | ○ none upstream (Baseflow manual demo) — verified by building + running the example on-sim | +| Upstream integration test | ○ none upstream (Baseflow manual demo) — verified by building + running the example on-sim (watchOS 27.0 Simulator, 2026-10-24) | | Internal unified demo | ✅ included | Marking: ✅ full / passes · ◐ partial — reason given · ○ not applicable (no upstream test) · ✗ unsupported on watchOS. @@ -21,6 +21,16 @@ Source: `geolocator_apple` 2.3.14 Base platform: ios (Objective-C) Output: `./geolocator_watchos` +## Example + +`geolocator`'s own example app, the Baseflow demo, ported with +`flutter-watchos plugin port --include-example`. It replaces a hand-written +demo (`AUTHORING.md` §3). Its code is unchanged; the README lists its two +watchOS deviations: `url_launcher_watchos` in the example's pubspec, so the +template's links open on the watch, and `FlutterWatchOSContentScale` 0.6 in +the runner's `Info.plist`, so the phone layout fits the watch screen. The +package's `LICENSE` is Baseflow's MIT licence, which covers the demo. + ## This is an FFI scaffold Method-channel plugins are not supported on watchOS — a `pluginClass:`-only implementation builds, but its channel calls throw `MissingPluginException`. The supported plugin model is **dart:ffi**, so this package is an FFI scaffold, not a copy of the source plugin's native code: diff --git a/packages/geolocator_watchos/README.md b/packages/geolocator_watchos/README.md index 3efd347..96105f2 100644 --- a/packages/geolocator_watchos/README.md +++ b/packages/geolocator_watchos/README.md @@ -97,10 +97,23 @@ updates with the wrist down. | Apple Watch (`watchos`) | yes | | Watch simulator (`watchsimulator`) | query methods verified; live fixes need a simulated location | -The example is geolocator's own upstream Baseflow demo, ported verbatim. That -demo ships no `integration_test/`, so this package is verified by building and -running the example on the watch simulator; the interactive permission prompt -and live position updates are verified on a physical Apple Watch. +The interactive permission prompt and live position updates were verified on +a physical Apple Watch with 0.1.x. 0.2.0 has run only on the Simulator so far +([#16](https://github.com/flutterwatch/plugins/issues/16)). + +## Example + +The example is `geolocator`'s own example app, the Baseflow demo, ported with +`flutter-watchos plugin port --include-example`. Its code is unchanged; these +are its watchOS deviations: + +| Where | Change | Why | +|---|---|---| +| `example/pubspec.yaml` | adds `url_launcher_watchos: ^0.1.1` | the Baseflow template opens its links with `url_launcher`, which needs it to open them on the watch | +| `example/watchos/Runner/Info.plist` | sets `FlutterWatchOSContentScale` to `0.6` | the demo is designed for a phone and overflowed the watch screen by 106 pixels on a 46 mm Simulator; the scale lays it out in a logical space 1.67 times the screen's size and draws it smaller, without touching the Dart code | + +The demo ships no `integration_test/`, so this package is verified by building +and running the example on the watch simulator. ## Not supported on watchOS diff --git a/packages/geolocator_watchos/example/lib/main.dart b/packages/geolocator_watchos/example/lib/main.dart index 3cbf194..5f4ec44 100644 --- a/packages/geolocator_watchos/example/lib/main.dart +++ b/packages/geolocator_watchos/example/lib/main.dart @@ -1,64 +1,429 @@ -// Copyright 2026 The FlutterWatch Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. +import 'dart:async'; +import 'dart:io' show Platform; +import 'package:baseflow_plugin_template/baseflow_plugin_template.dart'; import 'package:flutter/material.dart'; import 'package:geolocator/geolocator.dart'; -void main() => runApp(const ExampleApp()); +/// Defines the main theme color. +final MaterialColor themeMaterialColor = + BaseflowPluginExample.createMaterialColor( + const Color.fromRGBO(48, 49, 60, 1)); -class ExampleApp extends StatelessWidget { - const ExampleApp({super.key}); +void main() { + runApp(const GeolocatorWidget()); +} - @override - Widget build(BuildContext context) { - return const MaterialApp(home: GeolocatorDemo()); +/// Example [Widget] showing the functionalities of the geolocator plugin +class GeolocatorWidget extends StatefulWidget { + /// Creates a new GeolocatorWidget. + const GeolocatorWidget({super.key}); + + /// Utility method to create a page with the Baseflow templating. + static ExamplePage createPage() { + return ExamplePage( + Icons.location_on, (context) => const GeolocatorWidget()); } + + @override + State createState() => _GeolocatorWidgetState(); } -class GeolocatorDemo extends StatefulWidget { - const GeolocatorDemo({super.key}); +class _GeolocatorWidgetState extends State { + static const String _kLocationServicesDisabledMessage = + 'Location services are disabled.'; + static const String _kPermissionDeniedMessage = 'Permission denied.'; + static const String _kPermissionDeniedForeverMessage = + 'Permission denied forever.'; + static const String _kPermissionGrantedMessage = 'Permission granted.'; + + final GeolocatorPlatform _geolocatorPlatform = GeolocatorPlatform.instance; + final List<_PositionItem> _positionItems = <_PositionItem>[]; + StreamSubscription? _positionStreamSubscription; + StreamSubscription? _serviceStatusStreamSubscription; + bool positionStreamStarted = false; @override - State createState() => _GeolocatorDemoState(); -} + void initState() { + super.initState(); + _toggleServiceStatusStream(); + } + + PopupMenuButton _createActions() { + return PopupMenuButton( + elevation: 40, + onSelected: (value) async { + switch (value) { + case 1: + _getLocationAccuracy(); + break; + case 2: + _requestTemporaryFullAccuracy(); + break; + case 3: + _openAppSettings(); + break; + case 4: + _openLocationSettings(); + break; + case 5: + setState(_positionItems.clear); + break; + default: + break; + } + }, + itemBuilder: (context) => [ + if (Platform.isIOS) + const PopupMenuItem( + value: 1, + child: Text("Get Location Accuracy"), + ), + if (Platform.isIOS) + const PopupMenuItem( + value: 2, + child: Text("Request Temporary Full Accuracy"), + ), + const PopupMenuItem( + value: 3, + child: Text("Open App Settings"), + ), + if (Platform.isAndroid || Platform.isWindows) + const PopupMenuItem( + value: 4, + child: Text("Open Location Settings"), + ), + const PopupMenuItem( + value: 5, + child: Text("Clear"), + ), + ], + ); + } -class _GeolocatorDemoState extends State { - String _status = 'tap to locate'; + @override + Widget build(BuildContext context) { + const sizedBox = SizedBox( + height: 10, + ); - Future _run() async { - setState(() => _status = 'locating…'); - try { - LocationPermission permission = await Geolocator.checkPermission(); + return BaseflowPluginExample( + pluginName: 'Geolocator', + githubURL: 'https://github.com/Baseflow/flutter-geolocator', + pubDevURL: 'https://pub.dev/packages/geolocator', + appBarActions: [ + _createActions() + ], + pages: [ + ExamplePage( + Icons.location_on, + (context) => Scaffold( + backgroundColor: Theme.of(context).colorScheme.surface, + body: ListView.builder( + itemCount: _positionItems.length, + itemBuilder: (context, index) { + final positionItem = _positionItems[index]; + + if (positionItem.type == _PositionItemType.log) { + return ListTile( + title: Text(positionItem.displayValue, + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + )), + ); + } else { + return Card( + child: ListTile( + tileColor: themeMaterialColor, + title: Text( + positionItem.displayValue, + style: const TextStyle(color: Colors.white), + ), + ), + ); + } + }, + ), + floatingActionButton: Column( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + FloatingActionButton( + onPressed: () { + positionStreamStarted = !positionStreamStarted; + _toggleListening(); + }, + tooltip: (_positionStreamSubscription == null) + ? 'Start position updates' + : _positionStreamSubscription!.isPaused + ? 'Resume' + : 'Pause', + backgroundColor: _determineButtonColor(), + child: (_positionStreamSubscription == null || + _positionStreamSubscription!.isPaused) + ? const Icon(Icons.play_arrow) + : const Icon(Icons.pause), + ), + sizedBox, + FloatingActionButton( + onPressed: _getCurrentPosition, + child: const Icon(Icons.my_location), + ), + sizedBox, + FloatingActionButton( + onPressed: _getLastKnownPosition, + child: const Icon(Icons.bookmark), + ), + ], + ), + ), + ) + ]); + } + + Future _getCurrentPosition() async { + final hasPermission = await _handlePermission(); + + if (!hasPermission) { + return; + } + + final position = await _geolocatorPlatform.getCurrentPosition(); + _updatePositionList( + _PositionItemType.position, + position.toString(), + ); + } + + Future _handlePermission() async { + bool serviceEnabled; + LocationPermission permission; + + // Test if location services are enabled. + serviceEnabled = await _geolocatorPlatform.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. + _updatePositionList( + _PositionItemType.log, + _kLocationServicesDisabledMessage, + ); + + return false; + } + + permission = await _geolocatorPlatform.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await _geolocatorPlatform.requestPermission(); if (permission == LocationPermission.denied) { - permission = await Geolocator.requestPermission(); + // 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. + _updatePositionList( + _PositionItemType.log, + _kPermissionDeniedMessage, + ); + + return false; } - if (permission == LocationPermission.denied || - permission == LocationPermission.deniedForever) { - setState(() => _status = 'permission: $permission'); + } + + if (permission == LocationPermission.deniedForever) { + // Permissions are denied forever, handle appropriately. + _updatePositionList( + _PositionItemType.log, + _kPermissionDeniedForeverMessage, + ); + + return false; + } + + // When we reach here, permissions are granted and we can + // continue accessing the position of the device. + _updatePositionList( + _PositionItemType.log, + _kPermissionGrantedMessage, + ); + return true; + } + + void _updatePositionList(_PositionItemType type, String displayValue) { + _positionItems.add(_PositionItem(type, displayValue)); + setState(() {}); + } + + bool _isListening() => !(_positionStreamSubscription == null || + _positionStreamSubscription!.isPaused); + + Color _determineButtonColor() { + return _isListening() ? Colors.green : Colors.red; + } + + void _toggleServiceStatusStream() { + if (_serviceStatusStreamSubscription == null) { + final serviceStatusStream = _geolocatorPlatform.getServiceStatusStream(); + _serviceStatusStreamSubscription = + serviceStatusStream.handleError((error) { + _serviceStatusStreamSubscription?.cancel(); + _serviceStatusStreamSubscription = null; + }).listen((serviceStatus) { + String serviceStatusValue; + if (serviceStatus == ServiceStatus.enabled) { + if (positionStreamStarted) { + _toggleListening(); + } + serviceStatusValue = 'enabled'; + } else { + if (_positionStreamSubscription != null) { + setState(() { + _positionStreamSubscription?.cancel(); + _positionStreamSubscription = null; + _updatePositionList( + _PositionItemType.log, 'Position Stream has been canceled'); + }); + } + serviceStatusValue = 'disabled'; + } + _updatePositionList( + _PositionItemType.log, + 'Location service has been $serviceStatusValue', + ); + }); + } + } + + void _toggleListening() { + if (_positionStreamSubscription == null) { + final positionStream = _geolocatorPlatform.getPositionStream(); + _positionStreamSubscription = positionStream.handleError((error) { + _positionStreamSubscription?.cancel(); + _positionStreamSubscription = null; + }).listen((position) => _updatePositionList( + _PositionItemType.position, + position.toString(), + )); + _positionStreamSubscription?.pause(); + } + + setState(() { + if (_positionStreamSubscription == null) { return; } - final Position pos = await Geolocator.getCurrentPosition(); - setState(() => _status = '${pos.latitude.toStringAsFixed(4)}, ' - '${pos.longitude.toStringAsFixed(4)}'); - } catch (e) { - setState(() => _status = 'error: $e'); - } + + String statusDisplayValue; + if (_positionStreamSubscription!.isPaused) { + _positionStreamSubscription!.resume(); + statusDisplayValue = 'resumed'; + } else { + _positionStreamSubscription!.pause(); + statusDisplayValue = 'paused'; + } + + _updatePositionList( + _PositionItemType.log, + 'Listening for position updates $statusDisplayValue', + ); + }); } @override - Widget build(BuildContext context) { - return Scaffold( - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text(_status, textAlign: TextAlign.center), - const SizedBox(height: 8), - ElevatedButton(onPressed: _run, child: const Text('Locate')), - ], - ), - ), + void dispose() { + if (_positionStreamSubscription != null) { + _positionStreamSubscription!.cancel(); + _positionStreamSubscription = null; + } + + super.dispose(); + } + + void _getLastKnownPosition() async { + final position = await _geolocatorPlatform.getLastKnownPosition(); + if (position != null) { + _updatePositionList( + _PositionItemType.position, + position.toString(), + ); + } else { + _updatePositionList( + _PositionItemType.log, + 'No last known position available', + ); + } + } + + void _getLocationAccuracy() async { + final status = await _geolocatorPlatform.getLocationAccuracy(); + _handleLocationAccuracyStatus(status); + } + + void _requestTemporaryFullAccuracy() async { + final status = await _geolocatorPlatform.requestTemporaryFullAccuracy( + purposeKey: "TemporaryPreciseAccuracy", ); + _handleLocationAccuracyStatus(status); } + + void _handleLocationAccuracyStatus(LocationAccuracyStatus status) { + String locationAccuracyStatusValue; + if (status == LocationAccuracyStatus.precise) { + locationAccuracyStatusValue = 'Precise'; + } else if (status == LocationAccuracyStatus.reduced) { + locationAccuracyStatusValue = 'Reduced'; + } else { + locationAccuracyStatusValue = 'Unknown'; + } + _updatePositionList( + _PositionItemType.log, + '$locationAccuracyStatusValue location accuracy granted.', + ); + } + + void _openAppSettings() async { + final opened = await _geolocatorPlatform.openAppSettings(); + String displayValue; + + if (opened) { + displayValue = 'Opened Application Settings.'; + } else { + displayValue = 'Error opening Application Settings.'; + } + + _updatePositionList( + _PositionItemType.log, + displayValue, + ); + } + + void _openLocationSettings() async { + final opened = await _geolocatorPlatform.openLocationSettings(); + String displayValue; + + if (opened) { + displayValue = 'Opened Location Settings'; + } else { + displayValue = 'Error opening Location Settings'; + } + + _updatePositionList( + _PositionItemType.log, + displayValue, + ); + } +} + +enum _PositionItemType { + log, + position, +} + +class _PositionItem { + _PositionItem(this.type, this.displayValue); + + final _PositionItemType type; + final String displayValue; } diff --git a/packages/geolocator_watchos/example/pubspec.yaml b/packages/geolocator_watchos/example/pubspec.yaml index 1096b87..6331644 100644 --- a/packages/geolocator_watchos/example/pubspec.yaml +++ b/packages/geolocator_watchos/example/pubspec.yaml @@ -12,6 +12,11 @@ dependencies: geolocator: ^14.1.1 geolocator_watchos: path: ../ + # Upstream's example is built on the Baseflow template. + baseflow_plugin_template: ^2.1.2 + # The template's links open with url_launcher; this lets them open on the + # watch (a listed deviation, see the README). + url_launcher_watchos: ^0.1.1 dev_dependencies: flutter_test: diff --git a/packages/geolocator_watchos/example/watchos/Runner/Info.plist b/packages/geolocator_watchos/example/watchos/Runner/Info.plist index 1c0d002..139d364 100644 --- a/packages/geolocator_watchos/example/watchos/Runner/Info.plist +++ b/packages/geolocator_watchos/example/watchos/Runner/Info.plist @@ -20,6 +20,8 @@ $(FLUTTER_BUILD_NAME) CFBundleVersion $(FLUTTER_BUILD_NUMBER) + FlutterWatchOSContentScale + 0.6 NSLocationWhenInUseUsageDescription This example reads your location to demonstrate geolocator on the watch. WKApplication