#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": {},
|
"@menuActionSort": {},
|
||||||
"menuActionGroup": "Group",
|
"menuActionGroup": "Group",
|
||||||
"@menuActionGroup": {},
|
"@menuActionGroup": {},
|
||||||
|
"menuActionMap": "Map",
|
||||||
|
"@menuActionMap": {},
|
||||||
"menuActionStats": "Stats",
|
"menuActionStats": "Stats",
|
||||||
"@menuActionStats": {},
|
"@menuActionStats": {},
|
||||||
|
|
||||||
|
@ -706,6 +708,9 @@
|
||||||
"settingsCoordinateFormatTitle": "Coordinate Format",
|
"settingsCoordinateFormatTitle": "Coordinate Format",
|
||||||
"@settingsCoordinateFormatTitle": {},
|
"@settingsCoordinateFormatTitle": {},
|
||||||
|
|
||||||
|
"mapPageTitle": "Map",
|
||||||
|
"@mapPageTitle": {},
|
||||||
|
|
||||||
"statsPageTitle": "Stats",
|
"statsPageTitle": "Stats",
|
||||||
"@statsPageTitle": {},
|
"@statsPageTitle": {},
|
||||||
"statsImage": "{count, plural, =1{image} other{images}}",
|
"statsImage": "{count, plural, =1{image} other{images}}",
|
||||||
|
|
|
@ -147,6 +147,7 @@
|
||||||
|
|
||||||
"menuActionSort": "정렬",
|
"menuActionSort": "정렬",
|
||||||
"menuActionGroup": "묶음",
|
"menuActionGroup": "묶음",
|
||||||
|
"menuActionMap": "지도",
|
||||||
"menuActionStats": "통계",
|
"menuActionStats": "통계",
|
||||||
|
|
||||||
"aboutPageTitle": "앱 정보",
|
"aboutPageTitle": "앱 정보",
|
||||||
|
@ -338,6 +339,8 @@
|
||||||
"settingsCoordinateFormatTile": "좌표 표현",
|
"settingsCoordinateFormatTile": "좌표 표현",
|
||||||
"settingsCoordinateFormatTitle": "좌표 표현",
|
"settingsCoordinateFormatTitle": "좌표 표현",
|
||||||
|
|
||||||
|
"mapPageTitle": "지도",
|
||||||
|
|
||||||
"statsPageTitle": "통계",
|
"statsPageTitle": "통계",
|
||||||
"statsImage": "{count, plural, other{사진}}",
|
"statsImage": "{count, plural, other{사진}}",
|
||||||
"statsVideo": "{count, plural, other{동영상}}",
|
"statsVideo": "{count, plural, other{동영상}}",
|
||||||
|
|
|
@ -6,6 +6,7 @@ enum ChipSetAction {
|
||||||
// general
|
// general
|
||||||
sort,
|
sort,
|
||||||
group,
|
group,
|
||||||
|
map,
|
||||||
select,
|
select,
|
||||||
selectAll,
|
selectAll,
|
||||||
selectNone,
|
selectNone,
|
||||||
|
@ -35,6 +36,8 @@ extension ExtraChipSetAction on ChipSetAction {
|
||||||
return context.l10n.collectionActionSelectAll;
|
return context.l10n.collectionActionSelectAll;
|
||||||
case ChipSetAction.selectNone:
|
case ChipSetAction.selectNone:
|
||||||
return context.l10n.collectionActionSelectNone;
|
return context.l10n.collectionActionSelectNone;
|
||||||
|
case ChipSetAction.map:
|
||||||
|
return context.l10n.menuActionMap;
|
||||||
case ChipSetAction.stats:
|
case ChipSetAction.stats:
|
||||||
return context.l10n.menuActionStats;
|
return context.l10n.menuActionStats;
|
||||||
case ChipSetAction.createAlbum:
|
case ChipSetAction.createAlbum:
|
||||||
|
@ -68,6 +71,8 @@ extension ExtraChipSetAction on ChipSetAction {
|
||||||
case ChipSetAction.selectAll:
|
case ChipSetAction.selectAll:
|
||||||
case ChipSetAction.selectNone:
|
case ChipSetAction.selectNone:
|
||||||
return null;
|
return null;
|
||||||
|
case ChipSetAction.map:
|
||||||
|
return AIcons.map;
|
||||||
case ChipSetAction.stats:
|
case ChipSetAction.stats:
|
||||||
return AIcons.stats;
|
return AIcons.stats;
|
||||||
case ChipSetAction.createAlbum:
|
case ChipSetAction.createAlbum:
|
||||||
|
|
|
@ -5,6 +5,7 @@ enum CollectionAction {
|
||||||
select,
|
select,
|
||||||
selectAll,
|
selectAll,
|
||||||
selectNone,
|
selectNone,
|
||||||
|
map,
|
||||||
stats,
|
stats,
|
||||||
// apply to entry set
|
// apply to entry set
|
||||||
copy,
|
copy,
|
||||||
|
|
|
@ -13,7 +13,6 @@ import 'package:aves/services/service_policy.dart';
|
||||||
import 'package:aves/services/services.dart';
|
import 'package:aves/services/services.dart';
|
||||||
import 'package:aves/services/svg_metadata_service.dart';
|
import 'package:aves/services/svg_metadata_service.dart';
|
||||||
import 'package:aves/utils/change_notifier.dart';
|
import 'package:aves/utils/change_notifier.dart';
|
||||||
import 'package:aves/utils/math_utils.dart';
|
|
||||||
import 'package:aves/utils/time_utils.dart';
|
import 'package:aves/utils/time_utils.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:country_code/country_code.dart';
|
import 'package:country_code/country_code.dart';
|
||||||
|
@ -382,13 +381,6 @@ class AvesEntry {
|
||||||
|
|
||||||
LatLng? get latLng => hasGps ? LatLng(_catalogMetadata!.latitude!, _catalogMetadata!.longitude!) : null;
|
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>? _xmpSubjects;
|
||||||
|
|
||||||
List<String> get xmpSubjects {
|
List<String> get xmpSubjects {
|
||||||
|
|
|
@ -10,6 +10,8 @@ import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
extension ExtraAvesEntry on AvesEntry {
|
extension ExtraAvesEntry on AvesEntry {
|
||||||
|
bool isThumbnailReady({double extent = 0}) => _isReady(_getThumbnailProviderKey(extent));
|
||||||
|
|
||||||
ThumbnailProvider getThumbnail({double extent = 0}) {
|
ThumbnailProvider getThumbnail({double extent = 0}) {
|
||||||
return ThumbnailProvider(_getThumbnailProviderKey(extent));
|
return ThumbnailProvider(_getThumbnailProviderKey(extent));
|
||||||
}
|
}
|
||||||
|
|
|
@ -299,7 +299,7 @@ class SqfliteMetadataDb implements MetadataDb {
|
||||||
await batch.commit(noResult: true);
|
await batch.commit(noResult: true);
|
||||||
debugPrint('$runtimeType saveMetadata complete in ${stopwatch.elapsed.inMilliseconds}ms for ${metadataEntries.length} entries');
|
debugPrint('$runtimeType saveMetadata complete in ${stopwatch.elapsed.inMilliseconds}ms for ${metadataEntries.length} entries');
|
||||||
} catch (error, stack) {
|
} 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/model/entry.dart';
|
||||||
import 'package:aves/services/services.dart';
|
import 'package:aves/services/services.dart';
|
||||||
import 'package:aves/utils/android_file_utils.dart';
|
import 'package:aves/utils/android_file_utils.dart';
|
||||||
|
import 'package:aves/utils/math_utils.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
class AndroidAppService {
|
class AndroidAppService {
|
||||||
static const platform = MethodChannel('deckers.thibault/aves/app');
|
static const platform = MethodChannel('deckers.thibault/aves/app');
|
||||||
|
@ -77,7 +79,11 @@ class AndroidAppService {
|
||||||
return false;
|
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 {
|
try {
|
||||||
final result = await platform.invokeMethod('openMap', <String, dynamic>{
|
final result = await platform.invokeMethod('openMap', <String, dynamic>{
|
||||||
'geoUri': geoUri,
|
'geoUri': geoUri,
|
||||||
|
|
|
@ -3,6 +3,8 @@ import 'package:flutter/scheduler.dart';
|
||||||
class Durations {
|
class Durations {
|
||||||
// Flutter animations (with margin)
|
// Flutter animations (with margin)
|
||||||
static const popupMenuAnimation = Duration(milliseconds: 300 + 10); // ref `_kMenuDuration` used in `_PopupMenuRoute`
|
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 dialogTransitionAnimation = Duration(milliseconds: 150 + 10); // ref `transitionDuration` used in `DialogRoute`
|
||||||
static const drawerTransitionAnimation = Duration(milliseconds: 246 + 10); // ref `_kBaseSettleDuration` used in `DrawerControllerState`
|
static const drawerTransitionAnimation = Duration(milliseconds: 246 + 10); // ref `_kBaseSettleDuration` used in `DrawerControllerState`
|
||||||
static const toggleableTransitionAnimation = Duration(milliseconds: 200 + 10); // ref `_kToggleDuration` used in `ToggleableStateMixin`
|
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 import = MdiIcons.fileImportOutline;
|
||||||
static const IconData info = Icons.info_outlined;
|
static const IconData info = Icons.info_outlined;
|
||||||
static const IconData layers = Icons.layers_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 newTier = Icons.fiber_new_outlined;
|
||||||
static const IconData openOutside = Icons.open_in_new_outlined;
|
static const IconData openOutside = Icons.open_in_new_outlined;
|
||||||
static const IconData pin = Icons.push_pin_outlined;
|
static const IconData pin = Icons.push_pin_outlined;
|
||||||
|
|
|
@ -143,6 +143,11 @@ class Constants {
|
||||||
license: 'Apache 2.0',
|
license: 'Apache 2.0',
|
||||||
sourceUrl: 'https://github.com/google/charts',
|
sourceUrl: 'https://github.com/google/charts',
|
||||||
),
|
),
|
||||||
|
Dependency(
|
||||||
|
name: 'Custom rounded rectangle border',
|
||||||
|
license: 'MIT',
|
||||||
|
sourceUrl: 'https://github.com/lekanbar/custom_rounded_rectangle_border',
|
||||||
|
),
|
||||||
Dependency(
|
Dependency(
|
||||||
name: 'Decorated Icon',
|
name: 'Decorated Icon',
|
||||||
license: 'MIT',
|
license: 'MIT',
|
||||||
|
@ -233,6 +238,11 @@ class Constants {
|
||||||
license: 'MIT',
|
license: 'MIT',
|
||||||
sourceUrl: 'https://github.com/marcojakob/dart-event-bus',
|
sourceUrl: 'https://github.com/marcojakob/dart-event-bus',
|
||||||
),
|
),
|
||||||
|
Dependency(
|
||||||
|
name: 'Fluster',
|
||||||
|
license: 'MIT',
|
||||||
|
sourceUrl: 'https://github.com/alfonsocejudo/fluster',
|
||||||
|
),
|
||||||
Dependency(
|
Dependency(
|
||||||
name: 'Flutter Lints',
|
name: 'Flutter Lints',
|
||||||
license: 'BSD 3-Clause',
|
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/common/extensions/build_context.dart';
|
||||||
import 'package:aves/widgets/dialogs/add_shortcut_dialog.dart';
|
import 'package:aves/widgets/dialogs/add_shortcut_dialog.dart';
|
||||||
import 'package:aves/widgets/dialogs/aves_selection_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_button.dart';
|
||||||
import 'package:aves/widgets/search/search_delegate.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/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/scheduler.dart';
|
import 'package:flutter/scheduler.dart';
|
||||||
|
@ -212,6 +213,11 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
||||||
enabled: isNotEmpty,
|
enabled: isNotEmpty,
|
||||||
child: MenuRow(text: context.l10n.collectionActionSelect, icon: AIcons.select),
|
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(
|
PopupMenuItem(
|
||||||
value: CollectionAction.stats,
|
value: CollectionAction.stats,
|
||||||
enabled: isNotEmpty,
|
enabled: isNotEmpty,
|
||||||
|
@ -292,6 +298,9 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
||||||
case CollectionAction.selectNone:
|
case CollectionAction.selectNone:
|
||||||
context.read<Selection<AvesEntry>>().clearSelection();
|
context.read<Selection<AvesEntry>>().clearSelection();
|
||||||
break;
|
break;
|
||||||
|
case CollectionAction.map:
|
||||||
|
_goToMap();
|
||||||
|
break;
|
||||||
case CollectionAction.stats:
|
case CollectionAction.stats:
|
||||||
_goToStats();
|
_goToStats();
|
||||||
break;
|
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() {
|
void _goToStats() {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
|
|
|
@ -19,6 +19,7 @@ import 'package:provider/provider.dart';
|
||||||
class ThumbnailImage extends StatefulWidget {
|
class ThumbnailImage extends StatefulWidget {
|
||||||
final AvesEntry entry;
|
final AvesEntry entry;
|
||||||
final double extent;
|
final double extent;
|
||||||
|
final bool progressive;
|
||||||
final BoxFit? fit;
|
final BoxFit? fit;
|
||||||
final bool showLoadingBackground;
|
final bool showLoadingBackground;
|
||||||
final ValueNotifier<bool>? cancellableNotifier;
|
final ValueNotifier<bool>? cancellableNotifier;
|
||||||
|
@ -28,6 +29,7 @@ class ThumbnailImage extends StatefulWidget {
|
||||||
Key? key,
|
Key? key,
|
||||||
required this.entry,
|
required this.entry,
|
||||||
required this.extent,
|
required this.extent,
|
||||||
|
this.progressive = true,
|
||||||
this.fit,
|
this.fit,
|
||||||
this.showLoadingBackground = true,
|
this.showLoadingBackground = true,
|
||||||
this.cancellableNotifier,
|
this.cancellableNotifier,
|
||||||
|
@ -93,7 +95,7 @@ class _ThumbnailImageState extends State<ThumbnailImage> {
|
||||||
_lastException = null;
|
_lastException = null;
|
||||||
_providers.clear();
|
_providers.clear();
|
||||||
_providers.addAll([
|
_providers.addAll([
|
||||||
if (!entry.isSvg)
|
if (widget.progressive && !entry.isSvg)
|
||||||
_ConditionalImageProvider(
|
_ConditionalImageProvider(
|
||||||
ScrollAwareImageProvider(
|
ScrollAwareImageProvider(
|
||||||
context: _scrollAwareContext,
|
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:aves/widgets/viewer/overlay/common.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/scheduler.dart';
|
import 'package:flutter/scheduler.dart';
|
||||||
|
import 'package:latlong2/latlong.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!,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class MapButtonPanel extends StatelessWidget {
|
class MapButtonPanel extends StatelessWidget {
|
||||||
final String geoUri;
|
final LatLng latLng;
|
||||||
final void Function(double amount) zoomBy;
|
final Future<void> Function(double amount)? zoomBy;
|
||||||
|
|
||||||
static const double padding = 4;
|
static const double padding = 4;
|
||||||
|
|
||||||
const MapButtonPanel({
|
const MapButtonPanel({
|
||||||
Key? key,
|
Key? key,
|
||||||
required this.geoUri,
|
required this.latLng,
|
||||||
required this.zoomBy,
|
this.zoomBy,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -86,7 +43,7 @@ class MapButtonPanel extends StatelessWidget {
|
||||||
children: [
|
children: [
|
||||||
MapOverlayButton(
|
MapOverlayButton(
|
||||||
icon: AIcons.openOutside,
|
icon: AIcons.openOutside,
|
||||||
onPressed: () => AndroidAppService.openMap(geoUri).then((success) {
|
onPressed: () => AndroidAppService.openMap(latLng).then((success) {
|
||||||
if (!success) showNoMatchingAppDialog(context);
|
if (!success) showNoMatchingAppDialog(context);
|
||||||
}),
|
}),
|
||||||
tooltip: context.l10n.entryActionOpenMap,
|
tooltip: context.l10n.entryActionOpenMap,
|
||||||
|
@ -120,13 +77,13 @@ class MapButtonPanel extends StatelessWidget {
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
MapOverlayButton(
|
MapOverlayButton(
|
||||||
icon: AIcons.zoomIn,
|
icon: AIcons.zoomIn,
|
||||||
onPressed: () => zoomBy(1),
|
onPressed: zoomBy != null ? () => zoomBy!(1) : null,
|
||||||
tooltip: context.l10n.viewerInfoMapZoomInTooltip,
|
tooltip: context.l10n.viewerInfoMapZoomInTooltip,
|
||||||
),
|
),
|
||||||
const SizedBox(height: padding),
|
const SizedBox(height: padding),
|
||||||
MapOverlayButton(
|
MapOverlayButton(
|
||||||
icon: AIcons.zoomOut,
|
icon: AIcons.zoomOut,
|
||||||
onPressed: () => zoomBy(-1),
|
onPressed: zoomBy != null ? () => zoomBy!(-1) : null,
|
||||||
tooltip: context.l10n.viewerInfoMapZoomOutTooltip,
|
tooltip: context.l10n.viewerInfoMapZoomOutTooltip,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
@ -141,7 +98,7 @@ class MapButtonPanel extends StatelessWidget {
|
||||||
class MapOverlayButton extends StatelessWidget {
|
class MapOverlayButton extends StatelessWidget {
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final String tooltip;
|
final String tooltip;
|
||||||
final VoidCallback onPressed;
|
final VoidCallback? onPressed;
|
||||||
|
|
||||||
const MapOverlayButton({
|
const MapOverlayButton({
|
||||||
Key? key,
|
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 'dart:math';
|
||||||
|
|
||||||
import 'package:aves/widgets/common/basic/outlined_text.dart';
|
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/material.dart';
|
||||||
import 'package:flutter_map/flutter_map.dart';
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
import 'package:flutter_map/plugin_api.dart';
|
import 'package:flutter_map/plugin_api.dart';
|
||||||
|
|
||||||
import 'scalebar_utils.dart' as util;
|
|
||||||
|
|
||||||
class ScaleLayerOptions extends LayerOptions {
|
class ScaleLayerOptions extends LayerOptions {
|
||||||
final Widget Function(double width, String distance) builder;
|
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 {
|
class ScaleLayerWidget extends StatelessWidget {
|
||||||
final ScaleLayerOptions options;
|
final ScaleLayerOptions options;
|
||||||
|
|
||||||
|
@ -86,7 +86,7 @@ class ScaleLayer extends StatelessWidget {
|
||||||
: 2);
|
: 2);
|
||||||
final distance = scale[max(0, min(20, level))].toDouble();
|
final distance = scale[max(0, min(20, level))].toDouble();
|
||||||
final start = map.project(center);
|
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 end = map.project(targetPoint);
|
||||||
final displayDistance = distance > 999 ? '${(distance / 1000).toStringAsFixed(0)} km' : '${distance.toStringAsFixed(0)} m';
|
final displayDistance = distance > 999 ? '${(distance / 1000).toStringAsFixed(0)} km' : '${distance.toStringAsFixed(0)} m';
|
||||||
final width = end.x - (start.x as double);
|
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/model/entry.dart';
|
||||||
import 'package:aves/widgets/collection/thumbnail/image.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/material.dart';
|
||||||
import 'package:flutter/rendering.dart';
|
import 'package:flutter/rendering.dart';
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
|
|
||||||
class ImageMarker extends StatelessWidget {
|
class ImageMarker extends StatelessWidget {
|
||||||
final AvesEntry entry;
|
final AvesEntry? entry;
|
||||||
|
final int? count;
|
||||||
final double extent;
|
final double extent;
|
||||||
final Size pointerSize;
|
final Size pointerSize;
|
||||||
|
final bool progressive;
|
||||||
|
|
||||||
static const double outerBorderRadiusDim = 8;
|
static const double outerBorderRadiusDim = 8;
|
||||||
static const double outerBorderWidth = 1.5;
|
static const double outerBorderWidth = 1.5;
|
||||||
|
@ -18,21 +17,27 @@ class ImageMarker extends StatelessWidget {
|
||||||
static const outerBorderColor = Colors.white30;
|
static const outerBorderColor = Colors.white30;
|
||||||
static const innerBorderColor = Color(0xFF212121);
|
static const innerBorderColor = Color(0xFF212121);
|
||||||
static const outerBorderRadius = BorderRadius.all(Radius.circular(outerBorderRadiusDim));
|
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({
|
const ImageMarker({
|
||||||
Key? key,
|
Key? key,
|
||||||
required this.entry,
|
required this.entry,
|
||||||
|
required this.count,
|
||||||
required this.extent,
|
required this.extent,
|
||||||
this.pointerSize = Size.zero,
|
required this.pointerSize,
|
||||||
|
required this.progressive,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
Widget child = ThumbnailImage(
|
Widget child = entry != null
|
||||||
entry: entry,
|
? ThumbnailImage(
|
||||||
|
entry: entry!,
|
||||||
extent: extent,
|
extent: extent,
|
||||||
);
|
progressive: progressive,
|
||||||
|
)
|
||||||
|
: const SizedBox();
|
||||||
|
|
||||||
// need to be sized for the Google Maps marker generator
|
// need to be sized for the Google Maps marker generator
|
||||||
child = SizedBox(
|
child = SizedBox(
|
||||||
|
@ -57,6 +62,49 @@ class ImageMarker extends StatelessWidget {
|
||||||
borderRadius: innerBorderRadius,
|
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(
|
return CustomPaint(
|
||||||
foregroundPainter: MarkerPointerPainter(
|
foregroundPainter: MarkerPointerPainter(
|
||||||
color: innerBorderColor,
|
color: innerBorderColor,
|
||||||
|
@ -68,16 +116,9 @@ class ImageMarker extends StatelessWidget {
|
||||||
padding: EdgeInsets.only(bottom: pointerSize.height),
|
padding: EdgeInsets.only(bottom: pointerSize.height),
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: outerDecoration,
|
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
|
@override
|
||||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
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_dialog.dart';
|
||||||
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
|
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
|
||||||
import 'package:aves/widgets/dialogs/cover_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:collection/collection.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/scheduler.dart';
|
import 'package:flutter/scheduler.dart';
|
||||||
|
@ -51,6 +52,7 @@ abstract class ChipSetActionDelegate<T extends CollectionFilter> with FeedbackMi
|
||||||
case ChipSetAction.select:
|
case ChipSetAction.select:
|
||||||
case ChipSetAction.selectAll:
|
case ChipSetAction.selectAll:
|
||||||
case ChipSetAction.selectNone:
|
case ChipSetAction.selectNone:
|
||||||
|
case ChipSetAction.map:
|
||||||
case ChipSetAction.stats:
|
case ChipSetAction.stats:
|
||||||
case ChipSetAction.createAlbum:
|
case ChipSetAction.createAlbum:
|
||||||
return true;
|
return true;
|
||||||
|
@ -73,6 +75,9 @@ abstract class ChipSetActionDelegate<T extends CollectionFilter> with FeedbackMi
|
||||||
case ChipSetAction.sort:
|
case ChipSetAction.sort:
|
||||||
_showSortDialog(context);
|
_showSortDialog(context);
|
||||||
break;
|
break;
|
||||||
|
case ChipSetAction.map:
|
||||||
|
_goToMap(context);
|
||||||
|
break;
|
||||||
case ChipSetAction.stats:
|
case ChipSetAction.stats:
|
||||||
_goToStats(context);
|
_goToStats(context);
|
||||||
break;
|
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) {
|
void _goToStats(BuildContext context) {
|
||||||
final source = context.read<CollectionSource>();
|
final source = context.read<CollectionSource>();
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
|
|
|
@ -204,6 +204,7 @@ class _FilterGridAppBarState<T extends CollectionFilter> extends State<FilterGri
|
||||||
ChipSetAction.select,
|
ChipSetAction.select,
|
||||||
enabled: !widget.isEmpty,
|
enabled: !widget.isEmpty,
|
||||||
),
|
),
|
||||||
|
toMenuItem(ChipSetAction.map),
|
||||||
toMenuItem(ChipSetAction.stats),
|
toMenuItem(ChipSetAction.stats),
|
||||||
toMenuItem(ChipSetAction.createAlbum),
|
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}',
|
'hasAddress': '${entry.hasAddress}',
|
||||||
'hasFineAddress': '${entry.hasFineAddress}',
|
'hasFineAddress': '${entry.hasFineAddress}',
|
||||||
'latLng': '${entry.latLng}',
|
'latLng': '${entry.latLng}',
|
||||||
'geoUri': entry.geoUri ?? '',
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
@ -77,7 +77,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case EntryAction.openMap:
|
case EntryAction.openMap:
|
||||||
AndroidAppService.openMap(entry.geoUri!).then((success) {
|
AndroidAppService.openMap(entry.latLng!).then((success) {
|
||||||
if (!success) showNoMatchingAppDialog(context);
|
if (!success) showNoMatchingAppDialog(context);
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
|
@ -1,23 +1,15 @@
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/filters/location.dart';
|
import 'package:aves/model/filters/location.dart';
|
||||||
import 'package:aves/model/settings/coordinate_format.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/settings/settings.dart';
|
||||||
import 'package:aves/model/source/collection_lens.dart';
|
import 'package:aves/model/source/collection_lens.dart';
|
||||||
import 'package:aves/services/services.dart';
|
import 'package:aves/services/services.dart';
|
||||||
import 'package:aves/theme/durations.dart';
|
|
||||||
import 'package:aves/theme/icons.dart';
|
import 'package:aves/theme/icons.dart';
|
||||||
import 'package:aves/widgets/common/extensions/build_context.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/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/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:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
import 'package:tuple/tuple.dart';
|
|
||||||
|
|
||||||
class LocationSection extends StatefulWidget {
|
class LocationSection extends StatefulWidget {
|
||||||
final CollectionLens? collection;
|
final CollectionLens? collection;
|
||||||
|
@ -39,16 +31,7 @@ class LocationSection extends StatefulWidget {
|
||||||
_LocationSectionState createState() => _LocationSectionState();
|
_LocationSectionState createState() => _LocationSectionState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _LocationSectionState extends State<LocationSection> with TickerProviderStateMixin {
|
class _LocationSectionState extends State<LocationSection> {
|
||||||
// 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);
|
|
||||||
|
|
||||||
CollectionLens? get collection => widget.collection;
|
CollectionLens? get collection => widget.collection;
|
||||||
|
|
||||||
AvesEntry get entry => widget.entry;
|
AvesEntry get entry => widget.entry;
|
||||||
|
@ -85,8 +68,6 @@ class _LocationSectionState extends State<LocationSection> with TickerProviderSt
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (!entry.hasGps) return const SizedBox();
|
if (!entry.hasGps) return const SizedBox();
|
||||||
final latLng = entry.latLng!;
|
|
||||||
final geoUri = entry.geoUri!;
|
|
||||||
|
|
||||||
final filters = <LocationFilter>[];
|
final filters = <LocationFilter>[];
|
||||||
if (entry.hasAddress) {
|
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));
|
if (place != null && place.isNotEmpty) filters.add(LocationFilter(LocationLevel.place, place));
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget buildMarker(BuildContext context) => ImageMarker(
|
|
||||||
entry: entry,
|
|
||||||
extent: extent,
|
|
||||||
pointerSize: pointerSize,
|
|
||||||
);
|
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
if (widget.showTitle) const SectionRow(icon: AIcons.location),
|
if (widget.showTitle) const SectionRow(icon: AIcons.location),
|
||||||
FutureBuilder<bool>(
|
GeoMap(
|
||||||
future: availability.isConnected,
|
entries: [entry],
|
||||||
builder: (context, snapshot) {
|
interactive: false,
|
||||||
if (snapshot.data != true) return const SizedBox();
|
mapHeight: 200,
|
||||||
return Selector<Settings, EntryMapStyle>(
|
isAnimatingNotifier: widget.isScrollingNotifier,
|
||||||
selector: (context, s) => s.infoMapStyle,
|
onUserZoomChange: (zoom) => settings.infoMapZoom = zoom,
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
_AddressInfoGroup(entry: entry),
|
_AddressInfoGroup(entry: entry),
|
||||||
if (filters.isNotEmpty)
|
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"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.1"
|
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:
|
dbus:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -278,6 +285,13 @@ packages:
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.2"
|
version: "2.1.2"
|
||||||
|
fluster:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: fluster
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.0"
|
||||||
flutter:
|
flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description: flutter
|
description: flutter
|
||||||
|
|
|
@ -16,6 +16,8 @@ dependencies:
|
||||||
collection:
|
collection:
|
||||||
connectivity_plus:
|
connectivity_plus:
|
||||||
country_code:
|
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:
|
decorated_icon:
|
||||||
equatable:
|
equatable:
|
||||||
event_bus:
|
event_bus:
|
||||||
|
@ -29,6 +31,7 @@ dependencies:
|
||||||
firebase_core:
|
firebase_core:
|
||||||
firebase_crashlytics:
|
firebase_crashlytics:
|
||||||
flex_color_picker:
|
flex_color_picker:
|
||||||
|
fluster:
|
||||||
flutter_highlight:
|
flutter_highlight:
|
||||||
flutter_map:
|
flutter_map:
|
||||||
flutter_markdown:
|
flutter_markdown:
|
||||||
|
@ -39,7 +42,7 @@ dependencies:
|
||||||
google_maps_flutter:
|
google_maps_flutter:
|
||||||
intl:
|
intl:
|
||||||
latlong2:
|
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'
|
material_design_icons_flutter: '>=5.0.5955-rc.1'
|
||||||
overlay_support:
|
overlay_support:
|
||||||
package_info_plus:
|
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.AudioCapabilities ERROR
|
||||||
adb.exe shell setprop log.tag.AudioTrack INFO
|
adb.exe shell setprop log.tag.AudioTrack INFO
|
||||||
adb.exe shell setprop log.tag.CompatibilityChangeReporter 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.CustomizedTextParser INFO
|
||||||
adb.exe shell setprop log.tag.InputMethodManager WARN
|
adb.exe shell setprop log.tag.InputMethodManager WARN
|
||||||
adb.exe shell setprop log.tag.InsetsSourceConsumer INFO
|
adb.exe shell setprop log.tag.InsetsSourceConsumer INFO
|
||||||
|
|
Loading…
Reference in a new issue