Skip to content

Latest commit

 

History

History
837 lines (714 loc) · 25.2 KB

File metadata and controls

837 lines (714 loc) · 25.2 KB

Search Functionality Implementation Guide

This document provides a comprehensive guide for implementing search functionality in a Flutter map application using the Magic Lane SDK. The search system includes a search bar, search page with results, and persistent search history.

Search functionality demo

Table of Contents

Overview

The search functionality consists of four main components:

  1. Search Bar Widget - A UI component that triggers the search page
  2. Search Page - A full-screen search interface with real-time search
  3. Search Result Item - Individual result display components
  4. Search History - Persistent storage and display of previous searches

Architecture

lib/search/
├── search_bar_widget.dart     # Search trigger widget
├── search_page.dart           # Main search interface
└── search_result_item.dart    # Result display component

lib/utils/
└── formatting.dart            # Utility functions for formatting

lib/shared/widgets/
└── loading_indicator.dart     # Loading indicator widget

The search system integrates with:

  • Map Controller - For coordinate transformations and map interactions
  • Selected Landmark Provider - For managing landmark selection state
  • LandmarkStore Service - For persistent search history storage
  • SearchService - Magic Lane SDK search capabilities

Implementation

1. Search Bar Widget

The search bar acts as an entry point to the search functionality.

lib/search/search_bar_widget.dart

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

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () async {
        final controller = context.read<GemMapProvider>().controller;
        final selectedProvider = context.read<SelectedLandmarkStateProvider>();
        if (controller == null) return;

        // Get current map center coordinates for search context
        final screenCenter = controller.viewportCenter;
        final mapCoords = controller.transformScreenToWgs(screenCenter);

        // Navigate to search page and wait for result
        final result = await Navigator.of(context).push<Landmark?>(
          MaterialPageRoute(
            builder: (context) => SearchPage(initialCoordinates: mapCoords)
          )
        );

        if (result is Landmark) {
          // Clear any previous highlights and routes
          controller.deactivateAllHighlights();

          // Highlight the selected landmark
          controller.activateHighlight([result]);

          // Center on the landmark with animation
          controller.centerOnCoordinates(
            result.coordinates,
            zoomLevel: 70,
            viewAngle: 0.0,
            animation: GemAnimation(type: AnimationType.linear, duration: 1),
            screenPosition: controller.viewportCenter,
          );

          // Update the selected landmark provider
          selectedProvider.setSelected(result);
        }
      },
      child: Container(
        height: 48,
        padding: const EdgeInsets.symmetric(horizontal: 16),
        decoration: BoxDecoration(
          color: Colors.white,
          borderRadius: BorderRadius.circular(10),
          boxShadow: [
            BoxShadow(
              color: Colors.black26,
              blurRadius: 5,
              offset: Offset(0, 2)
            )
          ],
        ),
        child: Row(
          children: [
            const Icon(Icons.search, color: Colors.black54, size: 25.0),
            const SizedBox(width: 8),
            Expanded(
              child: Text(
                maxLines: 1,
                overflow: TextOverflow.ellipsis,
                AppLocalizations.of(context)!.searchPlacesHint,
                style: const TextStyle(color: Colors.black54, fontSize: 18),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

Key Features:

  • Coordinate Context: Uses current map center as search context
  • Result Handling: Processes selected landmarks from search page
  • Map Integration: Highlights and centers on selected landmarks
  • Visual Design: Clean, tappable search bar

2. Loading indicator Widget

lib/shared/widgets/loading_indicator.dart

class LoadingIndicator extends StatelessWidget {
  /// The type of loading indicator to display
  final LoadingType type;

  /// Optional message to display below the indicator
  final String? message;

  /// Progress value for progress indicators (0.0 to 1.0)
  final double? progress;

  /// Color of the indicator
  final Color? color;

  /// Background color for progress indicators
  final Color? backgroundColor;

  /// Size of the indicator
  final double? size;

  /// Stroke width of the indicator
  final double? strokeWidth;

  const LoadingIndicator({
    super.key,
    this.type = LoadingType.standard,
    this.message,
    this.progress,
    this.color,
    this.backgroundColor,
    this.size,
    this.strokeWidth,
  });

  /// Simple centered loading indicator
  const LoadingIndicator.centered({super.key, this.message, this.color})
    : type = LoadingType.centered,
      progress = null,
      backgroundColor = null,
      size = null,
      strokeWidth = null;

  /// Loading indicator with message below
  const LoadingIndicator.withMessage({super.key, required this.message, this.color})
    : type = LoadingType.withMessage,
      progress = null,
      backgroundColor = null,
      size = null,
      strokeWidth = null;

  /// Progress indicator with value
  const LoadingIndicator.progress({
    super.key,
    required this.progress,
    this.message,
    this.color,
    this.backgroundColor,
    this.size,
    this.strokeWidth,
  }) : type = LoadingType.progress;

  /// Small loading indicator for buttons
  const LoadingIndicator.button({super.key, this.color = Colors.white, this.size = 20, this.strokeWidth = 2.0})
    : type = LoadingType.button,
      message = null,
      progress = null,
      backgroundColor = null;

  @override
  Widget build(BuildContext context) {
    switch (type) {
      case LoadingType.standard:
        return _buildStandardIndicator();
      case LoadingType.centered:
        return _buildCenteredIndicator();
      case LoadingType.withMessage:
        return _buildIndicatorWithMessage();
      case LoadingType.progress:
        return _buildProgressIndicator();
      case LoadingType.button:
        return _buildButtonIndicator();
    }
  }

  Widget _buildStandardIndicator() {
    return CircularProgressIndicator(color: color, strokeWidth: strokeWidth ?? 4.0);
  }

  Widget _buildCenteredIndicator() {
    return Center(
      child: CircularProgressIndicator(color: color, strokeWidth: strokeWidth ?? 4.0),
    );
  }

  Widget _buildIndicatorWithMessage() {
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          CircularProgressIndicator(color: color, strokeWidth: strokeWidth ?? 4.0),
          if (message != null) ...[const SizedBox(height: 16), Text(message!, textAlign: TextAlign.center)],
        ],
      ),
    );
  }

  Widget _buildProgressIndicator() {
    final indicator = CircularProgressIndicator(
      value: progress,
      strokeWidth: strokeWidth ?? 2.0,
      backgroundColor: backgroundColor,
      valueColor: AlwaysStoppedAnimation<Color>(color ?? Colors.blue),
    );

    final widget = size != null ? SizedBox(width: size, height: size, child: indicator) : indicator;

    if (message != null) {
      return Column(mainAxisSize: MainAxisSize.min, children: [widget, const SizedBox(height: 8), Text(message!)]);
    }

    return widget;
  }

  Widget _buildButtonIndicator() {
    return SizedBox(
      width: size ?? 20,
      height: size ?? 20,
      child: CircularProgressIndicator(color: color, strokeWidth: strokeWidth ?? 2.0),
    );
  }
}

/// Types of loading indicators available
enum LoadingType {
  /// Standard circular progress indicator
  standard,

  /// Centered circular progress indicator
  centered,

  /// Circular progress indicator with message below
  withMessage,

  /// Circular progress indicator with progress value
  progress,

  /// Small indicator for buttons
  button,
}

3. Search Page Implementation

The main search interface with real-time search and history management.

lib/search/search_page.dart

class SearchPage extends StatefulWidget {
  // Reference coordinates for search context
  final Coordinates initialCoordinates;

  const SearchPage({super.key, required this.initialCoordinates});

  @override
  State<SearchPage> createState() => _SearchPageState();
}

class _SearchPageState extends State<SearchPage> {
  final TextEditingController _searchController = TextEditingController();
  List<Landmark> _searchResults = [];
  List<Landmark> _searchHistory = [];
  bool _isSearching = false;

  Timer? _debounceTimer;
  LandmarkStore? _historyStore;

  @override
  void initState() {
    super.initState();
    _searchController.addListener(_onSearchChanged);
    _initializeSearchHistory();
  }

  @override
  void dispose() {
    _searchController.dispose();
    _searchResults = [];
    _debounceTimer?.cancel();
    super.dispose();
  }

  void _initializeSearchHistory() {
    _historyStore = LandmarkStoreService.getLandmarkStoreByName("History") ?? 
                   LandmarkStoreService.createLandmarkStore("History");
    _loadSearchHistory();
  }

  void _loadSearchHistory() {
    if (_historyStore != null) {
      final landmarks = _historyStore!.getLandmarks();
      // Reverse to show most recent first
      _searchHistory = landmarks.reversed.toList();
      if (mounted) {
        setState(() {});
      }
    }
  }

  void _addToHistory(Landmark landmark) {
    if (_historyStore == null) return;

    // Check if landmark already exists in history
    final existingLandmarks = _historyStore!.getLandmarks();
    final alreadyExists = existingLandmarks.any((existing) {
      // consider landmarks identical if name matches and positions are within 5 meters
      final sameName = existing.name == landmark.name;
      final distanceMeters = existing.coordinates.distance(landmark.coordinates);
      return sameName && distanceMeters < 5.0;
    });

    if (!alreadyExists) {
      // Add to landmark store
      _historyStore!.addLandmark(landmark);

      // Limit history to last 20 items
      final allLandmarks = _historyStore!.getLandmarks();
      if (allLandmarks.length > 20) {
        // Remove oldest landmarks
        final landmarksToRemove = allLandmarks.take(allLandmarks.length - 20);
        for (final landmarkToRemove in landmarksToRemove) {
          _historyStore!.removeLandmark(landmarkToRemove);
        }
      }

      // Refresh history display
      _loadSearchHistory();
    }
  }

  void _clearHistory() {
    if (_historyStore == null) return;

    _historyStore!.removeAllLandmarks();
    _searchHistory.clear();
    if (mounted) {
      setState(() {});
    }
  }

  void _onSearchChanged() {
    // Debounce the search to avoid too many API calls
    _debounceTimer?.cancel();
    _debounceTimer = Timer(const Duration(milliseconds: 500), () {
      if (_searchController.text.isNotEmpty) {
        _performSearch(_searchController.text);
      } else {
        setState(() {
          _searchResults = [];
        });
        // Only reload history if we don't already have it loaded
        if (_searchHistory.isEmpty && _historyStore != null) {
          _loadSearchHistory();
        }
      }
    });
  }

  Future<void> _performSearch(String query) async {
    setState(() {
      _isSearching = true;
    });
    
    final Completer<List<Landmark>> completer = Completer<List<Landmark>>();

    // Configure search preferences
    final preferences = SearchPreferences(
      maxMatches: 15,
      allowFuzzyResults: true
    );

    // Execute search using Magic Lane SearchService
    SearchService.search(
      query,
      widget.initialCoordinates,
      (err, results) {
        if (err != GemError.success) {
          completer.complete([]);
          return;
        }
        completer.complete(results);
      },
      preferences: preferences
    );

    final results = await completer.future;

    if (mounted) {
      setState(() {
        _searchResults = results;
        _isSearching = false;
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(AppLocalizations.of(context)!.searchPlaces),
        backgroundColor: Colors.deepPurple[700],
        foregroundColor: Colors.white,
        elevation: 1,
        leading: IconButton(
          icon: const Icon(Icons.arrow_back),
          onPressed: () => Navigator.of(context).pop()
        ),
      ),
      body: Column(
        children: [
          // Search input field
          Padding(
            padding: const EdgeInsets.all(16.0),
            child: TextField(
              controller: _searchController,
              autofocus: true,
              decoration: InputDecoration(
                hintText: AppLocalizations.of(context)!.searchPlacesHint,
                prefixIcon: const Icon(Icons.search),
                suffixIcon: _searchController.text.isNotEmpty && !_isSearching
                    ? IconButton(
                        icon: const Icon(Icons.clear),
                        onPressed: () {
                          _searchController.clear();
                        },
                      )
                    : null,
                border: OutlineInputBorder(
                  borderRadius: BorderRadius.circular(10)
                ),
                contentPadding: const EdgeInsets.symmetric(vertical: 12),
              ),
            ),
          ),
          Expanded(child: _buildSearchContent()),
        ],
      ),
    );
  }

  Widget _buildSearchContent() {
    if (_isSearching) {
      return const LoadingIndicator.centered();
    }

    if (_searchController.text.isNotEmpty) {
      // Show search results
      if (_searchResults.isEmpty) {
        return Center(
          child: Text(AppLocalizations.of(context)!.noResultsFound)
        );
      }
      return ListView.builder(
        itemCount: _searchResults.length,
        itemBuilder: (context, index) {
          return SearchResultItem(
            initialCoordinates: widget.initialCoordinates,
            landmark: _searchResults[index],
            onTap: () {
              final selectedLandmark = _searchResults[index];
              _addToHistory(selectedLandmark);
              Navigator.of(context).pop(selectedLandmark);
            },
          );
        },
      );
    } else {
      // Show search history or empty state
      if (_searchHistory.isEmpty) {
        return Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              const Icon(Icons.history, size: 64, color: Colors.grey),
              const SizedBox(height: 16),
              Text(
                AppLocalizations.of(context)!.noSearchHistory,
                style: const TextStyle(fontSize: 18, color: Colors.grey),
              ),
              const SizedBox(height: 8),
              Text(
                AppLocalizations.of(context)!.searchHistoryHint,
                style: const TextStyle(color: Colors.grey)
              ),
            ],
          ),
        );
      }

      return Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Row(
            mainAxisAlignment: MainAxisAlignment.spaceBetween,
            children: [
              Padding(
                padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
                child: Text(
                  AppLocalizations.of(context)!.recentSearches,
                  style: const TextStyle(
                    fontSize: 16,
                    fontWeight: FontWeight.w600,
                    color: Colors.grey
                  ),
                ),
              ),
              IconButton(
                icon: const Icon(Icons.delete, color: Colors.grey),
                onPressed: _clearHistory,
                tooltip: AppLocalizations.of(context)!.clearHistory,
              ),
            ],
          ),
          Expanded(
            child: ListView.builder(
              itemCount: _searchHistory.length,
              itemBuilder: (context, index) {
                return SearchResultItem(
                  landmark: _searchHistory[index],
                  onTap: () {
                    final selectedLandmark = _searchHistory[index];
                    Navigator.of(context).pop(selectedLandmark);
                  },
                  isFromHistory: true,
                );
              },
            ),
          ),
        ],
      );
    }
  }
}

Key Features:

  • Debounced Search: Waits 500ms after typing stops to perform search
  • Real-time Results: Shows results as user types
  • Search History: Persistent storage of previous searches
  • History Management: Limits to 20 items, prevents duplicates
  • State Management: Handles loading, empty states, and errors
  • Coordinate Context: Uses provided coordinates for relevant results

4. Search Result Item

Component for displaying individual search results and history items.

lib/search/search_result_item.dart

class SearchResultItem extends StatelessWidget {
  final Landmark landmark;
  final Coordinates? initialCoordinates;
  final VoidCallback onTap;
  final bool isFromHistory;

  const SearchResultItem({
    super.key,
    required this.landmark,
    this.initialCoordinates,
    required this.onTap,
    this.isFromHistory = false,
  });

  @override
  Widget build(BuildContext context) {
    return ListTile(
      onTap: onTap,
      leading: Container(
        padding: const EdgeInsets.all(8),
        child: landmark.getImage(size: Size(100, 100)) != null
            ? Image.memory(
                landmark.getImage(size: Size(100, 100))!,
                gaplessPlayback: true
              )
            : SizedBox(
                width: 40,
                height: 40,
                child: Icon(isFromHistory ? Icons.history : Icons.place)
              ),
      ),
      title: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        mainAxisSize: MainAxisSize.min,
        children: [
          Text(
            landmark.name,
            overflow: TextOverflow.ellipsis,
            style: const TextStyle(fontWeight: FontWeight.w600),
            maxLines: 1,
          ),
          // Show distance if initial coordinates are provided
          if (initialCoordinates != null)
            Text(
              FormatUtils.convertDistance(
                initialCoordinates!.distance(landmark.coordinates).toInt()
              ),
              style: TextStyle(color: Colors.grey[700], fontSize: 15),
            ),
          Text(
            landmark.address.format(),
            overflow: TextOverflow.ellipsis,
            maxLines: 2,
            style: TextStyle(color: Colors.grey[600], fontSize: 13),
          ),
        ],
      ),
    );
  }
}

Key Features:

  • Distance Display: Shows distance from map viewport center when available
  • Landmark Images: Displays landmark images or fallback icons
  • Address Formatting: Clean display of landmark addresses
  • Responsive Design: Handles text overflow gracefully

5. Utility Functions

Distance Formatting (lib/utils/formatting.dart)

class FormatUtils {
  FormatUtils._();

  // Utility function to convert meters distance into a suitable format
  static String convertDistance(int meters) {
    if (meters >= 1000) {
      double kilometers = meters / 1000;
      return '${kilometers.toStringAsFixed(1)} km';
    } else {
      return '${meters.toString()} m';
    }
  }
}

Integration Requirements

Localization Strings

Add these localization keys to your app localization files:

// Required localization keys:
- searchPlaces
- searchPlacesHint  
- noResultsFound
- noSearchHistory
- searchHistoryHint
- recentSearches
- clearHistory

Dependencies

The search functionality uses these key dependencies:

  • magiclane_maps_flutter - Magic Lane SDK for search capabilities
  • provider - State management
  • Built-in Flutter navigation and UI components

Usage in Your App

1. Add Search Bar to Map Overlay

The search bar is designed to be placed alongside other map controls, particularly next to the follow position button for a cohesive user interface.

Recommended Layout: Search Bar with Follow Position Button

// Example from map_overlay_manager.dart
class _BottomControlsWidget extends StatelessWidget {
  const _BottomControlsWidget();

  @override
  Widget build(BuildContext context) {
    return Selector<SelectedLandmarkStateProvider, bool>(
      selector: (context, landmark) => landmark.selected == null,
      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: [
                  Row(
                    children: [
                      const Expanded(child: SearchBarWidget()),
                      const SizedBox(width: 10),
                      const FollowPositionButton(),
                    ],
                  ),
                ],
              ),
            ),
          ),
        );
      },
    );
  }
}

Key Layout Features:

  • Expanded Search Bar: Uses Expanded widget to take available horizontal space
  • Fixed Position Button: Follow position button maintains its size
  • Conditional Display: Only shows when no landmark is selected
  • Safe Area: Respects device safe areas and notches
  • Bottom Alignment: Positioned at the bottom for easy thumb access

2. Navigation Flow

  1. User taps search bar → Opens SearchPage
  2. User types query → Real-time search via Magic Lane SearchService
  3. User selects result → Returns to map with highlighted landmark
  4. Selected landmarks are automatically saved to search history

Features

Core Search Features

  • Real-time Search: Debounced search with 500ms delay
  • Fuzzy Matching: Allows approximate matches for better UX
  • Coordinate-based Results: Results prioritized by proximity
  • Result Limiting: Maximum 15 results to maintain performance

Search History Features

  • Persistent Storage: Uses Magic Lane LandmarkStore class for persistence
  • Duplicate Prevention: Avoids storing identical landmarks
  • History Limit: Automatically maintains 20 most recent searches
  • Clear Functionality: Users can clear entire history

UI/UX Features

  • Loading States: Shows spinner during search operations
  • Empty States: Helpful messages when no results or history
  • Distance Display: Shows distance from search center
  • Landmark Images: Displays actual landmark images when available
  • Responsive Design: Handles various screen sizes and text lengths
  • Integrated Layout: Works seamlessly alongside other map controls like follow position button
  • Conditional Display: Automatically hides when landmark panels are active
  • Thumb-Friendly Positioning: Bottom placement for easy mobile access

Layout Design Considerations

Search Bar Placement Strategy

The search bar is strategically positioned alongside the follow position button to create a cohesive control interface:

Design Rationale:

  • Functional Grouping: Both search and position tracking are navigation-related functions
  • Space Efficiency: Horizontal layout maximizes screen real estate
  • User Flow: Natural progression from searching to following current position
  • Visual Balance: Search bar (expandable) + fixed button creates good proportion

Responsive Behavior:

Row(
  children: [
    const Expanded(child: SearchBarWidget()),  // Takes available space
    const SizedBox(width: 10),                // Consistent spacing
    const FollowPositionButton(),             // Fixed width
  ],
)

State Management Integration:

  • Search bar automatically hides when landmark panels are active
  • Conditional display based on app state (landmark selection, routing, etc.)
  • Seamless integration with existing overlay management system

Conclusion

This implementation provides a robust, user-friendly search experience that integrates seamlessly with the Magic Lane SDK and follows Flutter best practices for state management and UI design.