#3 map page
This commit is contained in:
parent
2a82aef354
commit
7747e19f73
41 changed files with 1396 additions and 692 deletions
|
@ -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}}",
|
||||
|
|
|
@ -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{동영상}}",
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -5,6 +5,7 @@ enum CollectionAction {
|
|||
select,
|
||||
selectAll,
|
||||
selectNone,
|
||||
map,
|
||||
stats,
|
||||
// apply to entry set
|
||||
copy,
|
||||
|
|
|
@ -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<String>? _xmpSubjects;
|
||||
|
||||
List<String> get xmpSubjects {
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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<bool> openMap(String geoUri) async {
|
||||
static Future<bool> 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', <String, dynamic>{
|
||||
'geoUri': geoUri,
|
||||
|
|
|
@ -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`
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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<CollectionAppBar> 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<CollectionAppBar> with SingleTickerPr
|
|||
case CollectionAction.selectNone:
|
||||
context.read<Selection<AvesEntry>>().clearSelection();
|
||||
break;
|
||||
case CollectionAction.map:
|
||||
_goToMap();
|
||||
break;
|
||||
case CollectionAction.stats:
|
||||
_goToStats();
|
||||
break;
|
||||
|
@ -377,6 +386,19 @@ class _CollectionAppBarState extends State<CollectionAppBar> 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,
|
||||
|
|
|
@ -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<bool>? 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<ThumbnailImage> {
|
|||
_lastException = null;
|
||||
_providers.clear();
|
||||
_providers.addAll([
|
||||
if (!entry.isSvg)
|
||||
if (widget.progressive && !entry.isSvg)
|
||||
_ConditionalImageProvider(
|
||||
ScrollAwareImageProvider(
|
||||
context: _scrollAwareContext,
|
||||
|
|
47
lib/widgets/common/map/attribution.dart
Normal file
47
lib/widgets/common/map/attribution.dart
Normal file
|
@ -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);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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<void> 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,
|
48
lib/widgets/common/map/decorator.dart
Normal file
48
lib/widgets/common/map/decorator.dart
Normal file
|
@ -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!,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
41
lib/widgets/common/map/geo_entry.dart
Normal file
41
lib/widgets/common/map/geo_entry.dart
Normal file
|
@ -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}';
|
||||
}
|
202
lib/widgets/common/map/geo_map.dart
Normal file
202
lib/widgets/common/map/geo_map.dart
Normal file
|
@ -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<AvesEntry> entries;
|
||||
final bool interactive;
|
||||
final double? mapHeight;
|
||||
final ValueNotifier<bool> 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<GeoMap> 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<ZoomedBounds> boundsNotifier;
|
||||
|
||||
List<AvesEntry> 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<GeoEntry>(
|
||||
// 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<bool>(
|
||||
future: availability.isConnected,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.data != true) return const SizedBox();
|
||||
return Selector<Settings, EntryMapStyle>(
|
||||
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<bool>(
|
||||
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<Object?> get props => [entry, count];
|
||||
|
||||
const MarkerKey(this.entry, this.count);
|
||||
}
|
||||
|
||||
typedef EntryMarkerBuilder = Widget Function(MarkerKey key);
|
||||
typedef UserZoomChangeCallback = void Function(double zoom);
|
224
lib/widgets/common/map/google/map.dart
Normal file
224
lib/widgets/common/map/google/map.dart
Normal file
|
@ -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<ZoomedBounds> boundsNotifier;
|
||||
final bool interactive;
|
||||
final EntryMapStyle style;
|
||||
final EntryMarkerBuilder markerBuilder;
|
||||
final Fluster<GeoEntry> markerCluster;
|
||||
final List<AvesEntry> 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<StatefulWidget> createState() => _EntryGoogleMapState();
|
||||
}
|
||||
|
||||
class _EntryGoogleMapState extends State<EntryGoogleMap> with WidgetsBindingObserver {
|
||||
GoogleMapController? _controller;
|
||||
final Map<MarkerKey, Uint8List> _markerBitmaps = {};
|
||||
final AChangeNotifier _markerBitmapChangeNotifier = AChangeNotifier();
|
||||
|
||||
ValueNotifier<ZoomedBounds> 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<ZoomedBounds?>(
|
||||
valueListenable: boundsNotifier,
|
||||
builder: (context, visibleRegion, child) {
|
||||
final allEntries = widget.markerEntries;
|
||||
final clusters = visibleRegion != null ? widget.markerCluster.clusters(visibleRegion.boundingBox, visibleRegion.zoom.round()) : <GeoEntry>[];
|
||||
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<MarkerKey>(
|
||||
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<MarkerKey, GeoEntry> clusterByMarkerKey) {
|
||||
return AnimatedBuilder(
|
||||
animation: _markerBitmapChangeNotifier,
|
||||
builder: (context, child) {
|
||||
final markers = <Marker>{};
|
||||
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<void> _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<void> _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;
|
||||
}
|
||||
}
|
||||
}
|
121
lib/widgets/common/map/google/marker_generator.dart
Normal file
121
lib/widgets/common/map/google/marker_generator.dart
Normal file
|
@ -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<T extends Key> extends StatefulWidget {
|
||||
final List<Widget> 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<T>();
|
||||
}
|
||||
|
||||
class _MarkerGeneratorWidgetState<T extends Key> extends State<MarkerGeneratorWidget<T>> {
|
||||
final Set<_MarkerGeneratorItem<T>> _items = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_checkNextFrame();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant MarkerGeneratorWidget<T> 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<MediaQueryData, double>((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<T extends Key> {
|
||||
final T markerKey;
|
||||
GlobalKey? globalKey;
|
||||
MarkerGeneratorItemState state = MarkerGeneratorItemState.waiting;
|
||||
|
||||
_MarkerGeneratorItem(this.markerKey);
|
||||
|
||||
bool get isWaiting => state == MarkerGeneratorItemState.waiting;
|
||||
|
||||
Future<Uint8List?> 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}';
|
||||
}
|
16
lib/widgets/common/map/latlng_tween.dart
Normal file
16
lib/widgets/common/map/latlng_tween.dart
Normal file
|
@ -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<LatLng?> {
|
||||
LatLngTween({
|
||||
required LatLng? begin,
|
||||
required LatLng? end,
|
||||
}) : super(
|
||||
begin: begin,
|
||||
end: end,
|
||||
);
|
||||
|
||||
@override
|
||||
LatLng? lerp(double t) => LatLngUtils.lerp(begin, end, t);
|
||||
}
|
14
lib/widgets/common/map/latlng_utils.dart
Normal file
14
lib/widgets/common/map/latlng_utils.dart
Normal file
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
202
lib/widgets/common/map/leaflet/map.dart
Normal file
202
lib/widgets/common/map/leaflet/map.dart
Normal file
|
@ -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<ZoomedBounds> boundsNotifier;
|
||||
final bool interactive;
|
||||
final EntryMapStyle style;
|
||||
final EntryMarkerBuilder markerBuilder;
|
||||
final Fluster<GeoEntry> markerCluster;
|
||||
final List<AvesEntry> 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<StatefulWidget> createState() => _EntryLeafletMapState();
|
||||
}
|
||||
|
||||
class _EntryLeafletMapState extends State<EntryLeafletMap> with TickerProviderStateMixin {
|
||||
final MapController _mapController = MapController();
|
||||
final List<StreamSubscription> _subscriptions = [];
|
||||
|
||||
ValueNotifier<ZoomedBounds> 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<ZoomedBounds?>(
|
||||
valueListenable: boundsNotifier,
|
||||
builder: (context, visibleRegion, child) {
|
||||
final allEntries = widget.markerEntries;
|
||||
final clusters = visibleRegion != null ? widget.markerCluster.clusters(visibleRegion.boundingBox, visibleRegion.zoom.round()) : <GeoEntry>[];
|
||||
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<MarkerKey, GeoEntry> 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<void> _zoomBy(double amount) async {
|
||||
final endZoom = (_mapController.zoom + amount).clamp(_zoomMin, _zoomMax);
|
||||
widget.onUserZoomChange?.call(endZoom);
|
||||
|
||||
final zoomTween = Tween<double>(begin: _mapController.zoom, end: endZoom);
|
||||
await _animateCamera((animation) => _mapController.move(_mapController.center, zoomTween.evaluate(animation)));
|
||||
}
|
||||
|
||||
Future<void> _moveTo(LatLng point) async {
|
||||
final centerTween = LatLngTween(begin: _mapController.center, end: point);
|
||||
await _animateCamera((animation) => _mapController.move(centerTween.evaluate(animation)!, _mapController.zoom));
|
||||
}
|
||||
|
||||
Future<void> _animateCamera(void Function(Animation<double> 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();
|
||||
}
|
||||
}
|
|
@ -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);
|
119
lib/widgets/common/map/leaflet/scalebar_utils.dart
Normal file
119
lib/widgets/common/map/leaflet/scalebar_utils.dart
Normal file
|
@ -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);
|
||||
}
|
||||
}
|
48
lib/widgets/common/map/leaflet/tile_layers.dart
Normal file
48
lib/widgets/common/map/leaflet/tile_layers.dart
Normal file
|
@ -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<MediaQueryData, double>((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<MediaQueryData, double>((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<MediaQueryData, double>((mq) => mq.devicePixelRatio) > 1,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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<Widget> markers;
|
||||
final Duration delay;
|
||||
final Function(List<Uint8List> 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<MarkerGeneratorWidget> {
|
||||
final _globalKeys = <GlobalKey>[];
|
||||
|
||||
@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<MediaQueryData, double>((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<List<Uint8List>> _getBitmaps(BuildContext context) async {
|
||||
final pixelRatio = context.read<MediaQueryData>().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);
|
||||
}));
|
||||
}
|
||||
}
|
64
lib/widgets/common/map/zoomed_bounds.dart
Normal file
64
lib/widgets/common/map/zoomed_bounds.dart
Normal file
|
@ -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<double> get boundingBox => [west, south, east, north];
|
||||
|
||||
LatLng get center => LatLng((north + south) / 2, (east + west) / 2);
|
||||
|
||||
@override
|
||||
List<Object?> 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<LatLng> 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,
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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<T extends CollectionFilter> 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<T extends CollectionFilter> 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<T extends CollectionFilter> with FeedbackMi
|
|||
}
|
||||
}
|
||||
|
||||
void _goToMap(BuildContext context) {
|
||||
final source = context.read<CollectionSource>();
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
settings: const RouteSettings(name: MapPage.routeName),
|
||||
builder: (context) => MapPage(
|
||||
source: source,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _goToStats(BuildContext context) {
|
||||
final source = context.read<CollectionSource>();
|
||||
Navigator.push(
|
||||
|
|
|
@ -204,6 +204,7 @@ class _FilterGridAppBarState<T extends CollectionFilter> extends State<FilterGri
|
|||
ChipSetAction.select,
|
||||
enabled: !widget.isEmpty,
|
||||
),
|
||||
toMenuItem(ChipSetAction.map),
|
||||
toMenuItem(ChipSetAction.stats),
|
||||
toMenuItem(ChipSetAction.createAlbum),
|
||||
]);
|
||||
|
|
67
lib/widgets/map/map_page.dart
Normal file
67
lib/widgets/map/map_page.dart
Normal file
|
@ -0,0 +1,67 @@
|
|||
import 'package:aves/model/entry.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/model/source/collection_source.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/common/map/geo_map.dart';
|
||||
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
|
||||
class MapPage extends StatefulWidget {
|
||||
static const routeName = '/collection/map';
|
||||
|
||||
final CollectionSource source;
|
||||
final CollectionLens? parentCollection;
|
||||
late final List<AvesEntry> 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<MapPage> {
|
||||
late final ValueNotifier<bool> _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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -122,7 +122,6 @@ class ViewerDebugPage extends StatelessWidget {
|
|||
'hasAddress': '${entry.hasAddress}',
|
||||
'hasFineAddress': '${entry.hasFineAddress}',
|
||||
'latLng': '${entry.latLng}',
|
||||
'geoUri': entry.geoUri ?? '',
|
||||
},
|
||||
),
|
||||
],
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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<LocationSection> 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<LocationSection> {
|
||||
CollectionLens? get collection => widget.collection;
|
||||
|
||||
AvesEntry get entry => widget.entry;
|
||||
|
@ -85,8 +68,6 @@ class _LocationSectionState extends State<LocationSection> with TickerProviderSt
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!entry.hasGps) return const SizedBox();
|
||||
final latLng = entry.latLng!;
|
||||
final geoUri = entry.geoUri!;
|
||||
|
||||
final filters = <LocationFilter>[];
|
||||
if (entry.hasAddress) {
|
||||
|
@ -97,74 +78,16 @@ class _LocationSectionState extends State<LocationSection> 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<bool>(
|
||||
future: availability.isConnected,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.data != true) return const SizedBox();
|
||||
return Selector<Settings, EntryMapStyle>(
|
||||
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<bool>(
|
||||
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<double, double>(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)
|
||||
|
|
|
@ -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<double, double> latLng,
|
||||
required this.geoUri,
|
||||
required this.initialZoom,
|
||||
required this.markerId,
|
||||
required this.markerBuilder,
|
||||
}) : latLng = LatLng(latLng.item1, latLng.item2),
|
||||
super(key: key);
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _EntryGoogleMapState();
|
||||
}
|
||||
|
||||
class _EntryGoogleMapState extends State<EntryGoogleMap> {
|
||||
GoogleMapController? _controller;
|
||||
late Completer<Uint8List> _markerLoaderCompleter;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_markerLoaderCompleter = Completer<Uint8List>();
|
||||
}
|
||||
|
||||
@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<Uint8List>();
|
||||
}
|
||||
}
|
||||
|
||||
@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<Uint8List>(
|
||||
future: _markerLoaderCompleter.future,
|
||||
builder: (context, snapshot) {
|
||||
final markers = <Marker>{};
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<StatefulWidget> createState() => _EntryLeafletMapState();
|
||||
}
|
||||
|
||||
class _EntryLeafletMapState extends State<EntryLeafletMap> 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<double>(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<MediaQueryData, double>((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<MediaQueryData, double>((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<MediaQueryData, double>((mq) => mq.devicePixelRatio) > 1,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
14
pubspec.lock
14
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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue