This commit is contained in:
Thibault Deckers 2021-08-06 15:23:53 +09:00
parent 2a82aef354
commit 7747e19f73
41 changed files with 1396 additions and 692 deletions

View file

@ -321,6 +321,8 @@
"@menuActionSort": {},
"menuActionGroup": "Group",
"@menuActionGroup": {},
"menuActionMap": "Map",
"@menuActionMap": {},
"menuActionStats": "Stats",
"@menuActionStats": {},
@ -706,6 +708,9 @@
"settingsCoordinateFormatTitle": "Coordinate Format",
"@settingsCoordinateFormatTitle": {},
"mapPageTitle": "Map",
"@mapPageTitle": {},
"statsPageTitle": "Stats",
"@statsPageTitle": {},
"statsImage": "{count, plural, =1{image} other{images}}",

View file

@ -147,6 +147,7 @@
"menuActionSort": "정렬",
"menuActionGroup": "묶음",
"menuActionMap": "지도",
"menuActionStats": "통계",
"aboutPageTitle": "앱 정보",
@ -338,6 +339,8 @@
"settingsCoordinateFormatTile": "좌표 표현",
"settingsCoordinateFormatTitle": "좌표 표현",
"mapPageTitle": "지도",
"statsPageTitle": "통계",
"statsImage": "{count, plural, other{사진}}",
"statsVideo": "{count, plural, other{동영상}}",

View file

@ -6,6 +6,7 @@ enum ChipSetAction {
// general
sort,
group,
map,
select,
selectAll,
selectNone,
@ -35,6 +36,8 @@ extension ExtraChipSetAction on ChipSetAction {
return context.l10n.collectionActionSelectAll;
case ChipSetAction.selectNone:
return context.l10n.collectionActionSelectNone;
case ChipSetAction.map:
return context.l10n.menuActionMap;
case ChipSetAction.stats:
return context.l10n.menuActionStats;
case ChipSetAction.createAlbum:
@ -68,6 +71,8 @@ extension ExtraChipSetAction on ChipSetAction {
case ChipSetAction.selectAll:
case ChipSetAction.selectNone:
return null;
case ChipSetAction.map:
return AIcons.map;
case ChipSetAction.stats:
return AIcons.stats;
case ChipSetAction.createAlbum:

View file

@ -5,6 +5,7 @@ enum CollectionAction {
select,
selectAll,
selectNone,
map,
stats,
// apply to entry set
copy,

View file

@ -13,7 +13,6 @@ import 'package:aves/services/service_policy.dart';
import 'package:aves/services/services.dart';
import 'package:aves/services/svg_metadata_service.dart';
import 'package:aves/utils/change_notifier.dart';
import 'package:aves/utils/math_utils.dart';
import 'package:aves/utils/time_utils.dart';
import 'package:collection/collection.dart';
import 'package:country_code/country_code.dart';
@ -382,13 +381,6 @@ class AvesEntry {
LatLng? get latLng => hasGps ? LatLng(_catalogMetadata!.latitude!, _catalogMetadata!.longitude!) : null;
String? get geoUri {
if (!hasGps) return null;
final latitude = roundToPrecision(_catalogMetadata!.latitude!, decimals: 6);
final longitude = roundToPrecision(_catalogMetadata!.longitude!, decimals: 6);
return 'geo:$latitude,$longitude?q=$latitude,$longitude';
}
List<String>? _xmpSubjects;
List<String> get xmpSubjects {

View file

@ -10,6 +10,8 @@ import 'package:collection/collection.dart';
import 'package:flutter/widgets.dart';
extension ExtraAvesEntry on AvesEntry {
bool isThumbnailReady({double extent = 0}) => _isReady(_getThumbnailProviderKey(extent));
ThumbnailProvider getThumbnail({double extent = 0}) {
return ThumbnailProvider(_getThumbnailProviderKey(extent));
}

View file

@ -299,7 +299,7 @@ class SqfliteMetadataDb implements MetadataDb {
await batch.commit(noResult: true);
debugPrint('$runtimeType saveMetadata complete in ${stopwatch.elapsed.inMilliseconds}ms for ${metadataEntries.length} entries');
} catch (error, stack) {
debugPrint('$runtimeType failed to save metadata with exception=$error\n$stack');
debugPrint('$runtimeType failed to save metadata with error=$error\n$stack');
}
}

View file

@ -3,8 +3,10 @@ import 'dart:typed_data';
import 'package:aves/model/entry.dart';
import 'package:aves/services/services.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/utils/math_utils.dart';
import 'package:collection/collection.dart';
import 'package:flutter/services.dart';
import 'package:latlong2/latlong.dart';
class AndroidAppService {
static const platform = MethodChannel('deckers.thibault/aves/app');
@ -77,7 +79,11 @@ class AndroidAppService {
return false;
}
static Future<bool> openMap(String geoUri) async {
static Future<bool> openMap(LatLng latLng) async {
final latitude = roundToPrecision(latLng.latitude, decimals: 6);
final longitude = roundToPrecision(latLng.longitude, decimals: 6);
final geoUri = 'geo:$latitude,$longitude?q=$latitude,$longitude';
try {
final result = await platform.invokeMethod('openMap', <String, dynamic>{
'geoUri': geoUri,

View file

@ -3,6 +3,8 @@ import 'package:flutter/scheduler.dart';
class Durations {
// Flutter animations (with margin)
static const popupMenuAnimation = Duration(milliseconds: 300 + 10); // ref `_kMenuDuration` used in `_PopupMenuRoute`
// page transition duration also available via `ModalRoute.of(context)!.transitionDuration * timeDilation`
static const pageTransitionAnimation = Duration(milliseconds: 300 + 10); // ref `transitionDuration` used in `MaterialRouteTransitionMixin`
static const dialogTransitionAnimation = Duration(milliseconds: 150 + 10); // ref `transitionDuration` used in `DialogRoute`
static const drawerTransitionAnimation = Duration(milliseconds: 246 + 10); // ref `_kBaseSettleDuration` used in `DrawerControllerState`
static const toggleableTransitionAnimation = Duration(milliseconds: 200 + 10); // ref `_kToggleDuration` used in `ToggleableStateMixin`

View file

@ -50,6 +50,7 @@ class AIcons {
static const IconData import = MdiIcons.fileImportOutline;
static const IconData info = Icons.info_outlined;
static const IconData layers = Icons.layers_outlined;
static const IconData map = Icons.map_outlined;
static const IconData newTier = Icons.fiber_new_outlined;
static const IconData openOutside = Icons.open_in_new_outlined;
static const IconData pin = Icons.push_pin_outlined;

View file

@ -143,6 +143,11 @@ class Constants {
license: 'Apache 2.0',
sourceUrl: 'https://github.com/google/charts',
),
Dependency(
name: 'Custom rounded rectangle border',
license: 'MIT',
sourceUrl: 'https://github.com/lekanbar/custom_rounded_rectangle_border',
),
Dependency(
name: 'Decorated Icon',
license: 'MIT',
@ -233,6 +238,11 @@ class Constants {
license: 'MIT',
sourceUrl: 'https://github.com/marcojakob/dart-event-bus',
),
Dependency(
name: 'Fluster',
license: 'MIT',
sourceUrl: 'https://github.com/alfonsocejudo/fluster',
),
Dependency(
name: 'Flutter Lints',
license: 'BSD 3-Clause',

View file

@ -22,9 +22,10 @@ import 'package:aves/widgets/common/basic/menu_row.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/dialogs/add_shortcut_dialog.dart';
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
import 'package:aves/widgets/map/map_page.dart';
import 'package:aves/widgets/search/search_button.dart';
import 'package:aves/widgets/search/search_delegate.dart';
import 'package:aves/widgets/stats/stats.dart';
import 'package:aves/widgets/stats/stats_page.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
@ -212,6 +213,11 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
enabled: isNotEmpty,
child: MenuRow(text: context.l10n.collectionActionSelect, icon: AIcons.select),
),
PopupMenuItem(
value: CollectionAction.map,
enabled: isNotEmpty,
child: MenuRow(text: context.l10n.menuActionMap, icon: AIcons.map),
),
PopupMenuItem(
value: CollectionAction.stats,
enabled: isNotEmpty,
@ -292,6 +298,9 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
case CollectionAction.selectNone:
context.read<Selection<AvesEntry>>().clearSelection();
break;
case CollectionAction.map:
_goToMap();
break;
case CollectionAction.stats:
_goToStats();
break;
@ -377,6 +386,19 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
);
}
void _goToMap() {
Navigator.push(
context,
MaterialPageRoute(
settings: const RouteSettings(name: MapPage.routeName),
builder: (context) => MapPage(
source: source,
parentCollection: collection,
),
),
);
}
void _goToStats() {
Navigator.push(
context,

View file

@ -19,6 +19,7 @@ import 'package:provider/provider.dart';
class ThumbnailImage extends StatefulWidget {
final AvesEntry entry;
final double extent;
final bool progressive;
final BoxFit? fit;
final bool showLoadingBackground;
final ValueNotifier<bool>? cancellableNotifier;
@ -28,6 +29,7 @@ class ThumbnailImage extends StatefulWidget {
Key? key,
required this.entry,
required this.extent,
this.progressive = true,
this.fit,
this.showLoadingBackground = true,
this.cancellableNotifier,
@ -93,7 +95,7 @@ class _ThumbnailImageState extends State<ThumbnailImage> {
_lastException = null;
_providers.clear();
_providers.addAll([
if (!entry.isSvg)
if (widget.progressive && !entry.isSvg)
_ConditionalImageProvider(
ScrollAwareImageProvider(
context: _scrollAwareContext,

View 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);
}
},
),
);
}
}

View file

@ -13,61 +13,18 @@ import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
import 'package:aves/widgets/viewer/overlay/common.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
class MapDecorator extends StatelessWidget {
final Widget? child;
static const mapBorderRadius = BorderRadius.all(Radius.circular(24)); // to match button circles
static const mapBackground = Color(0xFFDBD5D3);
static const mapLoadingGrid = Color(0xFFC4BEBB);
const MapDecorator({
Key? key,
this.child,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return GestureDetector(
onScaleStart: (details) {
// absorb scale gesture here to prevent scrolling
// and triggering by mistake a move to the image page above
},
child: ClipRRect(
borderRadius: mapBorderRadius,
child: Container(
color: mapBackground,
height: 200,
child: Stack(
children: [
const GridPaper(
color: mapLoadingGrid,
interval: 10,
divisions: 1,
subdivisions: 1,
child: CustomPaint(
size: Size.infinite,
),
),
if (child != null) child!,
],
),
),
),
);
}
}
import 'package:latlong2/latlong.dart';
class MapButtonPanel extends StatelessWidget {
final String geoUri;
final void Function(double amount) zoomBy;
final LatLng latLng;
final Future<void> Function(double amount)? zoomBy;
static const double padding = 4;
const MapButtonPanel({
Key? key,
required this.geoUri,
required this.zoomBy,
required this.latLng,
this.zoomBy,
}) : super(key: key);
@override
@ -86,7 +43,7 @@ class MapButtonPanel extends StatelessWidget {
children: [
MapOverlayButton(
icon: AIcons.openOutside,
onPressed: () => AndroidAppService.openMap(geoUri).then((success) {
onPressed: () => AndroidAppService.openMap(latLng).then((success) {
if (!success) showNoMatchingAppDialog(context);
}),
tooltip: context.l10n.entryActionOpenMap,
@ -120,13 +77,13 @@ class MapButtonPanel extends StatelessWidget {
const Spacer(),
MapOverlayButton(
icon: AIcons.zoomIn,
onPressed: () => zoomBy(1),
onPressed: zoomBy != null ? () => zoomBy!(1) : null,
tooltip: context.l10n.viewerInfoMapZoomInTooltip,
),
const SizedBox(height: padding),
MapOverlayButton(
icon: AIcons.zoomOut,
onPressed: () => zoomBy(-1),
onPressed: zoomBy != null ? () => zoomBy!(-1) : null,
tooltip: context.l10n.viewerInfoMapZoomOutTooltip,
),
],
@ -141,7 +98,7 @@ class MapButtonPanel extends StatelessWidget {
class MapOverlayButton extends StatelessWidget {
final IconData icon;
final String tooltip;
final VoidCallback onPressed;
final VoidCallback? onPressed;
const MapOverlayButton({
Key? key,

View 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!,
],
),
),
),
);
}
}

View 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}';
}

View 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);

View 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;
}
}
}

View 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}';
}

View 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);
}

View 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,
);
}
}

View 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();
}
}

View file

@ -1,12 +1,11 @@
import 'dart:math';
import 'package:aves/widgets/common/basic/outlined_text.dart';
import 'package:aves/widgets/common/map/leaflet/scalebar_utils.dart';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_map/plugin_api.dart';
import 'scalebar_utils.dart' as util;
class ScaleLayerOptions extends LayerOptions {
final Widget Function(double width, String distance) builder;
@ -24,6 +23,7 @@ class ScaleLayerOptions extends LayerOptions {
}
}
// TODO TLAD [map] scale bar should not rotate together with map layer
class ScaleLayerWidget extends StatelessWidget {
final ScaleLayerOptions options;
@ -86,7 +86,7 @@ class ScaleLayer extends StatelessWidget {
: 2);
final distance = scale[max(0, min(20, level))].toDouble();
final start = map.project(center);
final targetPoint = util.calculateEndingGlobalCoordinates(center, 90, distance);
final targetPoint = ScaleBarUtils.calculateEndingGlobalCoordinates(center, 90, distance);
final end = map.project(targetPoint);
final displayDistance = distance > 999 ? '${(distance / 1000).toStringAsFixed(0)} km' : '${distance.toStringAsFixed(0)} m';
final width = end.x - (start.x as double);

View 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);
}
}

View 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,
),
);
}
}

View file

@ -1,16 +1,15 @@
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:aves/model/entry.dart';
import 'package:aves/widgets/collection/thumbnail/image.dart';
import 'package:custom_rounded_rectangle_border/custom_rounded_rectangle_border.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:provider/provider.dart';
class ImageMarker extends StatelessWidget {
final AvesEntry entry;
final AvesEntry? entry;
final int? count;
final double extent;
final Size pointerSize;
final bool progressive;
static const double outerBorderRadiusDim = 8;
static const double outerBorderWidth = 1.5;
@ -18,21 +17,27 @@ class ImageMarker extends StatelessWidget {
static const outerBorderColor = Colors.white30;
static const innerBorderColor = Color(0xFF212121);
static const outerBorderRadius = BorderRadius.all(Radius.circular(outerBorderRadiusDim));
static const innerBorderRadius = BorderRadius.all(Radius.circular(outerBorderRadiusDim - outerBorderWidth));
static const innerRadius = Radius.circular(outerBorderRadiusDim - outerBorderWidth);
static const innerBorderRadius = BorderRadius.all(innerRadius);
const ImageMarker({
Key? key,
required this.entry,
required this.count,
required this.extent,
this.pointerSize = Size.zero,
required this.pointerSize,
required this.progressive,
}) : super(key: key);
@override
Widget build(BuildContext context) {
Widget child = ThumbnailImage(
entry: entry,
Widget child = entry != null
? ThumbnailImage(
entry: entry!,
extent: extent,
);
progressive: progressive,
)
: const SizedBox();
// need to be sized for the Google Maps marker generator
child = SizedBox(
@ -57,6 +62,49 @@ class ImageMarker extends StatelessWidget {
borderRadius: innerBorderRadius,
);
child = DecoratedBox(
decoration: innerDecoration,
position: DecorationPosition.foreground,
child: ClipRRect(
borderRadius: innerBorderRadius,
child: child,
),
);
if (count != null) {
const borderSide = BorderSide(
color: innerBorderColor,
width: innerBorderWidth,
);
child = Stack(
children: [
child,
Container(
padding: const EdgeInsets.symmetric(vertical: 0, horizontal: 2),
decoration: ShapeDecoration(
color: Theme.of(context).accentColor,
shape: const CustomRoundedRectangleBorder(
leftSide: borderSide,
rightSide: borderSide,
topSide: borderSide,
bottomSide: borderSide,
topLeftCornerSide: borderSide,
bottomRightCornerSide: borderSide,
borderRadius: BorderRadius.only(
topLeft: innerRadius,
bottomRight: innerRadius,
),
),
),
child: Text(
'$count',
style: const TextStyle(fontSize: 12),
),
),
],
);
}
return CustomPaint(
foregroundPainter: MarkerPointerPainter(
color: innerBorderColor,
@ -68,16 +116,9 @@ class ImageMarker extends StatelessWidget {
padding: EdgeInsets.only(bottom: pointerSize.height),
child: Container(
decoration: outerDecoration,
child: DecoratedBox(
decoration: innerDecoration,
position: DecorationPosition.foreground,
child: ClipRRect(
borderRadius: innerBorderRadius,
child: child,
),
),
),
),
);
}
}
@ -124,65 +165,3 @@ class MarkerPointerPainter extends CustomPainter {
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
// generate bitmap from widget, for Google Maps
class MarkerGeneratorWidget extends StatefulWidget {
final List<Widget> markers;
final Duration delay;
final Function(List<Uint8List> bitmaps) onComplete;
const MarkerGeneratorWidget({
Key? key,
required this.markers,
this.delay = Duration.zero,
required this.onComplete,
}) : super(key: key);
@override
_MarkerGeneratorWidgetState createState() => _MarkerGeneratorWidgetState();
}
class _MarkerGeneratorWidgetState extends State<MarkerGeneratorWidget> {
final _globalKeys = <GlobalKey>[];
@override
void initState() {
super.initState();
WidgetsBinding.instance!.addPostFrameCallback((_) async {
if (widget.delay > Duration.zero) {
await Future.delayed(widget.delay);
}
widget.onComplete(await _getBitmaps(context));
});
}
@override
Widget build(BuildContext context) {
return Transform.translate(
offset: Offset(context.select<MediaQueryData, double>((mq) => mq.size.width), 0),
child: Material(
type: MaterialType.transparency,
child: Stack(
children: widget.markers.map((i) {
final key = GlobalKey(debugLabel: 'map-marker-$i');
_globalKeys.add(key);
return RepaintBoundary(
key: key,
child: i,
);
}).toList(),
),
),
);
}
Future<List<Uint8List>> _getBitmaps(BuildContext context) async {
final pixelRatio = context.read<MediaQueryData>().devicePixelRatio;
return Future.wait(_globalKeys.map((key) async {
final boundary = key.currentContext!.findRenderObject() as RenderRepaintBoundary;
final image = await boundary.toImage(pixelRatio: pixelRatio);
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
return byteData != null ? byteData.buffer.asUint8List() : Uint8List(0);
}));
}
}

View 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,
);
}
}

View file

@ -14,7 +14,8 @@ import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/dialogs/aves_dialog.dart';
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
import 'package:aves/widgets/dialogs/cover_selection_dialog.dart';
import 'package:aves/widgets/stats/stats.dart';
import 'package:aves/widgets/map/map_page.dart';
import 'package:aves/widgets/stats/stats_page.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
@ -51,6 +52,7 @@ abstract class ChipSetActionDelegate<T extends CollectionFilter> with FeedbackMi
case ChipSetAction.select:
case ChipSetAction.selectAll:
case ChipSetAction.selectNone:
case ChipSetAction.map:
case ChipSetAction.stats:
case ChipSetAction.createAlbum:
return true;
@ -73,6 +75,9 @@ abstract class ChipSetActionDelegate<T extends CollectionFilter> with FeedbackMi
case ChipSetAction.sort:
_showSortDialog(context);
break;
case ChipSetAction.map:
_goToMap(context);
break;
case ChipSetAction.stats:
_goToStats(context);
break;
@ -124,6 +129,19 @@ abstract class ChipSetActionDelegate<T extends CollectionFilter> with FeedbackMi
}
}
void _goToMap(BuildContext context) {
final source = context.read<CollectionSource>();
Navigator.push(
context,
MaterialPageRoute(
settings: const RouteSettings(name: MapPage.routeName),
builder: (context) => MapPage(
source: source,
),
),
);
}
void _goToStats(BuildContext context) {
final source = context.read<CollectionSource>();
Navigator.push(

View file

@ -204,6 +204,7 @@ class _FilterGridAppBarState<T extends CollectionFilter> extends State<FilterGri
ChipSetAction.select,
enabled: !widget.isEmpty,
),
toMenuItem(ChipSetAction.map),
toMenuItem(ChipSetAction.stats),
toMenuItem(ChipSetAction.createAlbum),
]);

View 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,
),
),
),
);
}
}

View file

@ -122,7 +122,6 @@ class ViewerDebugPage extends StatelessWidget {
'hasAddress': '${entry.hasAddress}',
'hasFineAddress': '${entry.hasFineAddress}',
'latLng': '${entry.latLng}',
'geoUri': entry.geoUri ?? '',
},
),
],

View file

@ -77,7 +77,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
});
break;
case EntryAction.openMap:
AndroidAppService.openMap(entry.geoUri!).then((success) {
AndroidAppService.openMap(entry.latLng!).then((success) {
if (!success) showNoMatchingAppDialog(context);
});
break;

View file

@ -1,23 +1,15 @@
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/location.dart';
import 'package:aves/model/settings/coordinate_format.dart';
import 'package:aves/model/settings/enums.dart';
import 'package:aves/model/settings/map_style.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/services/services.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/aves_filter_chip.dart';
import 'package:aves/widgets/common/map/geo_map.dart';
import 'package:aves/widgets/viewer/info/common.dart';
import 'package:aves/widgets/viewer/info/maps/common.dart';
import 'package:aves/widgets/viewer/info/maps/google_map.dart';
import 'package:aves/widgets/viewer/info/maps/leaflet_map.dart';
import 'package:aves/widgets/viewer/info/maps/marker.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart';
class LocationSection extends StatefulWidget {
final CollectionLens? collection;
@ -39,16 +31,7 @@ class LocationSection extends StatefulWidget {
_LocationSectionState createState() => _LocationSectionState();
}
class _LocationSectionState extends State<LocationSection> with TickerProviderStateMixin {
// as of google_maps_flutter v2.0.6, Google Maps initialization is blocking
// cf https://github.com/flutter/flutter/issues/28493
// it is especially severe the first time, but still significant afterwards
// so we prevent loading it while scrolling or animating
bool _googleMapsLoaded = false;
static const extent = 48.0;
static const pointerSize = Size(8.0, 6.0);
class _LocationSectionState extends State<LocationSection> {
CollectionLens? get collection => widget.collection;
AvesEntry get entry => widget.entry;
@ -85,8 +68,6 @@ class _LocationSectionState extends State<LocationSection> with TickerProviderSt
@override
Widget build(BuildContext context) {
if (!entry.hasGps) return const SizedBox();
final latLng = entry.latLng!;
final geoUri = entry.geoUri!;
final filters = <LocationFilter>[];
if (entry.hasAddress) {
@ -97,74 +78,16 @@ class _LocationSectionState extends State<LocationSection> with TickerProviderSt
if (place != null && place.isNotEmpty) filters.add(LocationFilter(LocationLevel.place, place));
}
Widget buildMarker(BuildContext context) => ImageMarker(
entry: entry,
extent: extent,
pointerSize: pointerSize,
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.showTitle) const SectionRow(icon: AIcons.location),
FutureBuilder<bool>(
future: availability.isConnected,
builder: (context, snapshot) {
if (snapshot.data != true) return const SizedBox();
return Selector<Settings, EntryMapStyle>(
selector: (context, s) => s.infoMapStyle,
builder: (context, mapStyle, child) {
final isGoogleMaps = mapStyle.isGoogleMaps;
return AnimatedSize(
alignment: Alignment.topCenter,
curve: Curves.easeInOutCubic,
duration: Durations.mapStyleSwitchAnimation,
vsync: this,
child: ValueListenableBuilder<bool>(
valueListenable: widget.isScrollingNotifier,
builder: (context, scrolling, child) {
if (!scrolling && isGoogleMaps) {
_googleMapsLoaded = true;
}
return Visibility(
visible: !isGoogleMaps || _googleMapsLoaded,
replacement: Stack(
children: [
const MapDecorator(),
MapButtonPanel(
geoUri: geoUri,
zoomBy: (_) {},
),
],
),
child: child!,
);
},
child: isGoogleMaps
? EntryGoogleMap(
// `LatLng` used by `google_maps_flutter` is not the one from `latlong` package
latLng: Tuple2<double, double>(latLng.latitude, latLng.longitude),
geoUri: geoUri,
initialZoom: settings.infoMapZoom,
markerId: entry.uri,
markerBuilder: buildMarker,
)
: EntryLeafletMap(
latLng: latLng,
geoUri: geoUri,
initialZoom: settings.infoMapZoom,
style: settings.infoMapStyle,
markerSize: Size(
extent + ImageMarker.outerBorderWidth * 2,
extent + ImageMarker.outerBorderWidth * 2 + pointerSize.height,
),
markerBuilder: buildMarker,
),
),
);
},
);
},
GeoMap(
entries: [entry],
interactive: false,
mapHeight: 200,
isAnimatingNotifier: widget.isScrollingNotifier,
onUserZoomChange: (zoom) => settings.infoMapZoom = zoom,
),
_AddressInfoGroup(entry: entry),
if (filters.isNotEmpty)

View file

@ -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;
}
}
}

View file

@ -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,
),
);
}
}

View file

@ -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);
}

View file

@ -169,6 +169,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.1"
custom_rounded_rectangle_border:
dependency: "direct main"
description:
name: custom_rounded_rectangle_border
url: "https://pub.dartlang.org"
source: hosted
version: "0.2.0-nullsafety.0"
dbus:
dependency: transitive
description:
@ -278,6 +285,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.2"
fluster:
dependency: "direct main"
description:
name: fluster
url: "https://pub.dartlang.org"
source: hosted
version: "1.2.0"
flutter:
dependency: "direct main"
description: flutter

View file

@ -16,6 +16,8 @@ dependencies:
collection:
connectivity_plus:
country_code:
# TODO TLAD as of 2021/08/04, null safe version is pre-release
custom_rounded_rectangle_border: '>=0.2.0-nullsafety.0'
decorated_icon:
equatable:
event_bus:
@ -29,6 +31,7 @@ dependencies:
firebase_core:
firebase_crashlytics:
flex_color_picker:
fluster:
flutter_highlight:
flutter_map:
flutter_markdown:
@ -39,7 +42,7 @@ dependencies:
google_maps_flutter:
intl:
latlong2:
# TODO TLAD as of 2021/07/08, MDI package null safe version is pre-release
# TODO TLAD as of 2021/08/04, null safe version is pre-release
material_design_icons_flutter: '>=5.0.5955-rc.1'
overlay_support:
package_info_plus:

View file

@ -12,6 +12,7 @@ adb.exe shell setprop log.tag.AHierarchicalStateMachine ERROR
adb.exe shell setprop log.tag.AudioCapabilities ERROR
adb.exe shell setprop log.tag.AudioTrack INFO
adb.exe shell setprop log.tag.CompatibilityChangeReporter INFO
adb.exe shell setprop log.tag.Counters WARN
adb.exe shell setprop log.tag.CustomizedTextParser INFO
adb.exe shell setprop log.tag.InputMethodManager WARN
adb.exe shell setprop log.tag.InsetsSourceConsumer INFO