Mastering Flutter Map: A Practical Guide — Part 2, Build a Restaurant Guide App Using…
Hello everyone,

Mastering Flutter Map: A Practical Guide — Part 2, Build a Restaurant Guide App Using OpenRouteService
Hello everyone,
Continuing our Flutter Map article series, today is Part 2, and we’ll dive into a beautiful API that lets you build powerful map features like drawing routes between two or more points — just like in Uber. You can geocode a geo point, or reverse geocode an address. You can calculate time and distance using the Matrix API, solve vehicle routing problems using the Optimization API, and much more — all for free! And if you’re working on a large-scale app, you can even use this API in your backend to build anything you want, also for free.
Note: I discussed in a previous article how this API can be a great alternative to traditional solutions like Google Maps or Mapbox. Of course, Google Maps, Mapbox, and others are still excellent solutions with great accuracy and rich APIs. But remember, these APIs are just tools — your use case and budget will guide you to the right solution for your project.
Previous article links: on LinkedIn → on Medium →

As always, we like to start by asking basic but important questions. So our first question is simple and predictable:
What is the OpenRouteService API?
OpenRouteService is an open-source platform that provides advanced geospatial tools, including route planning, isochrone mapping, geocoding, and points-of-interest search, all powered by OpenStreetMap data. It enables developers to build apps with location-based services for industries like transportation, logistics, urban planning, and more.

Note: If your app requires more than what the Standard plan offers, you can self-host this API in your own backend for free! I’ll explain how to do that (God willing) in an upcoming article.
🛠️ How to Get Your Own API Key
To get your API key, create an account on the OpenRouteService website.
Just sign up as you normally would:

Once your account is created successfully, log in, go to your profile, and there you’ll find your API key:

📦 Add Dependencies
In your pubspec.yaml, add the following packages:
dependencies:
flutter:
sdk: flutter
flutter_map: ^7.0.2
latlong2: ^0.9.1
flutter_map_marker_popup: ^7.0.0
geolocator: ^13.0.2
flutter_bloc: ^9.0.0
equatable: ^2.0.7
open_route_service: ^1.2.7
bloc_concurrency: ^0.3.0
dotted_line: ^3.2.3
🗂️ Project Structure
Now structure your project like this:

✨ Update the current position:
point → 33.5901, -7.6484

To explore this API in action, we’ll build a Restaurants Guide app. And while building it, we’ll learn a lot more.
But before anything else, let’s write our main file like this:
class SimpleBlocObserver extends BlocObserver {
@override
void onChange(BlocBase bloc, Change change) {
super.onChange(bloc, change);
debugPrint("${bloc.runtimeType} $change");
}
@override
void onTransition(Bloc bloc, Transition transition) {
super.onTransition(bloc, transition);
debugPrint("${bloc.runtimeType} $transition");
}
@override
void onError(BlocBase bloc, Object error, StackTrace stackTrace) {
super.onError(bloc, error, stackTrace);
debugPrint("${bloc.runtimeType} $stackTrace");
}
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MultiBlocProvider(
providers: [
BlocProvider<RestaurantsBloc>(
create: (context) =>
RestaurantsBloc()..add(const GetMyCurrentLocationFirstTime()),
),
],
child: MaterialApp(
title: 'Flutter Demo',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.white),
useMaterial3: true,
),
home: const MapPage(),
),
);
}
}
void main() {
Bloc.observer = SimpleBlocObserver();
runApp(const MyApp());
}
Before we dive in, there are a few points to clarify:
🚀 First, I used Bloc for state management and architecture. However, if you prefer another approach, feel free to use it — there’s no issue. If you’re not familiar with Bloc, you can copy and paste the Bloc code, but make sure you understand the underlying algorithm and business logic, as the main focus of this article is the OpenRouteService API.
🔒 Second, you’ll notice that I stored my API key in a constants file. This is a critical security vulnerability and should never be done in a production app, as it exposes your key. Here, it’s done purely for simplicity to avoid adding extra code.
📂 Third, I’ll share the GitLab link to the full project. If you find the article a bit long, you can clone the project and follow along more easily. Since this article covers several widely used APIs in on-demand apps like Uber, InDrive, Glovo, and others, it might be lengthy, but I believe it’s worth it.
Now let’s place the map, and inside it, mark 3 VIP restaurants using markers — just like we did in Part 1 of this series.
Copy and paste the following code:
- in
map_constantsfile:
class MapConstants {
static const API_KEY = "API_KEY";
static final List<String> names = [
"Mr.Flutter",
"The blue bird",
"Casablanca",
];
// List of images
static final List<String> images = [
"assets/images/restaurant.jpg",
"assets/images/restaurant_2.jpg",
"assets/images/restaurant_3.jpg",
];
// List of markers
static final List<LatLng> markerPositions = [
const LatLng(33.592638, -7.643341),
const LatLng(33.594438, -7.655744),
const LatLng(33.583760, -7.648748),
];
}
- in
format_helper_functionsfile:
// Helper function to calculate and format distance
String formatDistance(double distance) {
return distance > 999.99
? "${(distance / 1000).toStringAsFixed(2)} km"
: "${distance.toStringAsFixed(2)} m";
}
// Helper function to calculate and format duration
String formatDuration(double durationInSeconds) {
if (durationInSeconds >= 60) {
int minutes = durationInSeconds ~/ 60;
double remainingSeconds = durationInSeconds % 60;
if (remainingSeconds == 0) {
return "$minutes min"; // Show just minutes if no remaining seconds
} else {
return "$minutes min ${remainingSeconds.toStringAsFixed(0)} s"; // Show minutes and remaining seconds
}
} else {
return "${durationInSeconds.toStringAsFixed(2)} s"; // Less than 60 seconds
}
}
- in
map_page.dartfile:
class MapPage extends StatefulWidget {
const MapPage({super.key});
@override
State<MapPage> createState() => _MapPageState();
}
class _MapPageState extends State<MapPage> {
final MapController mapController = MapController();
@override
Widget build(BuildContext context) {
return Scaffold(
body: FlutterMap(
mapController: mapController,
options: const MapOptions(
initialCenter: LatLng(33.583760, -7.648748),
initialZoom: 15,
),
children: [
TileLayer(
urlTemplate:
'https://{s}.google.com/vt/lyrs=m,h&x={x}&y={y}&z={z}&hl=ar-MA&gl=MA',
subdomains: const ['mt0', 'mt1', 'mt2', 'mt3'],
userAgentPackageName: 'com.example.app',
),
const RichAttributionWidget(
attributions: [
TextSourceAttribution(
'OpenStreetMap contributors',
// onTap: () => launchUrl(Uri.parse('https://openstreetmap.org/copyright')),
),
],
),
const RestaurantsMarkerLayer(),
// const RestaurantsPolylineLayer()
],
),
);
}
}
❗ Fixing the Error
Right now, you’ll see an error because the restaurant marker layer hasn’t been written yet. So let’s fix that.
In marker_layer.dart, write the following:
class RestaurantsMarkerLayer extends StatelessWidget {
const RestaurantsMarkerLayer({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<RestaurantsBloc, RestaurantsState>(
builder: (context, state) {
return MarkerLayer(
markers: [
// the restaurants markers
...List.generate(MapConstants.markerPositions.length, (index) {
return Marker(
point: MapConstants.markerPositions[index],
width: 80,
height: 80,
child: GestureDetector(
onTap: () {
context.read<RestaurantsBloc>().add(GetRestaurantLocation(
restaurantPosition:
MapConstants.markerPositions[index]));
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.white,
builder: (BuildContext context) {
return Wrap(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: RestaurantInfo(
restaurantName: MapConstants.names[index],
),
),
],
);
},
);
},
child: CircleAvatar(
backgroundColor: Colors.white,
child: CircleAvatar(
radius: 35.0,
backgroundImage: AssetImage(MapConstants.images[index]),
),
),
),
);
}),
],
);
},
);
}
}
In this file, we generate the 3 markers just like in the previous article. You’ll notice the use of "..." (three dots)—this is called list concatenation. It allows us to add more markers without creating a new list every time, which improves performance.
Below that code, we have a condition to check whether the currentPosition state is not empty. If it's not, we build a marker to represent the user's current position. Here again, we use list concatenation for performance reasons.
📍 Show Detailed Info About a Restaurant
Now, we want to show the user more information about the selected restaurant:
- Name of the restaurant
- Address
- Distance (from and to the restaurant)
- Duration (from and to the restaurant)
- A “Guide Me” button that shows a route from the user’s current position to the restaurant
To do all this, we’ll use different OpenRouteService API endpoints.
🧠 State Setup
First, we created several states and data fields in the state class. Their purpose is clear from their names:
In restaurants_state.dart, write the following:
part of 'restaurants_bloc.dart';
class RestaurantsState extends Equatable {
const RestaurantsState({
this.currentPosition,
this.restaurantPosition,
this.roadPolylinePoints,
this.address,
this.distanceToRestaurant,
this.distanceFromRestaurant,
this.durationToRestaurant,
this.durationFromRestaurant,
});
final Position? currentPosition;
final LatLng? restaurantPosition;
final List<LatLng>? roadPolylinePoints;
final String? address;
final String? distanceToRestaurant;
final String? distanceFromRestaurant;
final String? durationToRestaurant;
final String? durationFromRestaurant;
RestaurantsState copyWith({
Position? currentPosition,
LatLng? restaurantPosition,
List<LatLng>? roadPolylinePoints,
String? address,
String? distanceToRestaurant,
String? distanceFromRestaurant,
String? durationToRestaurant,
String? durationFromRestaurant,
}) {
return RestaurantsState(
currentPosition: currentPosition ?? this.currentPosition,
restaurantPosition: restaurantPosition ?? this.restaurantPosition,
roadPolylinePoints: roadPolylinePoints ?? this.roadPolylinePoints,
address: address ?? this.address,
distanceToRestaurant: distanceToRestaurant ?? this.distanceToRestaurant,
distanceFromRestaurant:
distanceFromRestaurant ?? this.distanceFromRestaurant,
durationToRestaurant: durationToRestaurant ?? this.durationToRestaurant,
durationFromRestaurant:
durationFromRestaurant ?? this.durationFromRestaurant,
);
}
@override
List<Object?> get props => [
currentPosition,
restaurantPosition,
roadPolylinePoints,
address,
distanceToRestaurant,
distanceToRestaurant,
durationToRestaurant,
durationFromRestaurant,
];
}
⚙️ Events
Then we created the following event classes:
- GetUserCurrentPosition — to get the user’s current position
- GetRestaurantLocation — to get the selected restaurant’s location
- GetRestaurantInfo — to fetch restaurant information for the bottom sheet
- GuideMeToRestaurant — to draw a route (polyline) from the user’s position to the restaurant
In restaurants_event.dart, write the following:
part of 'restaurants_bloc.dart';
abstract class RestaurantsEvent extends Equatable {
const RestaurantsEvent();
}
class GetMyCurrentLocationFirstTime extends RestaurantsEvent {
const GetMyCurrentLocationFirstTime();
@override
List<Object?> get props => [];
}
class GetRestaurantLocation extends RestaurantsEvent {
const GetRestaurantLocation({this.restaurantPosition});
final LatLng? restaurantPosition;
@override
List<Object?> get props => [restaurantPosition];
}
class GetRestaurantInfo extends RestaurantsEvent {
const GetRestaurantInfo();
@override
List<Object?> get props => [];
}
class GuideMeToRestaurant extends RestaurantsEvent {
const GuideMeToRestaurant();
@override
List<Object?> get props => [];
}
GetUserCurrentPosition
Let’s begin with the logic for GetUserCurrentPosition.
We use the Geolocator package for this. To get the user’s current position, we first check if location services are enabled, then we ask the user for location permissions. If granted, we get the position and store it as a GeoPoint (LatLng) in the currentPosition state.
In restaurants_bloc.dart, write the following:
class RestaurantsBloc extends Bloc<RestaurantsEvent, RestaurantsState> {
RestaurantsBloc() : super(const RestaurantsState()) {
on<GetMyCurrentLocationFirstTime>(_onGetMyCurrentLocationFirstTime);
}
void _onGetMyCurrentLocationFirstTime(
GetMyCurrentLocationFirstTime event, Emitter emit) async {
final OpenRouteService client =
OpenRouteService(apiKey: MapConstants.API_KEY);
bool serviceEnabled;
LocationPermission permission;
// Test if location services are enabled.
serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) {
return Future.error('Location services are disabled.');
}
permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied) {
return Future.error('Location permissions are denied');
}
}
if (permission == LocationPermission.deniedForever) {
// Permissions are denied forever, handle appropriately.
return Future.error(
'Location permissions are permanently denied, we cannot request permissions.');
}
// When we reach here, permissions are granted and we can
// continue accessing the position of the device.
const LocationSettings locationSettings = LocationSettings(
accuracy: LocationAccuracy.best,
);
Position position =
await Geolocator.getCurrentPosition(locationSettings: locationSettings);
final geoCodingCurrentPosition = await client.geocodeReverseGet(
boundaryCountry: "MA",
size: 1,
point: ORSCoordinate(
latitude: position.latitude, longitude: position.longitude));
emit(state.copyWith(
currentPosition: position,
currentAdressController:
geoCodingCurrentPosition.features[0].properties["label"]));
}
}
In marker_layer.dart, add the following:
class RestaurantsMarkerLayer extends StatelessWidget {
const RestaurantsMarkerLayer({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<RestaurantsBloc, RestaurantsState>(
builder: (context, state) {
return MarkerLayer(
markers: [
// ...
//add
// the current position marker
if (state.currentPosition != null)
Marker(
point: LatLng(state.currentPosition!.latitude,
state.currentPosition!.longitude),
width: 20,
height: 20,
child: Container(
decoration: const BoxDecoration(
shape: BoxShape.circle, color: Colors.blueAccent),
),
),
],
);
},
);
}
}

GetRestaurantLocation & GetRestaurantInfo
In restaurants_bloc.dart, write the following:
class RestaurantsBloc extends Bloc<RestaurantsEvent, RestaurantsState> {
RestaurantsBloc() : super(const RestaurantsState()) {
// ...
// add
on<GetRestaurantLocation>(
_onGetRestaurantLocation,
transformer: sequential(),
);
}
// ...
// add
void _onGetRestaurantLocation(
GetRestaurantLocation event, Emitter emit) async {
emit(state.copyWith(restaurantPosition: event.restaurantPosition));
add(const GetRestaurantInfo());
}
void _onGetRestaurantInfo(GetRestaurantInfo event, Emitter emit) async {
final OpenRouteService client =
OpenRouteService(apiKey: MapConstants.API_KEY);
// Example coordinates to test between
double startLat = state.currentPosition!.latitude;
double startLng = state.currentPosition!.longitude;
double endLat = state.restaurantPosition!.latitude;
double endLng = state.restaurantPosition!.longitude;
final getDistanceBetweenTwoPoints = await client.matrixPost(
locations: [
ORSCoordinate(latitude: startLat, longitude: startLng),
ORSCoordinate(latitude: endLat, longitude: endLng),
],
profileOverride: ORSProfile.drivingCar,
metrics: ['duration', 'distance'],
);
final restaurantAddress = await client.geocodeReverseGet(
point: ORSCoordinate(
latitude: state.restaurantPosition!.latitude,
longitude: state.restaurantPosition!.longitude),
);
final distanceToRestaurant =
formatDistance(getDistanceBetweenTwoPoints.distances[0][1]);
final durationToRestaurant =
formatDuration(getDistanceBetweenTwoPoints.durations[0][1]);
final distanceFromRestaurant =
formatDistance(getDistanceBetweenTwoPoints.distances[1][0]);
final durationFromRestaurant =
formatDuration(getDistanceBetweenTwoPoints.durations[1][0]);
emit(state.copyWith(
address: restaurantAddress.features[1].properties["label"],
distanceToRestaurant: distanceToRestaurant,
durationToRestaurant: durationToRestaurant,
distanceFromRestaurant: distanceFromRestaurant,
durationFromRestaurant: durationFromRestaurant,
));
}
}
Once the user sees the list of available restaurants (in our case, 3), he can click on any one of them.
When a restaurant is selected, we perform two actions sequentially — and it’s crucial they are sequential:
- First, we get the restaurant’s location (LatLng / GeoPoint).
- Then, we get detailed restaurant information (like address, distance, and duration).
👉 Why do we first fetch the restaurant location? Because:
- To get the address via reverse geocoding, we need the restaurant’s exact coordinates.
- To calculate distance and duration between the user and the restaurant, we need both points (user’s and restaurant’s locations).
✅ Conclusion: Fetching the restaurant location first is essential for both tasks.
📍 Step 1: GetRestaurantLocation
In our marker display code (for example on the map), we use an onTap callback.
When a user taps a restaurant marker, we easily retrieve the associated location like this:
MapConstants.markerPositions[index];
➡️ This logic applies whether marker data comes from hardcoded data or from an API.
Then, send the selected position to the BLoC by triggering the GetRestaurantLocation event.
Inside the bloc:
emit(state.copyWith(restaurantPosition: event.restaurantPosition));
Because UI = f(state) (declarative UI like Flutter), this automatically updates the UI.
🌍 Step 2: GetRestaurantInfo
Now that we have both positions (user & restaurant), we can use the OpenRouteService API to fetch:
- Restaurant address (via reverse geocoding).
- Distance & duration info (via matrix routing).
Requirements
- Create an account on OpenRouteService.
- Get a free API key (as explained earlier).
🔑 Note: Don’t forget to add your API key to your constants file.
How to interact with OpenRouteService API?
You have two choices:
- Make standard HTTP requests manually.
- Or use their built-in methods (preferred: easier, less boilerplate).
🗺️ 2.1 Getting the Address
Use the geocodeReverseGet() method, and pass the restaurant's coordinates.
The response will look like this:
{
"features": [...],
"properties": {
"label": "Rue Mostapha Choukri, Casablanca, Morocco"
}
}
✅ We are interested in the label field inside features[1].
Print it for debugging:
debugPrint('This is the address: ${response.features[1].properties.label}');
Example output:
This is the address: Rue Mostapha Choukri, Casablanca, Morocco
🛣️ 2.2 Getting Distance & Duration
Use the matrixPost() method and pass two points:
- User’s current position
- Restaurant position
Note:
We leave sources and destinations parameters empty.
This means the API will compute all possible combinations:
- User → User
- User → Restaurant
- Restaurant → User
- Restaurant → Restaurant
❓ Why do we have User → User and Restaurant → Restaurant? Even if they seem useless here, in logistics scenarios (like multiple deliveries) they make sense.
Example response:
{
"distances": [
[0.0, 477.25],
[447.06, 0.0]
],
"durations": [
[0.0, 79.83],
[73.78, 0.0]
]
}
Explanation:

🔵 User → Restaurant and Restaurant → User may differ because of:
- Traffic rules (one-way streets, turn restrictions).
- Road geometry.
Also, snapped_distance shows how far the input coordinates are from the actual road:
- User point → 13.8 meters from nearest road.
- Restaurant point → 4.18 meters from nearest road.
🎯 Making it Even More Precise
If you want to reduce computations (and costs if self-hosted), you can specify sources and destinations.
Case 1: Only User → Restaurant
matrixPost(
locations: [userPosition, restaurantPosition],
sources: [0], // index of user
destinations: [1], // index of restaurant
)
Case 2: User → Restaurant and Restaurant → User
matrixPost(
locations: [userPosition, restaurantPosition],
sources: [0, 1],
destinations: [1, 0],
)
➡️ This way, the API skips computing User → User and Restaurant → Restaurant combinations.
🚗 Two Important Parameters
- profileOverride: Defines the mode of transportation (car, bike, foot, etc.) → which impacts both duration and distance.
- metrics: Defines what to compute: distance, duration, or both.
🧠 Finally
Now you have:
- The restaurant’s address.
- The distance and duration between the user and the selected restaurant.
Then, using BLoC again, send those values to the corresponding state and update your bottom-sheet UI accordingly.
Now that we have our state/data ready, we can start building our UI to display all the necessary information to the user. To make the code concise, modular, and avoid duplication, I built four reusable widgets, as follows:
- The first widget is
reusable_info_column. In this file, write:
class ReusableInfoColumn extends StatelessWidget {
final String title;
final String? value;
const ReusableInfoColumn({required this.title, this.value, super.key});
@override
Widget build(BuildContext context) {
return Column(
children: [
Text(
title,
style:
const TextStyle(color: Colors.grey, fontWeight: FontWeight.w500),
),
Text(
value ?? "___",
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
],
);
}
}
- The second widget is
reusable_info_row. In this file, write:
class ReusableInfoRow extends StatelessWidget {
final String imagePath;
final String label;
const ReusableInfoRow(
{required this.imagePath, required this.label, super.key});
@override
Widget build(BuildContext context) {
return Row(
children: [
Image.asset(imagePath, height: 36, width: 36),
const SizedBox(width: 16),
Text(
label,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Colors.red,
),
),
],
);
}
}
- The third widget is
reusable_vertical_divider. In this file, write:
class ReusableVerticalDivider extends StatelessWidget {
const ReusableVerticalDivider({super.key});
@override
Widget build(BuildContext context) {
return const SizedBox(
height: 50,
child: VerticalDivider(width: 20, thickness: 1, color: Colors.red),
);
}
}
- The fourth widget is
reusable_text_field. In this file, write:
class ReusableTextField extends StatefulWidget {
const ReusableTextField({
super.key,
required this.hintText,
this.labelText,
this.onChanged,
this.readOnly = false,
this.withBorderOrnot = false,
});
final String? hintText;
final String? labelText;
final ValueChanged<String>? onChanged;
final bool readOnly;
final bool withBorderOrnot;
@override
_ReusableTextFieldState createState() => _ReusableTextFieldState();
}
class _ReusableTextFieldState extends State<ReusableTextField> {
double bottomPaddingToError = 12;
@override
Widget build(BuildContext context) {
return TextFormField(
readOnly: widget.readOnly,
style: const TextStyle(
color: Colors.black,
fontSize: 16.0,
fontWeight: FontWeight.w200,
fontStyle: FontStyle.normal,
letterSpacing: 1.2,
),
decoration: InputDecoration(
labelText: widget.labelText,
labelStyle:
const TextStyle(color: Colors.black, fontWeight: FontWeight.w500),
fillColor: widget.readOnly ? Colors.grey.shade300 : Colors.white,
filled: true,
hintText: widget.hintText,
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(
color:
widget.readOnly ? Colors.grey.shade300 : Colors.grey.shade300,
),
),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(
color: widget.readOnly
? Colors.grey.shade300
: Colors.grey.shade700),
),
hintStyle: const TextStyle(fontWeight: FontWeight.w500),
contentPadding: EdgeInsets.only(
top: 12, bottom: bottomPaddingToError, left: 8.0, right: 8.0),
isDense: true,
),
onChanged: widget.onChanged,
);
}
}
All these widgets aim to make our code modular, smooth, and easy to maintain. Because big widgets consume more resources (which negatively affects performance), modular code keeps everything simple and efficient.
The code itself is quite straightforward. However, if you encounter any difficulty understanding something, feel free to ask — you’re always welcome!
Now that we have everything — the state/data and the reusable widgets — let’s build our restaurant info bottom-sheet like this:
✍️ But first, in guide_me_button.dart, write the following code, as we’ll need it:
class GuideMeButton extends StatelessWidget {
const GuideMeButton({super.key});
@override
Widget build(BuildContext context) {
return SizedBox(
width: double.infinity,
child: BlocBuilder<RestaurantsBloc, RestaurantsState>(
builder: (context, state) {
return ElevatedButton.icon(
onPressed: () => context
.read<RestaurantsBloc>()
.add(const GuideMeToRestaurant()),
label: const Text("Guide Me"),
icon: const Icon(Icons.navigation, color: Colors.white),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red[800],
foregroundColor: Colors.white,
),
);
},
),
);
}
}
In restaurant_info.dart, write the following:
class RestaurantInfo extends StatelessWidget {
const RestaurantInfo({super.key, required this.restaurantName});
final String restaurantName;
@override
Widget build(BuildContext context) {
return Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
"Restaurant Name: ",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.grey),
),
Text(
restaurantName,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Colors.black),
),
],
),
const Divider(color: Colors.red),
// Address Info Section
Column(
children: [
const ReusableInfoRow(
imagePath: "assets/images/address_icon.png", label: "Address"),
const SizedBox(height: 24),
BlocBuilder<RestaurantsBloc, RestaurantsState>(
builder: (context, state) {
return Text(
state.address ?? "___",
style: const TextStyle(
fontSize: 16, fontWeight: FontWeight.bold),
);
},
),
],
),
const Padding(
padding: EdgeInsets.symmetric(vertical: 8.0),
child: Divider(thickness: 1, color: Colors.red),
),
// Distance Info Section
Column(
children: [
const ReusableInfoRow(
imagePath: "assets/images/distance_icon.png",
label: "Distance"),
const SizedBox(height: 24),
BlocBuilder<RestaurantsBloc, RestaurantsState>(
builder: (context, state) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
ReusableInfoColumn(
title: "To Restaurant Using Car",
value: state.distanceToRestaurant),
const ReusableVerticalDivider(),
ReusableInfoColumn(
title: "From Restaurant Using Car",
value: state.distanceFromRestaurant),
],
);
},
),
],
),
const Padding(
padding: EdgeInsets.symmetric(vertical: 8.0),
child: Divider(thickness: 1, color: Colors.red),
),
// Duration Info Section
Column(
children: [
const ReusableInfoRow(
imagePath: "assets/images/duration_icon.png",
label: "Duration"),
const SizedBox(height: 24),
BlocBuilder<RestaurantsBloc, RestaurantsState>(
builder: (context, state) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
ReusableInfoColumn(
title: "To Restaurant Using Car",
value: state.durationToRestaurant),
const ReusableVerticalDivider(),
ReusableInfoColumn(
title: "From Restaurant Using Car",
value: state.durationFromRestaurant),
],
);
},
),
],
),
const SizedBox(height: 24),
const Row(
spacing: 8.0,
children: [
// Expanded(flex: 1, child: OptimizeRoadsButton()),
Expanded(flex: 2, child: GuideMeButton()),
],
),
],
);
}
}

In our code, we can access the state easily like this:
state.addressstate.distanceToRestaurantstate.durationFromRestaurant- …and so on.
It’s simple, clean, and easy. This is how using Bloc architecture contributes to keeping our code organized and clear. The rest of the code is simple too — again, don’t hesitate to ask if anything feels unclear!
Next Step: Guide the User to the Restaurant
Now that we show the restaurant info to the user, he may want guidance — in other words, the right roads and directions to the restaurant.
Here, we’ll use the Directions API from OpenRouteService, like this:
In the restaurant_bloc file, write the following code:
class RestaurantsBloc extends Bloc<RestaurantsEvent, RestaurantsState> {
RestaurantsBloc() : super(const RestaurantsState()) {
// ...
// add
on<GuideMeToRestaurant>(_onGuideMeToRestaurant);
;
}
// ...
// add
void _onGuideMeToRestaurant(GuideMeToRestaurant event, Emitter emit) async {
final OpenRouteService client =
OpenRouteService(apiKey: MapConstants.API_KEY);
// Example coordinates to test between
double startLat = state.currentPosition!.latitude;
double startLng = state.currentPosition!.longitude;
double endLat = state.restaurantPosition!.latitude;
double endLng = state.restaurantPosition!.longitude;
// Form Route between coordinates
final List<ORSCoordinate> routeCoordinates =
await client.directionsRouteCoordsGet(
startCoordinate: ORSCoordinate(latitude: startLat, longitude: startLng),
endCoordinate: ORSCoordinate(latitude: endLat, longitude: endLng),
profileOverride: ORSProfile.drivingCar,
);
final List<LatLng> routePoints = routeCoordinates
.map((coordinate) => LatLng(coordinate.latitude, coordinate.longitude))
.toList();
emit(state.copyWith(
roadPolylinePoints: routePoints,
));
}
}
Let’s explain everything:
To build a direction (road/path) that guides the user to the restaurant, the OpenRouteService API offers:
- An endpoint named
directions. - A method named
directionsRouteCoordsGet().
This method takes three parameters:
- Start point — in our case, the user’s current location.
- End point — in our case, the chosen restaurant.
- Profile override — to choose a mode of transport like
"driving-car","cycling", or"foot-walking", etc.
When we call directionsRouteCoordsGet(), the API responds with a list of points that form a road (or "polyline" in map terminology).
⚡ Important Note:
directionsRouteCoordsGet()returns aList<ORSCoordinate>.- However, to build a polyline on the map, we need a
List<LatLng>. - To solve this, we simply use the
map()method to convert eachORSCoordinatetoLatLng, then send it to the Bloc, updating theroadPolylinePointsstate.
It’s really easy once you see it!
Now that we have the list of LatLng points, we can build the UI to guide the user to the restaurant:
In the polyline_layer.dart file, write the following code:
class RestaurantsPolylineLayer extends StatelessWidget {
const RestaurantsPolylineLayer({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<RestaurantsBloc, RestaurantsState>(
builder: (context, state) {
return PolylineLayer(
polylines: [
Polyline(
points: state.roadPolylinePoints != null
? state.roadPolylinePoints!
: [],
strokeWidth: 3,
color: Colors.blue,
),
],
);
},
);
}
}
In the map_page.dart file:
uncomment “ const RestaurantsPolylineLayer()”

This code is very similar to what we wrote in the previous article. Notice that we create:
- PolylineLayer (for the route),
- MarkerLayer (for the markers), each one in its own class to ensure modularity — as we discussed earlier.
(And soon you’ll see why this modularity is so important.)
Now, let’s think about a slightly more complex scenario:
What if the user wants to try three restaurants, one after the other, but in an optimized way — without wasting time or taking duplicate routes?
This scenario is quite common, and that’s why most Map APIs provide an Optimization API for this purpose.
Optimization APIs are critical — especially for delivery companies, logistics, and fleet management — because they help save costs, minimize travel time, and increase efficiency.
Our mission:
We need to draw several roads (polylines) to optimize the user’s visit to the three restaurants.
We’ll use the OpenRouteService Optimization API like this:
First, in the state file, add the following 3 states:
part of 'restaurants_bloc.dart';
class RestaurantsState extends Equatable {
const RestaurantsState({
// ...
// add
this.listOfPolylinesPoints,
this.polylineInfos,
this.polylineColors,
});
// ...
// add
final List<List<LatLng>>? listOfPolylinesPoints;
final List<Map<String, dynamic>>? polylineInfos;
final List<Color>? polylineColors;
RestaurantsState copyWith({
// ...
// add
List<List<LatLng>>? listOfPolylinesPoints,
List<Map<String, dynamic>>? polylineInfos,
List<Color>? polylineColors,
}) {
return RestaurantsState(
// ...
// add
listOfPolylinesPoints:
listOfPolylinesPoints ?? this.listOfPolylinesPoints,
polylineInfos: polylineInfos ?? this.polylineInfos,
polylineColors: polylineColors ?? this.polylineColors,
);
}
@override
List<Object?> get props => [
// ...
// add
listOfPolylinesPoints,
polylineInfos,
polylineColors,
];
}
Second, in the event file, add the following event:
part of 'restaurants_bloc.dart';
abstract class RestaurantsEvent extends Equatable {
const RestaurantsEvent();
}
// ...
// add
class RoadsRestaurantsOptimization extends RestaurantsEvent {
const RoadsRestaurantsOptimization();
@override
List<Object?> get props => [];
}
Then, write the business logic of the optimization event In the restaurants_bloc.dart file, write the following code:
class RestaurantsBloc extends Bloc<RestaurantsEvent, RestaurantsState> {
RestaurantsBloc() : super(const RestaurantsState()) {
// ...
// add
on<RoadsRestaurantsOptimization>(_onRoadsRestaurantsOptimization);
}
// ...
// add
void _onRoadsRestaurantsOptimization(
RoadsRestaurantsOptimization event, Emitter emit) async {
final OpenRouteService client =
OpenRouteService(apiKey: MapConstants.API_KEY);
// Define the jobs (restaurants)
const job1 = VroomJob(
id: 1,
location: ORSCoordinate(latitude: 33.592638, longitude: -7.643341),
service: 0,
);
const job2 = VroomJob(
id: 2,
location: ORSCoordinate(latitude: 33.594438, longitude: -7.655744),
service: 0,
);
const job3 = VroomJob(
id: 3,
location: ORSCoordinate(latitude: 33.583760, longitude: -7.648748),
service: 0,
);
double startLat = state.currentPosition!.latitude;
double startLng = state.currentPosition!.longitude;
// Define the vehicle (car)
final vehicle = VroomVehicle(
id: 1,
start: ORSCoordinate(latitude: startLat, longitude: startLng),
end: ORSCoordinate(latitude: startLat, longitude: startLng),
);
// Make the optimization API request
final optimizeRoute = await client.optimizationDataPost(
jobs: [job1, job2, job3], // List of jobs (restaurants)
vehicles: [vehicle], // Single vehicle
);
final optimizationSteps = optimizeRoute.routes[0].steps;
if (optimizationSteps != null && optimizationSteps.isNotEmpty) {
List<List<LatLng>> listOfStepsPoints = [];
List<Map<String, dynamic>> polylineInfos = [];
List<Color>? polylineColors = [];
for (int i = 0; i < optimizationSteps.length; i++) {
if (i < optimizationSteps.length - 1) {
LatLng startLatLng = LatLng(
optimizationSteps[i].location.latitude,
optimizationSteps[i].location.longitude,
);
LatLng endLatLng = LatLng(
optimizationSteps[i + 1].location.latitude,
optimizationSteps[i + 1].location.longitude,
);
// Call the directions API to get route coordinates between start and end points
final List<ORSCoordinate> routeCoordinates =
await client.directionsRouteCoordsGet(
startCoordinate: ORSCoordinate(
latitude: startLatLng.latitude,
longitude: startLatLng.longitude,
),
endCoordinate: ORSCoordinate(
latitude: endLatLng.latitude,
longitude: endLatLng.longitude,
),
);
// Convert routeCoordinates to LatLng and store them
List<LatLng> polylineLatLngs = routeCoordinates
.map((coordinate) =>
LatLng(coordinate.latitude, coordinate.longitude))
.toList();
// Add the polyline points to the list
listOfStepsPoints.add(polylineLatLngs);
polylineColors.add(
Color((math.Random().nextDouble() * 0xFFFFFF).toInt())
.withOpacity(1.0));
if (i < optimizationSteps.length - 1) {
if (i == 0) {
polylineInfos.add({
"duration":
formatDuration(optimizationSteps[i + 1].arrival.toDouble()),
"type": optimizationSteps[i + 1].type,
});
} else {
polylineInfos.add({
"duration": formatDuration((optimizationSteps[i + 1].arrival -
optimizationSteps[i].arrival)
.toDouble()),
"type": optimizationSteps[i + 1].type,
});
}
}
}
}
emit(state.copyWith(
listOfPolylinesPoints: listOfStepsPoints,
polylineInfos: polylineInfos,
polylineColors: polylineColors));
}
}
}
Let’s explain the business logic:
- OpenRouteService provides the method
optimizationDataPost(). - It requires two main parameters:
- jobs — (a list of
VroomJob) → each job represents a restaurant. - vehicles — (a list of
VroomVehicle) → each vehicle represents the user (car/person).
In our case:
- 3 jobs = 3 restaurants.
- 1 vehicle = user’s car.
You may have noticed that VroomJob and VroomVehicle are prefixed with Vroom.
That's because OpenRouteService’s optimization is based on the VROOM project — a powerful open-source routing optimization engine.
VroomJob parameters we use:
idlocationservice(time spent at the job = time spent at the restaurant)
VroomVehicle parameters we use:
idstartendcapacity
🧠 Note:
- [start] and [end] are optional for a vehicle, as long as at least one of them is present.
- If [start] is missing, the route will start at the first visited job (chosen by optimization).
- If you want a round trip (start and end at the same location), you specify both [start] and [end] with the same coordinates — that’s exactly what we do, using the current position.
⚡ Advanced Tip:
The Optimization API is built to solve the famous VRP (Vehicle Routing Problem).
That’s why VroomJob and VroomVehicle are enriched with many parameters:
- service time (time spent at a job),
- capacity (for example, how many deliveries a vehicle can carry),
- and many more options.
All of these help manage more complex logistics and smart routing.
Finally, once the optimization request is sent, we get the response:
{
"code": 0,
"summary": {
"cost": 633,
"unassigned": 0,
"service": 0,
"duration": 633,
"waiting_time": 0,
"computing_times": {
"loading": 39,
"solving": 1,
"routing": 0
}
},
"routes": [
{
"vehicle": 1,
"cost": 633,
"service": 0,
"duration": 633,
"waiting_time": 0,
"steps": [
{
"type": "start",
"location": [
-7.652715,
33.5816333
],
"arrival": 0,
"duration": 0,
"service": 0,
"waiting_time": 0
},
{
"type": "job",
"location": [
-7.648748,
33.58376
],
"arrival": 91,
"duration": 91,
"id": 3,
"service": 0,
"waiting_time": 0,
"job": 3
},
{
"type": "job",
"location": [
-7.655744,
33.594438
],
"arrival": 300,
"duration": 300,
"id": 2,
"service": 0,
"waiting_time": 0,
"job": 2
},
{
"type": "job",
"location": [
-7.643341,
33.592638
],
"arrival": 430,
"duration": 430,
"id": 1,
"service": 0,
"waiting_time": 0,
"job": 1
},
{
"type": "end",
"location": [
-7.652715,
33.5816333
],
"arrival": 633,
"duration": 633,
"service": 0,
"waiting_time": 0
}
]
}
],
"unassigned": []
}
and we can build the multiple roads/polylines accordingly.
Understand the Optimization API Response and Bloc
"code": 0→ ✅ Means request success. No error occurred."unassigned": 0→ ✅ Means all jobs (restaurants) were assigned successfully. No missed assignments."duration": 633→ ✅ Means total route time = 633 seconds (around 10 minutes and 33 seconds) to visit all three restaurants."waiting_time"→ ✅ Means time spent waiting at places. Important if you plan on handling opening hours or reservation slots later.
Understand Routes → Steps
Inside the routes, there is a steps array:
Each step tells where the user should go:
- Start → Job 3 → Job 2 → Job 1 → Back to Start.
✅ It optimizes the order for the shortest or fastest path.
Plan to Draw Polylines Between Steps
We can’t just draw one line — we need to connect each step to the next one:
Step i ➡️ Step i+1
- For example:
- Start ➡️ Job 3
- Job 3 ➡️ Job 2
- Job 2 ➡️ Job 1
- Job 1 ➡️ Start
✅ That’s why we need to create several small polylines, not one big polyline.
Create “optimizationSteps” Variable
We created a variable:
✅ Makes it easier to work with steps.
final optimizationSteps = response.routes[0].steps;
✅ Avoids list index errors by adding this safety check:
if (i < optimizationSteps.length - 1)
Otherwise, when you reach the last step, i + 1 would crash!
Get Coordinates for Each Small Road
For each pair (i, i+1):
- Call
directionsRouteCoordsGet(start, end) - Convert the result from
ORSCoordinatetoLatLng. - Store it inside
routeCoordinates(a List<List<LatLng>>).
✅ This gives you:
several_roads = [
[LatLng1, LatLng2], // Start ➡️ Job3
[LatLng3, LatLng4], // Job3 ➡️ Job2
...
];
Different Color for Each Polyline
✅ we generate random colors using Random() in the Bloc.
✅ we store colors in polylineColors List in the Bloc state.
Each road/polyline will have its own unique color!
Interactive Polylines
When the user taps on a road:
- Show a snackbar with how long this road will take.
✅ To achieve that:
- we used layer interactivity (from
flutter_map). - we wrapped the
PolylineLayer()with aMouseRegionandGestureDetector. - we used
hitNotifierto detect which polyline the user clicked.
✅ we stored durations in polylineInfos (List<String>).
How to Calculate the Duration Between Steps
In the optimization API:
- Every step has an
"arrival"time (example: 91, 300...).
✅ To calculate duration between two steps:
duration = arrival[i + 1] - arrival[i];
Example:
- Job 3 arrival: 91
- Job 2 arrival: 300
thus:
duration = 300 - 91 = 209 seconds
✅ Now we know how long it takes between two steps!
✅ This value is saved and shown when the user taps a polyline.
Restaurant Info Screen: Add the Optimize Button
✅ Very simple: we added a Button to trigger optimization.
In the optimize_roads_button.dart file, write the following code:
class OptimizeRoadsButton extends StatelessWidget {
const OptimizeRoadsButton({super.key});
@override
Widget build(BuildContext context) {
return SizedBox(
child: BlocBuilder<RestaurantsBloc, RestaurantsState>(
builder: (context, state) {
return OutlinedButton(
onPressed: () => context
.read<RestaurantsBloc>()
.add(const RoadsRestaurantsOptimization()),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.black,
),
child: const Text("Optimize"),
);
},
),
);
}
}
In restaurant_info.dart, uncomment :
Expanded(flex: 1, child: OptimizeRoadsButton()),

PolylineLayer Update
You updated your PolylineLayer widget:
✅ Logic:
- If
listOfPolylinesPointsis not empty ➡️ Draw optimized polylines. - Otherwise ➡️ Draw only the road to a single selected restaurant if
roadPolylinePointsis not null. - If nothing requested ➡️ Show nothing.
✅ You used hitValue to store the arrival duration.

✅ You handled polyline taps inside MouseRegion ➡️ GestureDetector ➡️ Snackbar.
Update the polyline_layer.dart file like this:
class RestaurantsPolylineLayer extends StatelessWidget {
const RestaurantsPolylineLayer({super.key});
@override
Widget build(BuildContext context) {
final ValueNotifier<LayerHitResult?> hitNotifier = ValueNotifier(null);
return BlocBuilder<RestaurantsBloc, RestaurantsState>(
builder: (context, state) {
return MouseRegion(
hitTestBehavior: HitTestBehavior.deferToChild,
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () {
final hitResult = hitNotifier.value;
if (hitResult != null && hitResult.hitValues.isNotEmpty) {
// Get the first hit value
final hitValue = hitResult.hitValues.first;
if (hitValue is Map) {
// If hitValue is an index
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
backgroundColor: Colors.red[400],
content: hitValue["type"] == "end"
? Text(
'Start/End arrival: ${hitValue["duration"]}',
style:
const TextStyle(fontWeight: FontWeight.w500),
)
: Text(
'Restaurant arrival: ${hitValue["duration"]}',
style:
const TextStyle(fontWeight: FontWeight.w500),
),
),
);
}
}
},
child: PolylineLayer(
hitNotifier: hitNotifier,
polylines: state.listOfPolylinesPoints != null &&
state.listOfPolylinesPoints!.isNotEmpty
? state.listOfPolylinesPoints!.asMap().entries.map((entry) {
int index = entry.key;
List<LatLng> pointsList = entry.value;
Color? polylineColor = state.polylineColors?[index];
final hitValue = state.polylineInfos?[index];
return Polyline(
points: pointsList,
strokeWidth: 2,
color: polylineColor ?? Colors.red,
hitValue:
hitValue ?? {"duration": "___", "type": "___"});
}).toList()
: [
Polyline(
points: state.roadPolylinePoints != null
? state.roadPolylinePoints!
: [],
strokeWidth: 3,
color: Colors.blue,
),
],
),
),
);
},
);
}
}
MarkerLayer Update
✅ Above every step (start, job3, job2, job1):
You added a small marker with:
- The same color as the polyline.
- A number (1, 2, 3, 4) showing the step order.
✅ This helps users visually follow the path easily.
Update the marker_layer.dart file like this:
class RestaurantsMarkerLayer extends StatelessWidget {
const RestaurantsMarkerLayer({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<RestaurantsBloc, RestaurantsState>(
builder: (context, state) {
return MarkerLayer(
markers: [
// ...
// add
...state.listOfPolylinesPoints != null &&
state.listOfPolylinesPoints!.isNotEmpty
? state.listOfPolylinesPoints!.asMap().entries.map((entry) {
int index = entry.key;
LatLng startPoint = entry.value.first;
// Use the same pre-generated color
Color? markerColor = state.polylineColors?[index];
return Marker(
point: startPoint,
width: 37.5,
height: 37.5,
child: Container(
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
// Use the matching color for the marker
color: markerColor ?? Colors.red,
),
child: Text(
'${index + 1}',
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
);
}).toList()
: [],
],
);
},
);
}
}

✅ Now the user can:
- See an optimized path.
- Follow colorful roads.
- Tap a road to know how much time it will take.
- Easily recognize the order using small numbered markers.
Helping Users Find Restaurants Outside Collaborations
Now, let’s see how we can help users search for a restaurant even if we don’t have a collaboration with it. Imagine a user has a restaurant recommended by a friend, but that restaurant isn’t yet listed in our partner network. How can we still assist the user?
For this, we’ve built a Search Restaurant page. It contains:
- A beautiful restaurant image at the top.
- Above the image, a friendly message telling the user that we can help them find their delicious meals.
- Below, two text fields:
- First text field: Displays the user’s current position/address. It’s a read-only field (the user cannot edit it).
- Second text field: Allows the user to search for a recommended restaurant.

While the user types, we help by auto-completing their search. When the user chooses an address, we reverse-geocode it and show it temporarily as a marker on the map (since we don’t have an official collaboration with the restaurant yet).
Building the User Interface
To build this UI, we created the following widgets:
widgets/floating_action_button.dart
class MapFloatingActionButton extends StatelessWidget {
const MapFloatingActionButton({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<RestaurantsBloc, RestaurantsState>(
builder: (context, state) {
return FloatingActionButton(
backgroundColor: Colors.red[100],
child: const Icon(Icons.my_location_outlined),
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => const SearchRestaurant()),
);
},
);
},
);
}
}
In the map_page.dart file, add the following code:
class MapPage extends StatefulWidget {
const MapPage({super.key});
@override
State<MapPage> createState() => _MapPageState();
}
class _MapPageState extends State<MapPage> {
final MapController mapController = MapController();
@override
Widget build(BuildContext context) {
return Scaffold(
// ...
// add
floatingActionButton: const MapFloatingActionButton(),
);
}
}
widgets/SearchLocations.dart
class SearchLocations extends StatelessWidget {
const SearchLocations({super.key});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
children: [
Row(
spacing: 16.0,
children: [
Column(
children: [
Icon(Icons.person_pin_circle, color: Colors.grey.shade300),
const RotatedBox(
quarterTurns: 1,
child: DottedLine(
direction: Axis.horizontal,
lineLength: 50,
lineThickness: 1.0,
dashLength: 4.0,
dashColor: Colors.red,
),
),
const Icon(Icons.location_on, color: Colors.red),
],
),
Expanded(
child: Column(
spacing: 24.0,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
BlocBuilder<RestaurantsBloc, RestaurantsState>(
builder: (context, state) {
return ReusableTextField(
hintText: state.currentAdressController,
readOnly: true,
);
},
),
BlocBuilder<RestaurantsBloc, RestaurantsState>(
builder: (context, state) {
return ReusableTextField(
hintText: state.restaurantAdressController ??
'Search your end location',
onChanged: (value) {
if (value.isEmpty || value == "") {
context
.read<RestaurantsBloc>()
.add(const UserInputIsEmpty(true));
}
context
.read<RestaurantsBloc>()
.add(AddressSearchAutoComplete(value));
debugPrint("This is the current value: $value");
},
);
},
),
],
),
),
],
),
BlocBuilder<RestaurantsBloc, RestaurantsState>(
builder: (context, state) {
return Offstage(
offstage: state.userInputEmpty,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 24.0),
height: 250,
color: Colors.white,
child: ListView.builder(
itemCount: state.listOfAutoCompleteSuggestions.length,
itemBuilder: (context, index) {
return ListTile(
title: Text(state
.listOfAutoCompleteSuggestions[index].keys.first),
onTap: () {
context.read<RestaurantsBloc>().add(UserChhosedAdress(
choosedAdress: state
.listOfAutoCompleteSuggestions[index]
.keys
.first,
restaurantLocation: state
.listOfAutoCompleteSuggestions[index]
.values
.first));
},
);
},
),
),
);
},
),
],
),
);
}
}
🧩 Note: Here, we use the
Offstagewidget instead of theVisibilitywidget. TheOffstagewidget, when hidden, does not occupy any space, allowing other widgets to adjust and keep the UI dynamic. On the other hand, theVisibilitywidget, even when hidden, still occupies space, which can cause layout issues and make the UI less dynamic in our case. However,Visibilitycan be useful in other scenarios where maintaining the space is necessary.
widgets/SearchRestaurantButton.dart
class SearchRestaurantButton extends StatelessWidget {
const SearchRestaurantButton({super.key});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.only(left: 48, right: 8.0, top: 16.0),
width: double.infinity,
child: BlocBuilder<RestaurantsBloc, RestaurantsState>(
builder: (context, state) {
return ElevatedButton.icon(
onPressed: () {
ScaffoldMessenger.of(context)
.showSnackBar(
const SnackBar(
backgroundColor: Colors.black,
content: Text(
'We successfully marked this restaurant on the map! ✔️',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w500,
),
),
),
)
.setState;
Future.delayed(const Duration(seconds: 5), () {
Navigator.of(context).pop();
});
},
label: const Text("This is the restaurant"),
icon: const Icon(Icons.done, color: Colors.white),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red[800],
foregroundColor: Colors.white,
),
);
},
),
);
}
}
screens/SearchRestaurant.dart
class SearchRestaurant extends StatelessWidget {
const SearchRestaurant({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
title: const Text(
"Search Restaurant",
style: TextStyle(fontWeight: FontWeight.w500),
),
),
body: ListView(
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: ClipRRect(
borderRadius: BorderRadius.circular(8.0),
child: Stack(
alignment: Alignment.center,
children: [
Image.asset(
"assets/images/search_restaurant.jpg",
height: 150,
width: double.infinity,
fit: BoxFit.cover,
),
Container(
height: 150,
color: Colors.black.withOpacity(0.4),
),
const Text(
"Your guide to your delicious meals! ",
style: TextStyle(
color: Colors.white,
fontSize: 16.0,
fontWeight: FontWeight.bold),
),
],
),
),
),
const SearchLocations(),
const SearchRestaurantButton(),
],
),
);
}
}
All widgets used are common and familiar, except for the DottedLine() widget, which is from an external package.
Business Logic — State Management
The State
In the restaurant_state file (specifically for the search restaurant page), we added new states, which include:
- Managing user input.
- Holding address suggestions.
- Handling the selected address and geopoint.
Update the restaurant_state.dart file like this:
part of 'restaurants_bloc.dart';
class RestaurantsState extends Equatable {
const RestaurantsState({
// ...
//add
this.listOfAutoCompleteSuggestions = const [],
this.userInputEmpty = true,
this.currentAdressController,
this.restaurantAdressController,
this.restaurantBySearchLocation,
});
// ...
//add
final List<Map<String, dynamic>> listOfAutoCompleteSuggestions;
final bool userInputEmpty;
// The address of the user current position
final String? currentAdressController;
// The address of the restaurant chosen by the user
final String? restaurantAdressController;
// The location of the restaurant chosen by the user
final LatLng? restaurantBySearchLocation;
RestaurantsState copyWith({
// ...
//add
List<Map<String, dynamic>>? listOfAutoCompleteSuggestions,
bool userInputEmpty = true,
String? currentAdressController,
String? restaurantAdressController,
LatLng? restaurantBySearchLocation,
}) {
return RestaurantsState(
// ...
//add
listOfAutoCompleteSuggestions:
listOfAutoCompleteSuggestions ?? this.listOfAutoCompleteSuggestions,
userInputEmpty: userInputEmpty,
currentAdressController:
currentAdressController ?? this.currentAdressController,
restaurantAdressController:
restaurantAdressController ?? this.restaurantAdressController,
restaurantBySearchLocation:
restaurantBySearchLocation ?? this.restaurantBySearchLocation,
);
}
@override
List<Object?> get props => [
// ...
//add
listOfAutoCompleteSuggestions,
userInputEmpty,
currentAdressController,
restaurantAdressController,
restaurantBySearchLocation,
];
}
The Events
We introduced three new events in the restaurant_event file:
AddressSearchAutoComplete
- Sends the user’s input to search for addresses and display suggestions.
UserInputIsEmpty
- Detects when the user deletes their input. This is important because if the input is empty, we shouldn’t still show address suggestions.
UserChosedAddress
- Triggers when the user selects an address. We capture both the address text and its corresponding geopoint.
part of 'restaurants_bloc.dart';
abstract class RestaurantsEvent extends Equatable {
const RestaurantsEvent();
}
// ...
// add
class AddressSearchAutoComplete extends RestaurantsEvent {
const AddressSearchAutoComplete(this.addressSearch);
final String addressSearch;
@override
List<Object?> get props => [addressSearch];
}
class UserInputIsEmpty extends RestaurantsEvent {
const UserInputIsEmpty(this.userInputIsEmpty);
final bool userInputIsEmpty;
@override
List<Object?> get props => [userInputIsEmpty];
}
class UserChosedAddress extends RestaurantsEvent {
const UserChosedAddress({this.choosedAdress, this.restaurantLocation});
final String? choosedAdress;
final LatLng? restaurantLocation;
@override
List<Object?> get props => [choosedAdress, restaurantLocation];
}
Bloc Logic
Handling the events inside the Bloc works as follows:
Update the restaurants_bloc.dart file like this:
class RestaurantsBloc extends Bloc<RestaurantsEvent, RestaurantsState> {
RestaurantsBloc() : super(const RestaurantsState()) {
// ...
// add
on<AddressSearchAutoComplete>(_onAddressSearchAutoComplete);
on<UserInputIsEmpty>(_onUserInputIsEmpty);
on<UserChosedAddress>(_onUserChosedAddress);
}
// ...
// add
void _onAddressSearchAutoComplete(
AddressSearchAutoComplete event, Emitter emit) async {
final OpenRouteService client =
OpenRouteService(apiKey: MapConstants.API_KEY);
if (event.addressSearch.isNotEmpty) {
final restaurantAddress = await client.geocodeAutoCompleteGet(
text: event.addressSearch, boundaryCountry: "MA");
final responseOfsearchAutoCompletes = restaurantAddress.features;
final List<Map<String, dynamic>> listOfsearchAutoCompletes = [];
for (int i = 0; i < responseOfsearchAutoCompletes.length; i++) {
listOfsearchAutoCompletes.add({
responseOfsearchAutoCompletes[i].properties["label"]: LatLng(
responseOfsearchAutoCompletes[i]
.geometry
.coordinates
.first
.first
.latitude,
responseOfsearchAutoCompletes[i]
.geometry
.coordinates
.first
.first
.longitude)
});
}
emit(state.copyWith(
userInputEmpty: false,
listOfAutoCompleteSuggestions: listOfsearchAutoCompletes));
} else if (event.addressSearch.isEmpty) {
emit(state.copyWith(userInputEmpty: true));
}
}
void _onUserInputIsEmpty(UserInputIsEmpty event, Emitter emit) async {
emit(state.copyWith(userInputEmpty: event.userInputIsEmpty));
}
void _onUserChosedAddress(UserChosedAddress event, Emitter emit) async {
emit(state.copyWith(
restaurantAdressController: event.choosedAdress,
restaurantBySearchLocation: event.restaurantLocation,
userInputEmpty: true));
}
}
- UserInputIsEmpty
- Simply updates a boolean
userInputEmptyin the state. - We use this flag with the
Offstagewidget to show/hide address suggestions dynamically. - UserChosedAddress
- Saves the selected address and its geopoint.
- Also updates
userInputEmptyto hide the suggestion list (since the user has already chosen one).

- AddressSearchAutoComplete

- Here is where the magic happens:
- We first check if the user has actually typed something.
- We call
geocodeAutoCompleteGet()with two parameters:
- The user’s typed input.
- The country boundary (to make the results more accurate).
- From the API response, we extract:
- The address label (
properties.label). - The geopoint (
geometry.coordinates).
We then save all addresses and their corresponding geopoints into a List of Maps, where:
- Key = Address Label
- Value = Geopoint (LatLng)
This structure allows us to easily build suggestion widgets in SearchLocations and, when the user picks one, show it on the map as a temporary marker.
📍 Marker example: After the user picks an address, we place a marker on the map for visual reference — even though it’s not an officially partnered restaurant.


Update the marker_layer.dart file like this:
class RestaurantsMarkerLayer extends StatelessWidget {
const RestaurantsMarkerLayer({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<RestaurantsBloc, RestaurantsState>(
builder: (context, state) {
return MarkerLayer(
markers: [
// ...
// add
if (state.restaurantBySearchLocation != null)
Marker(
point: LatLng(
state.restaurantBySearchLocation!.latitude,
state.restaurantBySearchLocation!
.longitude),
width: 80,
height: 80,
child: const CircleAvatar(
backgroundColor: Colors.white,
child: CircleAvatar(
radius: 35.0,
backgroundImage:
AssetImage("assets/images/restaurant_icon.png"),
),
),
),
],
);
},
);
}
}
Why Map Structure?
You might wonder, why save the suggestions as a Map?
- Because it’s efficient: we can easily display suggestions (keys) and retrieve coordinates (values) when needed!A Small Note About OpenRouteService

A Small Note About OpenRouteService
The OpenRouteService API is incredibly powerful and offers much more than just autocomplete and geocoding:
- Isochrone: Helps determine which areas are reachable within a certain time or distance.
- POIs (Points of Interest): Helps find interesting places around a specific geographic coordinate.
And that’s just the beginning — it’s a rich open-source toolset!
Conclusion
I hope everything was clear and simple to follow! If you have any questions or need clarifications, feel free to leave a comment — I’m happy to discuss any point you find confusing.
I also encourage you to explore OpenRouteService’s other APIs. They can solve many real-world problems and greatly enhance your applications.
Thank you for reading and applying this tutorial! 🙌 Feel free to share your feedback or experiences!
메타데이터
- post_id
- a1e6d60a0afd
- slug
- mastering-flutter-map-a-practical-guide-part-2-build-a-restaurant-guide-app-using-a1e6d60a0afd
- url
- https://medium.com/@developerimad70/mastering-flutter-map-a-practical-guide-part-2-build-a-restaurant-guide-app-using-a1e6d60a0afd
- canonical_url
- https://medium.com/@developerimad70/mastering-flutter-map-a-practical-guide-part-2-build-a-restaurant-guide-app-using-a1e6d60a0afd
- author_url
- https://medium.com/@developerimad70
- status
- ok
- fetched_at
- 2026-07-31 19:37:12