diff --git a/packages/app/lib/locales/app_en.arb b/packages/app/lib/locales/app_en.arb index c9d6bd4..44a9f3c 100644 --- a/packages/app/lib/locales/app_en.arb +++ b/packages/app/lib/locales/app_en.arb @@ -71,6 +71,32 @@ "sightingDeleteAlertConfirm": "Delete", "sightingDeleteAlertTitle": "Delete Sighting", "sightingDeleteConfirmation": "Sighting deleted.", + "sightingLocationFieldTitle": "Location", + "sightingLocationLatitude": "Latitude: {latitude}", + "@sightingLocationLatitude": { + "placeholders": { + "latitude": { + "type": "double", + "format": "decimalPattern", + "optionalParameters": { + "decimalDigits": 4 + } + } + } + }, + "sightingLocationLongitude": "Longitude: {longitude}", + "@sightingLocationLongitude": { + "placeholders": { + "longitude": { + "type": "double", + "format": "decimalPattern", + "optionalParameters": { + "decimalDigits": 4 + } + } + } + }, + "sightingLocationNoLocation": "No location set", "sightingScreenTitle": "Sighting", "sightingUnspecified": "Unknown species", "speciesCardTitle": "Species", diff --git a/packages/app/lib/ui/screens/sighting.dart b/packages/app/lib/ui/screens/sighting.dart index e31d86c..779e420 100644 --- a/packages/app/lib/ui/screens/sighting.dart +++ b/packages/app/lib/ui/screens/sighting.dart @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import 'package:app/ui/widgets/location_field.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:graphql_flutter/graphql_flutter.dart'; @@ -159,6 +160,19 @@ class _SightingProfileState extends State { setState(() {}); } + void _updateLocation(Coordinates coordinates) async { + if (sighting.latitude == coordinates.latitude && + sighting.longitude == coordinates.longitude) { + // Nothing has changed + return; + } + + await sighting.update( + latitude: coordinates.latitude, longitude: coordinates.longitude); + + setState(() {}); + } + @override Widget build(BuildContext context) { final imagePaths = @@ -185,6 +199,12 @@ class _SightingProfileState extends State { EditableTextField(sighting.comment, title: AppLocalizations.of(context)!.noteCardTitle, onUpdate: _updateComment), + LocationField( + // Not the best way to check if a position has not been set, but works for now + coordinates: sighting.latitude == 0 && sighting.longitude == 0 + ? null + : (latitude: sighting.latitude, longitude: sighting.longitude), + onUpdate: _updateLocation), ]), ); } diff --git a/packages/app/lib/ui/widgets/location_field.dart b/packages/app/lib/ui/widgets/location_field.dart new file mode 100644 index 0000000..307fd85 --- /dev/null +++ b/packages/app/lib/ui/widgets/location_field.dart @@ -0,0 +1,290 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import 'package:app/ui/widgets/action_buttons.dart'; +import 'package:app/ui/widgets/editable_card.dart'; +import 'package:app/ui/widgets/read_only_value.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; + +typedef OnUpdate = void Function(Coordinates coordinates); +typedef Coordinates = ({double latitude, double longitude}); + +class LocationField extends StatefulWidget { + final Coordinates? coordinates; + + final OnUpdate onUpdate; + + const LocationField( + {super.key, required this.coordinates, required this.onUpdate}); + + @override + State createState() => _LocationFieldState(); +} + +const Coordinates BRAZIL_CENTROID_COORDINATES = + (latitude: -14.235004, longitude: -51.92528); +const double DEFAULT_ZOOMED_OUT_LEVEL = 1.5; +const double DEFAULT_ZOOMED_IN_LEVEL = 8; + +const EMPTY_SOURCE_GEOJSON_DATA = { + "type": "FeatureCollection", + // ignore: inference_failure_on_collection_literal + "features": [] +}; +const SOURCE_ID = 'points'; +const LAYER_ID = 'location'; +const BEE_IMAGE_ID = 'bee'; +const PLACEHOLDER_FEATURE_ID = 'placeholder'; +const FOCUSED_FEATURE_ID = 'focused'; + +const SHOW_FOCUSED_FILTER_EXPRESSION = [ + Expressions.equal, + [Expressions.id], + FOCUSED_FEATURE_ID, +]; +const SHOW_ALL_FILTER_EXPRESSION = [Expressions.literal, true]; +const ICON_OPACITY_EXPRESSION = [ + Expressions.caseExpression, + [ + Expressions.equal, + [Expressions.id], + PLACEHOLDER_FEATURE_ID, + ], + 0.6, + 1 +]; + +class _LocationFieldState extends State { + // Represents the coordinates used for the focused symbol on the map (i.e. what's centered on the map) + late Coordinates? _coordinates; + + bool _isEditMode = false; + + MapLibreMapController? _mapController; + + @override + void initState() { + _coordinates = widget.coordinates; + super.initState(); + } + + @override + Widget build(BuildContext context) { + final t = AppLocalizations.of(context)!; + + return EditableCard( + title: t.sightingLocationFieldTitle, + isEditMode: _isEditMode, + onChanged: _isEditMode ? _handleCancel : _handleStartEdit, + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + _renderMap(), + const SizedBox( + height: 20, + ), + if (_coordinates == null) + ReadOnlyValue(t.sightingLocationNoLocation) + else ...[ + Text(t.sightingLocationLongitude(_coordinates!.longitude), + style: Theme.of(context).textTheme.bodyLarge), + Text(t.sightingLocationLatitude(_coordinates!.latitude), + style: Theme.of(context).textTheme.bodyLarge), + ], + if (_isEditMode) + Padding( + padding: const EdgeInsets.only(top: 10.0), + child: + ActionButtons(onCancel: _handleCancel, onAction: _handleSave)) + ]), + ); + } + + Widget _renderMap() { + final initialCameraPosition = _coordinates == null + ? CameraPosition( + zoom: DEFAULT_ZOOMED_OUT_LEVEL, + target: LatLng(BRAZIL_CENTROID_COORDINATES.latitude, + BRAZIL_CENTROID_COORDINATES.longitude)) + : CameraPosition( + target: LatLng(_coordinates!.latitude, _coordinates!.longitude), + zoom: DEFAULT_ZOOMED_IN_LEVEL); + + return Container( + alignment: Alignment.center, + height: 200, + child: MapLibreMap( + initialCameraPosition: initialCameraPosition, + scrollGesturesEnabled: _isEditMode, + dragEnabled: _isEditMode, + zoomGesturesEnabled: _isEditMode, + trackCameraPosition: _isEditMode, + compassEnabled: false, + rotateGesturesEnabled: false, + onMapCreated: (controller) { + _mapController = controller; + }, + gestureRecognizers: >{ + Factory( + () => EagerGestureRecognizer()), + }, + onStyleLoadedCallback: _handleMapStyleLoaded, + onMapClick: _handleMapPress, + )); + } + + Future _handleMapStyleLoaded() async { + // Load asset used for symbol marker + final bytes = await rootBundle.load("assets/images/meliponini.png"); + final list = bytes.buffer.asUint8List(); + _mapController!.addImage(BEE_IMAGE_ID, list); + + // Create source + await _mapController!.addGeoJsonSource( + SOURCE_ID, + _coordinates == null + ? EMPTY_SOURCE_GEOJSON_DATA + : createSourceData(focusedCoord: _coordinates!)); + + // Create layer + await _mapController!.addSymbolLayer( + SOURCE_ID, + LAYER_ID, + const SymbolLayerProperties( + iconImage: BEE_IMAGE_ID, + iconSize: 0.07, + iconAllowOverlap: true, + iconOpacity: ICON_OPACITY_EXPRESSION, + ), + enableInteraction: false, + filter: SHOW_FOCUSED_FILTER_EXPRESSION); + } + + Future _handleMapPress(_, LatLng latLng) async { + if (!_isEditMode) return; + + _mapController!.setGeoJsonSource( + SOURCE_ID, + createSourceData(focusedCoord: ( + latitude: latLng.latitude, + longitude: latLng.longitude + ), placeholderCoord: widget.coordinates)); + + _mapController!.setFilter(LAYER_ID, SHOW_ALL_FILTER_EXPRESSION); + + _mapController!.animateCamera(CameraUpdate.newLatLng(latLng)); + + setState(() { + _coordinates = (latitude: latLng.latitude, longitude: latLng.longitude); + }); + } + + Future _handleStartEdit() async { + if (widget.coordinates == null) { + await _mapController!.setGeoJsonSource(SOURCE_ID, + createSourceData(focusedCoord: BRAZIL_CENTROID_COORDINATES)); + + setState(() { + _coordinates = ( + latitude: BRAZIL_CENTROID_COORDINATES.latitude, + longitude: BRAZIL_CENTROID_COORDINATES.longitude + ); + }); + } + + setState(() { + _isEditMode = true; + }); + } + + void _handleSave() async { + // Do nothing if coordinates they are not set or have not changed at all + if (_coordinates == null || _coordinates == widget.coordinates) { + _handleCancel(); + return; + } + + setState(() { + _isEditMode = false; + }); + + if (_coordinates != null) { + widget.onUpdate(_coordinates!); + } + + await _resetMap( + data: createSourceData(focusedCoord: _coordinates!), + cameraCoordinates: _coordinates!, + cameraZoom: DEFAULT_ZOOMED_IN_LEVEL); + } + + void _handleCancel() async { + setState(() { + _isEditMode = false; + }); + + if (widget.coordinates == null) { + await _resetMap( + data: EMPTY_SOURCE_GEOJSON_DATA, + cameraCoordinates: BRAZIL_CENTROID_COORDINATES, + cameraZoom: DEFAULT_ZOOMED_OUT_LEVEL); + } else { + await _resetMap( + data: createSourceData(focusedCoord: widget.coordinates!), + cameraCoordinates: widget.coordinates!, + cameraZoom: DEFAULT_ZOOMED_IN_LEVEL, + ); + } + + setState(() { + _coordinates = widget.coordinates; + }); + } + + Future _resetMap({ + required Map data, + required Coordinates cameraCoordinates, + required double cameraZoom, + }) async { + await _mapController!.setGeoJsonSource(SOURCE_ID, data); + await _mapController!.setFilter(LAYER_ID, SHOW_FOCUSED_FILTER_EXPRESSION); + await _mapController!.animateCamera(CameraUpdate.newLatLngZoom( + LatLng(cameraCoordinates.latitude, cameraCoordinates.longitude), + cameraZoom)); + } +} + +Map createSourceData( + {required Coordinates focusedCoord, Coordinates? placeholderCoord}) { + return { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "id": FOCUSED_FEATURE_ID, + // ignore: inference_failure_on_collection_literal + "properties": {}, + "geometry": { + "type": "Point", + "coordinates": [focusedCoord.longitude, focusedCoord.latitude] + } + }, + if (placeholderCoord != null) + { + "type": "Feature", + "id": PLACEHOLDER_FEATURE_ID, + // ignore: inference_failure_on_collection_literal + "properties": {}, + "geometry": { + "type": "Point", + "coordinates": [ + placeholderCoord.longitude, + placeholderCoord.latitude + ] + } + } + ], + }; +} diff --git a/packages/app/pubspec.lock b/packages/app/pubspec.lock index e44ddc4..13c62eb 100644 --- a/packages/app/pubspec.lock +++ b/packages/app/pubspec.lock @@ -693,6 +693,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" + maplibre_gl: + dependency: "direct main" + description: + name: maplibre_gl + sha256: ea2fa443e7d5dc18db7f37a0f6f5af40642888c56b81a14441aeddea077adaea + url: "https://pub.dev" + source: hosted + version: "0.20.0" + maplibre_gl_platform_interface: + dependency: transitive + description: + name: maplibre_gl_platform_interface + sha256: "718c3503f36936fbf35c34d6ddf8bf770474c5ba1e6cb1d8caece44efae424af" + url: "https://pub.dev" + source: hosted + version: "0.20.0" + maplibre_gl_web: + dependency: transitive + description: + name: maplibre_gl_web + sha256: e7d71b08f24dca70e9c9cf841b096704a677e6239447d87220ec071355768149 + url: "https://pub.dev" + source: hosted + version: "0.20.0" matcher: dependency: transitive description: diff --git a/packages/app/pubspec.yaml b/packages/app/pubspec.yaml index a693c4c..11ca713 100644 --- a/packages/app/pubspec.yaml +++ b/packages/app/pubspec.yaml @@ -27,6 +27,7 @@ dependencies: http: 1.2.1 image_picker: 1.1.2 intl: 0.19.0 + maplibre_gl: 0.20.0 mime: 1.0.5 p2panda: 0.1.1 p2panda_flutter: 0.1.1