Flutter Maps Localization: Multilingual Google Maps and MapBox Integration
Build map applications that adapt to any language and region. This guide covers localizing markers, directions, search, and map UI for Flutter apps using Google Maps and MapBox.
Maps Localization Challenges
Map apps require localization for:
- Place names - Cities, streets, landmarks
- Directions - Turn-by-turn navigation instructions
- Search - Autocomplete and place search
- Distance/Duration - Metric vs imperial, time formats
- UI Controls - Zoom, layers, compass labels
- Error messages - No results, connection issues
Google Maps Localization Setup
Configure Map Language
import 'package:google_maps_flutter/google_maps_flutter.dart';
class LocalizedGoogleMap extends StatefulWidget {
@override
State<LocalizedGoogleMap> createState() => _LocalizedGoogleMapState();
}
class _LocalizedGoogleMapState extends State<LocalizedGoogleMap> {
GoogleMapController? _controller;
@override
Widget build(BuildContext context) {
final locale = Localizations.localeOf(context);
return GoogleMap(
initialCameraPosition: CameraPosition(
target: LatLng(37.7749, -122.4194),
zoom: 12,
),
onMapCreated: (controller) {
_controller = controller;
_setMapLanguage(locale);
},
// Map settings
mapType: MapType.normal,
myLocationEnabled: true,
myLocationButtonEnabled: true,
compassEnabled: true,
zoomControlsEnabled: true,
);
}
Future<void> _setMapLanguage(Locale locale) async {
// Google Maps uses device language by default
// For custom language, use map styling or Places API
// Set map style for dark mode (localization-aware)
final isDark = Theme.of(context).brightness == Brightness.dark;
if (isDark) {
final style = await rootBundle.loadString('assets/map_dark_style.json');
await _controller?.setMapStyle(style);
}
}
}
Localized Markers
class LocalizedMarkerService {
final AppLocalizations l10n;
LocalizedMarkerService(this.l10n);
Set<Marker> createLocalizedMarkers(List<Place> places, String locale) {
return places.map((place) {
return Marker(
markerId: MarkerId(place.id),
position: LatLng(place.latitude, place.longitude),
infoWindow: InfoWindow(
title: place.getLocalizedName(locale),
snippet: place.getLocalizedDescription(locale),
),
onTap: () => _onMarkerTap(place),
);
}).toSet();
}
Marker createUserLocationMarker(LatLng position) {
return Marker(
markerId: MarkerId('user_location'),
position: position,
icon: BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueBlue),
infoWindow: InfoWindow(
title: l10n.yourLocation,
snippet: l10n.tapForDetails,
),
);
}
Marker createDestinationMarker(LatLng position, String name) {
return Marker(
markerId: MarkerId('destination'),
position: position,
icon: BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueRed),
infoWindow: InfoWindow(
title: name,
snippet: l10n.destination,
),
);
}
}
class Place {
final String id;
final double latitude;
final double longitude;
final Map<String, String> names;
final Map<String, String> descriptions;
Place({
required this.id,
required this.latitude,
required this.longitude,
required this.names,
required this.descriptions,
});
String getLocalizedName(String locale) {
return names[locale] ?? names['en'] ?? names.values.first;
}
String getLocalizedDescription(String locale) {
return descriptions[locale] ?? descriptions['en'] ?? '';
}
}
Localized Directions
Turn-by-Turn Instructions
class LocalizedDirectionsService {
final AppLocalizations l10n;
final String apiKey;
LocalizedDirectionsService({
required this.l10n,
required this.apiKey,
});
Future<DirectionsResult> getDirections({
required LatLng origin,
required LatLng destination,
required String locale,
TravelMode mode = TravelMode.driving,
}) async {
final url = Uri.parse(
'https://maps.googleapis.com/maps/api/directions/json'
'?origin=${origin.latitude},${origin.longitude}'
'&destination=${destination.latitude},${destination.longitude}'
'&mode=${mode.name}'
'&language=$locale'
'&units=${_getUnits(locale)}'
'&key=$apiKey',
);
final response = await http.get(url);
final data = json.decode(response.body);
if (data['status'] != 'OK') {
throw DirectionsException(_getErrorMessage(data['status']));
}
return DirectionsResult.fromJson(data, l10n, locale);
}
String _getUnits(String locale) {
// Use imperial for US, Liberia, Myanmar
final imperialLocales = ['en_US', 'en_LR', 'my'];
return imperialLocales.contains(locale) ? 'imperial' : 'metric';
}
String _getErrorMessage(String status) {
switch (status) {
case 'ZERO_RESULTS':
return l10n.directionsNoRoute;
case 'NOT_FOUND':
return l10n.directionsLocationNotFound;
case 'MAX_ROUTE_LENGTH_EXCEEDED':
return l10n.directionsRouteTooLong;
case 'OVER_QUERY_LIMIT':
return l10n.directionsQuotaExceeded;
default:
return l10n.directionsError;
}
}
}
class DirectionsResult {
final String distance;
final String duration;
final List<DirectionStep> steps;
final List<LatLng> polylinePoints;
DirectionsResult({
required this.distance,
required this.duration,
required this.steps,
required this.polylinePoints,
});
factory DirectionsResult.fromJson(
Map<String, dynamic> json,
AppLocalizations l10n,
String locale,
) {
final route = json['routes'][0];
final leg = route['legs'][0];
return DirectionsResult(
distance: leg['distance']['text'],
duration: leg['duration']['text'],
steps: (leg['steps'] as List)
.map((s) => DirectionStep.fromJson(s, l10n))
.toList(),
polylinePoints: _decodePolyline(route['overview_polyline']['points']),
);
}
}
class DirectionStep {
final String instruction;
final String distance;
final String duration;
final String maneuver;
final LatLng startLocation;
final LatLng endLocation;
DirectionStep({
required this.instruction,
required this.distance,
required this.duration,
required this.maneuver,
required this.startLocation,
required this.endLocation,
});
factory DirectionStep.fromJson(
Map<String, dynamic> json,
AppLocalizations l10n,
) {
// Google returns HTML, strip tags
final instruction = _stripHtml(json['html_instructions']);
return DirectionStep(
instruction: instruction,
distance: json['distance']['text'],
duration: json['duration']['text'],
maneuver: json['maneuver'] ?? '',
startLocation: LatLng(
json['start_location']['lat'],
json['start_location']['lng'],
),
endLocation: LatLng(
json['end_location']['lat'],
json['end_location']['lng'],
),
);
}
IconData get maneuverIcon {
switch (maneuver) {
case 'turn-left':
return Icons.turn_left;
case 'turn-right':
return Icons.turn_right;
case 'turn-slight-left':
return Icons.turn_slight_left;
case 'turn-slight-right':
return Icons.turn_slight_right;
case 'turn-sharp-left':
return Icons.turn_sharp_left;
case 'turn-sharp-right':
return Icons.turn_sharp_right;
case 'uturn-left':
case 'uturn-right':
return Icons.u_turn_left;
case 'roundabout-left':
case 'roundabout-right':
return Icons.roundabout_left;
case 'merge':
return Icons.merge;
case 'fork-left':
case 'fork-right':
return Icons.fork_left;
case 'ramp-left':
case 'ramp-right':
return Icons.ramp_left;
default:
return Icons.straight;
}
}
}
Directions UI Widget
class LocalizedDirectionsPanel extends StatelessWidget {
final DirectionsResult directions;
const LocalizedDirectionsPanel({required this.directions});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
boxShadow: [
BoxShadow(
color: Colors.black26,
blurRadius: 10,
offset: Offset(0, -2),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Summary
_buildSummary(l10n),
Divider(),
// Steps
Expanded(
child: ListView.builder(
itemCount: directions.steps.length,
itemBuilder: (context, index) {
return _buildStep(directions.steps[index], index, l10n);
},
),
),
],
),
);
}
Widget _buildSummary(AppLocalizations l10n) {
return Padding(
padding: EdgeInsets.all(16),
child: Row(
children: [
Icon(Icons.directions_car, size: 32),
SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
directions.duration,
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
Text(
'${directions.distance} • ${l10n.fastestRoute}',
style: TextStyle(color: Colors.grey[600]),
),
],
),
),
ElevatedButton(
onPressed: () {},
child: Text(l10n.startNavigation),
),
],
),
);
}
Widget _buildStep(DirectionStep step, int index, AppLocalizations l10n) {
return ListTile(
leading: CircleAvatar(
child: Icon(step.maneuverIcon),
),
title: Text(step.instruction),
subtitle: Text('${step.distance} • ${step.duration}'),
trailing: Text('${index + 1}'),
);
}
}
Localized Place Search
Search with Autocomplete
class LocalizedPlaceSearch extends StatefulWidget {
final Function(Place) onPlaceSelected;
const LocalizedPlaceSearch({required this.onPlaceSelected});
@override
State<LocalizedPlaceSearch> createState() => _LocalizedPlaceSearchState();
}
class _LocalizedPlaceSearchState extends State<LocalizedPlaceSearch> {
final _searchController = TextEditingController();
List<PlacePrediction> _predictions = [];
bool _isLoading = false;
Timer? _debounce;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Column(
children: [
// Search field
TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: l10n.searchPlaces,
prefixIcon: Icon(Icons.search),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: Icon(Icons.clear),
onPressed: _clearSearch,
tooltip: l10n.clear,
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
onChanged: _onSearchChanged,
),
// Results
if (_isLoading)
Padding(
padding: EdgeInsets.all(16),
child: CircularProgressIndicator(),
)
else if (_predictions.isNotEmpty)
ListView.builder(
shrinkWrap: true,
itemCount: _predictions.length,
itemBuilder: (context, index) {
return _buildPredictionTile(_predictions[index], l10n);
},
)
else if (_searchController.text.isNotEmpty)
Padding(
padding: EdgeInsets.all(16),
child: Text(l10n.noPlacesFound),
),
],
);
}
void _onSearchChanged(String value) {
_debounce?.cancel();
_debounce = Timer(Duration(milliseconds: 300), () {
if (value.length >= 2) {
_searchPlaces(value);
} else {
setState(() => _predictions = []);
}
});
}
Future<void> _searchPlaces(String query) async {
setState(() => _isLoading = true);
final locale = Localizations.localeOf(context);
final l10n = AppLocalizations.of(context)!;
try {
final predictions = await PlacesApi.autocomplete(
query: query,
language: locale.languageCode,
region: locale.countryCode,
);
setState(() {
_predictions = predictions;
_isLoading = false;
});
} catch (e) {
setState(() => _isLoading = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.searchError)),
);
}
}
Widget _buildPredictionTile(PlacePrediction prediction, AppLocalizations l10n) {
return ListTile(
leading: Icon(_getPlaceIcon(prediction.types)),
title: Text(prediction.mainText),
subtitle: Text(prediction.secondaryText),
onTap: () => _selectPlace(prediction),
);
}
IconData _getPlaceIcon(List<String> types) {
if (types.contains('restaurant')) return Icons.restaurant;
if (types.contains('lodging')) return Icons.hotel;
if (types.contains('store')) return Icons.store;
if (types.contains('transit_station')) return Icons.train;
if (types.contains('airport')) return Icons.flight;
if (types.contains('hospital')) return Icons.local_hospital;
if (types.contains('school')) return Icons.school;
return Icons.place;
}
}
Distance and Duration Formatting
Locale-Aware Formatting
class LocalizedDistanceFormatter {
final String locale;
final bool useMetric;
LocalizedDistanceFormatter({
required this.locale,
bool? useMetric,
}) : useMetric = useMetric ?? !_isImperialLocale(locale);
static bool _isImperialLocale(String locale) {
return ['en_US', 'en_LR', 'my_MM'].contains(locale);
}
String formatDistance(double meters) {
if (useMetric) {
if (meters < 1000) {
return '${meters.round()} m';
} else {
final km = meters / 1000;
return '${km.toStringAsFixed(1)} km';
}
} else {
final miles = meters / 1609.34;
if (miles < 0.1) {
final feet = meters * 3.28084;
return '${feet.round()} ft';
} else {
return '${miles.toStringAsFixed(1)} mi';
}
}
}
String formatDuration(Duration duration, AppLocalizations l10n) {
if (duration.inMinutes < 1) {
return l10n.lessThanMinute;
} else if (duration.inMinutes < 60) {
return l10n.minutes(duration.inMinutes);
} else if (duration.inHours < 24) {
final hours = duration.inHours;
final minutes = duration.inMinutes % 60;
if (minutes == 0) {
return l10n.hours(hours);
}
return l10n.hoursAndMinutes(hours, minutes);
} else {
final days = duration.inDays;
final hours = duration.inHours % 24;
if (hours == 0) {
return l10n.days(days);
}
return l10n.daysAndHours(days, hours);
}
}
String formatETA(DateTime arrivalTime, AppLocalizations l10n) {
final now = DateTime.now();
final formatter = DateFormat.jm(locale);
if (arrivalTime.day == now.day) {
return l10n.arriveAt(formatter.format(arrivalTime));
} else if (arrivalTime.day == now.day + 1) {
return l10n.arriveTomorrow(formatter.format(arrivalTime));
} else {
final dateFormatter = DateFormat.MMMd(locale);
return l10n.arriveOn(
dateFormatter.format(arrivalTime),
formatter.format(arrivalTime),
);
}
}
}
MapBox Localization
MapBox with Language Support
class LocalizedMapBox extends StatelessWidget {
final LatLng initialCenter;
final double initialZoom;
@override
Widget build(BuildContext context) {
final locale = Localizations.localeOf(context);
return MapboxMap(
accessToken: 'YOUR_MAPBOX_TOKEN',
initialCameraPosition: CameraPosition(
target: initialCenter,
zoom: initialZoom,
),
styleString: _getStyleForLocale(locale),
onMapCreated: (controller) {
_setLanguage(controller, locale);
},
);
}
String _getStyleForLocale(Locale locale) {
// MapBox supports language in style URL
return 'mapbox://styles/mapbox/streets-v11?language=${locale.languageCode}';
}
void _setLanguage(MapboxMapController controller, Locale locale) {
// Set text field to use localized names
controller.setSymbolTextLocale(locale.languageCode);
}
}
ARB File Structure
{
"@@locale": "en",
"searchPlaces": "Search places",
"clear": "Clear",
"noPlacesFound": "No places found",
"searchError": "Search failed. Please try again.",
"yourLocation": "Your location",
"tapForDetails": "Tap for details",
"destination": "Destination",
"directionsNoRoute": "No route found between these locations.",
"directionsLocationNotFound": "One or more locations could not be found.",
"directionsRouteTooLong": "The route is too long to calculate.",
"directionsQuotaExceeded": "Too many requests. Please try again later.",
"directionsError": "Could not get directions. Please try again.",
"fastestRoute": "Fastest route",
"startNavigation": "Start",
"lessThanMinute": "Less than a minute",
"minutes": "{count, plural, =1{1 min} other{{count} mins}}",
"@minutes": {
"placeholders": {"count": {"type": "int"}}
},
"hours": "{count, plural, =1{1 hour} other{{count} hours}}",
"@hours": {
"placeholders": {"count": {"type": "int"}}
},
"hoursAndMinutes": "{hours} h {minutes} min",
"@hoursAndMinutes": {
"placeholders": {
"hours": {"type": "int"},
"minutes": {"type": "int"}
}
},
"days": "{count, plural, =1{1 day} other{{count} days}}",
"@days": {
"placeholders": {"count": {"type": "int"}}
},
"daysAndHours": "{days} d {hours} h",
"@daysAndHours": {
"placeholders": {
"days": {"type": "int"},
"hours": {"type": "int"}
}
},
"arriveAt": "Arrive at {time}",
"@arriveAt": {
"placeholders": {"time": {"type": "String"}}
},
"arriveTomorrow": "Arrive tomorrow at {time}",
"@arriveTomorrow": {
"placeholders": {"time": {"type": "String"}}
},
"arriveOn": "Arrive {date} at {time}",
"@arriveOn": {
"placeholders": {
"date": {"type": "String"},
"time": {"type": "String"}
}
},
"mapLayerStreet": "Street",
"mapLayerSatellite": "Satellite",
"mapLayerTerrain": "Terrain",
"mapLayerTraffic": "Traffic",
"navigationStart": "Start navigation",
"navigationEnd": "End navigation",
"navigationRecalculating": "Recalculating route...",
"navigationArrived": "You have arrived at your destination"
}
Conclusion
Map localization in Flutter requires:
- Google Maps language parameter in API calls
- Localized markers with translated place names
- Directions in user's language with locale units
- Place search with regional autocomplete
- Distance/duration formatting per locale
With proper localization, your map app will feel native worldwide.