Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Sighting GPS location widget #91

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions packages/app/lib/locales/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
20 changes: 20 additions & 0 deletions packages/app/lib/ui/screens/sighting.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -159,6 +160,19 @@ class _SightingProfileState extends State<SightingProfile> {
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 =
Expand All @@ -185,6 +199,12 @@ class _SightingProfileState extends State<SightingProfile> {
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),
]),
);
}
Expand Down
290 changes: 290 additions & 0 deletions packages/app/lib/ui/widgets/location_field.dart
Original file line number Diff line number Diff line change
@@ -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<LocationField> 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<LocationField> {
// 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() {
achou11 marked this conversation as resolved.
Show resolved Hide resolved
_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<OneSequenceGestureRecognizer>>{
Factory<OneSequenceGestureRecognizer>(
() => EagerGestureRecognizer()),
},
onStyleLoadedCallback: _handleMapStyleLoaded,
onMapClick: _handleMapPress,
));
}

Future<void> _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<void> _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<void> _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<void> _resetMap({
required Map<String, dynamic> 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<String, dynamic> 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
]
}
}
],
};
}
24 changes: 24 additions & 0 deletions packages/app/pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading