11// SPDX-License-Identifier: AGPL-3.0-or-later
22
3- import 'package:app/ui/colors.dart' ;
43import 'package:app/ui/widgets/action_buttons.dart' ;
54import 'package:app/ui/widgets/editable_card.dart' ;
65import 'package:flutter/foundation.dart' ;
76import 'package:flutter/gestures.dart' ;
87import 'package:flutter/material.dart' ;
8+ import 'package:flutter/services.dart' ;
99import 'package:flutter_gen/gen_l10n/app_localizations.dart' ;
1010import 'package:maplibre_gl/maplibre_gl.dart' ;
1111
@@ -29,16 +29,42 @@ const Coordinates BRAZIL_CENTROID_COORDINATES =
2929const double DEFAULT_ZOOMED_OUT_LEVEL = 1.5 ;
3030const double DEFAULT_ZOOMED_IN_LEVEL = 8 ;
3131
32+ const EMPTY_SOURCE_GEOJSON_DATA = {
33+ "type" : "FeatureCollection" ,
34+ // ignore: inference_failure_on_collection_literal
35+ "features" : []
36+ };
37+ const SOURCE_ID = 'points' ;
38+ const LAYER_ID = 'location' ;
39+ const BEE_IMAGE_ID = 'bee' ;
40+ const PLACEHOLDER_FEATURE_ID = 'placeholder' ;
41+ const FOCUSED_FEATURE_ID = 'focused' ;
42+
43+ const SHOW_FOCUSED_FILTER_EXPRESSION = [
44+ Expressions .equal,
45+ [Expressions .id],
46+ FOCUSED_FEATURE_ID ,
47+ ];
48+ const SHOW_ALL_FILTER_EXPRESSION = [Expressions .literal, true ];
49+ const ICON_OPACITY_EXPRESSION = [
50+ Expressions .caseExpression,
51+ [
52+ Expressions .equal,
53+ [Expressions .id],
54+ PLACEHOLDER_FEATURE_ID ,
55+ ],
56+ 0.6 ,
57+ 1
58+ ];
59+
3260class _LocationFieldState extends State <LocationField > {
61+ // Represents the coordinates used for the focused symbol on the map (i.e. what's centered on the map)
3362 late Coordinates ? _coordinates;
3463
3564 bool _isEditMode = false ;
3665
3766 MapLibreMapController ? _mapController;
3867
39- // The marker that uses the saved coordinates of the sighting
40- Circle ? _existingMapMarker;
41-
4268 @override
4369 void initState () {
4470 _coordinates = widget.coordinates;
@@ -52,26 +78,25 @@ class _LocationFieldState extends State<LocationField> {
5278 return EditableCard (
5379 title: t.sightingLocationFieldTitle,
5480 isEditMode: _isEditMode,
55- onChanged: _isEditMode
56- ? _handleCancel
57- : () {
58- setState (() {
59- _isEditMode = true ;
60- });
61- },
81+ onChanged: _isEditMode ? _handleCancel : _handleStartEdit,
6282 child: Column (crossAxisAlignment: CrossAxisAlignment .start, children: [
6383 _renderMap (),
6484 const SizedBox (
6585 height: 20 ,
6686 ),
67- if (_coordinates != null ) ...[
87+ if (_coordinates == null )
88+ Text (t.sightingLocationNoLocation)
89+ else ...[
6890 Text (t.sightingLocationLongitude (_coordinates! .longitude),
6991 style: Theme .of (context).textTheme.bodyLarge),
7092 Text (t.sightingLocationLatitude (_coordinates! .latitude),
7193 style: Theme .of (context).textTheme.bodyLarge),
7294 ],
7395 if (_isEditMode)
74- ActionButtons (onCancel: _handleCancel, onAction: _handleSave)
96+ Padding (
97+ padding: const EdgeInsets .only (top: 10.0 ),
98+ child:
99+ ActionButtons (onCancel: _handleCancel, onAction: _handleSave))
75100 ]),
76101 );
77102 }
@@ -90,121 +115,175 @@ class _LocationFieldState extends State<LocationField> {
90115 alignment: Alignment .center,
91116 height: 200 ,
92117 child: MapLibreMap (
93- initialCameraPosition: initialCameraPosition,
94- scrollGesturesEnabled: _isEditMode,
95- dragEnabled: _isEditMode,
96- zoomGesturesEnabled: _isEditMode,
97- trackCameraPosition: _isEditMode,
98- compassEnabled: false ,
99- rotateGesturesEnabled: false ,
100- onMapCreated: (controller) {
101- _mapController = controller;
102- },
103- gestureRecognizers: < Factory <OneSequenceGestureRecognizer >> {
104- Factory <OneSequenceGestureRecognizer >(
105- () => EagerGestureRecognizer ()),
106- },
107- onStyleLoadedCallback: () async {
108- if (_coordinates != null ) {
109- _existingMapMarker = await _mapController! .addCircle (
110- createMapCircle (
111- latitude: _coordinates! .latitude,
112- longitude: _coordinates! .longitude));
113- }
114- },
115- onMapClick: (_, latLng) async {
116- if (! _isEditMode) return ;
117-
118- final nextMarker = _mapController! .circles.where ((c) {
119- return c != _existingMapMarker;
120- }).firstOrNull;
121-
122- // Add the "next" marker and update the visuals of the existing marker
123- if (nextMarker == null ) {
124- await _mapController! .addCircle (createMapCircle (
125- latitude: latLng.latitude, longitude: latLng.longitude));
126-
127- if (_existingMapMarker != null ) {
128- await _mapController! .updateCircle (
129- _existingMapMarker! ,
130- const CircleOptions (
131- circleOpacity: 0.5 ,
132- circleStrokeOpacity: 0.5 ,
133- ));
134- }
135- } else {
136- await _mapController!
137- .updateCircle (nextMarker, CircleOptions (geometry: latLng));
138- }
139-
140- _mapController! .animateCamera (CameraUpdate .newLatLng (latLng));
141-
142- setState (() {
143- _coordinates =
144- (latitude: latLng.latitude, longitude: latLng.longitude);
145- });
146- }));
118+ initialCameraPosition: initialCameraPosition,
119+ scrollGesturesEnabled: _isEditMode,
120+ dragEnabled: _isEditMode,
121+ zoomGesturesEnabled: _isEditMode,
122+ trackCameraPosition: _isEditMode,
123+ compassEnabled: false ,
124+ rotateGesturesEnabled: false ,
125+ onMapCreated: (controller) {
126+ _mapController = controller;
127+ },
128+ gestureRecognizers: < Factory <OneSequenceGestureRecognizer >> {
129+ Factory <OneSequenceGestureRecognizer >(
130+ () => EagerGestureRecognizer ()),
131+ },
132+ onStyleLoadedCallback: _handleMapStyleLoaded,
133+ onMapClick: _handleMapPress,
134+ ));
135+ }
136+
137+ Future <void > _handleMapStyleLoaded () async {
138+ // Load asset used for symbol marker
139+ final bytes = await rootBundle.load ("assets/images/meliponini.png" );
140+ final list = bytes.buffer.asUint8List ();
141+ _mapController! .addImage (BEE_IMAGE_ID , list);
142+
143+ // Create source
144+ await _mapController! .addGeoJsonSource (
145+ SOURCE_ID ,
146+ _coordinates == null
147+ ? EMPTY_SOURCE_GEOJSON_DATA
148+ : createSourceData (focusedCoord: _coordinates! ));
149+
150+ // Create layer
151+ await _mapController! .addSymbolLayer (
152+ SOURCE_ID ,
153+ LAYER_ID ,
154+ const SymbolLayerProperties (
155+ iconImage: BEE_IMAGE_ID ,
156+ iconSize: 0.07 ,
157+ iconAllowOverlap: true ,
158+ iconOpacity: ICON_OPACITY_EXPRESSION ,
159+ ),
160+ enableInteraction: false ,
161+ filter: SHOW_FOCUSED_FILTER_EXPRESSION );
162+ }
163+
164+ Future <void > _handleMapPress (_, LatLng latLng) async {
165+ if (! _isEditMode) return ;
166+
167+ _mapController! .setGeoJsonSource (
168+ SOURCE_ID ,
169+ createSourceData (focusedCoord: (
170+ latitude: latLng.latitude,
171+ longitude: latLng.longitude
172+ ), placeholderCoord: widget.coordinates));
173+
174+ _mapController! .setFilter (LAYER_ID , SHOW_ALL_FILTER_EXPRESSION );
175+
176+ _mapController! .animateCamera (CameraUpdate .newLatLng (latLng));
177+
178+ setState (() {
179+ _coordinates = (latitude: latLng.latitude, longitude: latLng.longitude);
180+ });
181+ }
182+
183+ Future <void > _handleStartEdit () async {
184+ if (widget.coordinates == null ) {
185+ await _mapController! .setGeoJsonSource (SOURCE_ID ,
186+ createSourceData (focusedCoord: BRAZIL_CENTROID_COORDINATES ));
187+
188+ setState (() {
189+ _coordinates = (
190+ latitude: BRAZIL_CENTROID_COORDINATES .latitude,
191+ longitude: BRAZIL_CENTROID_COORDINATES .longitude
192+ );
193+ });
194+ }
195+
196+ setState (() {
197+ _isEditMode = true ;
198+ });
147199 }
148200
149201 void _handleSave () async {
150- // Do nothing if coordinates have not changed at all
151- if (_coordinates == widget.coordinates) {
202+ // Do nothing if coordinates they are not set or have not changed at all
203+ if (_coordinates == null || _coordinates == widget.coordinates) {
152204 _handleCancel ();
153205 return ;
154206 }
155207
156208 setState (() {
157209 _isEditMode = false ;
158- if (_coordinates != null ) {
159- widget.onUpdate (_coordinates! );
160- }
161210 });
162211
163- _resetMap (coordinates: _coordinates! , zoomLevel: DEFAULT_ZOOMED_IN_LEVEL );
212+ if (_coordinates != null ) {
213+ widget.onUpdate (_coordinates! );
214+ }
215+
216+ await _resetMap (
217+ data: createSourceData (focusedCoord: _coordinates! ),
218+ cameraCoordinates: _coordinates! ,
219+ cameraZoom: DEFAULT_ZOOMED_IN_LEVEL );
164220 }
165221
166222 void _handleCancel () async {
167- if (widget.coordinates == null || _coordinates == null ) {
223+ setState (() {
224+ _isEditMode = false ;
225+ });
226+
227+ if (widget.coordinates == null ) {
168228 await _resetMap (
169- coordinates: BRAZIL_CENTROID_COORDINATES ,
170- zoomLevel: DEFAULT_ZOOMED_OUT_LEVEL );
229+ data: EMPTY_SOURCE_GEOJSON_DATA ,
230+ cameraCoordinates: BRAZIL_CENTROID_COORDINATES ,
231+ cameraZoom: DEFAULT_ZOOMED_OUT_LEVEL );
171232 } else {
172233 await _resetMap (
173- coordinates: widget.coordinates! , zoomLevel: DEFAULT_ZOOMED_IN_LEVEL );
234+ data: createSourceData (focusedCoord: widget.coordinates! ),
235+ cameraCoordinates: widget.coordinates! ,
236+ cameraZoom: DEFAULT_ZOOMED_IN_LEVEL ,
237+ );
174238 }
175239
176240 setState (() {
177- _isEditMode = false ;
178241 _coordinates = widget.coordinates;
179242 });
180243 }
181244
182- Future <void > _resetMap (
183- {required Coordinates coordinates, required double zoomLevel}) async {
184- final latLng = LatLng (coordinates.latitude, coordinates.longitude);
185-
186- if (_existingMapMarker != null ) {
187- await _mapController! .updateCircle (
188- _existingMapMarker! ,
189- CircleOptions (
190- geometry: latLng, circleOpacity: 1.0 , circleStrokeOpacity: 1.0 ));
191- }
192-
193- await _mapController! .removeCircles (_mapController! .circles.where ((c) {
194- return c != _existingMapMarker;
195- }));
196-
197- _mapController!
198- .animateCamera (CameraUpdate .newLatLngZoom (latLng, zoomLevel));
245+ Future <void > _resetMap ({
246+ required Map <String , dynamic > data,
247+ required Coordinates cameraCoordinates,
248+ required double cameraZoom,
249+ }) async {
250+ await _mapController! .setGeoJsonSource (SOURCE_ID , data);
251+ await _mapController! .setFilter (LAYER_ID , SHOW_FOCUSED_FILTER_EXPRESSION );
252+ await _mapController! .animateCamera (CameraUpdate .newLatLngZoom (
253+ LatLng (cameraCoordinates.latitude, cameraCoordinates.longitude),
254+ cameraZoom));
199255 }
200256}
201257
202- CircleOptions createMapCircle (
203- {required double latitude, required double longitude}) {
204- return CircleOptions (
205- circleRadius: 8 ,
206- circleColor: MeliColors .magnolia.toHexStringRGB (),
207- circleStrokeColor: MeliColors .black.toHexStringRGB (),
208- circleStrokeWidth: 2.0 ,
209- geometry: LatLng (latitude, longitude));
258+ Map <String , dynamic > createSourceData (
259+ {required Coordinates focusedCoord, Coordinates ? placeholderCoord}) {
260+ return {
261+ "type" : "FeatureCollection" ,
262+ "features" : [
263+ {
264+ "type" : "Feature" ,
265+ "id" : FOCUSED_FEATURE_ID ,
266+ // ignore: inference_failure_on_collection_literal
267+ "properties" : {},
268+ "geometry" : {
269+ "type" : "Point" ,
270+ "coordinates" : [focusedCoord.longitude, focusedCoord.latitude]
271+ }
272+ },
273+ if (placeholderCoord != null )
274+ {
275+ "type" : "Feature" ,
276+ "id" : PLACEHOLDER_FEATURE_ID ,
277+ // ignore: inference_failure_on_collection_literal
278+ "properties" : {},
279+ "geometry" : {
280+ "type" : "Point" ,
281+ "coordinates" : [
282+ placeholderCoord.longitude,
283+ placeholderCoord.latitude
284+ ]
285+ }
286+ }
287+ ],
288+ };
210289}
0 commit comments