From 7747e19f73ad5a986df11008ceb40e48f42070a8 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Fri, 6 Aug 2021 15:23:53 +0900 Subject: [PATCH] #3 map page --- lib/l10n/app_en.arb | 5 + lib/l10n/app_ko.arb | 3 + lib/model/actions/chip_set_actions.dart | 5 + lib/model/actions/collection_actions.dart | 1 + lib/model/entry.dart | 8 - lib/model/entry_images.dart | 2 + lib/model/metadata_db.dart | 2 +- lib/services/android_app_service.dart | 8 +- lib/theme/durations.dart | 2 + lib/theme/icons.dart | 1 + lib/utils/constants.dart | 10 + lib/widgets/collection/app_bar.dart | 24 +- lib/widgets/collection/thumbnail/image.dart | 4 +- lib/widgets/common/map/attribution.dart | 47 ++++ .../common.dart => common/map/buttons.dart} | 61 +---- lib/widgets/common/map/decorator.dart | 48 ++++ lib/widgets/common/map/geo_entry.dart | 41 ++++ lib/widgets/common/map/geo_map.dart | 202 ++++++++++++++++ lib/widgets/common/map/google/map.dart | 224 ++++++++++++++++++ .../common/map/google/marker_generator.dart | 121 ++++++++++ lib/widgets/common/map/latlng_tween.dart | 16 ++ lib/widgets/common/map/latlng_utils.dart | 14 ++ lib/widgets/common/map/leaflet/map.dart | 202 ++++++++++++++++ .../map/leaflet}/scale_layer.dart | 6 +- .../common/map/leaflet/scalebar_utils.dart | 119 ++++++++++ .../common/map/leaflet/tile_layers.dart | 48 ++++ .../info/maps => common/map}/marker.dart | 141 +++++------ lib/widgets/common/map/zoomed_bounds.dart | 64 +++++ .../common/action_delegates/chip_set.dart | 20 +- lib/widgets/filter_grids/common/app_bar.dart | 1 + lib/widgets/map/map_page.dart | 67 ++++++ .../stats/{stats.dart => stats_page.dart} | 0 lib/widgets/viewer/debug/debug_page.dart | 1 - lib/widgets/viewer/entry_action_delegate.dart | 2 +- lib/widgets/viewer/info/location_section.dart | 93 +------- lib/widgets/viewer/info/maps/google_map.dart | 134 ----------- lib/widgets/viewer/info/maps/leaflet_map.dart | 204 ---------------- .../viewer/info/maps/scalebar_utils.dart | 117 --------- pubspec.lock | 14 ++ pubspec.yaml | 5 +- scripts/fix_android_log_levels.bat | 1 + 41 files changed, 1396 insertions(+), 692 deletions(-) create mode 100644 lib/widgets/common/map/attribution.dart rename lib/widgets/{viewer/info/maps/common.dart => common/map/buttons.dart} (75%) create mode 100644 lib/widgets/common/map/decorator.dart create mode 100644 lib/widgets/common/map/geo_entry.dart create mode 100644 lib/widgets/common/map/geo_map.dart create mode 100644 lib/widgets/common/map/google/map.dart create mode 100644 lib/widgets/common/map/google/marker_generator.dart create mode 100644 lib/widgets/common/map/latlng_tween.dart create mode 100644 lib/widgets/common/map/latlng_utils.dart create mode 100644 lib/widgets/common/map/leaflet/map.dart rename lib/widgets/{viewer/info/maps => common/map/leaflet}/scale_layer.dart (94%) create mode 100644 lib/widgets/common/map/leaflet/scalebar_utils.dart create mode 100644 lib/widgets/common/map/leaflet/tile_layers.dart rename lib/widgets/{viewer/info/maps => common/map}/marker.dart (55%) create mode 100644 lib/widgets/common/map/zoomed_bounds.dart create mode 100644 lib/widgets/map/map_page.dart rename lib/widgets/stats/{stats.dart => stats_page.dart} (100%) delete mode 100644 lib/widgets/viewer/info/maps/google_map.dart delete mode 100644 lib/widgets/viewer/info/maps/leaflet_map.dart delete mode 100644 lib/widgets/viewer/info/maps/scalebar_utils.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 5a29e6150..9c666f5a5 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -321,6 +321,8 @@ "@menuActionSort": {}, "menuActionGroup": "Group", "@menuActionGroup": {}, + "menuActionMap": "Map", + "@menuActionMap": {}, "menuActionStats": "Stats", "@menuActionStats": {}, @@ -706,6 +708,9 @@ "settingsCoordinateFormatTitle": "Coordinate Format", "@settingsCoordinateFormatTitle": {}, + "mapPageTitle": "Map", + "@mapPageTitle": {}, + "statsPageTitle": "Stats", "@statsPageTitle": {}, "statsImage": "{count, plural, =1{image} other{images}}", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 48d2c694f..3622d2ec7 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -147,6 +147,7 @@ "menuActionSort": "정렬", "menuActionGroup": "묶음", + "menuActionMap": "지도", "menuActionStats": "통계", "aboutPageTitle": "앱 정보", @@ -338,6 +339,8 @@ "settingsCoordinateFormatTile": "좌표 표현", "settingsCoordinateFormatTitle": "좌표 표현", + "mapPageTitle": "지도", + "statsPageTitle": "통계", "statsImage": "{count, plural, other{사진}}", "statsVideo": "{count, plural, other{동영상}}", diff --git a/lib/model/actions/chip_set_actions.dart b/lib/model/actions/chip_set_actions.dart index fcfa6ab36..5fdbc2e3d 100644 --- a/lib/model/actions/chip_set_actions.dart +++ b/lib/model/actions/chip_set_actions.dart @@ -6,6 +6,7 @@ enum ChipSetAction { // general sort, group, + map, select, selectAll, selectNone, @@ -35,6 +36,8 @@ extension ExtraChipSetAction on ChipSetAction { return context.l10n.collectionActionSelectAll; case ChipSetAction.selectNone: return context.l10n.collectionActionSelectNone; + case ChipSetAction.map: + return context.l10n.menuActionMap; case ChipSetAction.stats: return context.l10n.menuActionStats; case ChipSetAction.createAlbum: @@ -68,6 +71,8 @@ extension ExtraChipSetAction on ChipSetAction { case ChipSetAction.selectAll: case ChipSetAction.selectNone: return null; + case ChipSetAction.map: + return AIcons.map; case ChipSetAction.stats: return AIcons.stats; case ChipSetAction.createAlbum: diff --git a/lib/model/actions/collection_actions.dart b/lib/model/actions/collection_actions.dart index 360fd00b9..45aa9b110 100644 --- a/lib/model/actions/collection_actions.dart +++ b/lib/model/actions/collection_actions.dart @@ -5,6 +5,7 @@ enum CollectionAction { select, selectAll, selectNone, + map, stats, // apply to entry set copy, diff --git a/lib/model/entry.dart b/lib/model/entry.dart index d3a3c3550..50a3f8a28 100644 --- a/lib/model/entry.dart +++ b/lib/model/entry.dart @@ -13,7 +13,6 @@ import 'package:aves/services/service_policy.dart'; import 'package:aves/services/services.dart'; import 'package:aves/services/svg_metadata_service.dart'; import 'package:aves/utils/change_notifier.dart'; -import 'package:aves/utils/math_utils.dart'; import 'package:aves/utils/time_utils.dart'; import 'package:collection/collection.dart'; import 'package:country_code/country_code.dart'; @@ -382,13 +381,6 @@ class AvesEntry { LatLng? get latLng => hasGps ? LatLng(_catalogMetadata!.latitude!, _catalogMetadata!.longitude!) : null; - String? get geoUri { - if (!hasGps) return null; - final latitude = roundToPrecision(_catalogMetadata!.latitude!, decimals: 6); - final longitude = roundToPrecision(_catalogMetadata!.longitude!, decimals: 6); - return 'geo:$latitude,$longitude?q=$latitude,$longitude'; - } - List? _xmpSubjects; List get xmpSubjects { diff --git a/lib/model/entry_images.dart b/lib/model/entry_images.dart index fc286bb8c..16a11be8b 100644 --- a/lib/model/entry_images.dart +++ b/lib/model/entry_images.dart @@ -10,6 +10,8 @@ import 'package:collection/collection.dart'; import 'package:flutter/widgets.dart'; extension ExtraAvesEntry on AvesEntry { + bool isThumbnailReady({double extent = 0}) => _isReady(_getThumbnailProviderKey(extent)); + ThumbnailProvider getThumbnail({double extent = 0}) { return ThumbnailProvider(_getThumbnailProviderKey(extent)); } diff --git a/lib/model/metadata_db.dart b/lib/model/metadata_db.dart index d2fc9c400..9393603e2 100644 --- a/lib/model/metadata_db.dart +++ b/lib/model/metadata_db.dart @@ -299,7 +299,7 @@ class SqfliteMetadataDb implements MetadataDb { await batch.commit(noResult: true); debugPrint('$runtimeType saveMetadata complete in ${stopwatch.elapsed.inMilliseconds}ms for ${metadataEntries.length} entries'); } catch (error, stack) { - debugPrint('$runtimeType failed to save metadata with exception=$error\n$stack'); + debugPrint('$runtimeType failed to save metadata with error=$error\n$stack'); } } diff --git a/lib/services/android_app_service.dart b/lib/services/android_app_service.dart index 63b13c0c1..d8b8dbf72 100644 --- a/lib/services/android_app_service.dart +++ b/lib/services/android_app_service.dart @@ -3,8 +3,10 @@ import 'dart:typed_data'; import 'package:aves/model/entry.dart'; import 'package:aves/services/services.dart'; import 'package:aves/utils/android_file_utils.dart'; +import 'package:aves/utils/math_utils.dart'; import 'package:collection/collection.dart'; import 'package:flutter/services.dart'; +import 'package:latlong2/latlong.dart'; class AndroidAppService { static const platform = MethodChannel('deckers.thibault/aves/app'); @@ -77,7 +79,11 @@ class AndroidAppService { return false; } - static Future openMap(String geoUri) async { + static Future openMap(LatLng latLng) async { + final latitude = roundToPrecision(latLng.latitude, decimals: 6); + final longitude = roundToPrecision(latLng.longitude, decimals: 6); + final geoUri = 'geo:$latitude,$longitude?q=$latitude,$longitude'; + try { final result = await platform.invokeMethod('openMap', { 'geoUri': geoUri, diff --git a/lib/theme/durations.dart b/lib/theme/durations.dart index 135e39dd8..2edc3b3bf 100644 --- a/lib/theme/durations.dart +++ b/lib/theme/durations.dart @@ -3,6 +3,8 @@ import 'package:flutter/scheduler.dart'; class Durations { // Flutter animations (with margin) static const popupMenuAnimation = Duration(milliseconds: 300 + 10); // ref `_kMenuDuration` used in `_PopupMenuRoute` + // page transition duration also available via `ModalRoute.of(context)!.transitionDuration * timeDilation` + static const pageTransitionAnimation = Duration(milliseconds: 300 + 10); // ref `transitionDuration` used in `MaterialRouteTransitionMixin` static const dialogTransitionAnimation = Duration(milliseconds: 150 + 10); // ref `transitionDuration` used in `DialogRoute` static const drawerTransitionAnimation = Duration(milliseconds: 246 + 10); // ref `_kBaseSettleDuration` used in `DrawerControllerState` static const toggleableTransitionAnimation = Duration(milliseconds: 200 + 10); // ref `_kToggleDuration` used in `ToggleableStateMixin` diff --git a/lib/theme/icons.dart b/lib/theme/icons.dart index ff4756a65..656334fe7 100644 --- a/lib/theme/icons.dart +++ b/lib/theme/icons.dart @@ -50,6 +50,7 @@ class AIcons { static const IconData import = MdiIcons.fileImportOutline; static const IconData info = Icons.info_outlined; static const IconData layers = Icons.layers_outlined; + static const IconData map = Icons.map_outlined; static const IconData newTier = Icons.fiber_new_outlined; static const IconData openOutside = Icons.open_in_new_outlined; static const IconData pin = Icons.push_pin_outlined; diff --git a/lib/utils/constants.dart b/lib/utils/constants.dart index c054097a6..fbc713a6d 100644 --- a/lib/utils/constants.dart +++ b/lib/utils/constants.dart @@ -143,6 +143,11 @@ class Constants { license: 'Apache 2.0', sourceUrl: 'https://github.com/google/charts', ), + Dependency( + name: 'Custom rounded rectangle border', + license: 'MIT', + sourceUrl: 'https://github.com/lekanbar/custom_rounded_rectangle_border', + ), Dependency( name: 'Decorated Icon', license: 'MIT', @@ -233,6 +238,11 @@ class Constants { license: 'MIT', sourceUrl: 'https://github.com/marcojakob/dart-event-bus', ), + Dependency( + name: 'Fluster', + license: 'MIT', + sourceUrl: 'https://github.com/alfonsocejudo/fluster', + ), Dependency( name: 'Flutter Lints', license: 'BSD 3-Clause', diff --git a/lib/widgets/collection/app_bar.dart b/lib/widgets/collection/app_bar.dart index 3caadebec..22d2f2f00 100644 --- a/lib/widgets/collection/app_bar.dart +++ b/lib/widgets/collection/app_bar.dart @@ -22,9 +22,10 @@ import 'package:aves/widgets/common/basic/menu_row.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/dialogs/add_shortcut_dialog.dart'; import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; +import 'package:aves/widgets/map/map_page.dart'; import 'package:aves/widgets/search/search_button.dart'; import 'package:aves/widgets/search/search_delegate.dart'; -import 'package:aves/widgets/stats/stats.dart'; +import 'package:aves/widgets/stats/stats_page.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; @@ -212,6 +213,11 @@ class _CollectionAppBarState extends State with SingleTickerPr enabled: isNotEmpty, child: MenuRow(text: context.l10n.collectionActionSelect, icon: AIcons.select), ), + PopupMenuItem( + value: CollectionAction.map, + enabled: isNotEmpty, + child: MenuRow(text: context.l10n.menuActionMap, icon: AIcons.map), + ), PopupMenuItem( value: CollectionAction.stats, enabled: isNotEmpty, @@ -292,6 +298,9 @@ class _CollectionAppBarState extends State with SingleTickerPr case CollectionAction.selectNone: context.read>().clearSelection(); break; + case CollectionAction.map: + _goToMap(); + break; case CollectionAction.stats: _goToStats(); break; @@ -377,6 +386,19 @@ class _CollectionAppBarState extends State with SingleTickerPr ); } + void _goToMap() { + Navigator.push( + context, + MaterialPageRoute( + settings: const RouteSettings(name: MapPage.routeName), + builder: (context) => MapPage( + source: source, + parentCollection: collection, + ), + ), + ); + } + void _goToStats() { Navigator.push( context, diff --git a/lib/widgets/collection/thumbnail/image.dart b/lib/widgets/collection/thumbnail/image.dart index b768bf4de..20965d397 100644 --- a/lib/widgets/collection/thumbnail/image.dart +++ b/lib/widgets/collection/thumbnail/image.dart @@ -19,6 +19,7 @@ import 'package:provider/provider.dart'; class ThumbnailImage extends StatefulWidget { final AvesEntry entry; final double extent; + final bool progressive; final BoxFit? fit; final bool showLoadingBackground; final ValueNotifier? cancellableNotifier; @@ -28,6 +29,7 @@ class ThumbnailImage extends StatefulWidget { Key? key, required this.entry, required this.extent, + this.progressive = true, this.fit, this.showLoadingBackground = true, this.cancellableNotifier, @@ -93,7 +95,7 @@ class _ThumbnailImageState extends State { _lastException = null; _providers.clear(); _providers.addAll([ - if (!entry.isSvg) + if (widget.progressive && !entry.isSvg) _ConditionalImageProvider( ScrollAwareImageProvider( context: _scrollAwareContext, diff --git a/lib/widgets/common/map/attribution.dart b/lib/widgets/common/map/attribution.dart new file mode 100644 index 000000000..6ea0210f3 --- /dev/null +++ b/lib/widgets/common/map/attribution.dart @@ -0,0 +1,47 @@ +import 'package:aves/model/settings/enums.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/viewer/info/common.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class Attribution extends StatelessWidget { + final EntryMapStyle style; + + const Attribution({ + Key? key, + required this.style, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + switch (style) { + case EntryMapStyle.osmHot: + return _buildAttributionMarkdown(context, context.l10n.mapAttributionOsmHot); + case EntryMapStyle.stamenToner: + case EntryMapStyle.stamenWatercolor: + return _buildAttributionMarkdown(context, context.l10n.mapAttributionStamen); + default: + return const SizedBox.shrink(); + } + } + + Widget _buildAttributionMarkdown(BuildContext context, String data) { + return Padding( + padding: const EdgeInsets.only(top: 4), + child: MarkdownBody( + data: data, + selectable: true, + styleSheet: MarkdownStyleSheet( + a: TextStyle(color: Theme.of(context).accentColor), + p: const TextStyle(color: Colors.white70, fontSize: InfoRowGroup.fontSize), + ), + onTapLink: (text, href, title) async { + if (href != null && await canLaunch(href)) { + await launch(href); + } + }, + ), + ); + } +} diff --git a/lib/widgets/viewer/info/maps/common.dart b/lib/widgets/common/map/buttons.dart similarity index 75% rename from lib/widgets/viewer/info/maps/common.dart rename to lib/widgets/common/map/buttons.dart index 429343e83..febc07549 100644 --- a/lib/widgets/viewer/info/maps/common.dart +++ b/lib/widgets/common/map/buttons.dart @@ -13,61 +13,18 @@ import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; import 'package:aves/widgets/viewer/overlay/common.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; - -class MapDecorator extends StatelessWidget { - final Widget? child; - - static const mapBorderRadius = BorderRadius.all(Radius.circular(24)); // to match button circles - static const mapBackground = Color(0xFFDBD5D3); - static const mapLoadingGrid = Color(0xFFC4BEBB); - - const MapDecorator({ - Key? key, - this.child, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return GestureDetector( - onScaleStart: (details) { - // absorb scale gesture here to prevent scrolling - // and triggering by mistake a move to the image page above - }, - child: ClipRRect( - borderRadius: mapBorderRadius, - child: Container( - color: mapBackground, - height: 200, - child: Stack( - children: [ - const GridPaper( - color: mapLoadingGrid, - interval: 10, - divisions: 1, - subdivisions: 1, - child: CustomPaint( - size: Size.infinite, - ), - ), - if (child != null) child!, - ], - ), - ), - ), - ); - } -} +import 'package:latlong2/latlong.dart'; class MapButtonPanel extends StatelessWidget { - final String geoUri; - final void Function(double amount) zoomBy; + final LatLng latLng; + final Future Function(double amount)? zoomBy; static const double padding = 4; const MapButtonPanel({ Key? key, - required this.geoUri, - required this.zoomBy, + required this.latLng, + this.zoomBy, }) : super(key: key); @override @@ -86,7 +43,7 @@ class MapButtonPanel extends StatelessWidget { children: [ MapOverlayButton( icon: AIcons.openOutside, - onPressed: () => AndroidAppService.openMap(geoUri).then((success) { + onPressed: () => AndroidAppService.openMap(latLng).then((success) { if (!success) showNoMatchingAppDialog(context); }), tooltip: context.l10n.entryActionOpenMap, @@ -120,13 +77,13 @@ class MapButtonPanel extends StatelessWidget { const Spacer(), MapOverlayButton( icon: AIcons.zoomIn, - onPressed: () => zoomBy(1), + onPressed: zoomBy != null ? () => zoomBy!(1) : null, tooltip: context.l10n.viewerInfoMapZoomInTooltip, ), const SizedBox(height: padding), MapOverlayButton( icon: AIcons.zoomOut, - onPressed: () => zoomBy(-1), + onPressed: zoomBy != null ? () => zoomBy!(-1) : null, tooltip: context.l10n.viewerInfoMapZoomOutTooltip, ), ], @@ -141,7 +98,7 @@ class MapButtonPanel extends StatelessWidget { class MapOverlayButton extends StatelessWidget { final IconData icon; final String tooltip; - final VoidCallback onPressed; + final VoidCallback? onPressed; const MapOverlayButton({ Key? key, diff --git a/lib/widgets/common/map/decorator.dart b/lib/widgets/common/map/decorator.dart new file mode 100644 index 000000000..58aedd0e2 --- /dev/null +++ b/lib/widgets/common/map/decorator.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; + +class MapDecorator extends StatelessWidget { + final bool interactive; + final Widget? child; + + static const mapBorderRadius = BorderRadius.all(Radius.circular(24)); // to match button circles + static const mapBackground = Color(0xFFDBD5D3); + static const mapLoadingGrid = Color(0xFFC4BEBB); + + const MapDecorator({ + Key? key, + required this.interactive, + this.child, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onScaleStart: interactive + ? null + : (details) { + // absorb scale gesture here to prevent scrolling + // and triggering by mistake a move to the image page above + }, + child: ClipRRect( + borderRadius: mapBorderRadius, + child: Container( + color: mapBackground, + child: Stack( + children: [ + const GridPaper( + color: mapLoadingGrid, + interval: 10, + divisions: 1, + subdivisions: 1, + child: CustomPaint( + size: Size.infinite, + ), + ), + if (child != null) child!, + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/common/map/geo_entry.dart b/lib/widgets/common/map/geo_entry.dart new file mode 100644 index 000000000..9eab3b881 --- /dev/null +++ b/lib/widgets/common/map/geo_entry.dart @@ -0,0 +1,41 @@ +import 'package:aves/model/entry.dart'; +import 'package:fluster/fluster.dart'; +import 'package:flutter/foundation.dart'; + +class GeoEntry extends Clusterable { + AvesEntry? entry; + + GeoEntry({ + this.entry, + double? latitude, + double? longitude, + bool? isCluster = false, + int? clusterId, + int? pointsSize, + String? markerId, + String? childMarkerId, + }) : super( + latitude: latitude, + longitude: longitude, + isCluster: isCluster, + clusterId: clusterId, + pointsSize: pointsSize, + markerId: markerId, + childMarkerId: childMarkerId, + ); + + factory GeoEntry.createCluster(BaseCluster cluster, double longitude, double latitude) { + return GeoEntry( + latitude: latitude, + longitude: longitude, + isCluster: cluster.isCluster, + clusterId: cluster.id, + pointsSize: cluster.pointsSize, + markerId: cluster.id.toString(), + childMarkerId: cluster.childMarkerId, + ); + } + + @override + String toString() => '$runtimeType#${shortHash(this)}{isCluster=$isCluster, lat=$latitude, lng=$longitude, clusterId=$clusterId, pointsSize=$pointsSize, markerId=$markerId, childMarkerId=$childMarkerId}'; +} diff --git a/lib/widgets/common/map/geo_map.dart b/lib/widgets/common/map/geo_map.dart new file mode 100644 index 000000000..ba2c26270 --- /dev/null +++ b/lib/widgets/common/map/geo_map.dart @@ -0,0 +1,202 @@ +import 'package:aves/model/entry.dart'; +import 'package:aves/model/settings/enums.dart'; +import 'package:aves/model/settings/map_style.dart'; +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/services/services.dart'; +import 'package:aves/theme/durations.dart'; +import 'package:aves/widgets/common/map/attribution.dart'; +import 'package:aves/widgets/common/map/buttons.dart'; +import 'package:aves/widgets/common/map/decorator.dart'; +import 'package:aves/widgets/common/map/geo_entry.dart'; +import 'package:aves/widgets/common/map/google/map.dart'; +import 'package:aves/widgets/common/map/leaflet/map.dart'; +import 'package:aves/widgets/common/map/marker.dart'; +import 'package:aves/widgets/common/map/zoomed_bounds.dart'; +import 'package:equatable/equatable.dart'; +import 'package:fluster/fluster.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class GeoMap extends StatefulWidget { + final List entries; + final bool interactive; + final double? mapHeight; + final ValueNotifier isAnimatingNotifier; + final UserZoomChangeCallback? onUserZoomChange; + + static const markerImageExtent = 48.0; + static const pointerSize = Size(8, 6); + + const GeoMap({ + Key? key, + required this.entries, + required this.interactive, + this.mapHeight, + required this.isAnimatingNotifier, + this.onUserZoomChange, + }) : super(key: key); + + @override + _GeoMapState createState() => _GeoMapState(); +} + +class _GeoMapState extends State with TickerProviderStateMixin { + // as of google_maps_flutter v2.0.6, Google Maps initialization is blocking + // cf https://github.com/flutter/flutter/issues/28493 + // it is especially severe the first time, but still significant afterwards + // so we prevent loading it while scrolling or animating + bool _googleMapsLoaded = false; + late ValueNotifier boundsNotifier; + + List get entries => widget.entries; + + bool get interactive => widget.interactive; + + double? get mapHeight => widget.mapHeight; + + @override + void initState() { + super.initState(); + boundsNotifier = ValueNotifier(ZoomedBounds.fromPoints( + points: entries.map((v) => v.latLng!).toSet(), + collocationZoom: settings.infoMapZoom, + )); + } + + @override + Widget build(BuildContext context) { + final markers = entries.map((entry) { + var latLng = entry.latLng!; + return GeoEntry( + entry: entry, + latitude: latLng.latitude, + longitude: latLng.longitude, + markerId: entry.uri, + ); + }).toList(); + final markerCluster = Fluster( + // we keep clustering on the whole range of zooms (including the maximum) + // to avoid collocated entries overlapping + minZoom: 0, + maxZoom: 22, + // TODO TLAD [map] derive `radius` / `extent`, from device pixel ratio and marker extent? + // (radius=120, extent=2 << 8) is equivalent to (radius=240, extent=2 << 9) + radius: 240, + extent: 2 << 9, + nodeSize: 64, + points: markers, + createCluster: (base, lng, lat) => GeoEntry.createCluster(base, lng, lat), + ); + + return FutureBuilder( + future: availability.isConnected, + builder: (context, snapshot) { + if (snapshot.data != true) return const SizedBox(); + return Selector( + selector: (context, s) => s.infoMapStyle, + builder: (context, mapStyle, child) { + final isGoogleMaps = mapStyle.isGoogleMaps; + final progressive = !isGoogleMaps; + Widget _buildMarker(MarkerKey key) => ImageMarker( + key: key, + entry: key.entry, + count: key.count, + extent: GeoMap.markerImageExtent, + pointerSize: GeoMap.pointerSize, + progressive: progressive, + ); + + Widget child = isGoogleMaps + ? EntryGoogleMap( + boundsNotifier: boundsNotifier, + interactive: interactive, + style: mapStyle, + markerBuilder: _buildMarker, + markerCluster: markerCluster, + markerEntries: entries, + onUserZoomChange: widget.onUserZoomChange, + ) + : EntryLeafletMap( + boundsNotifier: boundsNotifier, + interactive: interactive, + style: mapStyle, + markerBuilder: _buildMarker, + markerCluster: markerCluster, + markerEntries: entries, + markerSize: Size( + GeoMap.markerImageExtent + ImageMarker.outerBorderWidth * 2, + GeoMap.markerImageExtent + ImageMarker.outerBorderWidth * 2 + GeoMap.pointerSize.height, + ), + onUserZoomChange: widget.onUserZoomChange, + ); + + child = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + mapHeight != null + ? SizedBox( + height: mapHeight, + child: child, + ) + : Expanded(child: child), + Attribution(style: mapStyle), + ], + ); + + return AnimatedSize( + alignment: Alignment.topCenter, + curve: Curves.easeInOutCubic, + duration: Durations.mapStyleSwitchAnimation, + vsync: this, + child: ValueListenableBuilder( + valueListenable: widget.isAnimatingNotifier, + builder: (context, animating, child) { + if (!animating && isGoogleMaps) { + _googleMapsLoaded = true; + } + Widget replacement = Stack( + children: [ + MapDecorator( + interactive: interactive, + ), + MapButtonPanel( + latLng: boundsNotifier.value.center, + ), + ], + ); + if (mapHeight != null) { + replacement = SizedBox( + height: mapHeight, + child: replacement, + ); + } + return Visibility( + visible: !isGoogleMaps || _googleMapsLoaded, + replacement: replacement, + child: child!, + ); + }, + child: child, + ), + ); + }, + ); + }, + ); + } +} + +@immutable +class MarkerKey extends LocalKey with EquatableMixin { + final AvesEntry entry; + final int? count; + + @override + List get props => [entry, count]; + + const MarkerKey(this.entry, this.count); +} + +typedef EntryMarkerBuilder = Widget Function(MarkerKey key); +typedef UserZoomChangeCallback = void Function(double zoom); diff --git a/lib/widgets/common/map/google/map.dart b/lib/widgets/common/map/google/map.dart new file mode 100644 index 000000000..922c7c458 --- /dev/null +++ b/lib/widgets/common/map/google/map.dart @@ -0,0 +1,224 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:aves/model/entry.dart'; +import 'package:aves/model/entry_images.dart'; +import 'package:aves/model/settings/enums.dart'; +import 'package:aves/utils/change_notifier.dart'; +import 'package:aves/widgets/common/map/buttons.dart'; +import 'package:aves/widgets/common/map/decorator.dart'; +import 'package:aves/widgets/common/map/geo_entry.dart'; +import 'package:aves/widgets/common/map/geo_map.dart'; +import 'package:aves/widgets/common/map/google/marker_generator.dart'; +import 'package:aves/widgets/common/map/zoomed_bounds.dart'; +import 'package:collection/collection.dart'; +import 'package:fluster/fluster.dart'; +import 'package:flutter/material.dart'; +import 'package:google_maps_flutter/google_maps_flutter.dart'; +import 'package:latlong2/latlong.dart' as ll; + +class EntryGoogleMap extends StatefulWidget { + final ValueNotifier boundsNotifier; + final bool interactive; + final EntryMapStyle style; + final EntryMarkerBuilder markerBuilder; + final Fluster markerCluster; + final List markerEntries; + final UserZoomChangeCallback? onUserZoomChange; + + const EntryGoogleMap({ + Key? key, + required this.boundsNotifier, + required this.interactive, + required this.style, + required this.markerBuilder, + required this.markerCluster, + required this.markerEntries, + this.onUserZoomChange, + }) : super(key: key); + + @override + State createState() => _EntryGoogleMapState(); +} + +class _EntryGoogleMapState extends State with WidgetsBindingObserver { + GoogleMapController? _controller; + final Map _markerBitmaps = {}; + final AChangeNotifier _markerBitmapChangeNotifier = AChangeNotifier(); + + ValueNotifier get boundsNotifier => widget.boundsNotifier; + + ZoomedBounds get bounds => boundsNotifier.value; + + static const uninitializedLatLng = LatLng(0, 0); + + @override + void initState() { + super.initState(); + } + + @override + void didUpdateWidget(covariant EntryGoogleMap oldWidget) { + super.didUpdateWidget(oldWidget); + const eq = DeepCollectionEquality(); + if (!eq.equals(widget.markerEntries, oldWidget.markerEntries)) { + _markerBitmaps.clear(); + } + } + + @override + void dispose() { + _controller?.dispose(); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + switch (state) { + case AppLifecycleState.inactive: + case AppLifecycleState.paused: + case AppLifecycleState.detached: + break; + case AppLifecycleState.resumed: + // workaround for blank Google Maps when resuming app + // cf https://github.com/flutter/flutter/issues/40284 + _controller?.setMapStyle(null); + break; + } + } + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: boundsNotifier, + builder: (context, visibleRegion, child) { + final allEntries = widget.markerEntries; + final clusters = visibleRegion != null ? widget.markerCluster.clusters(visibleRegion.boundingBox, visibleRegion.zoom.round()) : []; + final clusterByMarkerKey = Map.fromEntries(clusters.map((v) { + if (v.isCluster!) { + final uri = v.childMarkerId; + final entry = allEntries.firstWhere((v) => v.uri == uri); + return MapEntry(MarkerKey(entry, v.pointsSize), v); + } + return MapEntry(MarkerKey(v.entry!, null), v); + })); + + return Stack( + children: [ + MarkerGeneratorWidget( + markers: clusterByMarkerKey.keys.map(widget.markerBuilder).toList(), + isReadyToRender: (key) => key.entry.isThumbnailReady(extent: GeoMap.markerImageExtent), + onRendered: (key, bitmap) { + _markerBitmaps[key] = bitmap; + _markerBitmapChangeNotifier.notifyListeners(); + }, + ), + MapDecorator( + interactive: widget.interactive, + child: _buildMap(clusterByMarkerKey), + ), + MapButtonPanel( + latLng: bounds.center, + zoomBy: _zoomBy, + ), + ], + ); + }, + ); + } + + Widget _buildMap(Map clusterByMarkerKey) { + return AnimatedBuilder( + animation: _markerBitmapChangeNotifier, + builder: (context, child) { + final markers = {}; + clusterByMarkerKey.forEach((markerKey, cluster) { + final bytes = _markerBitmaps[markerKey]; + if (bytes != null) { + final latLng = LatLng(cluster.latitude!, cluster.longitude!); + markers.add(Marker( + markerId: MarkerId(cluster.markerId!), + icon: BitmapDescriptor.fromBytes(bytes), + position: latLng, + )); + } + }); + + final interactive = widget.interactive; + return GoogleMap( + initialCameraPosition: CameraPosition( + target: _toGoogleLatLng(bounds.center), + zoom: bounds.zoom, + ), + onMapCreated: (controller) { + _controller = controller; + controller.getZoomLevel().then(_updateVisibleRegion); + setState(() {}); + }, + // TODO TLAD [map] add common compass button for both google/leaflet + compassEnabled: false, + mapToolbarEnabled: false, + mapType: _toMapType(widget.style), + // TODO TLAD [map] allow rotation when leaflet scale layer is fixed + rotateGesturesEnabled: false, + scrollGesturesEnabled: interactive, + // zoom controls disabled to use provider agnostic controls + zoomControlsEnabled: false, + zoomGesturesEnabled: interactive, + // lite mode disabled because it lacks camera animation + liteModeEnabled: false, + // tilt disabled to match leaflet + tiltGesturesEnabled: false, + myLocationEnabled: false, + myLocationButtonEnabled: false, + markers: markers, + onCameraMove: (position) => _updateVisibleRegion(position.zoom), + ); + }, + ); + } + + Future _updateVisibleRegion(double zoom) async { + final bounds = await _controller?.getVisibleRegion(); + if (bounds != null && (bounds.northeast != uninitializedLatLng || bounds.southwest != uninitializedLatLng)) { + boundsNotifier.value = ZoomedBounds( + west: bounds.southwest.longitude, + south: bounds.southwest.latitude, + east: bounds.northeast.longitude, + north: bounds.northeast.latitude, + zoom: zoom, + ); + } else { + // the visible region is sometimes uninitialized when queried right after creation, + // so we query it again next frame + WidgetsBinding.instance!.addPostFrameCallback((_) { + if (!mounted) return; + _updateVisibleRegion(zoom); + }); + } + } + + Future _zoomBy(double amount) async { + final controller = _controller; + if (controller == null) return; + + widget.onUserZoomChange?.call(await controller.getZoomLevel() + amount); + await controller.animateCamera(CameraUpdate.zoomBy(amount)); + } + + // `LatLng` used by `google_maps_flutter` is not the one from `latlong2` package + LatLng _toGoogleLatLng(ll.LatLng latLng) => LatLng(latLng.latitude, latLng.longitude); + + MapType _toMapType(EntryMapStyle style) { + switch (style) { + case EntryMapStyle.googleNormal: + return MapType.normal; + case EntryMapStyle.googleHybrid: + return MapType.hybrid; + case EntryMapStyle.googleTerrain: + return MapType.terrain; + default: + return MapType.none; + } + } +} diff --git a/lib/widgets/common/map/google/marker_generator.dart b/lib/widgets/common/map/google/marker_generator.dart new file mode 100644 index 000000000..bc572cad0 --- /dev/null +++ b/lib/widgets/common/map/google/marker_generator.dart @@ -0,0 +1,121 @@ +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:provider/provider.dart'; + +// generate bitmap from widget, for Google Maps +class MarkerGeneratorWidget extends StatefulWidget { + final List markers; + final bool Function(T markerKey) isReadyToRender; + final void Function(T markerKey, Uint8List bitmap) onRendered; + + const MarkerGeneratorWidget({ + Key? key, + required this.markers, + required this.isReadyToRender, + required this.onRendered, + }) : super(key: key); + + @override + _MarkerGeneratorWidgetState createState() => _MarkerGeneratorWidgetState(); +} + +class _MarkerGeneratorWidgetState extends State> { + final Set<_MarkerGeneratorItem> _items = {}; + + @override + void initState() { + super.initState(); + _checkNextFrame(); + } + + @override + void didUpdateWidget(covariant MarkerGeneratorWidget oldWidget) { + super.didUpdateWidget(oldWidget); + widget.markers.forEach((markerWidget) { + final item = getOrCreate(markerWidget.key as T); + item.globalKey = GlobalKey(); + }); + _checkNextFrame(); + } + + void _checkNextFrame() { + WidgetsBinding.instance!.addPostFrameCallback((_) async { + if (!mounted) return; + final waitingItems = _items.where((v) => v.isWaiting).toSet(); + final readyItems = waitingItems.where((v) => widget.isReadyToRender(v.markerKey)).toSet(); + readyItems.forEach((v) async { + final bitmap = await v.render(); + if (bitmap != null) { + widget.onRendered(v.markerKey, bitmap); + } + }); + if (readyItems.length < waitingItems.length) { + _checkNextFrame(); + setState(() {}); + } + }); + } + + @override + Widget build(BuildContext context) { + return Transform.translate( + offset: Offset(context.select((mq) => mq.size.width), 0), + child: Material( + type: MaterialType.transparency, + child: Stack( + children: _items.map((item) { + return RepaintBoundary( + key: item.globalKey, + child: widget.markers.firstWhereOrNull((v) => v.key == item.markerKey) ?? const SizedBox(), + ); + }).toList(), + ), + ), + ); + } + + _MarkerGeneratorItem getOrCreate(T markerKey) { + final existingItem = _items.firstWhereOrNull((v) => v.markerKey == markerKey); + if (existingItem != null) return existingItem; + + final newItem = _MarkerGeneratorItem(markerKey); + _items.add(newItem); + return newItem; + } +} + +enum MarkerGeneratorItemState { waiting, rendering, done } + +class _MarkerGeneratorItem { + final T markerKey; + GlobalKey? globalKey; + MarkerGeneratorItemState state = MarkerGeneratorItemState.waiting; + + _MarkerGeneratorItem(this.markerKey); + + bool get isWaiting => state == MarkerGeneratorItemState.waiting; + + Future render() async { + Uint8List? bytes; + final _globalKey = globalKey; + if (_globalKey != null) { + state = MarkerGeneratorItemState.rendering; + final boundary = _globalKey.currentContext!.findRenderObject() as RenderRepaintBoundary; + if (boundary.hasSize && boundary.size != Size.zero) { + final image = await boundary.toImage(pixelRatio: ui.window.devicePixelRatio); + final byteData = await image.toByteData(format: ui.ImageByteFormat.png); + bytes = byteData?.buffer.asUint8List(); + } + state = bytes != null ? MarkerGeneratorItemState.done : MarkerGeneratorItemState.waiting; + } + return bytes; + } + + @override + String toString() => '$runtimeType#${shortHash(this)}{markerKey=$markerKey, globalKey=$globalKey, state=$state}'; +} diff --git a/lib/widgets/common/map/latlng_tween.dart b/lib/widgets/common/map/latlng_tween.dart new file mode 100644 index 000000000..912e14179 --- /dev/null +++ b/lib/widgets/common/map/latlng_tween.dart @@ -0,0 +1,16 @@ +import 'package:aves/widgets/common/map/latlng_utils.dart'; +import 'package:flutter/widgets.dart'; +import 'package:latlong2/latlong.dart'; + +class LatLngTween extends Tween { + LatLngTween({ + required LatLng? begin, + required LatLng? end, + }) : super( + begin: begin, + end: end, + ); + + @override + LatLng? lerp(double t) => LatLngUtils.lerp(begin, end, t); +} diff --git a/lib/widgets/common/map/latlng_utils.dart b/lib/widgets/common/map/latlng_utils.dart new file mode 100644 index 000000000..35867b4ec --- /dev/null +++ b/lib/widgets/common/map/latlng_utils.dart @@ -0,0 +1,14 @@ +import 'package:latlong2/latlong.dart'; + +class LatLngUtils { + static LatLng? lerp(LatLng? a, LatLng? b, double t) { + if (a == null && b == null) return null; + + final _a = a ?? LatLng(0, 0); + final _b = b ?? LatLng(0, 0); + return LatLng( + _a.latitude + (_b.latitude - _a.latitude) * t, + _a.longitude + (_b.longitude - _a.longitude) * t, + ); + } +} diff --git a/lib/widgets/common/map/leaflet/map.dart b/lib/widgets/common/map/leaflet/map.dart new file mode 100644 index 000000000..51ad8e06f --- /dev/null +++ b/lib/widgets/common/map/leaflet/map.dart @@ -0,0 +1,202 @@ +import 'dart:async'; + +import 'package:aves/model/entry.dart'; +import 'package:aves/model/settings/enums.dart'; +import 'package:aves/widgets/common/map/buttons.dart'; +import 'package:aves/widgets/common/map/decorator.dart'; +import 'package:aves/widgets/common/map/geo_entry.dart'; +import 'package:aves/widgets/common/map/geo_map.dart'; +import 'package:aves/widgets/common/map/latlng_tween.dart'; +import 'package:aves/widgets/common/map/leaflet/scale_layer.dart'; +import 'package:aves/widgets/common/map/leaflet/tile_layers.dart'; +import 'package:aves/widgets/common/map/zoomed_bounds.dart'; +import 'package:fluster/fluster.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; + +class EntryLeafletMap extends StatefulWidget { + final ValueNotifier boundsNotifier; + final bool interactive; + final EntryMapStyle style; + final EntryMarkerBuilder markerBuilder; + final Fluster markerCluster; + final List markerEntries; + final Size markerSize; + final UserZoomChangeCallback? onUserZoomChange; + + const EntryLeafletMap({ + Key? key, + required this.boundsNotifier, + required this.interactive, + required this.style, + required this.markerBuilder, + required this.markerCluster, + required this.markerEntries, + required this.markerSize, + this.onUserZoomChange, + }) : super(key: key); + + @override + State createState() => _EntryLeafletMapState(); +} + +class _EntryLeafletMapState extends State with TickerProviderStateMixin { + final MapController _mapController = MapController(); + final List _subscriptions = []; + + ValueNotifier get boundsNotifier => widget.boundsNotifier; + + ZoomedBounds get bounds => boundsNotifier.value; + + // duration should match the uncustomizable Google Maps duration + static const _cameraAnimationDuration = Duration(milliseconds: 400); + static const _zoomMin = 1.0; + + // TODO TLAD [map] also limit zoom on pinch-to-zoom gesture + static const _zoomMax = 16.0; + + // TODO TLAD [map] allow rotation when leaflet scale layer is fixed + static const interactiveFlags = InteractiveFlag.all & ~InteractiveFlag.rotate; + + @override + void initState() { + super.initState(); + _subscriptions.add(_mapController.mapEventStream.listen((event) => _updateVisibleRegion())); + WidgetsBinding.instance!.addPostFrameCallback((_) => _updateVisibleRegion()); + } + + @override + void dispose() { + _subscriptions + ..forEach((sub) => sub.cancel()) + ..clear(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: boundsNotifier, + builder: (context, visibleRegion, child) { + final allEntries = widget.markerEntries; + final clusters = visibleRegion != null ? widget.markerCluster.clusters(visibleRegion.boundingBox, visibleRegion.zoom.round()) : []; + final clusterByMarkerKey = Map.fromEntries(clusters.map((v) { + if (v.isCluster!) { + final uri = v.childMarkerId; + final entry = allEntries.firstWhere((v) => v.uri == uri); + return MapEntry(MarkerKey(entry, v.pointsSize), v); + } + return MapEntry(MarkerKey(v.entry!, null), v); + })); + + return Stack( + children: [ + MapDecorator( + interactive: widget.interactive, + child: _buildMap(clusterByMarkerKey), + ), + MapButtonPanel( + latLng: bounds.center, + zoomBy: _zoomBy, + ), + ], + ); + }, + ); + } + + Widget _buildMap(Map clusterByMarkerKey) { + final markerSize = widget.markerSize; + final markers = clusterByMarkerKey.entries.map((kv) { + final markerKey = kv.key; + final cluster = kv.value; + final latLng = LatLng(cluster.latitude!, cluster.longitude!); + return Marker( + point: latLng, + builder: (context) => GestureDetector( + onTap: () => _moveTo(latLng), + child: widget.markerBuilder(markerKey), + ), + width: markerSize.width, + height: markerSize.height, + anchorPos: AnchorPos.align(AnchorAlign.top), + ); + }).toList(); + + return FlutterMap( + options: MapOptions( + center: bounds.center, + zoom: bounds.zoom, + interactiveFlags: widget.interactive ? interactiveFlags : InteractiveFlag.none, + ), + mapController: _mapController, + children: [ + _buildMapLayer(), + ScaleLayerWidget( + options: ScaleLayerOptions(), + ), + MarkerLayerWidget( + options: MarkerLayerOptions( + markers: markers, + rotate: true, + rotateAlignment: Alignment.bottomCenter, + ), + ), + ], + ); + } + + Widget _buildMapLayer() { + switch (widget.style) { + case EntryMapStyle.osmHot: + return const OSMHotLayer(); + case EntryMapStyle.stamenToner: + return const StamenTonerLayer(); + case EntryMapStyle.stamenWatercolor: + return const StamenWatercolorLayer(); + default: + return const SizedBox.shrink(); + } + } + + void _updateVisibleRegion() { + final bounds = _mapController.bounds; + if (bounds != null) { + boundsNotifier.value = ZoomedBounds( + west: bounds.west, + south: bounds.south, + east: bounds.east, + north: bounds.north, + zoom: _mapController.zoom, + ); + } + } + + Future _zoomBy(double amount) async { + final endZoom = (_mapController.zoom + amount).clamp(_zoomMin, _zoomMax); + widget.onUserZoomChange?.call(endZoom); + + final zoomTween = Tween(begin: _mapController.zoom, end: endZoom); + await _animateCamera((animation) => _mapController.move(_mapController.center, zoomTween.evaluate(animation))); + } + + Future _moveTo(LatLng point) async { + final centerTween = LatLngTween(begin: _mapController.center, end: point); + await _animateCamera((animation) => _mapController.move(centerTween.evaluate(animation)!, _mapController.zoom)); + } + + Future _animateCamera(void Function(Animation animation) animate) async { + final controller = AnimationController(duration: _cameraAnimationDuration, vsync: this); + final animation = CurvedAnimation(parent: controller, curve: Curves.fastOutSlowIn); + controller.addListener(() => animate(animation)); + animation.addStatusListener((status) { + if (status == AnimationStatus.completed) { + controller.dispose(); + } else if (status == AnimationStatus.dismissed) { + controller.dispose(); + } + }); + await controller.forward(); + } +} diff --git a/lib/widgets/viewer/info/maps/scale_layer.dart b/lib/widgets/common/map/leaflet/scale_layer.dart similarity index 94% rename from lib/widgets/viewer/info/maps/scale_layer.dart rename to lib/widgets/common/map/leaflet/scale_layer.dart index 30489bc8a..4a97aa3c1 100644 --- a/lib/widgets/viewer/info/maps/scale_layer.dart +++ b/lib/widgets/common/map/leaflet/scale_layer.dart @@ -1,12 +1,11 @@ import 'dart:math'; import 'package:aves/widgets/common/basic/outlined_text.dart'; +import 'package:aves/widgets/common/map/leaflet/scalebar_utils.dart'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/plugin_api.dart'; -import 'scalebar_utils.dart' as util; - class ScaleLayerOptions extends LayerOptions { final Widget Function(double width, String distance) builder; @@ -24,6 +23,7 @@ class ScaleLayerOptions extends LayerOptions { } } +// TODO TLAD [map] scale bar should not rotate together with map layer class ScaleLayerWidget extends StatelessWidget { final ScaleLayerOptions options; @@ -86,7 +86,7 @@ class ScaleLayer extends StatelessWidget { : 2); final distance = scale[max(0, min(20, level))].toDouble(); final start = map.project(center); - final targetPoint = util.calculateEndingGlobalCoordinates(center, 90, distance); + final targetPoint = ScaleBarUtils.calculateEndingGlobalCoordinates(center, 90, distance); final end = map.project(targetPoint); final displayDistance = distance > 999 ? '${(distance / 1000).toStringAsFixed(0)} km' : '${distance.toStringAsFixed(0)} m'; final width = end.x - (start.x as double); diff --git a/lib/widgets/common/map/leaflet/scalebar_utils.dart b/lib/widgets/common/map/leaflet/scalebar_utils.dart new file mode 100644 index 000000000..b3d852241 --- /dev/null +++ b/lib/widgets/common/map/leaflet/scalebar_utils.dart @@ -0,0 +1,119 @@ +import 'dart:math'; + +import 'package:aves/utils/math_utils.dart'; +import 'package:latlong2/latlong.dart'; + +class ScaleBarUtils { + static LatLng calculateEndingGlobalCoordinates(LatLng start, double startBearing, double distance) { + var mSemiMajorAxis = 6378137.0; //WGS84 major axis + var mSemiMinorAxis = (1.0 - 1.0 / 298.257223563) * 6378137.0; + var mFlattening = 1.0 / 298.257223563; + // double mInverseFlattening = 298.257223563; + + var a = mSemiMajorAxis; + var b = mSemiMinorAxis; + var aSquared = a * a; + var bSquared = b * b; + var f = mFlattening; + var phi1 = toRadians(start.latitude); + var alpha1 = toRadians(startBearing); + var cosAlpha1 = cos(alpha1); + var sinAlpha1 = sin(alpha1); + var s = distance; + var tanU1 = (1.0 - f) * tan(phi1); + var cosU1 = 1.0 / sqrt(1.0 + tanU1 * tanU1); + var sinU1 = tanU1 * cosU1; + + // eq. 1 + var sigma1 = atan2(tanU1, cosAlpha1); + + // eq. 2 + var sinAlpha = cosU1 * sinAlpha1; + + var sin2Alpha = sinAlpha * sinAlpha; + var cos2Alpha = 1 - sin2Alpha; + var uSquared = cos2Alpha * (aSquared - bSquared) / bSquared; + + // eq. 3 + var A = 1 + (uSquared / 16384) * (4096 + uSquared * (-768 + uSquared * (320 - 175 * uSquared))); + + // eq. 4 + var B = (uSquared / 1024) * (256 + uSquared * (-128 + uSquared * (74 - 47 * uSquared))); + + // iterate until there is a negligible change in sigma + double deltaSigma; + var sOverbA = s / (b * A); + var sigma = sOverbA; + double sinSigma; + var prevSigma = sOverbA; + double sigmaM2; + double cosSigmaM2; + double cos2SigmaM2; + + for (;;) { + // eq. 5 + sigmaM2 = 2.0 * sigma1 + sigma; + cosSigmaM2 = cos(sigmaM2); + cos2SigmaM2 = cosSigmaM2 * cosSigmaM2; + sinSigma = sin(sigma); + var cosSignma = cos(sigma); + + // eq. 6 + deltaSigma = B * sinSigma * (cosSigmaM2 + (B / 4.0) * (cosSignma * (-1 + 2 * cos2SigmaM2) - (B / 6.0) * cosSigmaM2 * (-3 + 4 * sinSigma * sinSigma) * (-3 + 4 * cos2SigmaM2))); + + // eq. 7 + sigma = sOverbA + deltaSigma; + + // break after converging to tolerance + if ((sigma - prevSigma).abs() < 0.0000000000001) break; + + prevSigma = sigma; + } + + sigmaM2 = 2.0 * sigma1 + sigma; + cosSigmaM2 = cos(sigmaM2); + cos2SigmaM2 = cosSigmaM2 * cosSigmaM2; + + var cosSigma = cos(sigma); + sinSigma = sin(sigma); + + // eq. 8 + var phi2 = atan2(sinU1 * cosSigma + cosU1 * sinSigma * cosAlpha1, (1.0 - f) * sqrt(sin2Alpha + pow(sinU1 * sinSigma - cosU1 * cosSigma * cosAlpha1, 2.0))); + + // eq. 9 + // This fixes the pole crossing defect spotted by Matt Feemster. When a + // path passes a pole and essentially crosses a line of latitude twice - + // once in each direction - the longitude calculation got messed up. + // Using + // atan2 instead of atan fixes the defect. The change is in the next 3 + // lines. + // double tanLambda = sinSigma * sinAlpha1 / (cosU1 * cosSigma - sinU1 * + // sinSigma * cosAlpha1); + // double lambda = Math.atan(tanLambda); + var lambda = atan2(sinSigma * sinAlpha1, (cosU1 * cosSigma - sinU1 * sinSigma * cosAlpha1)); + + // eq. 10 + var C = (f / 16) * cos2Alpha * (4 + f * (4 - 3 * cos2Alpha)); + + // eq. 11 + var L = lambda - (1 - C) * f * sinAlpha * (sigma + C * sinSigma * (cosSigmaM2 + C * cosSigma * (-1 + 2 * cos2SigmaM2))); + + // eq. 12 + // double alpha2 = Math.atan2(sinAlpha, -sinU1 * sinSigma + cosU1 * + // cosSigma * cosAlpha1); + + // build result + var latitude = toDegrees(phi2); + var longitude = start.longitude + toDegrees(L); + + // if ((endBearing != null) && (endBearing.length > 0)) { + // endBearing[0] = toDegrees(alpha2); + // } + + latitude = latitude < -90 ? -90 : latitude; + latitude = latitude > 90 ? 90 : latitude; + longitude = longitude < -180 ? -180 : longitude; + longitude = longitude > 180 ? 180 : longitude; + return LatLng(latitude, longitude); + } +} diff --git a/lib/widgets/common/map/leaflet/tile_layers.dart b/lib/widgets/common/map/leaflet/tile_layers.dart new file mode 100644 index 000000000..4a2649d0c --- /dev/null +++ b/lib/widgets/common/map/leaflet/tile_layers.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:provider/provider.dart'; + +class OSMHotLayer extends StatelessWidget { + const OSMHotLayer({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return TileLayerWidget( + options: TileLayerOptions( + urlTemplate: 'https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png', + subdomains: ['a', 'b', 'c'], + retinaMode: context.select((mq) => mq.devicePixelRatio) > 1, + ), + ); + } +} + +class StamenTonerLayer extends StatelessWidget { + const StamenTonerLayer({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return TileLayerWidget( + options: TileLayerOptions( + urlTemplate: 'https://stamen-tiles-{s}.a.ssl.fastly.net/toner-lite/{z}/{x}/{y}{r}.png', + subdomains: ['a', 'b', 'c', 'd'], + retinaMode: context.select((mq) => mq.devicePixelRatio) > 1, + ), + ); + } +} + +class StamenWatercolorLayer extends StatelessWidget { + const StamenWatercolorLayer({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return TileLayerWidget( + options: TileLayerOptions( + urlTemplate: 'https://stamen-tiles-{s}.a.ssl.fastly.net/watercolor/{z}/{x}/{y}.jpg', + subdomains: ['a', 'b', 'c', 'd'], + retinaMode: context.select((mq) => mq.devicePixelRatio) > 1, + ), + ); + } +} diff --git a/lib/widgets/viewer/info/maps/marker.dart b/lib/widgets/common/map/marker.dart similarity index 55% rename from lib/widgets/viewer/info/maps/marker.dart rename to lib/widgets/common/map/marker.dart index 516b174ce..c8e781c80 100644 --- a/lib/widgets/viewer/info/maps/marker.dart +++ b/lib/widgets/common/map/marker.dart @@ -1,16 +1,15 @@ -import 'dart:typed_data'; -import 'dart:ui' as ui; - import 'package:aves/model/entry.dart'; import 'package:aves/widgets/collection/thumbnail/image.dart'; +import 'package:custom_rounded_rectangle_border/custom_rounded_rectangle_border.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; -import 'package:provider/provider.dart'; class ImageMarker extends StatelessWidget { - final AvesEntry entry; + final AvesEntry? entry; + final int? count; final double extent; final Size pointerSize; + final bool progressive; static const double outerBorderRadiusDim = 8; static const double outerBorderWidth = 1.5; @@ -18,21 +17,27 @@ class ImageMarker extends StatelessWidget { static const outerBorderColor = Colors.white30; static const innerBorderColor = Color(0xFF212121); static const outerBorderRadius = BorderRadius.all(Radius.circular(outerBorderRadiusDim)); - static const innerBorderRadius = BorderRadius.all(Radius.circular(outerBorderRadiusDim - outerBorderWidth)); + static const innerRadius = Radius.circular(outerBorderRadiusDim - outerBorderWidth); + static const innerBorderRadius = BorderRadius.all(innerRadius); const ImageMarker({ Key? key, required this.entry, + required this.count, required this.extent, - this.pointerSize = Size.zero, + required this.pointerSize, + required this.progressive, }) : super(key: key); @override Widget build(BuildContext context) { - Widget child = ThumbnailImage( - entry: entry, - extent: extent, - ); + Widget child = entry != null + ? ThumbnailImage( + entry: entry!, + extent: extent, + progressive: progressive, + ) + : const SizedBox(); // need to be sized for the Google Maps marker generator child = SizedBox( @@ -57,6 +62,49 @@ class ImageMarker extends StatelessWidget { borderRadius: innerBorderRadius, ); + child = DecoratedBox( + decoration: innerDecoration, + position: DecorationPosition.foreground, + child: ClipRRect( + borderRadius: innerBorderRadius, + child: child, + ), + ); + + if (count != null) { + const borderSide = BorderSide( + color: innerBorderColor, + width: innerBorderWidth, + ); + child = Stack( + children: [ + child, + Container( + padding: const EdgeInsets.symmetric(vertical: 0, horizontal: 2), + decoration: ShapeDecoration( + color: Theme.of(context).accentColor, + shape: const CustomRoundedRectangleBorder( + leftSide: borderSide, + rightSide: borderSide, + topSide: borderSide, + bottomSide: borderSide, + topLeftCornerSide: borderSide, + bottomRightCornerSide: borderSide, + borderRadius: BorderRadius.only( + topLeft: innerRadius, + bottomRight: innerRadius, + ), + ), + ), + child: Text( + '$count', + style: const TextStyle(fontSize: 12), + ), + ), + ], + ); + } + return CustomPaint( foregroundPainter: MarkerPointerPainter( color: innerBorderColor, @@ -68,14 +116,7 @@ class ImageMarker extends StatelessWidget { padding: EdgeInsets.only(bottom: pointerSize.height), child: Container( decoration: outerDecoration, - child: DecoratedBox( - decoration: innerDecoration, - position: DecorationPosition.foreground, - child: ClipRRect( - borderRadius: innerBorderRadius, - child: child, - ), - ), + child: child, ), ), ); @@ -124,65 +165,3 @@ class MarkerPointerPainter extends CustomPainter { @override bool shouldRepaint(covariant CustomPainter oldDelegate) => false; } - -// generate bitmap from widget, for Google Maps -class MarkerGeneratorWidget extends StatefulWidget { - final List markers; - final Duration delay; - final Function(List bitmaps) onComplete; - - const MarkerGeneratorWidget({ - Key? key, - required this.markers, - this.delay = Duration.zero, - required this.onComplete, - }) : super(key: key); - - @override - _MarkerGeneratorWidgetState createState() => _MarkerGeneratorWidgetState(); -} - -class _MarkerGeneratorWidgetState extends State { - final _globalKeys = []; - - @override - void initState() { - super.initState(); - WidgetsBinding.instance!.addPostFrameCallback((_) async { - if (widget.delay > Duration.zero) { - await Future.delayed(widget.delay); - } - widget.onComplete(await _getBitmaps(context)); - }); - } - - @override - Widget build(BuildContext context) { - return Transform.translate( - offset: Offset(context.select((mq) => mq.size.width), 0), - child: Material( - type: MaterialType.transparency, - child: Stack( - children: widget.markers.map((i) { - final key = GlobalKey(debugLabel: 'map-marker-$i'); - _globalKeys.add(key); - return RepaintBoundary( - key: key, - child: i, - ); - }).toList(), - ), - ), - ); - } - - Future> _getBitmaps(BuildContext context) async { - final pixelRatio = context.read().devicePixelRatio; - return Future.wait(_globalKeys.map((key) async { - final boundary = key.currentContext!.findRenderObject() as RenderRepaintBoundary; - final image = await boundary.toImage(pixelRatio: pixelRatio); - final byteData = await image.toByteData(format: ui.ImageByteFormat.png); - return byteData != null ? byteData.buffer.asUint8List() : Uint8List(0); - })); - } -} diff --git a/lib/widgets/common/map/zoomed_bounds.dart b/lib/widgets/common/map/zoomed_bounds.dart new file mode 100644 index 000000000..a19f42e5f --- /dev/null +++ b/lib/widgets/common/map/zoomed_bounds.dart @@ -0,0 +1,64 @@ +import 'dart:math'; + +import 'package:equatable/equatable.dart'; +import 'package:flutter/foundation.dart'; +import 'package:latlong2/latlong.dart'; + +@immutable +class ZoomedBounds extends Equatable { + final double west, south, east, north, zoom; + + List get boundingBox => [west, south, east, north]; + + LatLng get center => LatLng((north + south) / 2, (east + west) / 2); + + @override + List get props => [west, south, east, north, zoom]; + + const ZoomedBounds({ + required this.west, + required this.south, + required this.east, + required this.north, + required this.zoom, + }); + + static const _collocationMaxDeltaThreshold = 360 / (2 << 19); + + factory ZoomedBounds.fromPoints({ + required Set points, + double collocationZoom = 20, + }) { + var west = .0, south = .0, east = .0, north = .0; + var zoom = collocationZoom; + + if (points.isNotEmpty) { + final first = points.first; + west = first.longitude; + south = first.latitude; + east = first.longitude; + north = first.latitude; + + for (var point in points) { + final lng = point.longitude; + final lat = point.latitude; + if (lng < west) west = lng; + if (lat < south) south = lat; + if (lng > east) east = lng; + if (lat > north) north = lat; + } + + final boundsDelta = max(north - south, east - west); + if (boundsDelta > _collocationMaxDeltaThreshold) { + zoom = max(1, log(360) / ln2 - log(boundsDelta) / ln2); + } + } + return ZoomedBounds( + west: west, + south: south, + east: east, + north: north, + zoom: zoom, + ); + } +} diff --git a/lib/widgets/filter_grids/common/action_delegates/chip_set.dart b/lib/widgets/filter_grids/common/action_delegates/chip_set.dart index 99333753e..1ff248eb8 100644 --- a/lib/widgets/filter_grids/common/action_delegates/chip_set.dart +++ b/lib/widgets/filter_grids/common/action_delegates/chip_set.dart @@ -14,7 +14,8 @@ import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; import 'package:aves/widgets/dialogs/cover_selection_dialog.dart'; -import 'package:aves/widgets/stats/stats.dart'; +import 'package:aves/widgets/map/map_page.dart'; +import 'package:aves/widgets/stats/stats_page.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; @@ -51,6 +52,7 @@ abstract class ChipSetActionDelegate with FeedbackMi case ChipSetAction.select: case ChipSetAction.selectAll: case ChipSetAction.selectNone: + case ChipSetAction.map: case ChipSetAction.stats: case ChipSetAction.createAlbum: return true; @@ -73,6 +75,9 @@ abstract class ChipSetActionDelegate with FeedbackMi case ChipSetAction.sort: _showSortDialog(context); break; + case ChipSetAction.map: + _goToMap(context); + break; case ChipSetAction.stats: _goToStats(context); break; @@ -124,6 +129,19 @@ abstract class ChipSetActionDelegate with FeedbackMi } } + void _goToMap(BuildContext context) { + final source = context.read(); + Navigator.push( + context, + MaterialPageRoute( + settings: const RouteSettings(name: MapPage.routeName), + builder: (context) => MapPage( + source: source, + ), + ), + ); + } + void _goToStats(BuildContext context) { final source = context.read(); Navigator.push( diff --git a/lib/widgets/filter_grids/common/app_bar.dart b/lib/widgets/filter_grids/common/app_bar.dart index 00d4c23f9..53d188ebd 100644 --- a/lib/widgets/filter_grids/common/app_bar.dart +++ b/lib/widgets/filter_grids/common/app_bar.dart @@ -204,6 +204,7 @@ class _FilterGridAppBarState extends State entries; + + MapPage({ + Key? key, + required this.source, + this.parentCollection, + }) : super(key: key) { + entries = (parentCollection?.sortedEntries.expand((entry) => entry.burstEntries ?? {entry}).toSet() ?? source.visibleEntries).where((entry) => entry.hasGps).toList(); + } + + @override + _MapPageState createState() => _MapPageState(); +} + +class _MapPageState extends State { + late final ValueNotifier _isAnimatingNotifier; + + @override + void initState() { + super.initState(); + if (settings.infoMapStyle.isGoogleMaps) { + _isAnimatingNotifier = ValueNotifier(true); + Future.delayed(Durations.pageTransitionAnimation * timeDilation).then((_) { + if (!mounted) return; + _isAnimatingNotifier.value = false; + }); + } else { + _isAnimatingNotifier = ValueNotifier(false); + } + } + + @override + Widget build(BuildContext context) { + return MediaQueryDataProvider( + child: Scaffold( + appBar: AppBar( + title: Text(context.l10n.mapPageTitle), + ), + body: SafeArea( + child: GeoMap( + entries: widget.entries, + interactive: true, + isAnimatingNotifier: _isAnimatingNotifier, + ), + ), + ), + ); + } +} diff --git a/lib/widgets/stats/stats.dart b/lib/widgets/stats/stats_page.dart similarity index 100% rename from lib/widgets/stats/stats.dart rename to lib/widgets/stats/stats_page.dart diff --git a/lib/widgets/viewer/debug/debug_page.dart b/lib/widgets/viewer/debug/debug_page.dart index d351d935b..73f376fb6 100644 --- a/lib/widgets/viewer/debug/debug_page.dart +++ b/lib/widgets/viewer/debug/debug_page.dart @@ -122,7 +122,6 @@ class ViewerDebugPage extends StatelessWidget { 'hasAddress': '${entry.hasAddress}', 'hasFineAddress': '${entry.hasFineAddress}', 'latLng': '${entry.latLng}', - 'geoUri': entry.geoUri ?? '', }, ), ], diff --git a/lib/widgets/viewer/entry_action_delegate.dart b/lib/widgets/viewer/entry_action_delegate.dart index c62bc91a6..e7b0793d6 100644 --- a/lib/widgets/viewer/entry_action_delegate.dart +++ b/lib/widgets/viewer/entry_action_delegate.dart @@ -77,7 +77,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix }); break; case EntryAction.openMap: - AndroidAppService.openMap(entry.geoUri!).then((success) { + AndroidAppService.openMap(entry.latLng!).then((success) { if (!success) showNoMatchingAppDialog(context); }); break; diff --git a/lib/widgets/viewer/info/location_section.dart b/lib/widgets/viewer/info/location_section.dart index 1a64ec9ff..bb3ecba87 100644 --- a/lib/widgets/viewer/info/location_section.dart +++ b/lib/widgets/viewer/info/location_section.dart @@ -1,23 +1,15 @@ import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/location.dart'; import 'package:aves/model/settings/coordinate_format.dart'; -import 'package:aves/model/settings/enums.dart'; -import 'package:aves/model/settings/map_style.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/services/services.dart'; -import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; +import 'package:aves/widgets/common/map/geo_map.dart'; import 'package:aves/widgets/viewer/info/common.dart'; -import 'package:aves/widgets/viewer/info/maps/common.dart'; -import 'package:aves/widgets/viewer/info/maps/google_map.dart'; -import 'package:aves/widgets/viewer/info/maps/leaflet_map.dart'; -import 'package:aves/widgets/viewer/info/maps/marker.dart'; import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import 'package:tuple/tuple.dart'; class LocationSection extends StatefulWidget { final CollectionLens? collection; @@ -39,16 +31,7 @@ class LocationSection extends StatefulWidget { _LocationSectionState createState() => _LocationSectionState(); } -class _LocationSectionState extends State with TickerProviderStateMixin { - // as of google_maps_flutter v2.0.6, Google Maps initialization is blocking - // cf https://github.com/flutter/flutter/issues/28493 - // it is especially severe the first time, but still significant afterwards - // so we prevent loading it while scrolling or animating - bool _googleMapsLoaded = false; - - static const extent = 48.0; - static const pointerSize = Size(8.0, 6.0); - +class _LocationSectionState extends State { CollectionLens? get collection => widget.collection; AvesEntry get entry => widget.entry; @@ -85,8 +68,6 @@ class _LocationSectionState extends State with TickerProviderSt @override Widget build(BuildContext context) { if (!entry.hasGps) return const SizedBox(); - final latLng = entry.latLng!; - final geoUri = entry.geoUri!; final filters = []; if (entry.hasAddress) { @@ -97,74 +78,16 @@ class _LocationSectionState extends State with TickerProviderSt if (place != null && place.isNotEmpty) filters.add(LocationFilter(LocationLevel.place, place)); } - Widget buildMarker(BuildContext context) => ImageMarker( - entry: entry, - extent: extent, - pointerSize: pointerSize, - ); - return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (widget.showTitle) const SectionRow(icon: AIcons.location), - FutureBuilder( - future: availability.isConnected, - builder: (context, snapshot) { - if (snapshot.data != true) return const SizedBox(); - return Selector( - selector: (context, s) => s.infoMapStyle, - builder: (context, mapStyle, child) { - final isGoogleMaps = mapStyle.isGoogleMaps; - return AnimatedSize( - alignment: Alignment.topCenter, - curve: Curves.easeInOutCubic, - duration: Durations.mapStyleSwitchAnimation, - vsync: this, - child: ValueListenableBuilder( - valueListenable: widget.isScrollingNotifier, - builder: (context, scrolling, child) { - if (!scrolling && isGoogleMaps) { - _googleMapsLoaded = true; - } - return Visibility( - visible: !isGoogleMaps || _googleMapsLoaded, - replacement: Stack( - children: [ - const MapDecorator(), - MapButtonPanel( - geoUri: geoUri, - zoomBy: (_) {}, - ), - ], - ), - child: child!, - ); - }, - child: isGoogleMaps - ? EntryGoogleMap( - // `LatLng` used by `google_maps_flutter` is not the one from `latlong` package - latLng: Tuple2(latLng.latitude, latLng.longitude), - geoUri: geoUri, - initialZoom: settings.infoMapZoom, - markerId: entry.uri, - markerBuilder: buildMarker, - ) - : EntryLeafletMap( - latLng: latLng, - geoUri: geoUri, - initialZoom: settings.infoMapZoom, - style: settings.infoMapStyle, - markerSize: Size( - extent + ImageMarker.outerBorderWidth * 2, - extent + ImageMarker.outerBorderWidth * 2 + pointerSize.height, - ), - markerBuilder: buildMarker, - ), - ), - ); - }, - ); - }, + GeoMap( + entries: [entry], + interactive: false, + mapHeight: 200, + isAnimatingNotifier: widget.isScrollingNotifier, + onUserZoomChange: (zoom) => settings.infoMapZoom = zoom, ), _AddressInfoGroup(entry: entry), if (filters.isNotEmpty) diff --git a/lib/widgets/viewer/info/maps/google_map.dart b/lib/widgets/viewer/info/maps/google_map.dart deleted file mode 100644 index 74744ba68..000000000 --- a/lib/widgets/viewer/info/maps/google_map.dart +++ /dev/null @@ -1,134 +0,0 @@ -import 'dart:async'; -import 'dart:typed_data'; - -import 'package:aves/model/settings/enums.dart'; -import 'package:aves/model/settings/settings.dart'; -import 'package:aves/widgets/viewer/info/maps/common.dart'; -import 'package:aves/widgets/viewer/info/maps/marker.dart'; -import 'package:flutter/material.dart'; -import 'package:google_maps_flutter/google_maps_flutter.dart'; -import 'package:tuple/tuple.dart'; - -class EntryGoogleMap extends StatefulWidget { - final LatLng latLng; - final String geoUri; - final double initialZoom; - final String markerId; - final WidgetBuilder markerBuilder; - - EntryGoogleMap({ - Key? key, - required Tuple2 latLng, - required this.geoUri, - required this.initialZoom, - required this.markerId, - required this.markerBuilder, - }) : latLng = LatLng(latLng.item1, latLng.item2), - super(key: key); - - @override - State createState() => _EntryGoogleMapState(); -} - -class _EntryGoogleMapState extends State { - GoogleMapController? _controller; - late Completer _markerLoaderCompleter; - - @override - void initState() { - super.initState(); - _markerLoaderCompleter = Completer(); - } - - @override - void didUpdateWidget(covariant EntryGoogleMap oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.latLng != oldWidget.latLng && _controller != null) { - _controller!.moveCamera(CameraUpdate.newLatLng(widget.latLng)); - } - if (widget.markerId != oldWidget.markerId) { - _markerLoaderCompleter = Completer(); - } - } - - @override - void dispose() { - _controller?.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Stack( - children: [ - MarkerGeneratorWidget( - key: Key(widget.markerId), - markers: [widget.markerBuilder(context)], - onComplete: (bitmaps) => _markerLoaderCompleter.complete(bitmaps.first), - ), - MapDecorator( - child: _buildMap(), - ), - MapButtonPanel( - geoUri: widget.geoUri, - zoomBy: _zoomBy, - ), - ], - ); - } - - Widget _buildMap() { - return FutureBuilder( - future: _markerLoaderCompleter.future, - builder: (context, snapshot) { - final markers = {}; - if (!snapshot.hasError && snapshot.connectionState == ConnectionState.done) { - final markerBytes = snapshot.data!; - markers.add(Marker( - markerId: MarkerId(widget.markerId), - icon: BitmapDescriptor.fromBytes(markerBytes), - position: widget.latLng, - )); - } - return GoogleMap( - // GoogleMap init perf issue: https://github.com/flutter/flutter/issues/28493 - initialCameraPosition: CameraPosition( - target: widget.latLng, - zoom: widget.initialZoom, - ), - onMapCreated: (controller) => setState(() => _controller = controller), - compassEnabled: false, - mapToolbarEnabled: false, - mapType: _toMapStyle(settings.infoMapStyle), - rotateGesturesEnabled: false, - scrollGesturesEnabled: false, - zoomControlsEnabled: false, - zoomGesturesEnabled: false, - liteModeEnabled: false, - // no camera animation in lite mode - tiltGesturesEnabled: false, - myLocationEnabled: false, - myLocationButtonEnabled: false, - markers: markers, - ); - }); - } - - void _zoomBy(double amount) { - settings.infoMapZoom += amount; - _controller?.animateCamera(CameraUpdate.zoomBy(amount)); - } - - MapType _toMapStyle(EntryMapStyle style) { - switch (style) { - case EntryMapStyle.googleNormal: - return MapType.normal; - case EntryMapStyle.googleHybrid: - return MapType.hybrid; - case EntryMapStyle.googleTerrain: - return MapType.terrain; - default: - return MapType.none; - } - } -} diff --git a/lib/widgets/viewer/info/maps/leaflet_map.dart b/lib/widgets/viewer/info/maps/leaflet_map.dart deleted file mode 100644 index aed8f9e05..000000000 --- a/lib/widgets/viewer/info/maps/leaflet_map.dart +++ /dev/null @@ -1,204 +0,0 @@ -import 'package:aves/model/settings/enums.dart'; -import 'package:aves/model/settings/settings.dart'; -import 'package:aves/widgets/common/extensions/build_context.dart'; -import 'package:aves/widgets/viewer/info/common.dart'; -import 'package:aves/widgets/viewer/info/maps/common.dart'; -import 'package:aves/widgets/viewer/info/maps/scale_layer.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_map/flutter_map.dart'; -import 'package:flutter_markdown/flutter_markdown.dart'; -import 'package:latlong2/latlong.dart'; -import 'package:provider/provider.dart'; -import 'package:url_launcher/url_launcher.dart'; - -class EntryLeafletMap extends StatefulWidget { - final LatLng latLng; - final String geoUri; - final double initialZoom; - final EntryMapStyle style; - final Size markerSize; - final WidgetBuilder markerBuilder; - - const EntryLeafletMap({ - Key? key, - required this.latLng, - required this.geoUri, - required this.initialZoom, - required this.style, - required this.markerBuilder, - required this.markerSize, - }) : super(key: key); - - @override - State createState() => _EntryLeafletMapState(); -} - -class _EntryLeafletMapState extends State with TickerProviderStateMixin { - final MapController _mapController = MapController(); - - @override - void didUpdateWidget(covariant EntryLeafletMap oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.latLng != oldWidget.latLng) { - _mapController.move(widget.latLng, settings.infoMapZoom); - } - } - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Stack( - children: [ - MapDecorator( - child: _buildMap(), - ), - MapButtonPanel( - geoUri: widget.geoUri, - zoomBy: _zoomBy, - ), - ], - ), - _buildAttribution(), - ], - ); - } - - Widget _buildMap() { - return FlutterMap( - options: MapOptions( - center: widget.latLng, - zoom: widget.initialZoom, - interactiveFlags: InteractiveFlag.none, - ), - mapController: _mapController, - children: [ - _buildMapLayer(), - ScaleLayerWidget( - options: ScaleLayerOptions(), - ), - MarkerLayerWidget( - options: MarkerLayerOptions( - markers: [ - Marker( - width: widget.markerSize.width, - height: widget.markerSize.height, - point: widget.latLng, - builder: widget.markerBuilder, - anchorPos: AnchorPos.align(AnchorAlign.top), - ), - ], - ), - ), - ], - ); - } - - Widget _buildMapLayer() { - switch (widget.style) { - case EntryMapStyle.osmHot: - return const OSMHotLayer(); - case EntryMapStyle.stamenToner: - return const StamenTonerLayer(); - case EntryMapStyle.stamenWatercolor: - return const StamenWatercolorLayer(); - default: - return const SizedBox.shrink(); - } - } - - Widget _buildAttribution() { - switch (widget.style) { - case EntryMapStyle.osmHot: - return _buildAttributionMarkdown(context.l10n.mapAttributionOsmHot); - case EntryMapStyle.stamenToner: - case EntryMapStyle.stamenWatercolor: - return _buildAttributionMarkdown(context.l10n.mapAttributionStamen); - default: - return const SizedBox.shrink(); - } - } - - Widget _buildAttributionMarkdown(String data) { - return Padding( - padding: const EdgeInsets.only(top: 4), - child: MarkdownBody( - data: data, - selectable: true, - styleSheet: MarkdownStyleSheet( - a: TextStyle(color: Theme.of(context).accentColor), - p: const TextStyle(color: Colors.white70, fontSize: InfoRowGroup.fontSize), - ), - onTapLink: (text, href, title) async { - if (href != null && await canLaunch(href)) { - await launch(href); - } - }, - ), - ); - } - - void _zoomBy(double amount) { - final endZoom = (settings.infoMapZoom + amount).clamp(1.0, 16.0); - settings.infoMapZoom = endZoom; - - final zoomTween = Tween(begin: _mapController.zoom, end: endZoom); - final controller = AnimationController(duration: const Duration(milliseconds: 200), vsync: this); - final animation = CurvedAnimation(parent: controller, curve: Curves.fastOutSlowIn); - controller.addListener(() => _mapController.move(widget.latLng, zoomTween.evaluate(animation))); - animation.addStatusListener((status) { - if (status == AnimationStatus.completed) { - controller.dispose(); - } else if (status == AnimationStatus.dismissed) { - controller.dispose(); - } - }); - controller.forward(); - } -} - -class OSMHotLayer extends StatelessWidget { - const OSMHotLayer({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return TileLayerWidget( - options: TileLayerOptions( - urlTemplate: 'https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png', - subdomains: ['a', 'b', 'c'], - retinaMode: context.select((mq) => mq.devicePixelRatio) > 1, - ), - ); - } -} - -class StamenTonerLayer extends StatelessWidget { - const StamenTonerLayer({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return TileLayerWidget( - options: TileLayerOptions( - urlTemplate: 'https://stamen-tiles-{s}.a.ssl.fastly.net/toner-lite/{z}/{x}/{y}{r}.png', - subdomains: ['a', 'b', 'c', 'd'], - retinaMode: context.select((mq) => mq.devicePixelRatio) > 1, - ), - ); - } -} - -class StamenWatercolorLayer extends StatelessWidget { - const StamenWatercolorLayer({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return TileLayerWidget( - options: TileLayerOptions( - urlTemplate: 'https://stamen-tiles-{s}.a.ssl.fastly.net/watercolor/{z}/{x}/{y}.jpg', - subdomains: ['a', 'b', 'c', 'd'], - retinaMode: context.select((mq) => mq.devicePixelRatio) > 1, - ), - ); - } -} diff --git a/lib/widgets/viewer/info/maps/scalebar_utils.dart b/lib/widgets/viewer/info/maps/scalebar_utils.dart deleted file mode 100644 index 8a3df22c7..000000000 --- a/lib/widgets/viewer/info/maps/scalebar_utils.dart +++ /dev/null @@ -1,117 +0,0 @@ -import 'dart:math'; - -import 'package:aves/utils/math_utils.dart'; -import 'package:latlong2/latlong.dart'; - -LatLng calculateEndingGlobalCoordinates(LatLng start, double startBearing, double distance) { - var mSemiMajorAxis = 6378137.0; //WGS84 major axis - var mSemiMinorAxis = (1.0 - 1.0 / 298.257223563) * 6378137.0; - var mFlattening = 1.0 / 298.257223563; - // double mInverseFlattening = 298.257223563; - - var a = mSemiMajorAxis; - var b = mSemiMinorAxis; - var aSquared = a * a; - var bSquared = b * b; - var f = mFlattening; - var phi1 = toRadians(start.latitude); - var alpha1 = toRadians(startBearing); - var cosAlpha1 = cos(alpha1); - var sinAlpha1 = sin(alpha1); - var s = distance; - var tanU1 = (1.0 - f) * tan(phi1); - var cosU1 = 1.0 / sqrt(1.0 + tanU1 * tanU1); - var sinU1 = tanU1 * cosU1; - - // eq. 1 - var sigma1 = atan2(tanU1, cosAlpha1); - - // eq. 2 - var sinAlpha = cosU1 * sinAlpha1; - - var sin2Alpha = sinAlpha * sinAlpha; - var cos2Alpha = 1 - sin2Alpha; - var uSquared = cos2Alpha * (aSquared - bSquared) / bSquared; - - // eq. 3 - var A = 1 + (uSquared / 16384) * (4096 + uSquared * (-768 + uSquared * (320 - 175 * uSquared))); - - // eq. 4 - var B = (uSquared / 1024) * (256 + uSquared * (-128 + uSquared * (74 - 47 * uSquared))); - - // iterate until there is a negligible change in sigma - double deltaSigma; - var sOverbA = s / (b * A); - var sigma = sOverbA; - double sinSigma; - var prevSigma = sOverbA; - double sigmaM2; - double cosSigmaM2; - double cos2SigmaM2; - - for (;;) { - // eq. 5 - sigmaM2 = 2.0 * sigma1 + sigma; - cosSigmaM2 = cos(sigmaM2); - cos2SigmaM2 = cosSigmaM2 * cosSigmaM2; - sinSigma = sin(sigma); - var cosSignma = cos(sigma); - - // eq. 6 - deltaSigma = B * sinSigma * (cosSigmaM2 + (B / 4.0) * (cosSignma * (-1 + 2 * cos2SigmaM2) - (B / 6.0) * cosSigmaM2 * (-3 + 4 * sinSigma * sinSigma) * (-3 + 4 * cos2SigmaM2))); - - // eq. 7 - sigma = sOverbA + deltaSigma; - - // break after converging to tolerance - if ((sigma - prevSigma).abs() < 0.0000000000001) break; - - prevSigma = sigma; - } - - sigmaM2 = 2.0 * sigma1 + sigma; - cosSigmaM2 = cos(sigmaM2); - cos2SigmaM2 = cosSigmaM2 * cosSigmaM2; - - var cosSigma = cos(sigma); - sinSigma = sin(sigma); - - // eq. 8 - var phi2 = atan2(sinU1 * cosSigma + cosU1 * sinSigma * cosAlpha1, (1.0 - f) * sqrt(sin2Alpha + pow(sinU1 * sinSigma - cosU1 * cosSigma * cosAlpha1, 2.0))); - - // eq. 9 - // This fixes the pole crossing defect spotted by Matt Feemster. When a - // path passes a pole and essentially crosses a line of latitude twice - - // once in each direction - the longitude calculation got messed up. - // Using - // atan2 instead of atan fixes the defect. The change is in the next 3 - // lines. - // double tanLambda = sinSigma * sinAlpha1 / (cosU1 * cosSigma - sinU1 * - // sinSigma * cosAlpha1); - // double lambda = Math.atan(tanLambda); - var lambda = atan2(sinSigma * sinAlpha1, (cosU1 * cosSigma - sinU1 * sinSigma * cosAlpha1)); - - // eq. 10 - var C = (f / 16) * cos2Alpha * (4 + f * (4 - 3 * cos2Alpha)); - - // eq. 11 - var L = lambda - (1 - C) * f * sinAlpha * (sigma + C * sinSigma * (cosSigmaM2 + C * cosSigma * (-1 + 2 * cos2SigmaM2))); - - // eq. 12 - // double alpha2 = Math.atan2(sinAlpha, -sinU1 * sinSigma + cosU1 * - // cosSigma * cosAlpha1); - - // build result - var latitude = toDegrees(phi2); - var longitude = start.longitude + toDegrees(L); - - // if ((endBearing != null) && (endBearing.length > 0)) { - // endBearing[0] = toDegrees(alpha2); - // } - - latitude = latitude < -90 ? -90 : latitude; - latitude = latitude > 90 ? 90 : latitude; - longitude = longitude < -180 ? -180 : longitude; - longitude = longitude > 180 ? 180 : longitude; - return LatLng(latitude, longitude); -} diff --git a/pubspec.lock b/pubspec.lock index 0d135e585..b060c499d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -169,6 +169,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "3.0.1" + custom_rounded_rectangle_border: + dependency: "direct main" + description: + name: custom_rounded_rectangle_border + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.0-nullsafety.0" dbus: dependency: transitive description: @@ -278,6 +285,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.1.2" + fluster: + dependency: "direct main" + description: + name: fluster + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.0" flutter: dependency: "direct main" description: flutter diff --git a/pubspec.yaml b/pubspec.yaml index dc926fcf8..0b360b4c9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,6 +16,8 @@ dependencies: collection: connectivity_plus: country_code: +# TODO TLAD as of 2021/08/04, null safe version is pre-release + custom_rounded_rectangle_border: '>=0.2.0-nullsafety.0' decorated_icon: equatable: event_bus: @@ -29,6 +31,7 @@ dependencies: firebase_core: firebase_crashlytics: flex_color_picker: + fluster: flutter_highlight: flutter_map: flutter_markdown: @@ -39,7 +42,7 @@ dependencies: google_maps_flutter: intl: latlong2: -# TODO TLAD as of 2021/07/08, MDI package null safe version is pre-release +# TODO TLAD as of 2021/08/04, null safe version is pre-release material_design_icons_flutter: '>=5.0.5955-rc.1' overlay_support: package_info_plus: diff --git a/scripts/fix_android_log_levels.bat b/scripts/fix_android_log_levels.bat index bfdc76a24..520b0df2d 100644 --- a/scripts/fix_android_log_levels.bat +++ b/scripts/fix_android_log_levels.bat @@ -12,6 +12,7 @@ adb.exe shell setprop log.tag.AHierarchicalStateMachine ERROR adb.exe shell setprop log.tag.AudioCapabilities ERROR adb.exe shell setprop log.tag.AudioTrack INFO adb.exe shell setprop log.tag.CompatibilityChangeReporter INFO +adb.exe shell setprop log.tag.Counters WARN adb.exe shell setprop log.tag.CustomizedTextParser INFO adb.exe shell setprop log.tag.InputMethodManager WARN adb.exe shell setprop log.tag.InsetsSourceConsumer INFO