This tutorial demonstrates how to add landmark selection functionality to your Flutter map application that already has the Magic Lane Maps SDK integrated with follow position capabilities. You'll learn to implement touch-based landmark selection, display detailed landmark information in a panel, and manage landmark highlighting on the map.
- Prerequisites
- What You'll Build
- Additional Dependencies
- Landmark Selection Architecture
- Implementation
- How It Works
- Common Issues and Solutions
- Conclusion
- Existing Flutter project with Magic Lane Maps SDK integrated
- Working
GemMapwidget with follow position functionality - Provider state management setup
- Magic Lane API token configured
- iOS/Android device or simulator
- IDE with Flutter support (VS Code, Android Studio, etc.)
By the end of this tutorial, your map application will feature:
- Touch-based landmark selection: Tap on landmarks or POIs to select them
- Interactive landmark panel: Display detailed information about selected landmarks
- Visual highlighting: Highlight selected landmarks on the map
- Smooth animations: Smooth panel transitions and map centering
- Clean state management: Proper landmark selection state handling
No additional dependencies are required beyond what you already have for the follow position functionality.
The landmark selection functionality has been implemented with these components:
lib/
├── landmark/
│ └── landmark_panel.dart # Landmark information panel
├── providers/
│ └── selected_landmark_provider.dart # Landmark selection state
└── widgets/
└── map_overlay_manager.dart # Overlay manager for landmark panel
The landmark selection functionality is already implemented in your project. Let's examine the existing code:
The provider manages landmark selection state:
// Provider for managing the selected landmark state throughout the app
class SelectedLandmarkStateProvider extends ChangeNotifier {
Landmark? _selected;
Landmark? get selected => _selected;
void setSelected(Landmark? lmk) {
_selected = lmk;
notifyListeners();
}
void clear() {
_selected = null;
notifyListeners();
}
}Key Features:
- State Management: Manages the currently selected landmark
- Reactive Updates: Notifies listeners when selection changes
- Simple API: Easy to use
setSelected()andclear()methods
The interactive panel displays landmark information:
// Panel widget to display information about a selected landmark and provide routing options.
class LandmarkPanel extends StatefulWidget {
const LandmarkPanel({super.key});
@override
State<LandmarkPanel> createState() => _LandmarkPanelState();
}
class _LandmarkPanelState extends State<LandmarkPanel> {
@override
Widget build(BuildContext context) {
final landmark = context.watch<SelectedLandmarkStateProvider>().selected;
if (landmark == null) {
return const SizedBox.shrink();
}
return Container(
margin: EdgeInsets.only(
left: 16.0,
right: 16.0,
bottom: MediaQuery.of(context).padding.bottom + 16.0,
),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12.0),
boxShadow: [
BoxShadow(
color: Colors.black.withAlpha(80),
blurRadius: 10.0,
offset: const Offset(0, 3),
)
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildHeader(landmark),
_buildContent(landmark),
_buildActions(context, landmark),
],
),
);
}
// Builds the header section of the landmark panel
Widget _buildHeader(Landmark landmark) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
decoration: BoxDecoration(
color: Colors.deepPurple[700],
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(12.0),
topRight: Radius.circular(12.0),
),
),
child: Row(
children: [
Expanded(
child: Text(
landmark.name,
style: const TextStyle(
fontSize: 18.0,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
IconButton(
icon: const Icon(Icons.close, color: Colors.white),
onPressed: () => _clearSelectedLandmark(context),
),
],
),
);
}
// Builds the content section with address and category information
Widget _buildContent(Landmark landmark) {
String address = AppLocalizations.of(context)!.noAddressAvailable;
address = landmark.address.format();
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.location_on, color: Colors.deepPurple),
const SizedBox(width: 8.0),
Expanded(child: Text(address)),
],
),
if (landmark.categories.isNotEmpty) ...[
const SizedBox(height: 12.0),
Row(
children: [
const Icon(Icons.category, color: Colors.deepPurple),
const SizedBox(width: 8.0),
Expanded(child: Text(landmark.categories.first.name)),
],
),
],
],
),
);
}
// Builds the action buttons section
Widget _buildActions(BuildContext context, Landmark landmark) {
return Padding(
padding: const EdgeInsets.only(left: 16.0, right: 16.0, bottom: 16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Expanded(
child: ElevatedButton.icon(
icon: const Icon(Icons.location_disabled),
label: Text(AppLocalizations.of(context)!.routingNotAvailable),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.grey,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12.0),
),
onPressed: null, // Disabled for now
),
),
],
),
);
}
// Clears the selected landmark and removes highlights
void _clearSelectedLandmark(BuildContext context) {
final gemMapProvider = context.read<GemMapProvider>();
final selectedLandmarkProvider = context.read<SelectedLandmarkStateProvider>();
if (gemMapProvider.controller != null) {
gemMapProvider.controller!.deactivateAllHighlights();
}
selectedLandmarkProvider.clear();
}
}Key Features:
- Responsive Design: Adapts to different screen sizes with proper margins
- Material Design: Clean, modern UI with proper shadows and colors
- Landmark Information: Displays name, address, and category information
- Highlight Management: Clears map highlights when panel is closed
The map widget handles touch events for landmark selection:
import 'package:flutter/material.dart';
import 'package:flutter_test_demo/providers/gem_map_provider.dart';
import 'package:flutter_test_demo/providers/selected_landmark_provider.dart';
import 'package:magiclane_maps_flutter/magiclane_maps_flutter.dart';
import 'package:provider/provider.dart';
class AppMap extends StatefulWidget {
final String appAuthorization;
const AppMap({super.key, required this.appAuthorization});
@override
State<AppMap> createState() => _AppMapState();
}
class _AppMapState extends State<AppMap> {
void _onMapCreated(GemMapController controller) {
WidgetsBinding.instance.addPostFrameCallback((_) {
final provider = context.read<GemMapProvider>();
final followProvider = context.read<FollowPositionProvider>();
provider.setController(controller);
followProvider.setController(controller);
_registerMapTouchGesture(controller);
});
setState(() {});
}
void _registerMapTouchGesture(GemMapController controller) {
controller.registerOnTouch((pos) async {
final landmarkProvider = context.read<SelectedLandmarkStateProvider>();
await controller.setCursorScreenPosition(pos);
// Landmark selection behavior
final landmarks = controller.cursorSelectionLandmarks();
if (landmarks.isNotEmpty) {
controller.activateHighlight(landmarks);
landmarkProvider.setSelected(landmarks[0]);
controller.centerOnCoordinates(
landmarks[0].coordinates,
zoomLevel: 70,
animation: GemAnimation(type: AnimationType.linear, duration: 1),
viewAngle: 0.0,
screenPosition: controller.viewportCenter,
);
}
});
}
@override
void dispose() {
GemKit.release();
super.dispose();
}
@override
Widget build(BuildContext context) {
return GemMap(onMapCreated: _onMapCreated, appAuthorization: widget.appAuthorization);
}
}Key Implementation Details:
- Touch Registration: Registers touch callback when map is created
- Cursor Positioning: Sets cursor position to touched location asynchronously
- Landmark Detection: Gets landmarks at cursor position
- Visual Feedback: Highlights selected landmarks automatically
- Map Centering: Centers map on selected landmark with smooth animation
- State Management: Updates landmark selection state reactively
The overlay system manages UI components including the landmark panel:
import 'package:flutter/material.dart' hide Route; // Avoid name conflict with Magic Lane SDK Route class
class MapOverlayManager extends StatelessWidget {
const MapOverlayManager({super.key});
@override
Widget build(BuildContext context) {
return const Stack(
children: [
// Bottom controls - only show when not in any specific mode
Positioned(bottom: 0, left: 0, right: 0, child: _BottomControlsWidget()),
// Dynamic bottom panel (landmark/routing/navigation)
Positioned(bottom: 0, left: 0, right: 0, child: _DynamicBottomPanel()),
],
);
}
}
class _BottomControlsWidget extends StatelessWidget {
const _BottomControlsWidget();
@override
Widget build(BuildContext context) {
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: [
Row(children: [const FollowPositionButton()]),
],
),
),
),
);
}
}
class _DynamicBottomPanel extends StatelessWidget {
const _DynamicBottomPanel();
@override
Widget build(BuildContext context) {
return Selector<SelectedLandmarkStateProvider, _PanelState>(
selector: (context, landmark) => _PanelState(landmark: landmark.selected),
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();
}
return const SizedBox.shrink();
}
}
class _PanelState {
final Landmark? landmark;
const _PanelState({this.landmark});
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is _PanelState &&
runtimeType == other.runtimeType &&
landmark == other.landmark;
@override
int get hashCode => Object.hashAll([landmark]);
}Now, add the MapOverlayManager to your main map page to manage overlays:
class MapPage extends StatefulWidget {
const MapPage({super.key});
@override
State<MapPage> createState() => _MapPageState();
}
class _MapPageState extends State<MapPage> {
@override
void initState() {
super.initState();
// Initialize error service when the main page is ready
WidgetsBinding.instance.addPostFrameCallback((_) {
ErrorService.initialize(MainApp.navigatorKey);
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
resizeToAvoidBottomInset: false,
body: Stack(
children: [
AppMap(appAuthorization: MainApp.projectApiToken),
const MapOverlayManager(), // Add overlay manager here
],
),
);
}
}Key Features:
- Selective Rebuilding: Uses
Selectorto rebuild only when landmark changes - Overlay Management: Manages multiple UI overlays in a stack
- Performance Optimized: Efficient state comparison with custom
_PanelState - Dynamic Panel Display: Shows landmark panel when landmark is selected
The app providers are configured to include landmark selection:
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()), // New provider added
],
child: child,
);
}
}The landmark selection functionality operates through these key interactions:
- Touch Detection: When you tap on the map, the touch position is captured
- Cursor Positioning: The map cursor is set to the touched location
- Landmark Search: The system searches for landmarks at the cursor position
- Selection & Highlighting: Found landmarks are highlighted and the first one is selected
- Panel Display: The landmark panel appears showing detailed information
- Map Centering: The map smoothly centers on the selected landmark
The landmark selection functionality provides:
- Touch Interaction: Tapping on landmarks triggers selection and highlighting
- Landmark Panel: Information panel slides up showing landmark details
- Visual Highlighting: Selected landmarks are highlighted on the map
- Map Centering: Map centers on selected landmarks with smooth animation
- Easy Dismissal: Tapping the close button clears selection and hides the panel
- ✅ Tap on known POIs (restaurants, shops, etc.)
- ✅ Verify panel information display
- ✅ Test highlight visibility on selected landmarks
- ✅ Confirm map centering behavior
- ✅ Test panel close functionality
- ✅ Test with follow position functionality simultaneously
- The system only detects landmarks where Magic Lane has POI data
- Not all map locations have associated landmark information
- Consider the touch accuracy and zoom level for better detection
- Ensure the
SelectedLandmarkStateProvideris properly configured - Check that the provider is included in your app's provider tree
- Verify the
MapOverlayManageris positioned correctly in your widget tree
- Touch events are only registered after map creation is complete
- The
_onMapCreatedcallback ensures proper initialization timing
Your Flutter map application now features a fully functional landmark selection system! The implementation includes:
- ✅ Touch-based landmark selection with visual feedback
- ✅ Interactive landmark panel with detailed information display
- ✅ Map highlighting with automatic cleanup
Your map application now provides an intuitive and engaging experience for exploring and discovering points of interest!
