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')), );}}