import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'location_service.dart';
/// The local state that is allowed to survive a page change or app restart.
/// It contains only the last device coordinate and the local prompt state;
/// it is never sent to WordPress as stored profile data.
class LocationPersistedState {
const LocationPersistedState({
this.access,
this.coordinate,
this.startupPromptCompleted = false,
});
final LocationAccess? access;
final LocationCoordinate? coordinate;
final bool startupPromptCompleted;
}
abstract interface class LocationStorage {
Future<LocationPersistedState> read();
Future<void> write(LocationPersistedState state);
}
class SharedPreferencesLocationStorage implements LocationStorage {
const SharedPreferencesLocationStorage();
static const _latitudeKey = 'meinweimar.location.latitude';
static const _longitudeKey = 'meinweimar.location.longitude';
static const _accessKey = 'meinweimar.location.access';
static const _promptKey = 'meinweimar.location.startup_prompt_completed';
@override
Future<LocationPersistedState> read() async {
try {
final preferences = await SharedPreferences.getInstance();
final latitude = preferences.getDouble(_latitudeKey);
final longitude = preferences.getDouble(_longitudeKey);
final coordinate =
latitude != null &&
longitude != null &&
_isValidCoordinate(latitude, longitude)
? LocationCoordinate(latitude: latitude, longitude: longitude)
: null;
final access = switch (preferences.getString(_accessKey)) {
'granted' => LocationAccess.granted,
'denied' => LocationAccess.denied,
'unavailable' => LocationAccess.unavailable,
_ => null,
};
return LocationPersistedState(
access: access,
coordinate: coordinate,
startupPromptCompleted: preferences.getBool(_promptKey) ?? false,
);
} catch (_) {
// A storage/plugin failure must never make the app unusable.
return const LocationPersistedState();
}
}
@override
Future<void> write(LocationPersistedState state) async {
try {
final preferences = await SharedPreferences.getInstance();
final coordinate = state.coordinate;
if (coordinate == null) {
await preferences.remove(_latitudeKey);
await preferences.remove(_longitudeKey);
} else {
await preferences.setDouble(_latitudeKey, coordinate.latitude);
await preferences.setDouble(_longitudeKey, coordinate.longitude);
}
final access = state.access;
if (access == null) {
await preferences.remove(_accessKey);
} else {
await preferences.setString(_accessKey, access.name);
}
await preferences.setBool(_promptKey, state.startupPromptCompleted);
} catch (_) {
// Location remains a session-only feature if local persistence is not
// available on a platform/build target.
}
}
static bool _isValidCoordinate(double latitude, double longitude) =>
latitude.isFinite &&
longitude.isFinite &&
latitude >= -90 &&
latitude <= 90 &&
longitude >= -180 &&
longitude <= 180;
}
/// App-wide location state shared by onboarding, the shell and every geo
/// discovery surface. A page must not create its own long-lived GPS state.
class LocationController extends ChangeNotifier {
LocationController({
LocationService? service,
LocationStorage? storage,
Duration requestTimeout = const Duration(seconds: 12),
}) : _service = service ?? LocationService(),
_storage = storage ?? const SharedPreferencesLocationStorage(),
_requestTimeout = requestTimeout;
final LocationService _service;
final LocationStorage _storage;
final Duration _requestTimeout;
LocationAccess? access;
LocationCoordinate? coordinate;
bool isLoading = false;
bool startupPromptCompleted = false;
bool _restored = false;
Future<void>? _restoreFuture;
Future<LocationResult>? _requestFuture;
bool get hasCoordinate => coordinate != null;
Future<void> restore() {
if (_restored) return Future<void>.value();
return _restoreFuture ??= _restoreInternal();
}
Future<void> _restoreInternal() async {
LocationPersistedState state;
try {
state = await _storage.read().timeout(const Duration(seconds: 2));
} catch (_) {
// Browser storage can be unavailable or stalled in a private WebView.
// Treat that as an empty local state instead of blocking the app shell.
state = const LocationPersistedState();
}
access = state.access;
coordinate = state.coordinate;
startupPromptCompleted = state.startupPromptCompleted;
_restored = true;
_restoreFuture = null;
notifyListeners();
}
Future<LocationResult> requestLocation({bool preserveOnUnavailable = false}) {
final inFlight = _requestFuture;
if (inFlight != null) return inFlight;
final request = _requestLocationInternal(
preserveOnUnavailable: preserveOnUnavailable,
);
late final Future<LocationResult> trackedRequest;
trackedRequest = request.whenComplete(() {
if (identical(_requestFuture, trackedRequest)) _requestFuture = null;
});
_requestFuture = trackedRequest;
return trackedRequest;
}
Future<LocationResult> _requestLocationInternal({
required bool preserveOnUnavailable,
}) async {
await restore();
final previousAccess = access;
final previousCoordinate = coordinate;
isLoading = true;
notifyListeners();
try {
// Browser/WebView geolocation can leave the platform future pending
// when permission UI is unavailable or the document is not allowed to
// resolve a position. Location must remain an optional enhancement, so
// it is bounded and falls back to the normal address/category search.
final result = await _service.requestCurrentLocation().timeout(
_requestTimeout,
onTimeout: () =>
const LocationResult(access: LocationAccess.unavailable),
);
final keepPreviousCoordinate =
preserveOnUnavailable &&
result.access == LocationAccess.unavailable &&
previousAccess == LocationAccess.granted &&
previousCoordinate != null;
if (!keepPreviousCoordinate) {
access = result.access;
coordinate = result.access == LocationAccess.granted
? result.coordinate
: null;
}
await _persistSafely();
return result;
} catch (_) {
final keepPreviousCoordinate =
preserveOnUnavailable &&
previousAccess == LocationAccess.granted &&
previousCoordinate != null;
if (!keepPreviousCoordinate) {
access = LocationAccess.unavailable;
coordinate = null;
}
await _persistSafely();
return const LocationResult(access: LocationAccess.unavailable);
} finally {
isLoading = false;
notifyListeners();
}
}
Future<void> _persistSafely() async {
try {
await _storage
.write(
LocationPersistedState(
access: access,
coordinate: coordinate,
startupPromptCompleted: startupPromptCompleted,
),
)
.timeout(const Duration(seconds: 2));
} catch (_) {
// A storage or browser-plugin stall must not keep the location action
// spinning after the platform request has already completed.
}
}
Future<void> markStartupPromptCompleted() async {
await restore();
if (startupPromptCompleted) return;
startupPromptCompleted = true;
await _persistSafely();
notifyListeners();
}
Future<void> clear() async {
await restore();
access = null;
coordinate = null;
await _persistSafely();
notifyListeners();
}
}
