Enhance Your Google Maps with Dynamic Themes Using Riverpod

Google maps

Since its launch in 2005, Google Maps has transformed from just a basic online mapping tool to a central part of modern mobile apps, used in everything from ride-sharing to travel guides and healthcare platforms. While Google Maps provides powerful default visuals, its generic theme may not always align with your app's branding or user experience goals. This leads to our topic of how to improve the user’s experience by adopting different map themes via the Google Maps Styling Wizard. Below is a working sample:

Project Setup

P.S. I assume this article's reader is familiar with the Flutter framework. 😅
There is a public repository for the full implementation on GitHub to follow along.

Installing 3rd-party plugins

The first step is creating a new Flutter project. Run “flutter create <DIRECTORY>” in the command line,

flutter create <DIRECTORY>

and add the necessary dependencies. Below is a list of the 3rd-party plugins used in this project:

  1. Shared_Preferences: “Wraps platform-specific persistent storage for simple data (NSUserDefaults on iOS and macOS, SharedPreferences on Android, etc)

  2. Flutter_Riverpod: “A reactive caching and data-binding framework.”

  3. Geolocator: “A Flutter geolocation plugin that provides easy access to platform-specific location services (FusedLocationProviderClient or, if unavailable, the LocationManager on Android and CLLocationManager on iOS).

  4. Google_Maps_Flutter: “A Flutter plugin that provides a Google Maps widget.

  5. Flutter_expandable_fab: “A speed dial FAB (Floating Action Button) that can animate the display and hiding of multiple action buttons.

dependencies:
  flutter:
    sdk: flutter
  flutter_riverpod: ^2.6.1
  flutter_expandable_fab: ^2.4.1
  geolocator: ^13.0.4 # for location access
  shared_preferences: ^2.5.3

Add Google Maps Dependency

To install Flutter Google Maps in the project, you should start by reading the documentation on pub: Google Maps Flutter

  • Add the google_maps_flutter ⁣dependency on your “pubspec.yaml” file: dependencies:

  •       dependencies:
            flutter:
              sdk: flutter
                      google_maps_flutter: ^2.12.1
    

Run flutter pub get

Get an API key:

  1. Go to the Google Cloud Console.

  2. Create or select a project.

  3. Enable the Maps SDK for Android and Maps SDK for iOS.

  4. Create API credentials.

  5. Copy the API key.

Configure Android

  1. Open android/app/src/main/AndroidManifest.xml

  2. Add the API key inside the <application>tag:

 <application
        android:label="Map Themes"
        android:name="${applicationName}"
        android:icon="@mipmap/ic_launcher">
    // ADD API KEY AS SEEN BELOW.....
        <meta-data android:name="com.google.android.geo.API_KEY"
                android:value="${google.maps.api.key}"/>

Configure iOS

  1. Open ios/Runner/AppDelegate.swift.

  2. Add the following import:

import Flutter
import UIKit
// ---------  IMPORT GOOGLE MAPS LIBRARY
import GoogleMaps

@main
@objc class AppDelegate: FlutterAppDelegate {
  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
  if let path = Bundle.main.path(forResource: "Info", ofType: "plist"),
   let dict = NSDictionary(contentsOfFile: path),
 // ------ ADD GOOGLE MAPS KEY HERE AS SEEN BELOW ......... 
     let apiKey = dict["GOOGLE_MAPS_API_KEY"] as? String {
    GMSServices.provideAPIKey(apiKey)
}
    GeneratedPluginRegistrant.register(with: self)
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }
}
  1. Open ios/Runner/Info.plist

  2. Add the following key inside the </dict>tag:

...
<key>GOOGLE_MAPS_API_KEY</key>
    <string>${API_KEY}</string> // insert your key here
</dict>
...
</plist>

Map Configurations

abstract class MapRepository {
  GoogleMapController setMapController(GoogleMapController controller);
  GoogleMapController? getMapController();
  (String, Stream<Position>) getLocationStream();
  Future<(String, Position?)> getCurrentLocation();
  Future<(String, bool)> locationPermissionStatus();
}

This repository helps maintain clean code by centralizing and abstracting all location-related logic using Google Maps in Flutter, promoting separation of concerns and reusability.
In my implementation, the MapRepository handles the following responsibilities using the geolocator plugin:

  1. getLocationStream(): This method returns a continuous stream of location updates; I use it to update the user’s location on the map as they move.

  2. getCurrentLocation(): This method retrieves the user’s current GPS location once; I used it to get the coordinates (latitude and Longitude) of the user on the initState of the map_view

  3. locationPermissionStatus(): Before accessing location data, your app must ensure that the user has granted the necessary permissions. This method checks the current permission status and optionally prompts the user if needed.

P.S.: Because this is a demo project, I combined the location methods above with the map methods and called it MapRepository. In a larger project, it could be separated into MapRepository and LocationRepository

  • Other map methods:
  1. setMapController(GoogleMapController controller): This method is used to store a reference to the GoogleMapController when the map is first created. I save this controller within the repository so it can interact with the map without directly accessing the UI layer.

  2. getMapController(): I use this method to retrieve the stored GoogleMapControllerinstance. This allows other classes or services to access the map controller safely and consistently.

P.S.: See the project map_repository.dartfile for MapRepository implementation.

Creating the Map Provider to access the locationStream() method

Utilizing the Riverpod StreamProviderAPI, I access the locationStream from MapRepository via ref

// -------- IMPORT THE CLASSES
import 'package:dynamic_map_themes/repository/map_repository.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:geolocator/geolocator.dart';

final mapViewProvider = StreamProvider<Position>(
  (ref) {
    var locationStream = ref.read(mapRepositoryProvider).getLocationStream().$2;

    return locationStream.handleError((error) {
      // Handle the error here
      print('Error: $error');
    });
  },
);

Creating the map view

import 'package:dynamic_map_themes/presentation/widgets/floating_themes_btn.dart';
import 'package:dynamic_map_themes/provider/map_styles_provider.dart';
import 'package:dynamic_map_themes/provider/map_view_provider.dart';
import 'package:dynamic_map_themes/repository/map_repository.dart';
import 'package:flutter/material.dart';
import 'package:flutter_expandable_fab/flutter_expandable_fab.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';

class MapView extends ConsumerStatefulWidget {
  const MapView({
    super.key,
  });

  @override
  ConsumerState<MapView> createState() => _MapViewState();
}

class _MapViewState extends ConsumerState<MapView> {
  @override
  void initState() {
    ref.read(mapRepositoryProvider).getCurrentLocation();
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    final mapStyles = ref.watch(mapStyleProvider).mapStyle; 
    final mapView = ref.watch(mapViewProvider); 
    return Scaffold(
      key: UniqueKey(),
      floatingActionButtonLocation: ExpandableFab.location,
      floatingActionButton: FloatingThemeBtnsWidget(),
      body: mapView.when(
        data: (data) {
          return GoogleMap(
            initialCameraPosition: CameraPosition(
              target: LatLng(data.latitude, data.longitude),
              zoom: 13,
            ),
            style: mapStyles.isEmpty == true ? null : mapStyles,
            myLocationButtonEnabled: false,
            mapType: MapType.normal,
            onMapCreated: (controller) {
              ref.read(mapRepositoryProvider).setMapController(controller);
            },
            myLocationEnabled: true,
          );
        },
        error: (error, stackTrace) {
          return const Center(
            child: Text(
              'Please enable your location, and try again!',
              style: TextStyle(color: Colors.red),
            ),
          );
        },
        loading: () => const Center(child: CircularProgressIndicator()),
      ),
    );
  }
}

  • In the initState(), we can create the map view by using a stateful widget, which gives us access to the initState, a lifecycle method that is called when a state object is created. It is called only once, and it’s best for initializing our getCurrentLocation() method, which requests permission and retrieves the user's location. This is why we used ref.read; it is used to access the current value of a provider without listening to it for changes.

  • In the build() method, we are permitted to use the Riverpod ref.watch method that listens to changes in providers. ref.watch(mapStyleProvider).mapStyle listens to the current map style, while the

    ref.watch(mapViewProvider) listens to the live stream of the user's location.

  • The Google Map Widgetdynamically updates:

  1. initialCameraPosition: uses the current coordinates from the mapViewProvider.

  2. style: uses the dynamic map style (null if not set).

  3. onMapCreated: stores the controller in the repository for later use.

  4. mapType: Google Maps provides this enum that contains four values (hybrid, satellite, normal, terrain). For this project, we would be using the normal type to access the themes JSON.

    The ExpandableFab renders floating buttons (defined in FloatingThemeBtnsWidget) to switch between themes dynamically.

See the lib/presentation/widgets/floating_themes_btn.dart in the project repository for the implementation of the custom floating action button used. You can also find the plugin on pub: flutter_expandable_fab

Creating the Map Styles

We download the map styles JSON we need for the project from Google's Styling Wizard at mapstyle.withgoogle by following these steps:

  • Adjust the map features, colors, and elements to match your preference using the available tools.

  • Once satisfied with your customizations, click on the "Finish" button.

  • Copy the JSON and paste it inside our Flutter project assets folder

Create a Provider class to manage the map styles; here, we utilize the Notifier class from the Riverpod plugin to manage the state

Creating the Map Styles Provider

import 'package:dynamic_map_themes/core/constants/asset_images.dart';
import 'package:dynamic_map_themes/core/enums/map_enums.dart';
import 'package:dynamic_map_themes/utils/shared_pref_helper.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

final mapStyleProvider = AsyncNotifierProvider<MapStyleAsyncNotifier, MapStylesState>(
  () => MapStyleAsyncNotifier(),
);

class MapStyleAsyncNotifier extends AsyncNotifier<MapStylesState> {
  @override
  Future<MapStylesState> build() async {
    final style = await _getSelectedMapStyle();
    return MapStylesState(mapStyle: style);
  }

  Future<String> _getSelectedMapStyle() async {
    final savedMapStyleName = SharedPreferencesHelper.getString(key: SharedKeys.mapStyle.name);
    final mapStyleEnum = MapStyles.styleName(savedMapStyleName ?? MapStyles.original.name);

    return switch (mapStyleEnum) {
      MapStyles.dark => await rootBundle.loadString(AssetImages.darkMapStylesJson),
      MapStyles.night => await rootBundle.loadString(AssetImages.nightMapStylesJson),
      MapStyles.nightBlue => await rootBundle.loadString(AssetImages.nightBlueMapStylesJson),
      MapStyles.retro => await rootBundle.loadString(AssetImages.retroMapStylesJson),
      MapStyles.original => '',
    };
  }

  Future<void> setMapStyles(String mapStyleName) async {
    // Save preference
    await SharedPreferencesHelper.setString(
      key: SharedKeys.mapStyle.name,
      value: mapStyleName,
    );

    // Reload the selected style and update state
    final style = await _getSelectedMapStyle();
    state = AsyncValue.data(MapStylesState(mapStyle: style));
  }
}

class MapStylesState {
  final String mapStyle;
  final bool tapped, isOffline;

  MapStylesState({
    this.mapStyle = '',
    this.tapped = false,
    this.isOffline = false,
  });

  MapStylesState copyWith({
    String? mapStyle,
    bool? tapped,
    bool? isOffline,
  }) {
    return MapStylesState(
      mapStyle: mapStyle ?? this.mapStyle,
      isOffline: isOffline ?? this.isOffline,
      tapped: tapped ?? this.tapped,
    );
  }
}
  • getSelectedMapStyle(): This method checks for a saved map style in shared preferences, converts it to a MapStyles enum using a static method, then uses a switch expression to load the corresponding style JSON file.

  • setMapStyles(): This method is triggered by the FloatingActionButton to choose a map style and update the state accordingly.

AsynNotifier: Riverpod provides multiple state management APIs, such as Notifier, AsyncNotifier, and StateNotifier, each is suited for different use cases. In this case, we use AsyncNotifier because getSelectedMapStyle() returns a Future<String>, and AsyncNotifier is designed to handle asynchronous state by exposing an AsyncValue<T> and supporting build() methods that can be async.

This is how I defined the MapStyles Enum

enum MapStyles {
  original,
  dark,
  night,
  nightBlue,
  retro;

  static MapStyles styleName(String name) {
    return MapStyles.values.firstWhere(
      (e) => e.name == name,
      orElse: () => MapStyles.original,
    );
  }
}

This is how I declare my assets path.

class AssetImages {
  static const String offlineMapStylesJson = 'assets/json/offline_map_style.json'; //
  static const String darkMapStylesJson = 'assets/json/dark_map_style.json'; //
  static const String nightMapStylesJson = 'assets/json/night_map_style.json'; //
  static const String retroMapStylesJson = 'assets/json/retro_map_style.json'; //
  static const String nightBlueMapStylesJson = 'assets/json/night_blue_map_style.json'; //
}

Result

Image 1

Image 2

Image 3

Image 4

Conclusion

This article provides a comprehensive guide to setting up a Flutter project with Google Maps integration, highlighting essential steps such as creating a new Flutter project, installing third-party plugins like Shared_Preferences, Flutter_Riverpod, Geolocator, and Google_Maps_Flutter, and configuring Android and iOS platforms for map functionality.

We see that integrating dynamic Google Map themes into your Flutter app with Riverpod is more than just a design enhancement — it's a powerful way to deliver a seamless, user-centric experience, and Riverpod reactive architecture makes it easy to manage state and update the themes in real time.

👉 Ready to get started? Check out the full implementation on GitHub and bring your maps to life!

References

Dynamic Map Themes Github Repository

Flutter_Riverpod (pub.dev)

Flutter_Expandable_Fab Plugin (pub.dev)

Shared_Preferences (pub.dev)

Geolocator (pub.dev)

Google_Maps_Flutter (pub.dev)

Google map style (map style wizard)

Riverpod Official Docs

24
Subscribe to my newsletter

Read articles from Nkpozi Marcel Kelechi directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Nkpozi Marcel Kelechi
Nkpozi Marcel Kelechi