This guide demonstrates how to implement the follow position functionality using the Magic Lane Maps SDK for Flutter, allowing your app to track and display the user's real-time location on the map.
The follow position feature enables users to:
- View their current location on the map with real-time tracking
- Request and manage location permissions
- Automatically center the map on their position
- Toggle position following on/off
- Use smooth animations when following position
- Dependencies
- Platform Configuration
- Architecture Overview
- Implementation
- Usage
- How It Works
- Error Handling
- Troubleshooting
- Platform-Specific Notes
- Conclusion
The application follows a clear separation of concerns with the following structure:
lib/
├── follow_position/
│ ├── follow_position_service.dart # Service: permission handling, live data source
│ └── follow_position_button.dart # UI: Floating action button
├── providers/
│ └── follow_position_provider.dart # Provider: exposes start/toggle methods
├── services/
│ └── error_service.dart # Service: centralized error handling
└── l10n/
├── app_en.arb # English localization strings
├── app_es.arb # Spanish localization strings
├── app_fr.arb # French localization strings
└── app_localizations*.dart # Generated localization classes
Add the following dependency to your pubspec.yaml:
dependencies:
flutter:
sdk: flutter
# other dependencies ...
permission_handler: ^12.0.1Add location permissions to your android/app/src/main/AndroidManifest.xml:
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Location permissions -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<application
android:label="your_app_name"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<!-- Your activity and other configurations -->
</application>
</manifest>Note
ACCESS_FINE_LOCATIONprovides precise location (GPS-based)ACCESS_COARSE_LOCATIONprovides approximate location (network-based)- Both permissions are required for the best location tracking experience
Add location usage descriptions to your ios/Runner/Info.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Location permission for when app is in use -->
<key>NSLocationWhenInUseUsageDescription</key>
<string>Location is needed to display your position on the map</string>
<!-- Other existing keys -->
</dict>
</plist>Replace the contents of your ios/Podfile with the following to ensure proper location permission handling:
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
target.build_configurations.each do |config|
config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [
'$(inherited)',
## dart: [PermissionGroup.location, PermissionGroup.locationAlways, PermissionGroup.locationWhenInUse]
'PERMISSION_LOCATION=1',
]
end
end
endThen run the following commands:
cd ios
pod install
cd ..
flutter clean && flutter pub getNote
NSLocationWhenInUseUsageDescriptionis required for iOS location access- The description string will be shown to users when requesting permission
- Make the description clear and user-friendly
Create lib/follow_position/follow_position_service.dart:
/// Service that handles permissions and live data source setup for following the user's position.
class FollowPositionService {
bool _hasLiveDataSource = false;
PermissionStatus? notificationStatus;
PermissionStatus? locationStatus;
GemMapController? _controller;
/// Set the map controller that the service will use for follow operations.
void setController(GemMapController? controller) {
_controller = controller;
}
GemMapController? get controller => _controller;
/// Ensure required permissions are granted and the Magic Lane live data source is set.
///
/// Returns true when location permission was granted and the live data source is (now) available.
Future<bool> ensurePermissionsAndLiveDataSource() async {
if (notificationStatus != PermissionStatus.granted) {
// Request notification permission but don't fail if user denies it — it's optional for following.
notificationStatus = await Permission.notification.request();
}
locationStatus = await Permission.locationWhenInUse.request();
if (locationStatus == PermissionStatus.granted) {
if (!_hasLiveDataSource) {
PositionService.setLiveDataSource();
_hasLiveDataSource = true;
}
return true;
}
return false;
}
/// Start following using the internally stored controller.
void startFollowing({GemAnimation? animation}) {
if (_controller == null) return;
if (animation != null) {
_controller!.startFollowingPosition(animation: animation);
} else {
_controller!.startFollowingPosition();
}
}
/// Toggle follow position using the internally stored controller.
void toggleFollow() {
if (_controller == null) return;
_controller!.startFollowingPosition();
}
}Key SDK Classes:
PositionService.setLiveDataSource()- Initializes the Magic Lane SDK's live data source for real-time position trackingGemMapController.startFollowingPosition()- Starts following the user's position on the mapGemAnimation- Provides smooth animation transitions when centering on position
Create lib/providers/follow_position_provider.dart:
/// Provider that exposes follow-position behavior backed by FollowPositionService.
class FollowPositionProvider extends ChangeNotifier {
final FollowPositionService _service = FollowPositionService();
bool _isFollowing = false;
bool _hasError = false;
String? _errorMessage;
bool get isFollowing => _isFollowing;
bool get hasError => _hasError;
String? get errorMessage => _errorMessage;
/// Set the GemMapController for the underlying service.
void setController(GemMapController? controller) {
_service.setController(controller);
}
/// Start following after ensuring permissions and live data source.
Future<void> startFollowing() async {
final isGranted = await _service.ensurePermissionsAndLiveDataSource();
if (!isGranted) {
_setError('Location permission not granted');
return;
}
_service.startFollowing();
_isFollowing = true;
notifyListeners();
}
/// Toggle follow using the service.
void toggleFollow() {
_service.toggleFollow();
_isFollowing = true;
notifyListeners();
}
void _setError(String message) {
_hasError = true;
_errorMessage = message;
notifyListeners();
}
}Provider Pattern Benefits:
- Centralized state management
- Automatic UI updates via
notifyListeners() - Clean separation between business logic and UI
- Easy error handling and state tracking
Update lib/providers/app_providers.dart to register the provider:
class AppProviders extends StatelessWidget {
final Widget child;
const AppProviders({super.key, required this.child});
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
// ... other providers
ChangeNotifierProvider(create: (_) => FollowPositionProvider()),
],
child: child,
);
}
}Update the lib/widgets/app_map.dart to set the controller inside the _onMapCreated method:
void _onMapCreated(GemMapController controller) {
WidgetsBinding.instance.addPostFrameCallback((_) {
final provider = context.read<GemMapProvider>();
final followProvider = context.read<FollowPositionProvider>();
provider.setController(controller);
followProvider.setController(controller);
});
setState(() {});
}Create lib/services/error_service.dart to handle error messages and user notifications:
/// Centralized error handling service for the application.
///
/// This service provides a unified way to handle and display errors throughout
/// the application using SnackBars.
class ErrorService {
/// Navigator key for accessing current build context
static GlobalKey<NavigatorState>? _navigatorKey;
/// Flag to enable/disable debug mode for detailed error logging
static bool _isDebugMode = true;
/// Initialize the error service with a navigator key.
///
/// This must be called during app initialization to enable the service
/// to display error messages via SnackBars.
static void initialize(GlobalKey<NavigatorState> navigatorKey, {bool isDebugMode = true}) {
_navigatorKey = navigatorKey;
_isDebugMode = isDebugMode;
}
/// Show warning messages.
///
/// Displays an orange SnackBar with a warning appearance for important
/// but non-critical information.
static void showWarning(String message) {
_showErrorMessage(message: message, isWarning: true);
}
/// Show success messages.
///
/// Displays a green SnackBar with a success icon for positive feedback.
static void showSuccess(String message) {
_showErrorMessage(message: message, isError: false);
}
/// Handle Magic Lane API errors with context-specific messages.
///
/// Converts Magic Lane API error codes into user-friendly, localized messages
/// and displays them via SnackBar.
static void handleGemError(GemError error, {String? context}) {
final message = _getGemErrorMessage(error, context);
if (_isDebugMode) {
debugPrint('GemError: $error${context != null ? ' in $context' : ''}');
}
_showErrorMessage(message: message, isError: error != GemError.success);
}
/// Handle general errors with custom messages.
///
/// Displays custom error messages via SnackBar.
static void handleError(String message, {String? context}) {
final fullMessage = context != null ? '$context: $message' : message;
if (_isDebugMode) {
debugPrint('Error: $fullMessage');
}
_showErrorMessage(message: fullMessage, isError: true);
}
static String _getGemErrorMessage(GemError error, String? context) {
final contextValue = _navigatorKey?.currentContext;
if (contextValue == null) {
// Fallback to English if no context available
return _getFallbackErrorMessage(error, context);
}
final l10n = AppLocalizations.of(contextValue)!;
final contextPrefix = context != null ? '$context: ' : '';
switch (error) {
case GemError.success:
return '$contextPrefix${l10n.operationCompletedSuccessfully}';
case GemError.networkFailed:
return '$contextPrefix${l10n.networkConnectionFailed}';
case GemError.suspended:
return '$contextPrefix${l10n.operationSuspended}';
default:
return '$contextPrefix${l10n.unexpectedError}${_isDebugMode ? ': $error' : ''}';
}
}
static String _getFallbackErrorMessage(GemError error, String? context) {
final contextPrefix = context != null ? '$context: ' : '';
// Try to get the current context for localization
final contextValue = _navigatorKey?.currentContext;
// If we have a context, try to use localization
if (contextValue != null) {
final l10n = AppLocalizations.of(contextValue);
if (l10n != null) {
switch (error) {
case GemError.success:
return '$contextPrefix${l10n.operationCompletedSuccessfully}';
case GemError.networkFailed:
return '$contextPrefix${l10n.networkConnectionFailed}';
case GemError.suspended:
return '$contextPrefix${l10n.operationSuspended}';
default:
return '$contextPrefix${l10n.unexpectedError}${_isDebugMode ? ': $error' : ''}';
}
}
}
return error.toString();
}
/// Internal method to show error/success/warning messages via SnackBar
static void _showErrorMessage({
required String message,
bool isError = false,
bool isWarning = false,
}) {
final context = _navigatorKey?.currentContext;
if (context == null) {
if (_isDebugMode) {
debugPrint('ErrorService: Cannot show message - no context available: $message');
}
return;
}
Color backgroundColor;
IconData icon;
if (isWarning) {
backgroundColor = Colors.orange;
icon = Icons.warning;
} else if (isError) {
backgroundColor = Colors.red;
icon = Icons.error;
} else {
backgroundColor = Colors.green;
icon = Icons.check_circle;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
Icon(icon, color: Colors.white),
const SizedBox(width: 8),
Expanded(
child: Text(
message,
style: const TextStyle(color: Colors.white),
),
),
],
),
backgroundColor: backgroundColor,
duration: const Duration(seconds: 3),
behavior: SnackBarBehavior.floating,
),
);
}
}Initialize in your app's main method:
class MainApp extends StatelessWidget {
static final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
const MainApp({super.key});
@override
Widget build(BuildContext context) {
// Initialize ErrorService with the navigator key
ErrorService.initialize(navigatorKey);
return AppProviders(
child: MaterialApp(navigatorKey: navigatorKey, home: const MapPage()),
);
}
}Key Features:
- Centralized Error Handling: All error messages go through one service
- SnackBar Display: Consistent UI for showing messages to users
- Multiple Message Types: Support for errors, warnings, and success messages
- Navigator Key: Uses a global navigator key to access context for displaying SnackBars
- Debug Support: Optional debug logging for development
Create lib/follow_position/follow_position_button.dart:
/// A floating action button that manages location following functionality for the map.
class FollowPositionButton extends StatefulWidget {
/// Determines the button's behavior mode.
///
/// When `true`, the button toggles position following without requesting permissions.
/// When `false` (default), the button requests necessary permissions before starting to follow position.
final bool isInNavigationMode;
const FollowPositionButton({super.key, this.isInNavigationMode = false});
@override
State<FollowPositionButton> createState() => _FollowPositionButtonState();
}
class _FollowPositionButtonState extends State<FollowPositionButton> {
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Pass the controller from GemMapProvider to FollowPositionProvider if available
final mapController = context.read<GemMapProvider>().controller;
final followProvider = context.read<FollowPositionProvider>();
if (mapController != null) {
followProvider.setController(mapController);
}
}
/// Handles the button press event for normal mode.
Future<void> _handlePressed() async {
final followProvider = context.read<FollowPositionProvider>();
await followProvider.startFollowing();
if (followProvider.hasError) {
if (!mounted) return;
ErrorService.showWarning(
followProvider.errorMessage ?? 'Location permission not granted'
);
return;
}
if (!mounted) return;
setState(() {});
}
/// Toggles position following without permission checks.
///
/// Used in navigation mode where location permissions are assumed to be already granted.
void _toggleFollowPosition() {
final followProvider = context.read<FollowPositionProvider>();
followProvider.toggleFollow();
if (!mounted) return;
setState(() {});
}
@override
Widget build(BuildContext context) {
return FloatingActionButton(
heroTag: 'followPositionButton',
onPressed: widget.isInNavigationMode ? _toggleFollowPosition : _handlePressed,
backgroundColor: Colors.deepPurple,
child: const Icon(Icons.my_location, color: Colors.white),
);
}
}Add the button to your map page:
class MapPage extends StatelessWidget {
const MapPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
resizeToAvoidBottomInset: false,
body: Stack(children: [AppMap(appAuthorization: MainApp.projectApiToken)]),
floatingActionButton: const FollowPositionButton(),
);
}
}Update the lib/main.dart file to include localization delegates and supported locales:
@override
Widget build(BuildContext context) {
ErrorService.initialize(navigatorKey);
return AppProviders(
child: MaterialApp(
navigatorKey: navigatorKey,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const MapPage(),
),
);
}Add the l10n package to your pubspec.yaml if not already included:
dependencies:
flutter:
sdk: flutter
flutter_localizations:
sdk: flutter
intl: ^0.20.2
# Other dependencies...
flutter:
generate: true
uses-material-design: trueNow, create a l10n directory in your project root and add the necessary localization files (app_localizations_en.arb, etc.) with the required keys for localized text inside the ErrorService. Then run the Flutter localization generation command:
flutter gen-l10nThe ErrorService uses localized text for user interface elements. Your MaterialApp must be properly configured with internationalization support for the landmark functionality to work correctly. However, you can still use the follow position functionality without localization: simply replace the localized strings in ErrorService with hardcoded strings as you like.
The follow position button handles the entire flow automatically:
- User taps the button
- App requests location permission (if not already granted)
- Permission dialog appears (managed by the OS)
- If granted, the map centers on the user's current location
- The map continuously follows the user's position
- User taps the follow position button
- Permission dialog appears (Android/iOS native dialog)
- User grants permission
- Live data source is initialized
- Map starts following position
- User taps the follow position button
- Permission already granted (cached)
- Map immediately starts following position
For scenarios where permissions are already granted (e.g., during navigation):
FollowPositionButton(isInNavigationMode: true)This skips permission checks and directly toggles position following.
The FollowPositionService handles all permission-related operations:
-
Request Notification Permission (optional)
- Used for better UX on Android
- Not critical for basic functionality
-
Request Location Permission (required)
- Uses
Permission.locationWhenInUse - Only requests when needed
- Caches permission status
- Uses
The Magic Lane SDK requires a live data source for position tracking:
PositionService.setLiveDataSource();This method:
- Initializes the SDK's position tracking system
- Connects to device location services
- Provides real-time position updates
- Should only be called once
Once permissions are granted and the data source is set:
controller.startFollowingPosition(animation: GemAnimation(type: AnimationType.linear));This method:
- Centers the map on the user's position
- Continuously updates as the user moves
- Applies smooth animations during transitions
- Keeps the map oriented and scaled appropriately
The implementation includes comprehensive error handling:
Future<bool> ensurePermissionsAndLiveDataSource() async {
locationStatus = await Permission.locationWhenInUse.request();
if (locationStatus == PermissionStatus.granted) {
if (!_hasLiveDataSource) {
PositionService.setLiveDataSource();
_hasLiveDataSource = true;
}
return true;
}
return false; // Permission denied
}Future<void> startFollowing() async {
final isGranted = await _service.ensurePermissionsAndLiveDataSource();
if (!isGranted) {
_setError('Location permission not granted');
return;
}
_service.startFollowing();
_isFollowing = true;
notifyListeners();
}
void _setError(String message) {
_hasError = true;
_errorMessage = message;
notifyListeners();
}if (followProvider.hasError) {
ErrorService.showWarning(followProvider.errorMessage);
return;
}- Verify location permissions are granted
- Check that
PositionService.setLiveDataSource()was called - Ensure
GemMapControlleris properly initialized - Confirm device location services are enabled
- Check AndroidManifest.xml has location permissions
- Verify Info.plist has usage description
- Ensure permission_handler is properly configured
- Check device location settings (High Accuracy mode)
- Verify network connectivity (for assisted GPS)
- Consider device hardware limitations
- Permissions can be requested at runtime
- Users can grant/deny each time or choose "Don't ask again"
- Location services must be enabled in device settings
- Permission dialogs show the usage description from Info.plist
- "When In Use" permission allows location access while app is active
- Users can change permissions in Settings app
The Magic Lane Maps SDK's position tracking, combined with proper permission management and the Provider pattern, enables robust location following functionality for your Flutter application.
