Mastering Offline Maps in Flutter: A Deep Dive (Part 3)

Lavkant Kachhwaha
Captainfresh Tech
Published in
6 min readFeb 5, 2024

Embark on the third instalment of our Flutter journey, building on the success of achieving offline capability with mapbox_gl. We've seamlessly integrated flutter_map and FMTC plugin, and now, we're optimizing the download/caching process. Dive into the strategy of dividing regions into equal squares for efficient data retrieval and witness the magic as your map capabilities soar to new heights.

In this installment of our ‘Mastering Offline Maps in Flutter’ series, we embark on a journey to optimize the map downloading experience. 🌐✨

🗺️ Key Highlights:

Intelligent Region Division: Say goodbye to hefty downloads! We break down the map into manageable squared regions.
On-Demand Downloads: Empower your users to download specific regions with a simple click, ensuring they get precisely what they need.
Real-time Status Updates: Watch the download status evolve dynamically, providing a seamless and interactive experience.

Dividing the Map: A Closer Look at the CSV Data

In our quest for optimizing offline map functionality, we leverage a structured CSV file that holds more than just geographical data — it’s a key player in the strategic division of our map region. Let’s delve into the details.

The CSV Blueprint

Our CSV file encapsulates the geographical intricacies of various polygons, each representing a distinct region on the map. Here’s a glimpse of what each row entails:

  • wkt_geom: The well-known text representation of the polygon’s geometry.
  • id: A unique identifier for the polygon.
  • left, top, right, bottom: Geographical coordinates defining the boundaries of the polygon.
  • row_index, col_index: Indices representing the position of the polygon in our gridded map.

Map Division

We implement a dynamic approach to map division. Each polygon’s coordinates act as a blueprint for a specific region. By strategically dividing the map into equal squared regions, we optimize the download size and ensure users receive precisely what they need.

The Game Plan

  1. Dynamic Squared Regions: Divide the map into equally sized squares based on the given polygons.
  2. On-Demand Downloading: Users can now interact with the map and trigger the download of specific regions of interest. This on-demand approach ensures efficient data utilization.
  3. Unified Storage: All downloaded data is intelligently stored in a single store, harmonising the map and enhancing its overall performance.

Data :

🤔 How we get this data ?
will cover this part in upcoming blogs :

Downloading and Rendering Regions

Our CustomLayer is responsible for rendering the polygons on the map, and it triggers the download process when a user taps on a specific region:

class CustomLayer extends StatefulWidget {
final List<PolygonData>? polygons;
final List<RegionBound>? regionBounds;

const CustomLayer({super.key, this.polygons, this.regionBounds});

@override
State<CustomLayer> createState() => _CustomLayerState();
}

class _CustomLayerState extends State<CustomLayer> {
@override
Widget build(BuildContext context) {
final mapState = FlutterMapState.of(context);

if (widget.polygons != null) {
return Material(
color: Colors.transparent,
child: Container(
color: Colors.transparent,
child: Stack(
children: [
...widget.polygons!.map((e) {
final index = widget.polygons?.indexOf(e);
return DrawComponent(
offsets: e.polygonPoints.map((e) => mapState.getOffsetFromOrigin(e)).toList(),
points: e.polygonPoints.map((e) => e).toList(),
regionBound: widget.regionBounds!.elementAt(index!),
);
}).toList(),
],
),
),
);
} else {
return Container();
}
}
}


class DrawComponent extends StatelessWidget {
final List<Offset> offsets;
final List<LatLng> points;
final RegionBound regionBound;
const DrawComponent({super.key, required this.offsets, required this.points, required this.regionBound});

@override
Widget build(BuildContext context) {
final x1 = offsets[0].dx;
final y1 = offsets[0].dy;

final x2 = offsets[1].dx;
final y2 = offsets[1].dy;

final x3 = offsets[2].dx;
final y3 = offsets[2].dy;

final x4 = offsets[3].dx;
final y4 = offsets[3].dy;

final height = y3 - y2;
final width = x2 - x1;

final topLeft = points[0];
final bottomRight = points[2];

return Positioned(
left: x1,
top: y1,
child: Container(
decoration: BoxDecoration(
color: regionBound.isDownloaded == true ? Colors.green.withOpacity(0.5) : Colors.red.withOpacity(0.5),
border: Border.all(width: 0.2)),
height: height,
width: width,
child: GestureDetector(
onTap: () async {
debugPrint("TOP LEFT $topLeft BOTTOM RIGHT $bottomRight");
GetIt.instance<StoreService2>().downloadBasemap(regionName: regionBound.name);
},
child: regionBound.isDownloaded == false
? Icon(
Icons.download,
color: Colors.black.withOpacity(0.1),
)
: Icon(
Icons.check,
color: Colors.black.withOpacity(0.1),
)

// child: Text(
// "$topLeft $bottomRight",
// style: const TextStyle(fontSize: 10),
// ),
),
));
}
}

In the DrawComponent, the GestureDetector triggers the download process when a user taps on a region. The downloadBasemap method is responsible for initiating the download, and the region's status is updated accordingly.

Displaying Download Progress

We’ve added a progress indicator to show the download progress at the bottom of the screen. This progress bar is updated dynamically as the download progresses:

class CustomMap extends StatefulWidget {
// ...

@override
Widget build(BuildContext context) {
return Scaffold(
// ...

body: StreamBuilder(
stream: GetIt.instance<OfflineRegionsBloc>().loadingController,
builder: (context, snapshot) {
return StreamBuilder<DownloadProgress>(
stream: GetIt.instance<StoreService2>().downloadProgress,
builder: (context, snapshot) {
if (snapshot.hasError) {
return SizedBox(height: 50, child: Text(snapshot.error.toString()));
}

return Stack(
children: [
FlutterMap(
options: MapOptions(
// ...
),
children: [
TileLayer(
// ...
),
CustomLayer(
polygons: GetIt.instance<OfflineRegionsBloc>().polygons,
regionBounds: _regionBoundsFromDB,
),
],
),
if (snapshot.data?.percentageProgress != null && snapshot.data?.percentageProgress != 100.0)
Container(
// Display download progress
),
],
);
},
);
},
),

// ...
);
}

// ...
}

The progress indicator is displayed only when the download is in progress. It provides feedback to the user about the ongoing download process.

Clearing Data and Error Handling

class CustomMap extends StatefulWidget {
// ...

@override
Widget build(BuildContext context) {
return Scaffold(
// ...

bottomNavigationBar: StreamBuilder(
stream: GetIt.instance<StoreService2>().loadingController,
builder: (context, snapshot) {
return StreamBuilder<DownloadProgress>(
stream: GetIt.instance<StoreService2>().downloadProgress,
builder: (context, snapshot) {
if (snapshot.hasError) {
return SizedBox(height: 50, child: Text(snapshot.error.toString()));
}

return SizedBox(
height: 50,
child: Row(
children: [
Text("${snapshot.data?.percentageProgress} %"),
IconButton(
onPressed: () {
// Clear data from the store and SQLite database
GetIt.instance<StoreService2>().clearDataFromStore();
GetIt.instance<OfflineRegionsBloc>().removeAllDownloadStatusAndRegions();
},
icon: const Icon(Icons.delete_forever),
),
],
),
);
},
);
},
),

// ...
);
}

// ...
}

This section provides users with the ability to clear stored data and regions, enhancing the overall user experience.

Store Management For Regions :

Creating Stores for Different Map Layers

We initiate stores for base maps and bathymetry layers using the createStoreForBaseMap and createBathymetryLayerStore methods, ensuring that our data has a well-organized home.

createStoreForBaseMap() async {
_baseMapStore = FMTC.instance(baseMapStoreData['storeName']!);
// Additional store setup...
}

createBathymetryLayerStore() async {
_bathymetryLayerStore = FMTC.instance(bathyMapStoreData['storeName']!);
// Additional store setup...
}

Clearing Data and Stores

clearDataFromStore() async {
await _baseMapStore?.manage.delete();
await _bathymetryLayerStore?.manage.delete();
}

Downloading Map Layers

The downloadBasemap and downloadBathymetryMapStore methods take charge of downloading specific regions and storing them in the respective stores. Users can trigger these downloads with a simple tap on a region of interest.

downloadBasemap({required String regionName}) async {
BaseRegion? region = GetIt.instance<OfflineRegionsBloc>().getBaseRegionForBaseMap(regionName: regionName);
// Logic to determine the region and initiate download...
_baseMapStore!.download.startForeground(
region: region.toDownloadable(/* parameters */),
// Additional download settings...
);
}

downloadBathymetryMapStore({required String regionName}) async {
BaseRegion? region = GetIt.instance<OfflineRegionsBloc>().getBaseRegionForBaseMap(regionName: regionName);
// Logic to determine the region and initiate download...
_bathymetryLayerStore!.download.startForeground(
region: region.toDownloadable(/* parameters */),
// Additional download settings...
);
}

Additional : We have parsed the CSV to Model RegionBounds and Saved a list of regionsBounds, and same regions were referenced for render/download.

class RegionBound {
final String name;
final LatLngBounds bounds;
bool isDownloaded;
RegionBound({required this.name, required this.bounds, this.isDownloaded = false});
}
  List<RegionBound> getRegionBounds({required List<PolygonData> polygons}) {
List<RegionBound> list = [];
for (var e in polygons) {
final index = polygons.indexOf(e);
final LatLngBounds latLongBound = LatLngBounds(e.polygonPoints[0], e.polygonPoints[2]);
list.add(RegionBound(name: "$regionString$index", bounds: latLongBound));
}
return list;
}

getBaseRegionForBaseMap({required String regionName}) {
final region = regionBounds?.where((element) => element.name == regionName);
if (region != null) {
return RectangleRegion(region.first.bounds, name: region.first.name);
}
}

Summary: Mastering Offline Maps in Flutter (Part 3)

In this installment, we’ve supercharged Flutter’s offline maps using flutter_map and FMTC plugins. Key highlights include dynamic map division with CSV data, on-demand downloading via CustomLayer, real-time progress updates, user-friendly data clearing, and efficient store management with StoreService. Our Flutter journey propels offline maps to new heights, promising users an interactive, efficient, and seamless experience. Stay tuned for upcoming enhancements! 🚀🌐

Stay tuned and happy coding!

If you’ve found this post helpful, a few claps would mean a lot! Your support keeps the Flutter community thriving. Thanks for being a part of our journey! 👋🚀

https://www.linkedin.com/in/lavkant-kachhawaha-075a90b4/

--

--