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.
- Overview
- Architecture
- Implementation
- Integration Requirements
- Usage in Your App
- Features
- Layout Design Considerations
- Conclusion
The search functionality consists of four main components:
- Search Bar Widget - A UI component that triggers the search page
- Search Page - A full-screen search interface with real-time search
- Search Result Item - Individual result display components
- Search History - Persistent storage and display of previous searches
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
The search bar acts as an entry point to the search functionality.
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
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,
}The main search interface with real-time search and history management.
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
Component for displaying individual search results and history items.
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
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';
}
}
}Add these localization keys to your app localization files:
// Required localization keys:
- searchPlaces
- searchPlacesHint
- noResultsFound
- noSearchHistory
- searchHistoryHint
- recentSearches
- clearHistoryThe search functionality uses these key dependencies:
magiclane_maps_flutter- Magic Lane SDK for search capabilitiesprovider- State management- Built-in Flutter navigation and UI components
The search bar is designed to be placed alongside other map controls, particularly next to the follow position button for a cohesive user interface.
// 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
Expandedwidget 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
- User taps search bar → Opens
SearchPage - User types query → Real-time search via Magic Lane
SearchService - User selects result → Returns to map with highlighted landmark
- Selected landmarks are automatically saved to search history
- 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
- Persistent Storage: Uses Magic Lane
LandmarkStoreclass for persistence - Duplicate Prevention: Avoids storing identical landmarks
- History Limit: Automatically maintains 20 most recent searches
- Clear Functionality: Users can clear entire history
- 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
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
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.
