Skip to content

Latest commit

 

History

History
1779 lines (1511 loc) · 57.1 KB

File metadata and controls

1779 lines (1511 loc) · 57.1 KB

Recording Functionality Implementation

This guide demonstrates how to implement location recording functionality using the Magic Lane Maps SDK for Flutter, allowing users to record their routes and tracks with background location support.

Recording functionality demo

Overview

The recording feature enables users to:

  • Start, pause, resume, and stop location recording
  • Record routes while the app is running in the background or foreground.
  • View saved recordings with metadata (duration, distance, elevation, etc.)
  • Display recorded routes on the map
  • Delete unwanted recordings

Table of Contents

Architecture

The recording functionality is organized into several components:

lib/
├── recording/
│   ├── record_button.dart              # FAB to control recording (start/stop/pause/resume)
│   ├── recordings_history_page.dart    # Page displaying list of saved recordings
│   └── recording_item.dart             # Individual recording item widget with metadata
├── services/
│   ├── recording_service.dart          # Magic Lane SDK recording operations
│   └── android_foreground_service.dart # Android foreground service for background recording
├── providers/
│   └── recording_provider.dart         # Recording state management
├── widgets/
│   ├── recordings_button.dart          # Widget for displaying saved recordings
│   └── clear_recording_button.dart     # Widget for clearing displayed recordings
dependencies:
  flutter:
    sdk: flutter
  magiclane_maps_flutter: ^3.1.2
  provider: ^6.1.5+1
  flutter_local_notifications: ^19.5.0
  flutter_background_service: ^5.1.0
  permission_handler: ^12.0.1
  path: ^1.9.1

Platform Configuration

Android Configuration

AndroidManifest.xml

Configure the Android manifest file at android/app/src/main/AndroidManifest.xml with the required permissions and service declarations for background location recording:

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <!-- Location permissions for foreground and background -->
    <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
    <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
    <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
    
    <!-- Foreground service permissions (Android 9+) -->
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
    
    <!-- Notification permission (Android 13+) -->
    <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
    
    <application
        android:label="flutter_test_demo" // Replace with your application name
        android:name="${applicationName}"
        android:icon="@mipmap/ic_launcher">
        
        <!-- Your activity configuration -->
        <activity
            android:name=".MainActivity"
            android:exported="true"
            android:launchMode="singleTop"
            android:theme="@style/LaunchTheme"
            android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
            android:hardwareAccelerated="true"
            android:windowSoftInputMode="adjustResize">
            <meta-data
              android:name="io.flutter.embedding.android.NormalTheme"
              android:resource="@style/NormalTheme" />
            <intent-filter>
                <action android:name="android.intent.action.MAIN"/>
                <category android:name="android.intent.category.LAUNCHER"/>
            </intent-filter>
        </activity>
        
        <!-- Flutter Embedding -->
        <meta-data
            android:name="flutterEmbedding"
            android:value="2" />
            
        <!-- Background Service Declaration -->
        <!-- Required by flutter_background_service for background location recording -->
        <service
            android:name="id.flutter.flutter_background_service.BackgroundService"
            android:enabled="true"
            android:exported="true"
            android:stopWithTask="false"
            android:foregroundServiceType="location" />
    </application>
</manifest>

build.gradle.kts

Ensure that your android/app/build.gradle.kts has this dependency included:

dependencies {
    coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
}

Important

  • ACCESS_BACKGROUND_LOCATION is required for Android 10+ to continue recording when the app is in the background
  • FOREGROUND_SERVICE and FOREGROUND_SERVICE_LOCATION are needed to run the recording service in the foreground
  • The BackgroundService declaration with foregroundServiceType="location" is mandatory for the flutter_background_service plugin

iOS Configuration

Info.plist

Configure the iOS Info.plist file at ios/Runner/Info.plist to enable background location updates:

<?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 permissions -->
    <key>NSLocationWhenInUseUsageDescription</key>
    <string>Location is needed for map localization and navigation</string>
    
    <key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
    <string>Location access is required in background to continue recording.</string>
    
    <!-- Background modes for continuous location tracking -->
    <key>UIBackgroundModes</key>
    <array>
        <string>location</string>
        <string>processing</string>
    </array>
    
    <!-- Other existing keys -->
</dict>
</plist>

Note

  • NSLocationWhenInUseUsageDescription explains why the app needs location when in use
  • NSLocationAlwaysAndWhenInUseUsageDescription is required for background location access
  • UIBackgroundModes with location enables continuous background location updates
  • The processing background mode allows for additional background processing

1. Services Layer

RecordingService (lib/services/recording_service.dart)

Handles all Magic Lane SDK recording operations:

import 'dart:io';
import 'package:magiclane_maps_flutter/magiclane_maps_flutter.dart';
import 'package:path/path.dart' as path;

/// Result wrapper for recording operations
class RecordingResult<T> {
  final T? data;
  final GemError? error;
  final String? errorMessage;

  RecordingResult.success(this.data) : error = null, errorMessage = null;

  RecordingResult.failure(this.error, [this.errorMessage]) : data = null;

  bool get isSuccess => error == null || error == GemError.success;
  bool get isFailure => !isSuccess;
}

/// Data class for recording presentation information
class RecordingPresentationData {
  final Path path;
  final Landmark startLandmark;
  final Landmark endLandmark;

  RecordingPresentationData({required this.path, required this.startLandmark, required this.endLandmark});
}

/// Service class that handles all Magic Lane SDK recording operations
class RecordingService {
  String? _logsDirectory;
  Recorder? _activeRecorder;

  /// Get the logs directory, initializing it if necessary
  Future<String> getLogsDirectory() async {
    if (_logsDirectory != null) {
      return _logsDirectory!;
    }

    // Use a simple approach - create a temporary directory for now
    // In a real app, you would use path_provider to get proper app directories
    final tempDir = Directory.systemTemp;
    final logsPath = path.join(tempDir.path, 'MagicLaneMaps', 'Data', 'Tracks');

    // Ensure directory exists
    final directory = Directory(logsPath);
    if (!await directory.exists()) {
      await directory.create(recursive: true);
    }

    _logsDirectory = logsPath;
    return _logsDirectory!;
  }

  /// Create and start a new recorder
  Future<RecordingResult<Recorder>> startRecording() async {
    if (PositionService.getDataSource() == null) {
      return RecordingResult.failure(GemError.general, 'Position service is not initialized');
    }
    // Ensure we have the logs directory
    final logsDir = await getLogsDirectory();

    // Create live data source
    final dataSource = DataSource.createLiveDataSource();
    if (dataSource == null) {
      return RecordingResult.failure(GemError.general, 'Failed to create data source');
    }

    // Configure for background recording
    final sensorConfiguration = dataSource.getConfiguration(DataType.position);
    sensorConfiguration.allowsBackgroundLocationUpdates = true;
    dataSource.setConfiguration(type: DataType.position, config: sensorConfiguration);

    // Create recorder configuration
    final recorderConfiguration = RecorderConfiguration(
      dataSource: dataSource,
      logsDir: logsDir,
      recordedTypes: [DataType.position],
      minDurationSeconds: 0,
      continuousRecording: false,
    );

    // Create recorder
    final recorder = Recorder.create(recorderConfiguration);

    // Start recording
    final error = await recorder.startRecording();
    if (error != GemError.success) {
      return RecordingResult.failure(error, 'Failed to start recording');
    }

    _activeRecorder = recorder;
    return RecordingResult.success(recorder);
  }

  /// Stop the active recorder
  Future<RecordingResult<void>> stopRecording() async {
    if (_activeRecorder == null) {
      return RecordingResult.failure(GemError.general, 'No active recorder to stop');
    }

    final error = await _activeRecorder!.stopRecording();
    if (error != GemError.success) {
      return RecordingResult.failure(error, 'Failed to stop recording');
    }

    _activeRecorder = null;
    return RecordingResult.success(null);
  }

  /// Pause the active recorder
  RecordingResult<void> pauseRecording() {
    if (_activeRecorder == null) {
      return RecordingResult.failure(GemError.general, 'No active recorder to pause');
    }

    final error = _activeRecorder!.pauseRecording();
    if (error != GemError.success) {
      return RecordingResult.failure(error, 'Failed to pause recording');
    }

    return RecordingResult.success(null);
  }

  /// Resume the active recorder
  RecordingResult<void> resumeRecording() {
    if (_activeRecorder == null) {
      return RecordingResult.failure(GemError.general, 'No active recorder to resume');
    }

    final error = _activeRecorder!.resumeRecording();
    if (error != GemError.success) {
      return RecordingResult.failure(error, 'Failed to resume recording');
    }

    return RecordingResult.success(null);
  }

  /// Get all saved recordings using RecorderBookmarks
  Future<RecordingResult<List<String>>> getSavedRecordings() async {
    final logsDir = await getLogsDirectory();

    final bookmarks = RecorderBookmarks.create(logsDir);
    if (bookmarks == null) {
      return RecordingResult.failure(GemError.general, 'Failed to create recorder bookmarks');
    }

    final recordings = bookmarks.getLogsList();
    return RecordingResult.success(recordings);
  }

  /// Delete a specific recording
  Future<RecordingResult<void>> deleteRecording(String recordingName) async {
    final logsDir = await getLogsDirectory();

    final bookmarks = RecorderBookmarks.create(logsDir);
    if (bookmarks == null) {
      return RecordingResult.failure(GemError.general, 'Failed to access recordings');
    }

    final error = bookmarks.deleteLog(recordingName);
    if (error != GemError.success) {
      return RecordingResult.failure(error, 'Failed to delete recording');
    }

    return RecordingResult.success(null);
  }

  /// Get recording metadata
  Future<RecordingResult<LogMetadata>> getRecordingMetadata(String recordingName) async {
    final logsDir = await getLogsDirectory();

    final bookmarks = RecorderBookmarks.create(logsDir);
    if (bookmarks == null) {
      return RecordingResult.failure(GemError.general, 'Failed to access recordings');
    }

    final metadata = bookmarks.getLogMetadata(recordingName);
    if (metadata == null) {
      return RecordingResult.failure(GemError.general, 'Failed to get recording metadata');
    }

    return RecordingResult.success(metadata);
  }

  /// Get recording presentation data for display on map
  Future<RecordingResult<RecordingPresentationData>> getRecordingPresentationData(String recordingName) async {
    final metadataResult = await getRecordingMetadata(recordingName);
    if (metadataResult.isFailure) {
      return RecordingResult.failure(metadataResult.error, metadataResult.errorMessage);
    }

    final metadata = metadataResult.data!;

    // Use preciseRoute for better accuracy, fallback to route if needed
    List<Coordinates> routeCoordinates = metadata.preciseRoute;
    if (routeCoordinates.isEmpty) {
      routeCoordinates = metadata.route;
    }

    if (routeCoordinates.isEmpty) {
      return RecordingResult.failure(GemError.general, 'No route coordinates available for recording');
    }

    // Create a path from the recorded coordinates
    final path = Path.fromCoordinates(routeCoordinates);

    // Add start and end landmarks
    Landmark startLandmark = Landmark.withCoordinates(routeCoordinates.first);
    Landmark endLandmark = Landmark.withCoordinates(routeCoordinates.last);

    startLandmark.setImageFromIcon(GemIcon.waypointStart);
    endLandmark.setImageFromIcon(GemIcon.waypointFinish);

    final presentationData = RecordingPresentationData(
      path: path,
      startLandmark: startLandmark,
      endLandmark: endLandmark,
    );

    return RecordingResult.success(presentationData);
  }

  /// Check if there's an active recorder
  bool get hasActiveRecorder => _activeRecorder != null;

  /// Clean up resources
  Future<void> dispose() async {
    if (_activeRecorder != null) {
      await stopRecording();
    }
  }
}

Key SDK Classes:

  • DataSource.createLiveDataSource() - Creates a live data source for real-time position tracking
  • PositionSensorConfiguration.allowsBackgroundLocationUpdates - Enables background location updates (critical for continuous recording)
  • RecorderConfiguration - Configures the recorder with data source, directory, and data types
  • Recorder.create() - Creates a recorder instance
  • recorder.startRecording() - Starts the recording session
  • recorder.stopRecording() - Stops and saves the recording
  • RecorderBookmarks - Manages saved recordings (list, metadata, deletion)

AndroidForegroundService (lib/services/android_foreground_service.dart)

Manages Android foreground service for background recording:

@pragma('vm:entry-point')
class AndroidForegroundService {
  static final service = FlutterBackgroundService();
  static final notificationsPlugin = FlutterLocalNotificationsPlugin();
  static final notificationId = 888;
  static bool hasGrantedNotificationsPermission = false;
  static late final AndroidNotificationChannel channel;

  @pragma('vm:entry-point')
  static Future<void> initialize(bool isForegroundMode) async {
    if (!Platform.isAndroid) return;

    const initSettingsAndroid = AndroidInitializationSettings('@mipmap/ic_launcher');

    const initSettings = InitializationSettings(android: initSettingsAndroid);

    await notificationsPlugin.initialize(initSettings);

    channel = AndroidNotificationChannel(
      notificationId.toString(),
      'MY FOREGROUND SERVICE',
      description: 'This channel is used for background location.',
      importance: Importance.low,
    );

    hasGrantedNotificationsPermission =
        await notificationsPlugin
            .resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
            ?.requestNotificationsPermission() ??
        false;

    if (!hasGrantedNotificationsPermission) {
      return;
    }

    await notificationsPlugin
        .resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
        ?.createNotificationChannel(channel);

    await service.configure(
      iosConfiguration: IosConfiguration(),
      androidConfiguration: AndroidConfiguration(
        onStart: onStart,
        autoStart: false,
        autoStartOnBoot: false,
        isForegroundMode: isForegroundMode,
        notificationChannelId: notificationId.toString(),
        foregroundServiceNotificationId: notificationId,
        initialNotificationTitle: 'Background location',
        initialNotificationContent: 'Background location is active',
        foregroundServiceTypes: [AndroidForegroundType.location],
      ),
    );
  }

  @pragma('vm:entry-point')
  static Future<bool> hasGrantedPermission() async {
    if (!Platform.isAndroid) return false;

    return hasGrantedNotificationsPermission =
        await notificationsPlugin
            .resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
            ?.areNotificationsEnabled() ??
        false;
  }

  @pragma('vm:entry-point')
  static Future<bool> start() async {
    if (!Platform.isAndroid) return false;
    final service = FlutterBackgroundService();
    return await service.startService();
  }

  @pragma('vm:entry-point')
  static Future<void> stop() async {
    if (!Platform.isAndroid) return;
    service.invoke("stopService");

    await notificationsPlugin.cancelAll();
  }

  @pragma('vm:entry-point')
  static Future<bool> onIosBackground(ServiceInstance service) async {
    return false;
  }

  @pragma('vm:entry-point')
  static void onStart(ServiceInstance service) {
    if (!Platform.isAndroid) return;
    service.on("stopService").listen((event) {
      service.stopSelf();
    });
  }
}

2. Provider Layer

RecordingProvider (lib/providers/recording_provider.dart)

Manages recording state and coordinates between UI and services:

class RecordingProvider extends ChangeNotifier {
  final RecordingService _recordingService = RecordingService();
  bool _isRecording = false;
  bool _isPaused = false;
  bool _hasError = false;
  String? _errorMessage;
  bool _isForegroundServiceInitialized = false;
  String? _currentRecordingName;
  DateTime? _recordingStartTime;
  bool _isRecordingPresented = false;

  // Getters
  bool get isRecording => _isRecording;
  bool get isPaused => _isPaused;
  bool get hasError => _hasError;
  String? get errorMessage => _errorMessage;
  String? get currentRecordingName => _currentRecordingName;
  DateTime? get recordingStartTime => _recordingStartTime;
  bool get isRecordingPresented => _isRecordingPresented;

  Duration? get currentRecordingDuration {
    if (_recordingStartTime == null) return null;
    return DateTime.now().difference(_recordingStartTime!);
  }

  /// Start recording
  Future<void> startRecording() async {
    // Initialize foreground service if needed
    if (!_isForegroundServiceInitialized) {
      if (await AndroidForegroundService.hasGrantedPermission() == true) {
        await AndroidForegroundService.initialize(true);
        _isForegroundServiceInitialized = true;
      }
    }

    // Stop any existing recording
    if (_isRecording) {
      AndroidForegroundService.stop();
      await stopRecording();
    }

    // Start foreground service
    AndroidForegroundService.start();

    // Start recording via service
    final result = await _recordingService.startRecording();

    if (result.isFailure) {
      _setError(result.errorMessage ?? 'Failed to start recording');
      AndroidForegroundService.stop();
      return;
    }

    _isRecording = true;
    _isPaused = false;
    _recordingStartTime = DateTime.now();
    _currentRecordingName = _generateRecordingName();
    _clearError();

    notifyListeners();
  }

  /// Stop recording
  Future<void> stopRecording() async {
    if (!_isRecording) return;

    AndroidForegroundService.stop();

    final result = await _recordingService.stopRecording();

    if (result.isFailure) {
      _setError(result.errorMessage ?? 'Failed to stop recording');
      return;
    }

    _isRecording = false;
    _isPaused = false;
    _recordingStartTime = null;
    _currentRecordingName = null;
    _clearError();

    notifyListeners();
  }

  /// Pause recording
  Future<void> pauseRecording() async {
    if (!_isRecording || _isPaused) return;

    final result = _recordingService.pauseRecording();

    if (result.isFailure) {
      _setError(result.errorMessage ?? 'Failed to pause recording');
      return;
    }

    _isPaused = true;
    _clearError();
    notifyListeners();
  }

  /// Resume recording
  Future<void> resumeRecording() async {
    if (!_isPaused) return;

    final result = _recordingService.resumeRecording();

    if (result.isFailure) {
      _setError(result.errorMessage ?? 'Failed to resume recording');
      return;
    }

    _isPaused = false;
    _clearError();
    notifyListeners();
  }

  /// Get all saved recordings
  Future<List<String>> getSavedRecordings() async {
    final result = await _recordingService.getSavedRecordings();

    if (result.isFailure) {
      _setError(result.errorMessage ?? 'Failed to get recordings');
      return [];
    }

    _clearError();
    return result.data ?? [];
  }

  /// Delete a specific recording
  Future<void> deleteRecording(String recordingName) async {
    final result = await _recordingService.deleteRecording(recordingName);

    if (result.isFailure) {
      _setError(result.errorMessage ?? 'Failed to delete recording');
      return;
    }

    _clearError();
    notifyListeners();
  }

  /// Get recording details/metadata
  Future<LogMetadata?> getRecordingDetails(String recordingName) async {
    final result = await _recordingService.getRecordingMetadata(recordingName);

    if (result.isFailure) {
      _setError(result.errorMessage ?? 'Failed to get recording details');
      return null;
    }

    _clearError();
    return result.data;
  }

  /// Get recording presentation data for display on map
  Future<RecordingPresentationData?> getRecordingPresentationData(String recordingName) async {
    final result = await _recordingService.getRecordingPresentationData(recordingName);

    if (result.isFailure) {
      _setError(result.errorMessage ?? 'Failed to get recording presentation data');
      return null;
    }

    _clearError();
    return result.data;
  }

  /// Set the recording presentation state (used by UI after presenting on map)
  void setRecordingPresented(bool presented) {
    _isRecordingPresented = presented;
    notifyListeners();
  }

  /// Clear any error state
  void clearError() {
    _clearError();
    notifyListeners();
  }

  /// Generate a recording name with timestamp
  String _generateRecordingName() {
    final now = DateTime.now();
    return '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}_'
        '${now.hour.toString().padLeft(2, '0')}-${now.minute.toString().padLeft(2, '0')}-${now.second.toString().padLeft(2, '0')}';
  }

  /// Set error state
  void _setError(String message) {
    _hasError = true;
    _errorMessage = message;
    notifyListeners();
    if (kDebugMode) {
      print('RecordingProvider Error: $message');
    }
  }

  /// Clear error state
  void _clearError() {
    _hasError = false;
    _errorMessage = null;
  }

  @override
  void dispose() {
    if (_isRecording) {
      stopRecording();
    }
    _recordingService.dispose();
    super.dispose();
  }
}

3. Add Recording Provider to App Providers

lib/providers/app_providers.dart

class AppProviders extends StatelessWidget {
  final Widget child;

  const AppProviders({super.key, required this.child});

  @override
  Widget build(BuildContext context) {
    return MultiProvider(
      providers: [
        ChangeNotifierProvider(create: (_) => GemMapProvider()),
        ChangeNotifierProvider(create: (_) => FollowPositionProvider()),
        ChangeNotifierProvider(create: (_) => SelectedLandmarkStateProvider()),
        ChangeNotifierProvider(create: (_) => RoutingStateProvider()),
        ChangeNotifierProvider(create: (_) => NavigationStateProvider()), 
        ChangeNotifierProvider(create: (_) => RecordingProvider()),
      ],
      child: child,
    );
  }
}

4. Add present recording on map functionality

/// Present recording data on the map
Future<void> presentRecordingOnMap({
  required Path path,
  required Landmark startLandmark,
  required Landmark endLandmark,
}) async {
  if (_controller == null) return;

  HighlightRenderSettings renderSettings = HighlightRenderSettings(options: {HighlightOptions.showLandmark});

  // Activate highlights for start and end points
  _controller!.activateHighlight([startLandmark, endLandmark], renderSettings: renderSettings, highlightId: 1);

  // Add the path to the map
  _controller!.preferences.paths.add(path);

  // Center the map on the recorded path
  _controller!.centerOnAreaRect(
    path.area,
    viewRc: Rectangle(
      (_controller!.viewport.width) ~/ 3,
      (_controller!.viewport.height) ~/ 3,
      (_controller!.viewport.width) ~/ 3,
      (_controller!.viewport.height) ~/ 3,
    ),
  );
  notifyListeners();
}

/// Clear the presented recording from the map
void clearRecordingPresentation() {
  if (_controller == null) return;

  // Clear paths from the map
  _controller!.preferences.paths.clear();

  // Clear highlights (using the same highlightId used in presentRecordingOnMap)
  _controller!.deactivateHighlight(highlightId: 1);
  notifyListeners();
}

5. Formatting methods

Add the following utility method for formatting timestamps in lib/utils/formatting.dart:

static String formatTimestamp({required int? timestamp, required AppLocalizations localizations}) {
  if (timestamp == null) return localizations.unknown;
  final dateTime = DateTime.fromMillisecondsSinceEpoch(timestamp);
  final now = DateTime.now();
  final difference = now.difference(dateTime);
  // Use DateFormat.jm() for localized time (e.g., "5:30 PM" or "17:30")
  // This replaces TimeOfDay.fromDateTime(dateTime).format(context)
  final timeStr = DateFormat.jm().format(dateTime);
  if (difference.inDays == 0) {
    return localizations.todayAt(timeStr);
  } else if (difference.inDays == 1) {
    return localizations.yesterdayAt(timeStr);
  } else if (difference.inDays < 7) {
    final weekdays = [
      localizations.weekdayMon,
      localizations.weekdayTue,
      localizations.weekdayWed,
      localizations.weekdayThu,
      localizations.weekdayFri,
      localizations.weekdaySat,
      localizations.weekdaySun,
    ];
    // dateTime.weekday is 1 for Monday and 7 for Sunday
    return '${weekdays[dateTime.weekday - 1]} ${localizations.at} $timeStr';
  } else {
    // Use DateFormat.yMd() for a localized date (e.g., "11/3/2025" or "3/11/2025")
    // This is better than the hardcoded '${dateTime.day}/${dateTime.month}/${dateTime.year}'
    return DateFormat.yMd().format(dateTime);
  }
}

5. UI Components

RecordButton (lib/recording/record_button.dart)

Floating action button for recording control:

/// Floating action button for recording functionality
class RecordButton extends StatefulWidget {
  const RecordButton({super.key});

  @override
  State<RecordButton> createState() => _RecordButtonState();
}

class _RecordButtonState extends State<RecordButton> {
  bool _hasShownError = false;

  @override
  Widget build(BuildContext context) {
    return Consumer<RecordingProvider>(
      builder: (context, recordingProvider, child) {
        // Handle error display after the build phase
        if (recordingProvider.hasError && !_hasShownError) {
          WidgetsBinding.instance.addPostFrameCallback((_) {
            if (mounted && recordingProvider.hasError) {
              ErrorService.handleError(
                recordingProvider.errorMessage ??
                    AppLocalizations.of(context)!.unexpectedError,
              );
              _hasShownError = true;
            }
          });
        } else if (!recordingProvider.hasError) {
          _hasShownError = false; // Reset flag when error is cleared
        }

        return FloatingActionButton(
          onPressed: () => _handleRecordButtonPress(context, recordingProvider),
          backgroundColor: _getButtonColor(recordingProvider),
          foregroundColor: Colors.white,
          heroTag: 'recordButton',
          child: Icon(_getButtonIcon(recordingProvider)),
        );
      },
    );
  }

  Color _getButtonColor(RecordingProvider provider) {
    if (provider.hasError) {
      return Colors.red.shade700;
    } else if (provider.isRecording) {
      return provider.isPaused ? Colors.orange.shade700 : Colors.red.shade600;
    } else {
      return Colors.grey.shade600;
    }
  }

  IconData _getButtonIcon(RecordingProvider provider) {
    if (provider.hasError) {
      return Icons.error;
    } else if (provider.isRecording) {
      return provider.isPaused ? Icons.play_arrow : Icons.stop;
    } else {
      return Icons.fiber_manual_record;
    }
  }

  void _handleRecordButtonPress(
    BuildContext context,
    RecordingProvider provider,
  ) {
    if (provider.hasError) {
      provider.clearError();
      return;
    }

    if (provider.isRecording) {
      if (provider.isPaused) {
        _resumeRecording(provider);
      } else {
        _showStopRecordingDialog(context, provider);
      }
      return;
    }
    _startRecording(provider);
  }

  void _showStopRecordingDialog(
    BuildContext context,
    RecordingProvider provider,
  ) {
    showDialog<void>(
      context: context,
      builder: (context) => AlertDialog(
        title: Text(AppLocalizations.of(context)!.recordingStopTitle),
        content: Column(
          mainAxisSize: MainAxisSize.min,
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(AppLocalizations.of(context)!.recordingStopPrompt),
            if (provider.currentRecordingName != null) ...[
              const SizedBox(height: 8),
              Text(
                AppLocalizations.of(
                  context,
                )!.recordingLabel(provider.currentRecordingName!),
                style: Theme.of(
                  context,
                ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500),
              ),
            ],
            if (provider.currentRecordingDuration != null) ...[
              const SizedBox(height: 4),
              Text(
                AppLocalizations.of(context)!.recordingDuration(
                  FormatUtils.convertDuration(
                    provider.currentRecordingDuration!.inSeconds,
                  ),
                ),
                style: Theme.of(context).textTheme.bodySmall,
              ),
            ],
          ],
        ),
        actions: [
          TextButton(
            onPressed: () => Navigator.of(context).pop(),
            child: Text(AppLocalizations.of(context)!.cancel),
          ),
          if (!provider.isPaused) ...[
            TextButton(
              onPressed: () {
                Navigator.of(context).pop();
                _pauseRecording(provider);
              },
              child: Text(AppLocalizations.of(context)!.pauseTooltip),
            ),
          ],
          ElevatedButton(
            onPressed: () {
              Navigator.of(context).pop();
              _stopRecording(provider);
            },
            style: ElevatedButton.styleFrom(
              backgroundColor: Colors.red.shade600,
              foregroundColor: Colors.white,
            ),
            child: Text(AppLocalizations.of(context)!.stop),
          ),
        ],
      ),
    );
  }

  void _startRecording(RecordingProvider provider) async {
    await provider.startRecording();
    if (mounted && !provider.hasError) {
      ErrorService.showSuccess(
        AppLocalizations.of(context)!.recordingStartedSuccess,
      );
    }
  }

  void _stopRecording(RecordingProvider provider) async {
    await provider.stopRecording();
    if (mounted && !provider.hasError) {
      ErrorService.showSuccess(
        AppLocalizations.of(context)!.recordingStoppedSuccess,
      );
    }
  }

  void _pauseRecording(RecordingProvider provider) async {
    await provider.pauseRecording();
    if (mounted && !provider.hasError) {
      ErrorService.showWarning(AppLocalizations.of(context)!.recordingPaused);
    }
  }

  void _resumeRecording(RecordingProvider provider) async {
    await provider.resumeRecording();
    if (mounted && !provider.hasError) {
      ErrorService.showWarning(AppLocalizations.of(context)!.recordingResumed);
    }
  }
}

RecordingsPageButton (lib/widgets/recordings_button.dart)

Button to navigate to recordings history page:

class RecordingsPageButton extends StatelessWidget {
  const RecordingsPageButton({super.key});

  @override
  Widget build(BuildContext context) {
    return FloatingActionButton(
      heroTag: 'recordings',
      backgroundColor: Colors.deepPurple,
      foregroundColor: Colors.white,
      onPressed: () {
        Navigator.of(context).push(MaterialPageRoute(builder: (context) => const RecordingsHistoryPage()));
      },
      child: const Icon(Icons.history),
    );
  }
}

RecordingsHistoryPage (lib/recording/recordings_history_page.dart)

Page to display and manage saved recordings:

class RecordingsHistoryPage extends StatefulWidget {
  const RecordingsHistoryPage({super.key});

  @override
  State<RecordingsHistoryPage> createState() => _RecordingsHistoryPageState();
}

class _RecordingsHistoryPageState extends State<RecordingsHistoryPage> {
  List<String> _recordings = [];
  bool _isLoading = true;
  String? _error;

  @override
  void initState() {
    super.initState();
    _loadRecordings();
  }

  Future<void> _loadRecordings() async {
    setState(() {
      _isLoading = true;
      _error = null;
    });

    if (!mounted) return;
    final recordingProvider = context.read<RecordingProvider>();
    final recordings = await recordingProvider.getSavedRecordings();

    // Show newest recordings first
    final ordered = List<String>.from(recordings.reversed);

    if (mounted) {
      setState(() {
        _recordings = ordered;
        _isLoading = false;
      });
    }
  }

  Future<void> _deleteRecording(String recordingName) async {
    if (!mounted) return;
    final recordingProvider = context.read<RecordingProvider>();
    final localizations = AppLocalizations.of(context)!;

    final confirmed = await showDialog<bool>(
      context: context,
      builder: (context) => AlertDialog(
        title: Text(localizations.deleteRecording),
        content: Text(localizations.deleteRecordingConfirmation(recordingName)),
        actions: [
          TextButton(onPressed: () => Navigator.of(context).pop(false), child: Text(localizations.cancel)),
          TextButton(
            onPressed: () => Navigator.of(context).pop(true),
            style: TextButton.styleFrom(foregroundColor: Colors.red),
            child: Text(localizations.delete),
          ),
        ],
      ),
    );

    if (confirmed == true) {
      await recordingProvider.deleteRecording(recordingName);
      ErrorService.showSuccess(localizations.recordingDeletedSuccessfully);
      _loadRecordings(); // Refresh the list
    }
  }

  Future<void> _presentRecording(String recordingName) async {
    if (!mounted) return;
    final recordingProvider = context.read<RecordingProvider>();
    final gemMapProvider = context.read<GemMapProvider>();
    final localizations = AppLocalizations.of(context)!;

    // Get recording presentation data
    final presentationData = await recordingProvider.getRecordingPresentationData(recordingName);
    if (presentationData == null) return;

    // Present on map
    await gemMapProvider.presentRecordingOnMap(
      path: presentationData.path,
      startLandmark: presentationData.startLandmark,
      endLandmark: presentationData.endLandmark,
    );

    // Update recording provider state
    recordingProvider.setRecordingPresented(true);

    ErrorService.showSuccess(localizations.recordingPresentedOnMap);

    if (mounted) Navigator.of(context).pop();
  }

  Future<void> _showRecordingDetails(String recordingName) async {
    if (!mounted) return;
    final recordingProvider = context.read<RecordingProvider>();
    final localizations = AppLocalizations.of(context)!;

    final LogMetadata? details = await recordingProvider.getRecordingDetails(recordingName);

    if (!mounted) return;

    if (details != null) {
      showDialog<void>(
        context: context,
        builder: (context) => AlertDialog(
          title: Text(localizations.recordingDetails),
          content: SingleChildScrollView(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              mainAxisSize: MainAxisSize.min,
              children: [
                _buildDetailRow(localizations.name, recordingName),
                if (details.startTimestampInMillis != 0)
                  _buildDetailRow(
                    localizations.startTime,
                    DateTime.fromMillisecondsSinceEpoch(details.startTimestampInMillis).toString(),
                  ),
                if (details.endTimestampInMillis != 0)
                  _buildDetailRow(
                    localizations.endTime,
                    DateTime.fromMillisecondsSinceEpoch(details.endTimestampInMillis).toString(),
                  ),
                if (details.durationMillis != 0)
                  _buildDetailRow(
                    localizations.duration,
                    FormatUtils.convertDuration(Duration(milliseconds: details.durationMillis).inSeconds),
                  ),
                if (details.startPosition.latitude != 0 || details.startPosition.longitude != 0)
                  _buildDetailRow(
                    localizations.startPosition,
                    '${details.startPosition.latitude.toStringAsFixed(6)}, ${details.startPosition.longitude.toStringAsFixed(6)}',
                  ),
                if (details.endPosition.latitude != 0 || details.endPosition.longitude != 0)
                  _buildDetailRow(
                    localizations.endPosition,
                    '${details.endPosition.latitude.toStringAsFixed(6)}, ${details.endPosition.longitude.toStringAsFixed(6)}',
                  ),
                if (details.logMetrics.distanceMeters != 0)
                  _buildDetailRow(
                    localizations.distance,
                    '${(details.logMetrics.distanceMeters / 1000).toStringAsFixed(2)} km',
                  ),
                if (details.logMetrics.elevationGainMeters != 0)
                  _buildDetailRow(
                    localizations.elevationGain,
                    '${details.logMetrics.elevationGainMeters.toStringAsFixed(1)} m',
                  ),
                if (details.logMetrics.avgSpeedMps != 0)
                  _buildDetailRow(
                    localizations.averageSpeed,
                    '${(details.logMetrics.avgSpeedMps * 3.6).toStringAsFixed(1)} km/h',
                  ),
              ],
            ),
          ),
          actions: [
            TextButton(onPressed: () => Navigator.of(context).pop(), child: Text(localizations.close)),
            ElevatedButton(
              onPressed: () {
                Navigator.of(context).pop();
                _presentRecording(recordingName);
              },
              child: Text(localizations.showOnMap),
            ),
          ],
        ),
      );
    } else {
      ErrorService.showWarning(localizations.noDetailsAvailable);
    }
  }

  Widget _buildDetailRow(String label, String value) {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 4),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          SizedBox(
            width: 100,
            child: Text('$label:', style: const TextStyle(fontWeight: FontWeight.bold)),
          ),
          Expanded(child: Text(value)),
        ],
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    final localizations = AppLocalizations.of(context)!;
    return Scaffold(
      appBar: AppBar(
        title: Text(localizations.recordingsHistory),
        backgroundColor: Colors.deepPurple[700],
        foregroundColor: Colors.white,
      ),
      body: _buildBody(),
    );
  }

  Widget _buildBody() {
    final localizations = AppLocalizations.of(context)!;

    if (_isLoading) {
      return const Center(child: CircularProgressIndicator());
    }

    if (_error != null) {
      return Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(Icons.error_outline, size: 64, color: Colors.red[300]),
            const SizedBox(height: 16),
            Text(_error!, style: Theme.of(context).textTheme.titleMedium, textAlign: TextAlign.center),
            const SizedBox(height: 16),
            ElevatedButton(onPressed: _loadRecordings, child: Text(localizations.retry)),
          ],
        ),
      );
    }

    if (_recordings.isEmpty) {
      return Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(Icons.videocam_off, size: 64, color: Colors.grey[400]),
            const SizedBox(height: 16),
            Text(localizations.noRecordingsFound, style: Theme.of(context).textTheme.titleMedium),
            const SizedBox(height: 8),
            Text(
              localizations.startRecordingPrompt,
              style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.grey[600]),
            ),
          ],
        ),
      );
    }

    return RefreshIndicator(
      onRefresh: _loadRecordings,
      child: ListView.separated(
        padding: const EdgeInsets.all(16),
        itemCount: _recordings.length,
        separatorBuilder: (context, index) => const SizedBox(height: 8),
        itemBuilder: (context, index) {
          final recordingName = _recordings[index];
          return RecordingItem(
            recordingName: recordingName,
            onTap: () => _showRecordingDetails(recordingName),
            onDelete: () => _deleteRecording(recordingName),
            onPresent: () => _presentRecording(recordingName),
          );
        },
      ),
    );
  }
}

Recording Item (lib/recording/recording_item.dart)

Widget to display individual recording with metadata and actions:

class RecordingItem extends StatefulWidget {
  final String recordingName;
  final VoidCallback onTap;
  final VoidCallback onDelete;
  final VoidCallback onPresent;

  const RecordingItem({
    super.key,
    required this.recordingName,
    required this.onTap,
    required this.onDelete,
    required this.onPresent,
  });

  @override
  State<RecordingItem> createState() => _RecordingItemState();
}

class _RecordingItemState extends State<RecordingItem> {
  LogMetadata? _details;
  bool _isLoading = true;

  @override
  void initState() {
    super.initState();
    _loadDetails();
  }

  Future<void> _loadDetails() async {
    if (!mounted) return;
    final recordingProvider = context.read<RecordingProvider>();
    final details = await recordingProvider.getRecordingDetails(widget.recordingName);

    if (mounted) {
      setState(() {
        _details = details;
        _isLoading = false;
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return Card(
      elevation: 2,
      margin: EdgeInsets.zero,
      child: InkWell(
        onTap: widget.onTap,
        borderRadius: BorderRadius.circular(12),
        child: Padding(
          padding: const EdgeInsets.all(16),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Row(
                children: [
                  Container(
                    padding: const EdgeInsets.all(8),
                    decoration: BoxDecoration(
                      color: Theme.of(context).primaryColor.withValues(alpha: 0.1),
                      borderRadius: BorderRadius.circular(8),
                    ),
                    child: Icon(Icons.videocam, color: Theme.of(context).primaryColor, size: 20),
                  ),
                  const SizedBox(width: 12),
                  Expanded(
                    child: Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: [
                        Text(
                          widget.recordingName,
                          style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600),
                          maxLines: 1,
                          overflow: TextOverflow.ellipsis,
                        ),
                        const SizedBox(height: 4),
                        if (_isLoading)
                          const SizedBox(height: 12, width: 12, child: CircularProgressIndicator(strokeWidth: 2))
                        else
                          Text(
                            FormatUtils.formatTimestamp(
                              timestamp: _details?.startTimestampInMillis,
                              localizations: AppLocalizations.of(context)!,
                            ),
                            style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey[600]),
                          ),
                      ],
                    ),
                  ),
                  PopupMenuButton<String>(
                    onSelected: (value) {
                      switch (value) {
                        case 'view':
                          widget.onTap();
                          break;
                        case 'present':
                          widget.onPresent();
                          break;
                        case 'delete':
                          widget.onDelete();
                          break;
                      }
                    },
                    itemBuilder: (context) => [
                      PopupMenuItem(
                        value: 'view',
                        child: Row(
                          children: [
                            Icon(Icons.info_outline),
                            SizedBox(width: 8),
                            Text(AppLocalizations.of(context)!.viewDetails),
                          ],
                        ),
                      ),
                      PopupMenuItem(
                        value: 'present',
                        child: Row(
                          children: [
                            Icon(Icons.map),
                            SizedBox(width: 8),
                            Text(AppLocalizations.of(context)!.showOnMap),
                          ],
                        ),
                      ),
                      PopupMenuItem(
                        value: 'delete',
                        child: Row(
                          children: [
                            Icon(Icons.delete_outline, color: Colors.red),
                            SizedBox(width: 8),
                            Text(AppLocalizations.of(context)!.delete, style: TextStyle(color: Colors.red)),
                          ],
                        ),
                      ),
                    ],
                  ),
                ],
              ),
              if (!_isLoading && _details != null) ...[
                const SizedBox(height: 12),
                Row(
                  children: [
                    if (_details!.durationMillis != 0) ...[
                      Icon(Icons.schedule, size: 16, color: Colors.grey[600]),
                      const SizedBox(width: 4),
                      Text(
                        FormatUtils.convertDuration(Duration(milliseconds: _details!.durationMillis).inSeconds),
                        style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey[600]),
                      ),
                      const SizedBox(width: 16),
                    ],
                    if (_details!.logMetrics.distanceMeters != 0) ...[
                      Icon(Icons.straighten, size: 16, color: Colors.grey[600]),
                      const SizedBox(width: 4),
                      Text(
                        FormatUtils.convertDistance(_details!.logMetrics.distanceMeters.toInt()),
                        style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey[600]),
                      ),
                      const SizedBox(width: 16),
                    ],
                    if (_details!.startPosition.latitude != 0 || _details!.startPosition.longitude != 0) ...[
                      Icon(Icons.location_on, size: 16, color: Colors.grey[600]),
                      const SizedBox(width: 4),
                      Expanded(
                        child: Text(
                          '${_details!.startPosition.latitude.toStringAsFixed(4)}, ${_details!.startPosition.longitude.toStringAsFixed(4)}',
                          style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey[600]),
                          maxLines: 1,
                          overflow: TextOverflow.ellipsis,
                        ),
                      ),
                    ],
                  ],
                ),
              ],
            ],
          ),
        ),
      ),
    );
  }
}

ClearRecordingButton (lib/widgets/clear_recording_button.dart)

/// Floating button to clear the presented recorded path on map.
class ClearRecordingButton extends StatelessWidget {
  const ClearRecordingButton({super.key});

  @override
  Widget build(BuildContext context) {
    return Consumer2<RecordingProvider, GemMapProvider>(
      builder: (context, recordingProvider, gemMapProvider, child) {
        return Container(
          margin: EdgeInsets.only(
            bottom: MediaQuery.of(context).padding.bottom + 16.0,
          ),
          child: FloatingActionButton.extended(
            onPressed: () {
              gemMapProvider.clearRecordingPresentation();
              recordingProvider.setRecordingPresented(false);
            },
            backgroundColor: Colors.red.shade600,
            foregroundColor: Colors.white,
            heroTag: 'clearRecording',
            icon: const Icon(Icons.clear),
            label: Text(AppLocalizations.of(context)!.clearRoute),
          ),
        );
      },
    );
  }
}

Update the _DynamicBottomPanel class

Update the _DynamicBottomPanel class in lib/map/map_overlay_manager.dart to include the ClearRecordingButton when a recording is being presented:

class _DynamicBottomPanel extends StatelessWidget {
  const _DynamicBottomPanel();

  @override
  Widget build(BuildContext context) {
    return Selector4<
      SelectedLandmarkStateProvider,
      RoutingStateProvider,
      NavigationStateProvider,
      RecordingProvider,
      _PanelState
    >(
      selector: (context, landmark, routing, navigation, recording) => _PanelState(
        landmark: landmark.selected,
        route: routing.currentRoute,
        isNavigating: navigation.isNavigating,
        instruction: navigation.currentInstruction,
        isPresentingRecordedRoute: recording.isRecordingPresented, // Add recording presentation state
      ),
      builder: (context, state, child) {
        return Align(alignment: Alignment.bottomCenter, child: _buildPanelForState(context, state));
      },
    );
  }

  Widget _buildPanelForState(BuildContext context, _PanelState state) {
    if (state.landmark != null) {
      return const LandmarkPanel();
    }

    if (state.route != null) {
      return const RoutingPanel();
    }

    if (state.isNavigating && state.instruction != null) {
      return NavigationControls(instruction: state.instruction!);
    }

    if (state.isPresentingRecordedRoute) {
      return const ClearRecordingButton();
    }

    return const SizedBox.shrink();
  }
}

class _PanelState {
  final Landmark? landmark;
  final Route? route;
  final bool isNavigating;
  final NavigationInstruction? instruction;
  final bool isPresentingRecordedRoute;

  const _PanelState({
    this.landmark,
    this.route,
    required this.isNavigating,
    this.instruction,
    required this.isPresentingRecordedRoute,
  });

  @override
  bool operator ==(Object other) =>
      identical(this, other) ||
      other is _PanelState &&
          runtimeType == other.runtimeType &&
          landmark == other.landmark &&
          route == other.route &&
          isNavigating == other.isNavigating &&
          instruction == other.instruction &&
          isPresentingRecordedRoute == other.isPresentingRecordedRoute;

  @override
  int get hashCode => Object.hash(
    landmark,
    route,
    isNavigating,
    instruction,
    isPresentingRecordedRoute,
  );
}

Integrate Recording Buttons in MapOverlayManager

Update the _BottomControlsWidget class in lib/map/map_overlay_manager.dart to include the RecordButton:

class _BottomControlsWidget extends StatelessWidget {
  const _BottomControlsWidget();

  @override
  Widget build(BuildContext context) {
    return Selector4<
      SelectedLandmarkStateProvider,
      RoutingStateProvider,
      NavigationStateProvider,
      RecordingProvider,
      bool
    >(
      selector: (context, landmark, routing, navigation, recording) =>
          landmark.selected == null &&
          routing.currentRoute == null &&
          !navigation.isNavigating &&
          !recording.isRecordingPresented, // Add condition for recording presentation
      builder: (context, shouldShow, child) {
        if (!shouldShow) return const SizedBox.shrink();

        return SafeArea(
          child: Align(
            alignment: Alignment.bottomCenter,
            child: Padding(
              padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 10.0),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                mainAxisAlignment: MainAxisAlignment.end,
                children: [
                  const RecordButton(), // Add the RecordButton here
                  const SizedBox(height: 10),
                  const RecordingsPageButton(), // Add button to open recordings history
                  const SizedBox(height: 10),
                  const MapDownloadsButton(),
                  const SizedBox(height: 10),
                  Row(
                    children: [
                      const TransportModeButton(),
                      const SizedBox(width: 10),
                      const Expanded(child: SearchBarWidget()),
                      const SizedBox(width: 10),
                      const FollowPositionButton(),
                    ],
                  ),
                ],
              ),
            ),
          ),
        );
      },
    );
  }
}

Usage

Recording a Route

  1. Tap the record button to start recording
  2. The app will request location permissions if not already granted
  3. The recording runs in the background (notification shown on Android)
  4. Tap the stop button to end and save the recording

Viewing Recordings

  1. Open the recordings history page
  2. Tap on a recording to view details
  3. Use "Show on Map" to display the recorded route
  4. Delete unwanted recordings using the menu

Key Features

Background Recording

  • Android: Uses foreground service with notification
  • iOS: Uses background location updates
  • Continues recording even when app is minimized

Recording Metadata

Each recording includes:

  • Log name as path
  • Duration
  • Distance traveled in meters
  • Start/end timestamps
  • Start/end positions
  • Precise route coordinates

Recording Controls

  • Start: Begin new recording session
  • Pause: Temporarily pause recording
  • Resume: Continue paused recording
  • Stop: End and save recording

Tip

In order for the recording functionality to work correctly, ensure that the device is moving to generate location updates. Stationary devices will not produce sufficient location data for recording.

Troubleshooting

Recording stops when app goes to background

  • Verify allowsBackgroundLocationUpdates is set to true
  • Check Android manifest has ACCESS_BACKGROUND_LOCATION permission
  • Ensure foreground service is configured and running

No notification shown on Android

  • Check POST_NOTIFICATIONS permission is granted
  • Verify notification channel is created
  • Ensure foreground service is properly initialized

iOS background recording not working

  • Verify NSLocationAlwaysAndWhenInUseUsageDescription is in Info.plist
  • Check UIBackgroundModes includes location
  • Request "Always" location permission from user

Conclusion

This implementation provides a complete recording solution with:

  • ✅ Background recording support
  • ✅ Platform-specific configurations
  • ✅ Error handling and user feedback
  • ✅ Recording management (list, view, delete)
  • ✅ Map visualization of recorded routes

The Magic Lane Maps SDK's Recorder API, combined with proper platform configuration and foreground services, enables robust location recording functionality for your Flutter application.