Utilisation du package Flutter_map

Hello world !

absent lors du dernier coaching de groupe (25/01), j’ai pu le regarder en replay (c’est vraiment top comme fonctionnalité !!! merci @mbritto ) :slight_smile:

Dans les commentaires du replay, ‹ Kevin Y › et @Quentin, vous évoquez le package Flutter_map.

De mon côté, je suis en train de l’implémenter dans une application et voici mon avancement :

  • Récupération de coordonnées dans Directus (ok)
  • Affichage de la carte avec les marker des coordonnées (ok)
  • Bouton pour se géolocaliser (ok)
  • Boutons pour se positionner sur certains types de marker (ok)

Packages :

  • Flutter
  • latlong2
  • geolocator
  • flutter_map_marker_cluster

De mon point de vue, j’ai l’impression d’avoir fait beaucoup de bricolage et énormément de test de package (surtout pour la géolocalisation).

J’ai uniquement un bug que je n’ai pas encore résolu :
Quand je change d’écran avant d’avoir terminé l’affichage de la carte, j’ai le message suivant sur mes setState() :
FlutterError (setState() called after dispose(): _MapScreenState#8131c(lifecycle state: defunct, not mounted)

je me demande également si c’est normal de n’avoir aucun dispose() dans mon écran ?
(j’utilise les deux controllers : MapController et AnimationController)

Je vous propose mon écran et son viewModel si ca peut aider quelqu’un :slight_smile:

map_screen.dart

import 'dart:async';

import 'package:bordered_text/bordered_text.dart';

import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:geolocator/geolocator.dart';
import 'package:pub_et_moi/ui/screens/map_viewmodel.dart';

import '../../data/model/mission.dart';
import '../../ressources/assets_manager.dart';

import 'package:latlong2/latlong.dart';
import 'package:flutter_map_marker_cluster/flutter_map_marker_cluster.dart';

class MapScreen extends StatefulWidget {
  final MapViewModel _viewModel;
  const MapScreen(this._viewModel, {Key? key}) : super(key: key);

  @override
  State<MapScreen> createState() => _MapScreenState();
}

class _MapScreenState extends State<MapScreen> with TickerProviderStateMixin {
  final MapController mapController = MapController();

  final markersVestimentaires = <Marker>[];
  int markersVestimentairesCount = 0;
  bool _isEnableVestimentaires = true;

  final markersVoitures = <Marker>[];
  int markersVoituresCount = 0;
  bool _isEnableVoitures = true;

  final markersFlyers = <Marker>[];
  int markersFlyersCount = 0;
  bool _isEnableFlyers = true;

  var localisationMarker = <Marker>[];

  bool _isLoading = false;

  List<Marker> myMarkers = [];
  final minLatLng = LatLng(49.8566, 1.3522);
  final maxLatLng = LatLng(58.3498, -10.2603);

/*
 * Initialisation de la Classe
 */
  @override
  void initState() {
    super.initState();
    _getMissions();
  }

/*
 * Récupération des Missions
 */
  _getMissions() async {
    setState(() {
      _isLoading = true;
    });

    final List<Mission> missions = await widget._viewModel.getMissions();

    for (var element in missions) {
      LatLng point = LatLng(element.coordonnees.coordinates[1],
          element.coordonnees.coordinates[0]);

      if (element.typeMission.libelle == Assets.strMissionsVestimentaires) {
        markersVestimentaires.add(
          addMarker(point, element, Assets.iconMissionsVestimentaires),
        );
      }

      if (element.typeMission.libelle == Assets.strMissionsVoitures) {
        markersVoitures.add(
          addMarker(point, element, Assets.iconMissionsVoitures),
        );
      }
      if (element.typeMission.libelle == Assets.strMissionsFlyers) {
        markersFlyers.add(
          addMarker(point, element, Assets.iconMissionsFlyers),
        );
      }
    }

    markersVestimentairesCount = markersVestimentaires.length;
    markersVoituresCount = markersVoitures.length;
    markersFlyersCount = markersFlyers.length;

    myMarkers = [];
    for (var element in markersVestimentaires) {
      myMarkers.add(element);
    }
    for (var element in markersVoitures) {
      myMarkers.add(element);
    }
    for (var element in markersFlyers) {
      myMarkers.add(element);
    }

    _getCurrentLocation();
  }

  Marker addMarker(LatLng point, Mission element, Icon icone) {
    return Marker(
      width: 200,
      height: 200,
      point: point,
      builder: (ctx) => GestureDetector(
        onTap: () {
          _showModal(context, element.titre, element.details);
        },
        child: Column(
          children: [
            FloatingActionButton.extended(
              backgroundColor: Assets.couleur,
              label: Text(element.titre),
              icon: icone,
              onPressed: () =>
                  _showModal(context, element.titre, element.details),
            ),
            const Icon(Icons.push_pin),
          ],
        ),
      ),
    );
  }

/*
 * Récupération de la Localisation
 */
  void _getCurrentLocation() async {
    setState(() {
      _isLoading = true;
    });

    final bool hasPermission = await widget._viewModel.determinePermission();
    if (!hasPermission) {
      setState(() {
        _isLoading = false;
      });
      return;
    }

    final Position myPosition = await widget._viewModel.determinePosition();

    LatLng point = LatLng(myPosition.latitude, myPosition.longitude);
    localisationMarker = [];
    localisationMarker.add(
      Marker(
        width: 80,
        height: 80,
        point: point,
        builder: (ctx) => GestureDetector(
          child: Column(
            children: const [
              Icon(Icons.gps_fixed),
            ],
          ),
        ),
      ),
    );

    _animatedMapMove(LatLng(myPosition.latitude, myPosition.longitude), 8);
  }

/*
 * Animation de la MAP 
 */
  void _animatedMapMove(LatLng destLocation, double destZoom) {
    setState(() {
      _isLoading = true;
    });
    final latTween = Tween<double>(
        begin: mapController.center.latitude, end: destLocation.latitude);
    final lngTween = Tween<double>(
        begin: mapController.center.longitude, end: destLocation.longitude);
    final zoomTween = Tween<double>(begin: mapController.zoom, end: destZoom);

    final controller = AnimationController(
        duration: const Duration(milliseconds: 500), vsync: this);

    final Animation<double> animation =
        CurvedAnimation(parent: controller, curve: Curves.fastOutSlowIn);

    controller.addListener(() {
      mapController.move(
          LatLng(latTween.evaluate(animation), lngTween.evaluate(animation)),
          zoomTween.evaluate(animation));
    });

    animation.addStatusListener((status) {
      if (status == AnimationStatus.completed) {
        controller.dispose();
      } else if (status == AnimationStatus.dismissed) {
        controller.dispose();
      }
    });

    setState(() {
      _isLoading = false;
    });

    controller.forward();
  }

  _onButtonVestimentaire() {
    _isEnableVestimentaires = !_isEnableVestimentaires;
    _onRefreshButton();
    if (_isEnableVestimentaires) {
      _animatedMapMove(
          LatLng(markersVestimentaires[0].point.latitude,
              markersVestimentaires[0].point.longitude),
          8);
    }
    setState(() {});
  }

  _onButtonVoiture() {
    _isEnableVoitures = !_isEnableVoitures;
    _onRefreshButton();

    if (_isEnableVoitures) {
      _animatedMapMove(
          LatLng(markersVoitures[0].point.latitude,
              markersVoitures[0].point.longitude),
          8);
    }

    setState(() {});
  }

  _onButtonFlyers() {
    _isEnableFlyers = !_isEnableFlyers;

    _onRefreshButton();
    if (_isEnableFlyers) {
      _animatedMapMove(
          LatLng(markersFlyers[0].point.latitude,
              markersFlyers[0].point.longitude),
          8);
    }

    setState(() {});
  }

  _onRefreshButton() {
    myMarkers = [];
    if (_isEnableVestimentaires) {
      myMarkers = myMarkers + markersVestimentaires;
    }
    if (_isEnableVoitures) {
      myMarkers = myMarkers + markersVoitures;
    }
    if (_isEnableFlyers) {
      myMarkers = myMarkers + markersFlyers;
    }
  }

  @override
  Widget build(BuildContext context) {
    bool isLoading;
    if (_isLoading) {
      isLoading = true;
    } else {
      isLoading = false;
    }

    return Stack(
      children: <Widget>[
        Align(
          alignment: Alignment.center,
          child: FlutterMap(
            mapController: mapController,
            options: MapOptions(
              interactiveFlags:
                  InteractiveFlag.pinchZoom | InteractiveFlag.drag,
              center: LatLng(51.5, -0.09),
              zoom: 5,
            ),
            children: [
              TileLayer(
                urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
              ),
              MarkerLayer(markers: localisationMarker),
              MarkerClusterLayerWidget(
                options: MarkerClusterLayerOptions(
                  spiderfyCircleRadius: 80,
                  spiderfySpiralDistanceMultiplier: 2,
                  circleSpiralSwitchover: 12,
                  maxClusterRadius: 120,
                  rotate: true,
                  size: const Size(40, 40),
                  anchor: AnchorPos.align(AnchorAlign.center),
                  fitBoundsOptions: const FitBoundsOptions(
                    padding: EdgeInsets.all(50),
                    maxZoom: 15,
                  ),
                  polygonOptions: const PolygonOptions(
                      borderColor: Assets.couleur,
                      color: Colors.black12,
                      borderStrokeWidth: 3),
                  markers: myMarkers,
                  builder: (context, markers) {
                    return FloatingActionButton(
                      onPressed: null,
                      child: Text(markers.length.toString()),
                    );
                  },
                ),
              ),
            ],
          ),
        ),
        if (isLoading)
          const Center(
            child: CircularProgressIndicator(),
          ),
        Align(
          alignment: Alignment.topCenter,
          child: Transform.scale(
            scale: 0.7,
            child: Container(
              margin: const EdgeInsets.all(1),
              padding: const EdgeInsets.all(8.0),
              decoration: BoxDecoration(
                color: const Color.fromARGB(100, 15, 170, 206),
                border: Border.all(
                  color: Assets.couleur,
                  style: BorderStyle.solid,
                  width: 3,
                ),
                borderRadius: BorderRadius.circular(30.0),
              ),
              child: Column(
                mainAxisSize: MainAxisSize.min,
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Row(
                    mainAxisAlignment: MainAxisAlignment.spaceBetween,
                    children: [
                      FloatingActionButton.small(
                        onPressed: _onButtonVestimentaire,
                        backgroundColor: _isEnableVestimentaires
                            ? Assets.couleur
                            : Colors.grey,
                        child: const Icon(
                          Icons.checkroom,
                          //  size: 36.0,
                        ),
                      ),
                      BorderedText(
                        strokeWidth: 4,
                        strokeColor: Assets.couleur,
                        child: Text(
                          "Missions ${Assets.strMissionsVestimentaires} : $markersVestimentairesCount",
                          style: const TextStyle(
                            color: Colors.white,
                            fontSize: 21.0,
                            fontStyle: FontStyle.italic,
                            fontWeight: FontWeight.bold,
                          ),
                        ),
                      ),
                    ],
                  ),
                  Row(
                    mainAxisAlignment: MainAxisAlignment.spaceBetween,
                    children: [
                      FloatingActionButton.small(
                        onPressed: _onButtonVoiture,
                        backgroundColor:
                            _isEnableVoitures ? Assets.couleur : Colors.grey,
                        child: const Icon(
                          Icons.directions_car,
                          //                          size: 36.0,
                        ),
                      ),
                      BorderedText(
                        strokeWidth: 4,
                        strokeColor: Assets.couleur,
                        child: Text(
                          "Missions ${Assets.strMissionsVoitures} : $markersVoituresCount",
                          style: const TextStyle(
                            color: Colors.white,
                            fontSize: 21.0,
                            fontStyle: FontStyle.italic,
                            fontWeight: FontWeight.bold,
                          ),
                        ),
                      ),
                    ],
                  ),
                  Row(
                    mainAxisAlignment: MainAxisAlignment.spaceBetween,
                    children: [
                      FloatingActionButton.small(
                        onPressed: _onButtonFlyers,
                        backgroundColor:
                            _isEnableFlyers ? Assets.couleur : Colors.grey,
                        child: const Icon(
                          Icons.description,
                          //            size: 36.0,
                        ),
                      ),
                      BorderedText(
                        strokeWidth: 4,
                        strokeColor: Assets.couleur,
                        child: Text(
                          "Missions ${Assets.strMissionsFlyers} : $markersFlyersCount",
                          style: const TextStyle(
                            color: Colors.white,
                            fontSize: 21.0,
                            fontStyle: FontStyle.italic,
                            fontWeight: FontWeight.bold,
                          ),
                        ),
                      ),
                    ],
                  ),
                ],
              ),
            ),
          ),
        ),
        Align(
          alignment: Alignment.bottomRight,
          child: Container(
            margin: const EdgeInsets.all(15.0),
            child: FloatingActionButton(
              onPressed: _getCurrentLocation,
              backgroundColor: Assets.couleur,
              child: const Icon(Icons.gps_fixed_outlined),
            ),
          ),
        ),
      ],
    );
  }

  Future<dynamic> _showModal(BuildContext context, title, details) {
    return showModalBottomSheet(
        context: context,
        shape: const RoundedRectangleBorder(
          borderRadius: BorderRadius.vertical(
            top: Radius.circular(30.0),
          ),
        ),
        backgroundColor: Assets.couleur,
        builder: (context) {
          return SizedBox(
            height: 250.0,
            child: Column(
              mainAxisAlignment: MainAxisAlignment.spaceEvenly,
              children: [
                Text(title),
                Text(details),
                ElevatedButton.icon(
                    onPressed: () {
                      //OnPressed Logic
                    },
                    icon: const Icon(Icons.check),
                    label: const Text("Accepter la mission")),
              ],
            ),
          );
        });
  }
}

ma_viewmodel.dart

import 'package:geolocator/geolocator.dart';
import '../../data/model/mission.dart';

abstract class MapRouter {
  recupMissions();
}

class MapViewModel {
  final MapRouter _router;
  MapViewModel(this._router);

  Future<List<Mission>> getMissions() async {
    final List<Mission> missions = await _router.recupMissions();
    return missions;
  }

  Future<bool> determinePermission() async {
    bool serviceEnabled;
    LocationPermission permission;

    serviceEnabled = await Geolocator.isLocationServiceEnabled();

    if (!serviceEnabled) {
      return false;
    }

    permission = await Geolocator.checkPermission();
    if (permission == LocationPermission.denied) {
      permission = await Geolocator.requestPermission();
      if (permission == LocationPermission.denied) {
        return false;
      }
    }

    if (permission == LocationPermission.deniedForever) {
      return false;
    }
    return true;
  }

  Future<Position> determinePosition() async {
    return await Geolocator.getCurrentPosition(
        desiredAccuracy: LocationAccuracy.high);
  }
}

Hello @morey,

Merci beaucoup pour le partage !! Ca va surement me servir :slight_smile:

Il faut le temps que je digère un peu le code pour le comprendre.

Mais en première lecture, mon viewmodel est identique au tien.

Tu peux peut-être simplifier ton code en utilisant la propriété floatingActionButton de ton Scaffold :

return Scaffold(
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          widget._viewModel.mapController.move(
              LatLng(widget._viewModel.latitude, widget._viewModel.longitude),
              widget._viewModel.mapController.zoom);
        },
        backgroundColor: Colors.blue,
        tooltip: 'Center user location',
        splashColor: Colors.grey,
        child: const Icon(
          Icons.my_location,
          color: Colors.white,
          size: 30,
        ),
      ),
      body: AnimatedBuilder(

Pour ton erreur, pour l’instant je sèche …

1 « J'aime »

Hello @morey ,

Pour ton erreur si je comprend bien tu essaie de mettre à jour ton State après l’avoir détruit (en changeant d’écran).
Généralement cela arrive quand tu mélange fonctions asynchrones et StatefulWidget, le State est détruit mais la fonction Async tourne toujours, l’appel au SetState déclenche alors une erreur.

Quelques pistes :

  • Passer en StatelessWidget avec la méthode du Routeur v2.
  • Essayer de désactiver les fonctions asynchrones dans le Dispose du StatefulWidget.
  • Utiliser la petite ligne if(!mounted) return; (voir vidéo ci-dessous)
1 « J'aime »

Merci beaucoup @Tazooou pour ton idée !

Je viens d’implémenter le floatingActionButton et j’ai simplifié ma méthode d’animation de la map.

Génial !!!

Par contre, tu as réussi à mettre ton controller map dans un viewModel ?
De mémoire j’avais été bloqué à cause du TickerProviderStateMixin…

Merci @isanforc !

Je me plonge dans tes explications ! (et je te fais un retour au plus vite)

it’s good @isanforc !!! merci beaucoup pour la vidéo :slight_smile:

La bonne pratique de rajouter <<< if (!mounted) return; >>> après chaque await corrige mes problèmes :slight_smile:

Merciiiiiiiiiiiiiiii

Hello @morey,

Oui, j’ai mis mon controller dans le viewModel ainsi que la gestion de mes marqueurs. Je te mets en l’état mon code qui est fonctionnel :

import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_map/plugin_api.dart';
import 'package:latlong2/latlong.dart';
import 'package:pickfungi/view_viewmodel/discover/discover_view.dart';

abstract class IPickingViewModel extends ChangeNotifier {
  double get latitude;
  double get longitude;
  MapController get mapController;
  List<Marker> get marker;
  checkUserLocation();
  getUserLocation();
}

class PickingView extends StatefulWidget {
  final IPickingViewModel _viewModel;
  final IBottomNavigationBarWidget _bottomNavigationBarWidget;
  const PickingView(this._viewModel, this._bottomNavigationBarWidget,
      {Key? key})
      : super(key: key);

  @override
  State<PickingView> createState() => _PickingViewState();
}

class _PickingViewState extends State<PickingView> {

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          widget._viewModel.getUserLocation();
        },
        backgroundColor: Colors.blue,
        tooltip: 'Center user location',
        splashColor: Colors.grey,
        child: const Icon(
          Icons.my_location,
          color: Colors.white,
          size: 30,
        ),
      ),
      body: AnimatedBuilder(
          animation: widget._viewModel,
          builder: (context, child) {
            return FlutterMap(
              mapController: widget._viewModel.mapController,
              options: MapOptions(
                  interactiveFlags:
                      InteractiveFlag.all & ~InteractiveFlag.rotate,
                  onMapReady: (() {
                    widget._viewModel.checkUserLocation();
                  }),
                  center: LatLng(
                      widget._viewModel.latitude, widget._viewModel.longitude),
                  zoom: 15.0,
                  minZoom: 1.0,
                  maxZoom: 18.0,
                  maxBounds: LatLngBounds(
                    LatLng(-90, -180.0),
                    LatLng(90.0, 180.0),
                  ),
                  keepAlive: false),
              children: [
                TileLayer(
                  urlTemplate: "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
                ),
                MarkerLayer(
                  markers: widget._viewModel.marker,
                )
              ],
            );
          }),
      bottomNavigationBar: widget._bottomNavigationBarWidget,
    );
  }
}
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import 'package:location/location.dart';
import 'package:pickfungi/view_viewmodel/picking/picking_view.dart';

abstract class IPickingRouter {}

class PickingViewModel extends IPickingViewModel {
  final IPickingRouter _router;

  PickingViewModel(this._router);

  final _location = Location();
  final _mapController = MapController();
  List<Marker> _marker = [];
  final double _defaultLatitude = 48.854213;
  final double _defaultLongitude = 2.347184;
  double? _userLatitude;
  double? _userLongitude;

  bool _serviceEnabled = false;
  PermissionStatus _permissionGranted = PermissionStatus.denied;

  @override
  double get latitude {
    final latitude = _userLatitude;
    if (latitude != null) {
      return latitude;
    } else {
      return _defaultLatitude;
    }
  }

  @override
  double get longitude {
    final longitude = _userLongitude;
    if (longitude != null) {
      return longitude;
    } else {
      return _defaultLongitude;
    }
  }

  @override
  MapController get mapController => _mapController;

  @override
  List<Marker> get marker => _marker;

  @override
  checkUserLocation() async {
    await checkLocationServices();
    await checkLocationAuthorization();
    _location.changeSettings(accuracy: LocationAccuracy.high);
    await getUserLocation();
    _mapController.move(LatLng(latitude, longitude), _mapController.zoom);
  }

  checkLocationServices() async {
    _serviceEnabled = await _location.serviceEnabled();
    if (!_serviceEnabled) {
      _serviceEnabled = await _location.requestService();
      if (!_serviceEnabled) {
        return;
      }
    }
  }

  checkLocationAuthorization() async {
    _permissionGranted = await _location.hasPermission();
    if (_permissionGranted == PermissionStatus.denied) {
      _permissionGranted = await _location.requestPermission();
      if (_permissionGranted != PermissionStatus.granted) {
        return;
      }
    }
  }

  @override
  getUserLocation() async {
    LocationData locationData = await _location.getLocation();
    _userLatitude = locationData.latitude;
    _userLongitude = locationData.longitude;
    _updateUserLocationMarker();
    _mapController.move(LatLng(latitude, longitude), _mapController.zoom);
    notifyListeners();
  }

  _updateUserLocationMarker() {
    _marker = [];
    _marker.add(Marker(
        point: LatLng(latitude, longitude),
        width: 20,
        height: 20,
        builder: ((context) => const Icon(
              size: 20.0,
              Icons.circle,
              color: Colors.white,
            ))));
    _marker.add(Marker(
        point: LatLng(latitude, longitude),
        width: 15,
        height: 15,
        builder: ((context) => const Icon(
              size: 15.0,
              Icons.circle,
              color: Colors.blue,
            ))));
  }
}

1 « J'aime »

Ah oui super @Tazooou !

J’ai adapté mon code avec ta solution ! Merci beaucoup :slight_smile: :heart:

Au cas où tu n’y aurais pas pensé, si tu es en routeur 2.0, tu peux basculer ton bottomNavigationBar dans ton delegate.

Et sinon, as tu une idée de où implémenter un Progress Indicator (le temps de récupérer la localisation ou les markers) dans le scaffold ?

Hello @morey,

Oui j’ai bien prévu de le faire, c’est dans ma todo :wink:

Pas de progress indicator pour moi. J’ai opté pour une autre stratégie. Je charge d’abord la map en m’appuyant sur la dernière position connue de l’utilisateur que j’ai enregistré dans les shared preferences lors de la dernière session.
Et je réactualise la map dès que j’ai sa nouvelle position. C’est ce que fait Google map. Ca évite un écran de chargement :slight_smile:

Hello à tous les deux,
Je viens de me rendre compte que vous utilisez Map alors même que je me pose des questions :slight_smile: (j’ai crée un thread). Ce qui me gêne c’est cette histoire d’API, et sa limite d’utilisation. Vous n’avez pas cette problématique ou bien c’est moi qui ai rien compris ? :slight_smile:

Hello @Sylvain,

Pour ma part, j’utilise openStreetMap. Google Map est devenu payant pour les pros et dès que tu dépasses un petit volume de téléchargement de tuiles, la facture grimpe donc j’ai abandonné l’idée.

OpenStreetMap est gratuit mais en effet, leur API est limité si tu surconsommes, ils peuvent te shooter mais j’ai pas trouvé de limite sur l’utilisation …

Utilisation intensive ( par ex. la distribution d’une application à usage intensif qui utilise des tuiles de openstreetmap.org ) est interdit sans autorisation préalable du Groupe de travail sur les opérations.

Je me tâte à les contacter directement :slight_smile:

Si quelqu’un d’autres à déjà creuser le sujet, je suis preneur également !

Hello @Tazooou ,

Oui on est en phase, et j’ai aussi regardé Openstreetmap. Si l’app marche ne serait ce que pas trop mal, je pense qu’il y a soucis. Logique, leurs serveurs sont sollicités. Je pense que tu n’as pas le choix de les contacter.
De mon côté, je creuse le sujet ici :slight_smile:

Perso, je suis en train de tester Google Map. Par contre, je ne comprends pas l’histoire du prix.

Google Map est devenu payant pour les pros et dès que tu dépasses un petit volume de téléchargement de tuiles, la facture grimpe donc j’ai abandonné l’idée.

Mon cas d’utilisation est juste d’afficher une carte et de placer des marqueurs dessus. Si je comprends bien la page de tarification, cela est gratuit pour Android et iOS, mais pas le web. (Il y a des trucs que je ne comprendrai jamais)

Tarification de l’api Google Map

En complément au sujet, je viens de comprendre comment rajouter un CircularProgressIndicator :slight_smile:

Il faut rajouter un widget Stack de cette manière :

   return Stack(
              children: [
                FlutterMap(...),
                if (_viewModel.isLoading)
                  const Center(
                    child: CircularProgressIndicator(),
                  ),
              ],

La subtilité (que j’ai eu du mal à trouver) pour voir s’afficher le CircularProgressIndicator, c’est qu’il faut le placer en dernier :slight_smile:

On en apprend tous les jours :slight_smile:

Allez l’OM !!! :slight_smile: