Skip to content

Latest commit

 

History

History
544 lines (441 loc) · 16.7 KB

File metadata and controls

544 lines (441 loc) · 16.7 KB

Adding Landmark Selection Functionality with LandmarkPanel

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.

Landmark selection demo

Table of Contents

Prerequisites

  • Existing Flutter project with Magic Lane Maps SDK integrated
  • Working GemMap widget 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.)

What You'll Build

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

Additional Dependencies

No additional dependencies are required beyond what you already have for the follow position functionality.

Landmark Selection Architecture

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

Implementation

The landmark selection functionality is already implemented in your project. Let's examine the existing code:

1. Selected Landmark Provider

The provider manages landmark selection state:

lib/providers/selected_landmark_provider.dart

// 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() and clear() methods

2. Landmark Panel Implementation

The interactive panel displays landmark information:

lib/landmark/landmark_panel.dart

// 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

3. Touch-Based Landmark Selection

The map widget handles touch events for landmark selection:

lib/widgets/app_map.dart

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:

  1. Touch Registration: Registers touch callback when map is created
  2. Cursor Positioning: Sets cursor position to touched location asynchronously
  3. Landmark Detection: Gets landmarks at cursor position
  4. Visual Feedback: Highlights selected landmarks automatically
  5. Map Centering: Centers map on selected landmark with smooth animation
  6. State Management: Updates landmark selection state reactively

4. Map Overlay Manager

The overlay system manages UI components including the landmark panel:

lib/widgets/map_overlay_manager.dart

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 Selector to 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

5. Provider Configuration

The app providers are configured to include landmark selection:

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()), // New provider added
      ],
      child: child,
    );
  }
}

How It Works

The landmark selection functionality operates through these key interactions:

  1. Touch Detection: When you tap on the map, the touch position is captured
  2. Cursor Positioning: The map cursor is set to the touched location
  3. Landmark Search: The system searches for landmarks at the cursor position
  4. Selection & Highlighting: Found landmarks are highlighted and the first one is selected
  5. Panel Display: The landmark panel appears showing detailed information
  6. Map Centering: The map smoothly centers on the selected landmark

Expected Behavior

The landmark selection functionality provides:

  1. Touch Interaction: Tapping on landmarks triggers selection and highlighting
  2. Landmark Panel: Information panel slides up showing landmark details
  3. Visual Highlighting: Selected landmarks are highlighted on the map
  4. Map Centering: Map centers on selected landmarks with smooth animation
  5. Easy Dismissal: Tapping the close button clears selection and hides the panel

Testing Checklist

  • ✅ 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

Common Issues and Solutions

1. Landmark Not Found

  • 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

2. Panel Not Appearing

  • Ensure the SelectedLandmarkStateProvider is properly configured
  • Check that the provider is included in your app's provider tree
  • Verify the MapOverlayManager is positioned correctly in your widget tree

3. Map Controller Not Ready

  • Touch events are only registered after map creation is complete
  • The _onMapCreated callback ensures proper initialization timing

Conclusion

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!