Skip to content

Commit ed93c61

Browse files
v1.5.4+310: fix iOS GPS stalling — AppleSettings + lifecycle restart
THE bug user kept hitting on iPad: AndroidSettings was being passed to geolocator on iOS too. Comments claimed iOS would 'fall back gracefully', which is true for accuracy + distanceFilter but does NOT override CLLocationManager's default `pausesLocationUpdatesAutomatically = YES`. A stationary iPad on a desk during testing therefore gets a brief location stream that iOS auto-pauses after a few seconds, and the user never sees a position fix. Same code worked in v1.5.2 only because that test happened to involve enough movement that iOS didn't pause. Fixes: - LocationService._startStream now switches on Platform: AppleSettings on iOS with pauseLocationUpdatesAutomatically: false + activityType: ActivityType.fitness; AndroidSettings on Android; base LocationSettings everywhere else. - HomeScreen: convert to ConsumerStatefulWidget with WidgetsBindingObserver. On app resume invalidate locationInitProvider so a permission grant from iOS Settings (or a stalled stream from a long backgrounding) re-triggers LocationService.initialize(). - Add kDebugMode prints around initialize / _startStream / first fix so device-tethered Xcode log captures pinpoint where the path fails next time.
1 parent 7abfa07 commit ed93c61

3 files changed

Lines changed: 114 additions & 20 deletions

File tree

lib/services/location/location_service.dart

Lines changed: 75 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import 'dart:async';
2+
import 'dart:io' show Platform;
23

4+
import 'package:flutter/foundation.dart';
35
import 'package:geolocator/geolocator.dart' as geo;
46
import 'package:red_grid_link/core/utils/mgrs.dart';
57
import 'package:red_grid_link/data/models/position.dart';
@@ -224,31 +226,78 @@ class LocationService {
224226
/// and [initialize] will start the stream on next invocation.
225227
Future<void> initialize() async {
226228
final status = await _permissionHandler.checkStatus();
227-
if (status != LocationPermissionStatus.granted) return;
229+
if (kDebugMode) {
230+
print('[LocationService] initialize: permission status = $status');
231+
}
232+
if (status != LocationPermissionStatus.granted) {
233+
if (kDebugMode) {
234+
print('[LocationService] initialize: NOT starting stream — '
235+
'permission $status (waiting for user grant via PermissionsPage)');
236+
}
237+
return;
238+
}
228239

229240
_startStream();
230241
}
231242

243+
/// Whether the GPS position stream is currently subscribed.
244+
///
245+
/// Used by callers (notably HomeScreen on app resume) to detect a
246+
/// stalled stream and call [initialize] again.
247+
bool get isStreamRunning => _geoSubscription != null;
248+
232249
/// Start the underlying geolocator position stream.
233250
void _startStream() {
234251
_geoSubscription?.cancel();
235252

236-
// Use AndroidSettings for richer configuration on Android.
237-
// On iOS, geolocator falls back gracefully when AndroidSettings
238-
// is provided — it uses the accuracy and distanceFilter fields
239-
// from the base LocationSettings.
240-
// Background location is structured here but not enabled yet;
241-
// Phase 7 polish will add foreground service configuration.
242-
final locationSettings = geo.AndroidSettings(
243-
accuracy: _accuracy,
244-
distanceFilter: _distanceFilter,
245-
intervalDuration: _updateInterval,
246-
foregroundNotificationConfig: const geo.ForegroundNotificationConfig(
247-
notificationTitle: 'Red Grid Link',
248-
notificationText: 'Tracking your position',
249-
enableWakeLock: true,
250-
),
251-
);
253+
// CRITICAL: use platform-specific settings. The previous code passed
254+
// `AndroidSettings` on iOS too, on the (incorrect) assumption that
255+
// iOS would fall back to the base `LocationSettings` fields and
256+
// ignore the rest. In practice this leaves CLLocationManager at its
257+
// default `pausesLocationUpdatesAutomatically = YES`, which means a
258+
// stationary iPad on a desk DURING TESTING sees iOS auto-pause the
259+
// location stream after a few seconds and the app NEVER receives a
260+
// position fix. Setting `pauseLocationUpdatesAutomatically: false`
261+
// via `AppleSettings` is the only way to override this — bug
262+
// discovered in v1.5.4 TestFlight, fixed in v1.5.4+310.
263+
//
264+
// Also explicitly set `activityType` so iOS chooses correct power
265+
// management (otherwise it assumes "automotive" which uses different
266+
// accuracy/cadence rules).
267+
final geo.LocationSettings locationSettings;
268+
if (Platform.isIOS) {
269+
locationSettings = geo.AppleSettings(
270+
accuracy: _accuracy,
271+
distanceFilter: _distanceFilter,
272+
activityType: geo.ActivityType.fitness,
273+
pauseLocationUpdatesAutomatically: false,
274+
showBackgroundLocationIndicator: false,
275+
// We do NOT set allowBackgroundLocationUpdates: only the
276+
// "WhileInUse" entitlement is in our Info.plist; setting this
277+
// to true would crash on apps without "Always" auth.
278+
);
279+
} else if (Platform.isAndroid) {
280+
locationSettings = geo.AndroidSettings(
281+
accuracy: _accuracy,
282+
distanceFilter: _distanceFilter,
283+
intervalDuration: _updateInterval,
284+
foregroundNotificationConfig: const geo.ForegroundNotificationConfig(
285+
notificationTitle: 'Red Grid Link',
286+
notificationText: 'Tracking your position',
287+
enableWakeLock: true,
288+
),
289+
);
290+
} else {
291+
locationSettings = geo.LocationSettings(
292+
accuracy: _accuracy,
293+
distanceFilter: _distanceFilter,
294+
);
295+
}
296+
297+
if (kDebugMode) {
298+
print('[LocationService] _startStream: platform=${Platform.operatingSystem} '
299+
'accuracy=$_accuracy distanceFilter=$_distanceFilter');
300+
}
252301

253302
final stream = geo.GeolocatorPlatform.instance.getPositionStream(
254303
locationSettings: locationSettings,
@@ -258,6 +307,10 @@ class LocationService {
258307
_onPositionUpdate,
259308
onError: _onPositionError,
260309
);
310+
311+
if (kDebugMode) {
312+
print('[LocationService] _startStream: subscription created');
313+
}
261314
}
262315

263316
/// Restart the GPS stream (e.g., after configuration changes).
@@ -269,6 +322,11 @@ class LocationService {
269322

270323
/// Handle an incoming geolocator position update.
271324
void _onPositionUpdate(geo.Position geoPos) {
325+
if (kDebugMode && _lastPosition == null) {
326+
// Log only the FIRST fix — subsequent updates would spam.
327+
print('[LocationService] FIRST FIX: lat=${geoPos.latitude} '
328+
'lon=${geoPos.longitude} acc=${geoPos.accuracy}m');
329+
}
272330
final position = _convertPosition(geoPos);
273331
_lastPosition = position;
274332
_positionController.add(position);

lib/ui/screens/home/home_screen.dart

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,47 @@ final activeTabProvider = StateProvider<int>((ref) => 0);
2626
/// A thin mode indicator bar sits above the bottom navigation,
2727
/// showing the current operational mode (SAR, Backcountry, Hunting,
2828
/// or Training) so the user always knows which context is active.
29-
class HomeScreen extends ConsumerWidget {
29+
class HomeScreen extends ConsumerStatefulWidget {
3030
const HomeScreen({super.key});
3131

3232
@override
33-
Widget build(BuildContext context, WidgetRef ref) {
33+
ConsumerState<HomeScreen> createState() => _HomeScreenState();
34+
}
35+
36+
class _HomeScreenState extends ConsumerState<HomeScreen>
37+
with WidgetsBindingObserver {
38+
@override
39+
void initState() {
40+
super.initState();
41+
WidgetsBinding.instance.addObserver(this);
42+
}
43+
44+
@override
45+
void dispose() {
46+
WidgetsBinding.instance.removeObserver(this);
47+
super.dispose();
48+
}
49+
50+
@override
51+
void didChangeAppLifecycleState(AppLifecycleState state) {
52+
// On app resume, force a re-check of location permission and
53+
// restart the GPS stream if it stalled. Three scenarios this
54+
// catches:
55+
// 1. User granted location in iOS Settings while the app was
56+
// backgrounded — invalidate makes the next watch re-run
57+
// LocationService.initialize().
58+
// 2. Stream stalled (iOS sometimes tears down location after
59+
// long backgrounding) — re-running initialize() restarts it.
60+
// 3. Fresh-install upgrade where prior version had different
61+
// permission state — covers any state mismatch on first run.
62+
// This is a no-op when the stream is already running and emitting.
63+
if (state == AppLifecycleState.resumed) {
64+
ref.invalidate(locationInitProvider);
65+
}
66+
}
67+
68+
@override
69+
Widget build(BuildContext context) {
3470
final colors = ref.watch(currentThemeProvider);
3571
final activeTab = ref.watch(activeTabProvider);
3672
final mode = ref.watch(currentModeProvider);

pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
name: red_grid_link
22
description: Offline-first MGRS-native proximity coordination platform
33
publish_to: 'none'
4-
version: 1.5.4+309
4+
version: 1.5.4+310
55

66
environment:
77
sdk: '>=3.2.0 <4.0.0'

0 commit comments

Comments
 (0)