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.
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
- Overview
- Architecture
- Platform Configuration
- Services Layer
- Provider Layer
- App Providers Setup
- Map Presentation
- Formatting Methods
- UI Components
- Usage
- Key Features
- Troubleshooting
- Conclusion
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.1Configure 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>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_LOCATIONis required for Android 10+ to continue recording when the app is in the backgroundFOREGROUND_SERVICEandFOREGROUND_SERVICE_LOCATIONare needed to run the recording service in the foreground- The
BackgroundServicedeclaration withforegroundServiceType="location"is mandatory for the flutter_background_service plugin
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
NSLocationWhenInUseUsageDescriptionexplains why the app needs location when in useNSLocationAlwaysAndWhenInUseUsageDescriptionis required for background location accessUIBackgroundModeswithlocationenables continuous background location updates- The
processingbackground mode allows for additional background processing
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 trackingPositionSensorConfiguration.allowsBackgroundLocationUpdates- Enables background location updates (critical for continuous recording)RecorderConfiguration- Configures the recorder with data source, directory, and data typesRecorder.create()- Creates a recorder instancerecorder.startRecording()- Starts the recording sessionrecorder.stopRecording()- Stops and saves the recordingRecorderBookmarks- Manages saved recordings (list, metadata, deletion)
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();
});
}
}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();
}
}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,
);
}
}/// 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();
}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);
}
}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);
}
}
}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),
);
}
}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),
);
},
),
);
}
}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,
),
),
],
],
),
],
],
),
),
),
);
}
}/// 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 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,
);
}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(),
],
),
],
),
),
),
);
},
);
}
}- Tap the record button to start recording
- The app will request location permissions if not already granted
- The recording runs in the background (notification shown on Android)
- Tap the stop button to end and save the recording
- Open the recordings history page
- Tap on a recording to view details
- Use "Show on Map" to display the recorded route
- Delete unwanted recordings using the menu
- Android: Uses foreground service with notification
- iOS: Uses background location updates
- Continues recording even when app is minimized
Each recording includes:
- Log name as path
- Duration
- Distance traveled in meters
- Start/end timestamps
- Start/end positions
- Precise route coordinates
- 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.
- Verify
allowsBackgroundLocationUpdatesis set totrue - Check Android manifest has
ACCESS_BACKGROUND_LOCATIONpermission - Ensure foreground service is configured and running
- Check
POST_NOTIFICATIONSpermission is granted - Verify notification channel is created
- Ensure foreground service is properly initialized
- Verify
NSLocationAlwaysAndWhenInUseUsageDescriptionis in Info.plist - Check
UIBackgroundModesincludeslocation - Request "Always" location permission from user
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.
