import 'dart:core'; import 'package:comunes_flutter/comunes_flutter.dart'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_redux/flutter_redux.dart'; import 'package:latlong2/latlong.dart'; import 'package:redux/src/store.dart'; import 'package:share_plus/share_plus.dart'; import 'attributionMapPlugin.dart'; import 'colors.dart'; import 'compassMapPlugin.dart'; import 'customMoment.dart'; import 'dummyMapPlugin.dart'; import 'fireMarkType.dart'; import 'fireMarker.dart'; import 'generated/i18n.dart'; import 'genericMapBottom.dart'; import 'globals.dart' as globals; import 'layerSelectorMapPlugin.dart'; import 'locationUtils.dart'; import 'models/appState.dart'; import 'models/falsePositiveTypes.dart'; import 'models/fireNotification.dart'; import 'models/yourLocation.dart'; import 'redux/actions.dart'; import 'sentryReport.dart'; import 'slider.dart'; import 'zoomMapPlugin.dart'; @immutable class _ViewModel { const _ViewModel( {required this.mapState, required this.serverUrl, required this.lang, required this.onSubs, required this.onSubsConfirmed, required this.onUnSubs, required this.onSlide, required this.onEdit, required this.onEditing, required this.onEditConfirm, required this.onFalsePositive, required this.onFirePressed, required this.onEditCancel}); final String serverUrl; final String lang; final FireMapState mapState; final OnSubscribeFunction onSubs; final OnSubscribeConfirmedFunction onSubsConfirmed; final OnUnSubscribeFunction onUnSubs; final OnSubscribeDistanceChangeFunction onSlide; final OnLocationEdit onEdit; final OnLocationEditConfirm onEditConfirm; final OnLocationEditCancel onEditCancel; final OnLocationEditing onEditing; final OnFalsePositive onFalsePositive; final OnFirePressedInMap onFirePressed; @override bool operator ==(Object other) => identical(this, other) || other is _ViewModel && runtimeType == other.runtimeType && serverUrl == other.serverUrl && lang == other.lang && mapState == other.mapState; @override int get hashCode => serverUrl.hashCode ^ lang.hashCode ^ mapState.hashCode; } class GenericMap extends StatefulWidget { const GenericMap({super.key}); @override GenericMapState createState() => GenericMapState(); } class GenericMapState extends State { // This needs to be stateful so when resizes don't get a new globalkey // https://github.com/flutter/flutter/issues/1632#issuecomment-180478202 final GlobalKey _scaffoldKey = GlobalKey(); YourLocation? _location; YourLocation? _initialLocation; @override Widget build(BuildContext context) { return StoreConnector( distinct: true, onInitialBuild: (_ViewModel store) { _initialLocation = _location?.copyWith(); }, converter: (Store store) { return _ViewModel( onSubs: (YourLocation loc) { store.dispatch(SubscribeAction()); }, onSubsConfirmed: (YourLocation loc) { loc.subscribed = true; store.dispatch(SubscribeConfirmAction(loc)); }, onUnSubs: (YourLocation loc) { loc.subscribed = false; store.dispatch(UnSubscribeAction(loc)); }, onSlide: (YourLocation loc) { store.dispatch(UpdateYourLocationMapAction(loc)); }, onEdit: (YourLocation loc) => store.dispatch(EditYourLocationAction(loc)), onEditing: (YourLocation loc) { store.dispatch(UpdateYourLocationMapAction(loc)); }, onEditCancel: (YourLocation loc) => store.dispatch(EditCancelYourLocationAction(loc)), onEditConfirm: (YourLocation loc) { store.dispatch(UpdateYourLocationAction(loc)); store.dispatch(UpdateYourLocationMapAction(loc)); store.dispatch(EditConfirmYourLocationAction(loc)); }, onFalsePositive: (FireNotification notif, FalsePositiveType type) => store.dispatch(MarkFireAsFalsePositiveAction(notif, type)), onFirePressed: (LatLng latLng, DateTime when, String type) { _showFireDialog(latLng, when, type); }, serverUrl: store.state.serverUrl, // Not used yet, but maybe in the future for date format lang: store.state.user.lang, mapState: store.state.fireMapState); }, builder: (BuildContext context, _ViewModel view) { final YourLocation? location = view.mapState.yourLocation; _location = location?.copyWith(); final FireMapState mapState = view.mapState; final FireMapStatus status = mapState.status; final FireMapLayer layer = mapState.layer; const double maxZoom = 18.0; // works? final MapOptions mapOptions = MapOptions( initialCenter: LatLng(_location?.lat ?? 0.0, _location?.lon ?? 0.0), maxZoom: maxZoom, onTap: (TapPosition tapPosition, LatLng latLng) { if (status == FireMapStatus.edit && _location != null) { _location = _location! .copyWith(lat: latLng.latitude, lon: latLng.longitude); view.onEditing(_location!); } }, // onPositionChanged: (positionCallback) { // print('${positionCallback.center}, ${positionCallback.zoom}'); //} ); // var mapController = new MapController(); // mapController.fitBounds(bounds); // mapController.center final String btnText = status == FireMapStatus.view ? S.of(context).toFiresNotifications : status == FireMapStatus.subscriptionConfirm ? S.of(context).confirm : S.of(context).unsubscribe; final IconData btnIcon = status == FireMapStatus.view ? Icons.notifications_active : status == FireMapStatus.subscriptionConfirm ? Icons.check : Icons.notifications_off; String baseLayer; List subdomains = []; String attribution; switch (layer) { case FireMapLayer.osmc: case FireMapLayer.osmcGrey: attribution = '© OpenStreetMap contributors'; break; case FireMapLayer.esri: case FireMapLayer.esriSatellite: case FireMapLayer.esriTerrain: attribution = '© ESRI'; } /* tiles from: https://github.com/dceejay/RedMap/blob/1914ed3b9ce4e8a496049849a93282730b4fff02/worldmap/index.html */ switch (layer) { case FireMapLayer.osmc: subdomains = ['a', 'b', 'c']; baseLayer = 'https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png'; break; case FireMapLayer.osmcGrey: baseLayer = 'https://cartodb-basemaps-{s}.global.ssl.fastly.net/light_all/{z}/{x}/{y}.png'; subdomains = ['a', 'b', 'c', 'd']; break; case FireMapLayer.esri: baseLayer = 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/{z}/{y}/{x}'; break; case FireMapLayer.esriSatellite: baseLayer = 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'; break; case FireMapLayer.esriTerrain: baseLayer = 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Shaded_Relief/MapServer/tile/{z}/{y}/{x}'; } final FlutterMap map = FlutterMap( options: mapOptions, children: [ TileLayer( maxZoom: maxZoom, urlTemplate: baseLayer, subdomains: subdomains, userAgentPackageName: 'com.example.fires_flutter', ), if (globals.isDevelopment) const ZoomMapPluginWidget() else const DummyMapPluginWidget(), const CompassMapPluginWidget(), MarkerLayer( markers: buildMarkers( mapState.status == FireMapStatus.viewFireNotification && mapState.fireNotification != null ? LatLng(mapState.fireNotification!.lat, mapState.fireNotification!.lon) : LatLng(_location!.lat, _location!.lon), mapState.fires, mapState.industries, mapState.falsePos, mapState.status == FireMapStatus.viewFireNotification, view.onFirePressed), ), // new AttributionPluginWidget(text: "© OpenStreetMap contributors"), const LayerSelectorMapPluginWidget(), AttributionPluginWidget(text: attribution), ], ); // mapController. /* FlutterMapState leafletState = map.createState(); leafletState.mapState.onMoved.listen((Null) { ; }); */ // Do something with it return Scaffold( key: _scaffoldKey, resizeToAvoidBottomInset: true, appBar: AppBar( title: status == FireMapStatus.edit ? TextField( // autofocus: true, key: const Key('LocationDescField'), keyboardType: TextInputType.text, controller: TextEditingController.fromValue( TextEditingValue( text: _location!.description, selection: TextSelection.collapsed( offset: _location!.description.length))), onChanged: (String newDesc) { debugPrint('OnChanged'); _location = _location!.copyWith(description: newDesc); }, onSubmitted: (String newDesc) { debugPrint('OnSubmitted'); _location = _location!.copyWith(description: newDesc); view.onEditConfirm(_location!); }, ) : status == FireMapStatus.viewFireNotification ? Text(S.of(context).fireNotificationTitle) : Text(_location!.description), actions: buildAppBarActions(status, view, _location!), ), floatingActionButton: status == FireMapStatus.edit || status == FireMapStatus.viewFireNotification ? null : FloatingActionButton.extended( onPressed: () { switch (status) { case FireMapStatus.view: view.onSubs(_location!); break; case FireMapStatus.subscriptionConfirm: view.onSubsConfirmed(_location!); break; case FireMapStatus.unsubscribe: view.onUnSubs(_location!); break; case FireMapStatus.edit: case FireMapStatus.viewFireNotification: break; } }, // https://github.com/flutter/flutter/issues/17583 heroTag: 'firesmap${_location!.id.hexString}', icon: Icon(btnIcon, color: fires600), label: Text( btnText, style: const TextStyle(color: fires600), ), backgroundColor: Colors.white, ), floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat, bottomNavigationBar: GenericMapBottom( onSave: () => view.onEditConfirm(_location!), onCancel: () => view.onEditCancel(_initialLocation ?? _location!), onFalsePositive: (FireNotification sealed, FalsePositiveType type) => view.onFalsePositive(sealed, type), state: view.mapState, scaffoldKey: _scaffoldKey, ), body: LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) => Stack(fit: StackFit.expand, children: [ // Material(color: Colors.yellowAccent), Opacity( opacity: status == FireMapStatus.subscriptionConfirm || status == FireMapStatus.edit ? 0.5 : 1.0, child: map), Positioned( top: constraints.maxHeight - 200, right: 10.0, left: 10.0, child: CenteredRow( // Fit sample: // https://github.com/apptreesoftware/flutter_map/blob/master/flutter_map_example/lib/pages/map_controller.dart children: status == FireMapStatus.subscriptionConfirm || (status == FireMapStatus.edit && _location != null && _location!.subscribed) ? [ FireDistanceSlider( initialValue: _location?.distance ?? 0, onSlide: (int distance) { if (_location != null) { _location!.distance = distance; view.onSlide(_location!); } }) ] : []), ) ]))); }); } List buildAppBarActions( FireMapStatus status, _ViewModel view, YourLocation location) { switch (status) { case FireMapStatus.view: case FireMapStatus.unsubscribe: return [ IconButton( icon: const Icon(Icons.edit), onPressed: () => view.onEdit(location)) ]; case FireMapStatus.edit: return [ IconButton( icon: const Icon(Icons.save), onPressed: () => view.onEditConfirm(_location!)) ]; case FireMapStatus.viewFireNotification: return [ IconButton( icon: const Icon(Icons.share), onPressed: () { Share.share( '${view.mapState.fireNotification?.description ?? 'Fire'}. ${view.serverUrl}fire/${view.mapState.fireNotification?.sealed ?? ''}'); }) ]; default: return []; } } List buildMarkers( LatLng pos, List fires, List falsePosList, List industries, bool isNotif, OnFirePressedInMap onFirePressed) { final List markers = []; // Debug: building markers: fires: ${fires.length} falsePos: ${falsePosList.length} industries: ${industries.length}, isNotif: $isNotif // const calibrate = false; // useful when we change the fire icons size for (final falsePos in falsePosList) { try { final List coords = falsePos['geo']['coordinates'] as List; // print('false pos: ${coords}'); final LatLng loc = LatLng( (coords[1] as num).toDouble(), (coords[0] as num).toDouble()); markers.add(FireMarker(loc, FireMarkType.falsePos, () { _showFalsePositiveDialog(loc); })); // if (calibrate) markers.add(FireMarker(loc, FireMarkType.pixel)); } catch (e, stackTrace) { reportError(e, stackTrace); } } for (final industry in industries) { try { // print(fire['geo']['coordinates']); final List coords = industry['geo']['coordinates'] as List; final LatLng loc = LatLng( (coords[1] as num).toDouble(), (coords[0] as num).toDouble()); markers.add(FireMarker(loc, FireMarkType.industry, () { _showIndustryDialog(loc); })); // if (calibrate) markers.add(FireMarker(loc, FireMarkType.pixel)); } catch (e, stackTrace) { reportError(e, stackTrace); } } for (final fire in fires) { try { final LatLng loc = LatLng( (fire['lat'] as num).toDouble(), (fire['lon'] as num).toDouble()); markers.add(FireMarker(loc, FireMarkType.fire, () { onFirePressed(loc, DateTime.parse(fire['when'].toString()), fire['type'] as String); })); markers.add(FireMarker(loc, FireMarkType.pixel)); } catch (e, stackTrace) { reportError(e, stackTrace); } } markers.add( FireMarker(pos, isNotif ? FireMarkType.fire : FireMarkType.position)); // if (calibrate) markers.add(FireMarker(pos, FireMarkType.pixel)); return markers; } void _showFireDialog(LatLng pos, DateTime date, String type) { final String when = Moment.fromDate(date).fromNow(context); getReverseLocation(lat: pos.latitude, lon: pos.longitude) .then((String reverseLoc) { final String by = type == 'vecinal' ? S.of(context).byOurUsers : S.of(context).byNASAsatellites; final String fireDesc = S.of(context).additionalInfoAboutFire(reverseLoc, when, by); showDialog( context: _scaffoldKey.currentContext!, builder: (_) => AlertDialog( content: Text(fireDesc), actions: [ TextButton( child: Text(S.of(context).CLOSE), onPressed: () { Navigator.pop(context); }, ), ], )); }); } void _showIndustryDialog(LatLng pos) { getReverseLocation(lat: pos.latitude, lon: pos.longitude) .then((String reverseLoc) { final String industryDesc = '${S.of(context).itSeemsAIndustry}\n\n' 'Type: Industry\n' 'Location: $reverseLoc'; showDialog( context: _scaffoldKey.currentContext!, builder: (_) => AlertDialog( title: Text(S.of(context).notAWildfire), content: Text(industryDesc), actions: [ TextButton( child: Text(S.of(context).CLOSE), onPressed: () { Navigator.pop(context); }, ), ], )); }); } void _showFalsePositiveDialog(LatLng pos) { getReverseLocation(lat: pos.latitude, lon: pos.longitude) .then((String reverseLoc) { final String falseDesc = '${S.of(context).itSeemsNotAtForesFire}\n\n' 'Type: False Positive\n' 'Location: $reverseLoc'; showDialog( context: _scaffoldKey.currentContext!, builder: (_) => AlertDialog( title: Text(S.of(context).notAWildfire), content: Text(falseDesc), actions: [ TextButton( child: Text(S.of(context).CLOSE), onPressed: () { Navigator.pop(context); }, ), ], )); }); } }