Skip to content

Latest commit

 

History

History
797 lines (633 loc) · 23.2 KB

File metadata and controls

797 lines (633 loc) · 23.2 KB

Follow Position Functionality Implementation

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.

Overview

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

Follow position functionality demo

Table of Contents

Architecture Overview

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

Dependencies

Add the following dependency to your pubspec.yaml:

dependencies:
  flutter:
    sdk: flutter
  # other dependencies ...
  permission_handler: ^12.0.1

Platform Configuration

Android Configuration

AndroidManifest.xml

Add 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_LOCATION provides precise location (GPS-based)
  • ACCESS_COARSE_LOCATION provides approximate location (network-based)
  • Both permissions are required for the best location tracking experience

iOS Configuration

Info.plist

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>

Podfile

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
end

Then run the following commands:

cd ios
pod install
cd ..
flutter clean && flutter pub get

Note

  • NSLocationWhenInUseUsageDescription is required for iOS location access
  • The description string will be shown to users when requesting permission
  • Make the description clear and user-friendly

Implementation

Step 1: Create the Service Layer

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 tracking
  • GemMapController.startFollowingPosition() - Starts following the user's position on the map
  • GemAnimation - Provides smooth animation transitions when centering on position

Step 2: Create the Provider Layer

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

Step 3: Register the Provider

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,
    );
  }
}

Step 4: Set the provider's controller

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(() {});
}

Step 4: Create the Error Service

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

Step 6: Create the UI Component

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),
    );
  }
}

Step 7: Integrate into Your Map Screen

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(),
    );
  }
}

Step 8: Internationalization Configuration

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: true

Now, 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-l10n

The 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.

Usage

Basic Usage

The follow position button handles the entire flow automatically:

  1. User taps the button
  2. App requests location permission (if not already granted)
  3. Permission dialog appears (managed by the OS)
  4. If granted, the map centers on the user's current location
  5. The map continuously follows the user's position

Permission Flow

First Time Usage

  1. User taps the follow position button
  2. Permission dialog appears (Android/iOS native dialog)
  3. User grants permission
  4. Live data source is initialized
  5. Map starts following position

Subsequent Usage

  1. User taps the follow position button
  2. Permission already granted (cached)
  3. Map immediately starts following position

Navigation Mode

For scenarios where permissions are already granted (e.g., during navigation):

FollowPositionButton(isInNavigationMode: true)

This skips permission checks and directly toggles position following.

How It Works

Permission Management

The FollowPositionService handles all permission-related operations:

  1. Request Notification Permission (optional)

    • Used for better UX on Android
    • Not critical for basic functionality
  2. Request Location Permission (required)

    • Uses Permission.locationWhenInUse
    • Only requests when needed
    • Caches permission status

Live Data Source

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

Map Following

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

Error Handling

The implementation includes comprehensive error handling:

Service Layer

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
}

Provider Layer

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();
}

UI Layer

if (followProvider.hasError) {
  ErrorService.showWarning(followProvider.errorMessage);
  return;
}

Troubleshooting

Map doesn't follow position

  • Verify location permissions are granted
  • Check that PositionService.setLiveDataSource() was called
  • Ensure GemMapController is properly initialized
  • Confirm device location services are enabled

Permission dialog not appearing

  • Check AndroidManifest.xml has location permissions
  • Verify Info.plist has usage description
  • Ensure permission_handler is properly configured

Position updates are slow

  • Check device location settings (High Accuracy mode)
  • Verify network connectivity (for assisted GPS)
  • Consider device hardware limitations

Platform-Specific Notes

Android

  • 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

iOS

  • 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

Conclusion

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.