Skip to content

Commit 15e9802

Browse files
committed
more data-oriented implementation approach
1 parent dc93d09 commit 15e9802

File tree

2 files changed

+183
-114
lines changed

2 files changed

+183
-114
lines changed

packages/app/lib/locales/app_en.arb

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -96,17 +96,7 @@
9696
}
9797
}
9898
},
99-
"sightingLocationFieldCoordinates": "Coordinates: Lat {latitude}, Lon {longitude}",
100-
"@sightingLocationFieldCoordinates": {
101-
"placeholders": {
102-
"latitude": {
103-
"type": "double"
104-
},
105-
"longitude": {
106-
"type": "double"
107-
}
108-
}
109-
},
99+
"sightingLocationNoLocation": "No location set",
110100
"sightingScreenTitle": "Sighting",
111101
"sightingUnspecified": "Unknown species",
112102
"speciesCardTitle": "Species",
Lines changed: 182 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
// SPDX-License-Identifier: AGPL-3.0-or-later
22

3-
import 'package:app/ui/colors.dart';
43
import 'package:app/ui/widgets/action_buttons.dart';
54
import 'package:app/ui/widgets/editable_card.dart';
65
import 'package:flutter/foundation.dart';
76
import 'package:flutter/gestures.dart';
87
import 'package:flutter/material.dart';
8+
import 'package:flutter/services.dart';
99
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
1010
import 'package:maplibre_gl/maplibre_gl.dart';
1111

@@ -29,16 +29,42 @@ const Coordinates BRAZIL_CENTROID_COORDINATES =
2929
const double DEFAULT_ZOOMED_OUT_LEVEL = 1.5;
3030
const 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+
3260
class _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

Comments
 (0)