· 6 min read

Flutter Local Package: Split the Map Engine From the App

How I moved Mapika's map engine into a Flutter local package with one rule for the boundary and a stream of overlay objects as the only contract.

A map screen is the fastest way to grow a Flutter widget that nobody wants to touch. Rendering, hit testing, GeoJSON parsing, colors, user taps and business rules all end up in one place. In Mapika, my travel map app, I pulled the map engine out into a Flutter local package called mapika_map. This post is about the one rule that keeps that package clean, and the small contract the app and the package use to talk.

Why a Flutter local package, not just a folder

You can get some separation with folders inside lib/. The problem is that folders do not stop imports. Any file can import any other file, and on a busy day it will.

A local package is a real boundary. It has its own pubspec.yaml, its own dependencies and its own tests. The app depends on the package, and the package cannot import anything from the app, because it does not know the app exists. The compiler enforces the rule for you.

You do not need to publish it. A path dependency in the app's pubspec.yaml is enough:

dependencies:
  mapika_map:
    path: packages/mapika_map

The Dart docs on path dependencies cover the details. Changes in the package show up in the app on the next hot reload or build, so day-to-day work feels the same as before.

The rule: the package draws, the app decides

This is the whole architecture in one line:

The test I use for any new piece of code: does it need to know what "visited" means? If yes, it belongs in the app. If it only needs to know "fill this shape with this color", it belongs in the package.

That test sounds trivial, but it catches most mistakes. The tempting shortcut is to add a visitedCountries parameter to the map widget. It saves ten minutes today. Then the package knows a product concept, and every new status (lived in, want to visit, a new badge) changes the package API.

The contract: a stream of overlay objects

The app and the package talk through one thing: a stream of overlay objects. An overlay says what to draw on which shape. It does not say why.

Here is a simplified version of the idea (names and fields are shortened for the post):

// packages/mapika_map/lib/src/overlay.dart
sealed class MapOverlay {
  const MapOverlay();
}

class RegionFill extends MapOverlay {
  const RegionFill({required this.regionId, required this.color});
  final String regionId;
  final Color color;
}

class RegionOutline extends MapOverlay {
  const RegionOutline({required this.regionId, required this.width});
  final String regionId;
  final double width;
}

class PointMarker extends MapOverlay {
  const PointMarker({required this.position, required this.style});
  final LatLng position;
  final MarkerStyle style;
}

The map widget takes the stream and reports taps back with plain IDs:

MapView(
  overlays: overlayStream,           // Stream<List<MapOverlay>>
  onRegionTap: (regionId) { ... },   // the app decides what a tap means
)

On the app side, a small mapper turns state into overlays. This is the only place that knows both worlds:

// app/lib/map/overlay_mapper.dart
List<MapOverlay> toOverlays(PlacesState state, MapTheme theme) => [
  for (final place in state.places)
    RegionFill(
      regionId: place.regionId,
      color: theme.colorFor(place.status), // visited, lived, want-to-visit
    ),
];

And the stream itself comes straight from the Cubit:

final overlayStream = placesCubit.stream
    .map((state) => toOverlays(state, mapTheme));

The package never sees PlacesState. The Cubit never sees a canvas.

Why a stream and not a list

A plain List<MapOverlay> parameter would work too, and for a static map it is simpler. A stream fits better when state changes often. The user marks a country, the Cubit emits, the mapper produces a new list, and the map updates. Inside the package, you are free to compare the new overlays with the old ones and repaint only what changed. The app does not need to know how.

Why sealed classes

A sealed class means the package can switch over every overlay type and the compiler tells you when a case is missing. When I add a new overlay type, the package code that ignores it fails to compile. That is exactly the reminder I want.

What the split gave me

These are the practical wins of this kind of split:

It is the same idea as the ad service interface in Mapika: the app owns the contract, and the details sit behind it. The difference is scale. An ad service is one class. A map engine is a whole package.

Mistakes to avoid

A few things I would watch from day one:

The Flutter docs on developing packages are a good reference for the package layout itself.

Takeaways