CIGMAP I 10.06.2025 I UNDER EDIT (20.06.2025)

A Flutter App where the user can find and add vending mashines

CIGMAP

CIGMAP

The App uses Firestore as database and backend and is coded in Dart/Flutter.

Idea

There is no widely known database/application which holds vending machines nearby. I've started with cigarette machines because of the special need to find the closest and thos are widespread. The user can add loactions, report them or find the fastest direction. That means, the data collection is on the users side, which has mostly advantages but some disadvantages as well (see Challanges).

Background

There are around 610.800 machines in germany, that is one machine for 135 germans. 266.600 of these are hot-drink machines in companys and offices. 72% of all machines are located in companys over all. This brings us to around 200.000 machines spread over germanys area. In germany there daily sales around 11.5 million, which leads in a annual turnover of ~2.5 billion euros.

Challanges

Data quality: The fact, that the user is providing the data and locations makes it hard to secure data quality due to inprecise location tracking, wrong inputs or sabotage.

Data basement: To sell this application as a sulotion to find the nearest machine, there has to ba a database already. Nobody will add some locations when there are no entrys.

Code:

The GoogleMaps Flutter Package is the base of this project. It provides the interactable map with it's enourmus data base.
Please use my code only for information purposes!

Creating the Widgets/Mainpage

Placing the GoogleMapWidget, TopBar & SlidingUpPanel on the main page

return Scaffold(
      body: Stack(
        children: [
          _currentUserPosition == null
              ? const Center(child: CircularProgressIndicator())
              : GoogleMapWidget(
                initialPosition: germanycenter,
                initialZoom: defaultzoom,
                onMapCreated: (controller) {
                  _googleMapController = controller;

                  if (_currentUserPosition != null && !_hasAnimatedToUser) {
                    _googleMapController?.animateCamera(
                      CameraUpdate.newLatLngZoom(_currentUserPosition!, 15),
                    );
                    _hasAnimatedToUser = true;
                  }
                },
                onMarkerTapped: _onMarkerTapped,
                onMapTapped: _onMapTapped,
                markerRed: _markerRed,
                markerYellow: _markerYellow,
                markerGreen: _markerGreen,
              ),
          TopAppBar0(
            currentMapPosition: _currentUserPosition,
            onFindNearestPressed: _findNearestMarker,
            onMenuClosed: () {
              _panelController.open();
            },
            onMenuOpened: () {
              _panelController.close();
            },
          ),
          SlidingUpPanel(
            controller: _panelController,
            minHeight: _selectedDocumentId != null ? 158 : 0,
            maxHeight: 314,
            panel:
                _selectedDocumentId != null
                    ? SlidingUpMarkerPanel(
                      documentId: _selectedDocumentId!,
                      onDirectionsPressed: _showDirectionsToSelectedMarker,
                    )
                    : const SizedBox.shrink(),
            borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
          ),
        ],
      ),
    )

Building the TopBar

@override
  Widget build(BuildContext context) {
    final screenHeight = MediaQuery.of(context).size.height;

    return Align(
      alignment: Alignment.topCenter,
      child: Column(
        children: [
          SizedBox(height: MediaQuery.of(context).padding.top + 10),
          SizedBox(
            height: screenHeight * 0.08,
            child: Padding(
              padding: EdgeInsets.only(top: 10, right: 10, left: 10),
              child: Row(
                crossAxisAlignment: CrossAxisAlignment.stretch,
                children: [
                  Expanded(
                    child: ElevatedButton(
                      style: ElevatedButton.styleFrom(
                        padding: EdgeInsets.zero,
                        backgroundColor: darkGreen,
                        shadowColor: Colors.black.withOpacity(0.8),
                        elevation: 4,
                      ),
                      onPressed: () {
                        widget.onFindNearestPressed?.call();
                        _closeMenu();
                      },
                      child: Text(
                        'FIND NEAREST',
                        style: TextStyle(
                          fontFamily: 'NeueHaasGrotesk',
                          fontWeight: FontWeight.w500,
                          color: Colors.white,
                          fontSize: 31,
                        ),
                      ),
                    ),
                  ),
                  SizedBox(width: 10),

                  ElevatedButton(
                    onPressed: () {
                      _toggleMenu();
                    },
                    style: ElevatedButton.styleFrom(
                      backgroundColor: Colors.white,
                      elevation: 4,
                      shadowColor: Colors.black.withOpacity(0.8),
                    ),
                    child: Icon(
                      _isMenuOpen
                          ? Icons.close
                          : Icons.add_location_alt_outlined,
                      size: 42,
                      color: darkGreen,
                    ),
                  ),
                ],
              ),
            ),
          ),

          AnimatedSwitcher(
            duration: Duration(milliseconds: 50),
            child:
                _isMenuOpen
                    ? Padding(
                      key: ValueKey(true),
                      padding: const EdgeInsets.symmetric(
                        horizontal: 0,
                        vertical: 0,
                      ),
                      child: AddLocationForm(
                        currentMapPosition: widget.currentMapPosition,
                        onLocationAdded: () {
                          setState(() {
                            _isMenuOpen = false;
                          });
                        },
                      ),
                    )
                    : SizedBox.shrink(key: ValueKey(false)),
          ),
        ],
      ),
    );
  }

Building the SlidingUpPanel

return Padding(
          padding: const EdgeInsets.only(top: 9, left: 16, right: 16),
          child: Column(
            mainAxisSize: MainAxisSize.min,
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: [
              Align(
                alignment: Alignment.center,
                child: SizedBox(
                  width: 40,
                  height: 3,
                  child: DecoratedBox(
                    decoration: BoxDecoration(
                      color: Colors.grey.withOpacity(0.24),
                    ),
                  ),
                ),
              ),
              const SizedBox(height: 14),
              Container(
                alignment: Alignment.center,
                child: Text(
                  formattedName,
                  style: const TextStyle(fontSize: 35, height: 1),
                  textAlign: TextAlign.center,
                ),
              ),

              const SizedBox(height: 2),
              Container(
                alignment: Alignment.center,
                child: Text(
                  "$type$reportText",
                  style: const TextStyle(
                    fontSize: 24,
                    color: Colors.grey,
                    fontWeight: FontWeight.w400,
                  ),
                  textAlign: TextAlign.center,
                ),
              ),

              Column(
                children: [
                  if (showColumn)
                    Container(
                      alignment: Alignment.center,
                      child: Text(
                        reportInfoText,
                        style: TextStyle(
                          fontSize: 18,
                          color: reportTextColor,
                          fontWeight: FontWeight.w400,
                        ),
                        textAlign: TextAlign.center,
                      ),
                    )
                  else
                    SizedBox(height: 22),
                ],
              ),

              const SizedBox(height: 6),

              Column(
                children: [
                  ElevatedButton(
                    onPressed: onDirectionsPressed,
                    style: ElevatedButton.styleFrom(
                      backgroundColor: darkGreen,
                      elevation: 0,
                      minimumSize: Size(double.infinity, 60),
                    ),
                    child: Row(
                      mainAxisAlignment: MainAxisAlignment.center,
                      children: [
                        Icon(Icons.directions, size: 48, color: Colors.white),
                        SizedBox(width: 8),
                        Text(
                          "Directions",
                          style: TextStyle(
                            color: Colors.white,
                            fontFamily: 'NeueHaasGrotesk',
                            fontSize: 28,
                          ),
                        ),
                      ],
                    ),
                  ),
                  SizedBox(height: 10),
                  Row(
                    mainAxisAlignment: MainAxisAlignment.center,
                    children: [
                      Expanded(
                        child: ElevatedButton(
                          onPressed: () async {
                            if (!await canReport(documentId, "defect")) {
                              ScaffoldMessenger.of(context).showSnackBar(
                                SnackBar(
                                  content: Text(
                                    'You have already reported a defect',
                                  ),
                                ),
                              );
                              return;
                            }

                            await FirebaseFirestore.instance
                                .collection('locations')
                                .doc(documentId)
                                .update({'defect': FieldValue.increment(1)});
                            await markReport(documentId, "defect");

                            ScaffoldMessenger.of(context).showSnackBar(
                              SnackBar(content: Text("Reported as defect!")),
                            );
                          },

                          style: ElevatedButton.styleFrom(
                            backgroundColor: darkRed.withOpacity(0.2),
                            elevation: 0,
                            minimumSize: Size(double.infinity, 60),
                          ),
                          child: Row(
                            mainAxisAlignment: MainAxisAlignment.center,
                            children: [
                              Icon(Icons.error, size: 38, color: darkRed),
                              SizedBox(width: 8),
                              Text(
                                "Defect",
                                style: TextStyle(
                                  color: darkRed,
                                  fontFamily: 'NeueHaasGrotesk',
                                  fontSize: 24,
                                ),
                              ),
                            ],
                          ),
                        ),
                      ),
                      SizedBox(width: 10),
                      Expanded(
                        child: SizedBox(
                          height: 60,
                          child: ElevatedButton(
                            onPressed: () async {
                              if (!await canReport(documentId, "missing")) {
                                ScaffoldMessenger.of(context).showSnackBar(
                                  SnackBar(
                                    content: Text(
                                      "You have already reported it's not existing",
                                    ),
                                  ),
                                );
                                return;
                              }

                              await FirebaseFirestore.instance
                                  .collection('locations')
                                  .doc(documentId)
                                  .update({'missing': FieldValue.increment(1)});
                              await markReport(documentId, "missing");

                              ScaffoldMessenger.of(context).showSnackBar(
                                SnackBar(
                                  content: Text("Reported as not existing!"),
                                ),
                              );
                            },

                            style: ElevatedButton.styleFrom(
                              backgroundColor: darkRed.withOpacity(0.2),
                              elevation: 0,
                              minimumSize: Size(double.infinity, 60),
                            ),
                            child: Row(
                              mainAxisAlignment: MainAxisAlignment.center,
                              children: [
                                Icon(
                                  Icons.remove_circle,
                                  size: 38,
                                  color: darkRed,
                                ),
                                SizedBox(width: 8),
                                Text(
                                  "Don't exist",
                                  style: TextStyle(
                                    color: darkRed,
                                    fontFamily: 'NeueHaasGrotesk',
                                    fontSize: 24, 
                          ),),],),),),),],),],),],),)

Creating the markers

Loading the locations for the markers from firestore, placing it on the map & setting the dynamic color depending on any defect report entrys

Future<void> _loadAllMarkers() async {
    _markerRed = await getCustomMarkerDescriptor(
      asset: 'assets/markers/LocationMarker_red_124.png',
    );
    _markerYellow = await getCustomMarkerDescriptor(
      asset: 'assets/markers/LocationMarker_yellow_124.png',
    );
    _markerGreen = await getCustomMarkerDescriptor(
      asset: 'assets/markers/LocationMarker_green_124.png',
    );
    setState(() {});
  }

@override
  Widget build(BuildContext context) {
    return StreamBuilder<QuerySnapshot>(
      stream: FirebaseFirestore.instance.collection('locations').snapshots(),
      builder: (context, snapshot) {
        if (!snapshot.hasData || snapshot.hasError) {
          return const Center(child: CircularProgressIndicator());
        }

        final markers =
            snapshot.data!.docs
                .map((doc) {
                  final data = doc.data() as Map<String, dynamic>;
                  final geo = data['location'] as GeoPoint?;
                  if (geo == null) return null;

                  final defectCount = (data['defect'] ?? 0) as int;
                  final missingCount = (data['missing'] ?? 0) as int;
                  final reportCount = missingCount + defectCount;

                  BitmapDescriptor icon;
                  if (reportCount >= 3) {
                    icon = markerRed ?? BitmapDescriptor.defaultMarker;
                  } else if (reportCount > 0) {
                    icon = markerYellow ?? BitmapDescriptor.defaultMarker;
                  } else {
                    icon = markerGreen ?? BitmapDescriptor.defaultMarker;
                  }

                  return Marker(
                    markerId: MarkerId(doc.id),
                    position: LatLng(geo.latitude, geo.longitude),
                    icon: icon,
                    onTap:
                        () => onMarkerTapped(
                          doc.id,
                          LatLng(geo.latitude, geo.longitude),
                        ),
                  );
                })
                .whereType<Marker>()
                .toSet()

Setting the dynamic size of the marker depending on display size /dpi

Future<BitmapDescriptor> getCustomMarkerDescriptor({
    required String asset,
    int size = 128,
  }) async {
    final byteData = await rootBundle.load(asset);
    final codec = await ui.instantiateImageCodec(
      byteData.buffer.asUint8List(),
      targetWidth: size,
      targetHeight: size,
    );
    final frame = await codec.getNextFrame();
    final image = frame.image;
    final byteDataPng = await image.toByteData(format: ui.ImageByteFormat.png);
    return BitmapDescriptor.fromBytes(byteDataPng!.buffer.asUint8List());
  }

Handling the users position & naviagtion:

Checking if the user granted location permissions

Future<void> _listenToLocationUpdates() async {
    bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
    if (!serviceEnabled) {
      await Geolocator.openLocationSettings();
      serviceEnabled = await Geolocator.isLocationServiceEnabled();
      if (!serviceEnabled) {
        _showError('Location permissions are denied');
        return;
      }
    }

    LocationPermission permission = await Geolocator.checkPermission();
    if (permission == LocationPermission.denied ||
        permission == LocationPermission.deniedForever) {
      permission = await Geolocator.requestPermission();
      if (permission == LocationPermission.denied ||
          permission == LocationPermission.deniedForever) {
        _showError(
          'Location permissions are permanently denied, we cannot request permissions.',
        );
        return;
      }}

Listening on user location updates

final locationSettings = LocationSettings(
      accuracy: LocationAccuracy.best,
      distanceFilter: 4,
    );
    _positionStreamSubscription?.cancel();
    _positionStreamSubscription = Geolocator.getPositionStream(
      locationSettings: locationSettings,
    ).listen((Position? position) {
      if (position != null && mounted) {
        setState(() {
          _currentUserPosition = LatLng(position.latitude, position.longitude);
});}});}

Try getting the current user position. If not available, fallback on last known

try {
      final pos = await Geolocator.getCurrentPosition(
        desiredAccuracy: LocationAccuracy.best,
        timeLimit: Duration(seconds: 5),
      );
      if (mounted) {
        setState(() {
          _currentUserPosition = LatLng(pos.latitude, pos.longitude);
        });
      }
    } catch (e) {
      // Fallback LastKnown
      final lastKnown = await Geolocator.getLastKnownPosition();
      if (lastKnown != null && mounted) {
        setState(() {
          _currentUserPosition = LatLng(
            lastKnown.latitude,
            lastKnown.longitude,
          );
        });
      } else {
        _showError("No location found");
      }}

Find the nearest marker to the users location with camera animation on it

double _calculateDistance(LatLng p1, LatLng p2) {
    const earthRadius = 6371000.0;
    final dLat = _degreesToRadians(p2.latitude - p1.latitude);
    final dLon = _degreesToRadians(p2.longitude - p1.longitude);
    final a =
        sin(dLat / 2) * sin(dLat / 2) +
        cos(_degreesToRadians(p1.latitude)) *
            cos(_degreesToRadians(p2.latitude)) *
            sin(dLon / 2) *
            sin(dLon / 2);
    final c = 2 * atan2(sqrt(a), sqrt(1 - a));
    return earthRadius * c;
  }

  double _degreesToRadians(double degrees) => degrees * pi / 180.0;

  void _findNearestMarker() async {
    if (_currentUserPosition == null) return;

    final snapshot =
        await FirebaseFirestore.instance.collection('locations').get();
    LatLng? nearestMarker;
    String? nearestDocumentId;
    double? minDistance;

    for (var doc in snapshot.docs) {
      final data = doc.data() as Map<String, dynamic>?;
      if (data == null) continue;
      final geo = data['location'] as GeoPoint?;
      if (geo == null) continue;
      final markerPos = LatLng(geo.latitude, geo.longitude);
      final distance = _calculateDistance(_currentUserPosition!, markerPos);
      if (minDistance == null || distance < minDistance) {
        minDistance = distance;
        nearestMarker = markerPos;
        nearestDocumentId = doc.id;
      }
    }

    if (nearestMarker != null) {
      _googleMapController?.animateCamera(
        CameraUpdate.newLatLngZoom(nearestMarker, 16),
      );
      setState(() {
        _selectedDocumentId = nearestDocumentId;
      });
      _panelController.open();
    }}

Starting navigation to selected marker

void _showDirectionsToSelectedMarker() async {
    if (_selectedDocumentId == null || _currentUserPosition == null) return;

    final doc =
        await FirebaseFirestore.instance
            .collection('locations')
            .doc(_selectedDocumentId)
            .get();

    final data = doc.data();
    if (data == null) return;
    final geo = data['location'] as GeoPoint?;
    if (geo == null) return;

    final markerLatLng = LatLng(geo.latitude, geo.longitude);

    final url =
        'https://www.google.com/maps/dir/?api=1'
        '&origin=${_currentUserPosition!.latitude},${_currentUserPosition!.longitude}'
        '&destination=${markerLatLng.latitude},${markerLatLng.longitude}'
        '&travelmode=walking';

    if (await canLaunchUrl(Uri.parse(url))) {
      await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
    } else {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('Could not launch Google Maps')),
      );}}