map: changed navigation concept, improved gestures, toggle fullscreen
This commit is contained in:
parent
35b10b3470
commit
ea6f5d7df6
36 changed files with 936 additions and 321 deletions
|
@ -1,6 +1,6 @@
|
|||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
buildscript {
|
||||
ext.kotlin_version = '1.5.30'
|
||||
ext.kotlin_version = '1.5.31'
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
|
|
|
@ -103,7 +103,7 @@
|
|||
"@entryActionOpen": {},
|
||||
"entryActionSetAs": "Set as…",
|
||||
"@entryActionSetAs": {},
|
||||
"entryActionOpenMap": "Show on map…",
|
||||
"entryActionOpenMap": "Show in map app…",
|
||||
"@entryActionOpenMap": {},
|
||||
"entryActionRotateScreen": "Rotate screen",
|
||||
"@entryActionRotateScreen": {},
|
||||
|
@ -871,6 +871,8 @@
|
|||
"@mapAttributionStamen": {},
|
||||
"openMapPageTooltip": "View on Map page",
|
||||
"@openMapPageTooltip": {},
|
||||
"mapEmptyRegion": "No images in this region",
|
||||
"@mapEmpty": {},
|
||||
|
||||
"viewerInfoOpenEmbeddedFailureFeedback": "Failed to extract embedded data",
|
||||
"@viewerInfoOpenEmbeddedFailureFeedback": {},
|
||||
|
|
|
@ -52,7 +52,7 @@
|
|||
"entryActionEdit": "편집…",
|
||||
"entryActionOpen": "다른 앱에서 열기…",
|
||||
"entryActionSetAs": "다음 용도로 사용…",
|
||||
"entryActionOpenMap": "지도에서 보기…",
|
||||
"entryActionOpenMap": "지도 앱에서 보기…",
|
||||
"entryActionRotateScreen": "화면 회전",
|
||||
"entryActionAddFavourite": "즐겨찾기에 추가",
|
||||
"entryActionRemoveFavourite": "즐겨찾기에서 삭제",
|
||||
|
@ -425,6 +425,7 @@
|
|||
"mapAttributionOsmHot": "지도 데이터 © [OpenStreetMap](https://www.openstreetmap.org/copyright) 기여자 • 타일 [HOT](https://www.hotosm.org/) • 호스팅 [OSM France](https://openstreetmap.fr/)",
|
||||
"mapAttributionStamen": "지도 데이터 © [OpenStreetMap](https://www.openstreetmap.org/copyright) 기여자 • 타일 [Stamen Design](http://stamen.com), [CC BY 3.0](http://creativecommons.org/licenses/by/3.0)",
|
||||
"openMapPageTooltip": "지도 페이지에서 보기",
|
||||
"mapEmptyRegion": "이 지역의 사진이 없습니다",
|
||||
|
||||
"viewerInfoOpenEmbeddedFailureFeedback": "첨부 데이터 추출 오류",
|
||||
"viewerInfoOpenLinkText": "열기",
|
||||
|
|
|
@ -28,6 +28,7 @@ class CollectionLens with ChangeNotifier {
|
|||
final List<StreamSubscription> _subscriptions = [];
|
||||
int? id;
|
||||
bool listenToSource;
|
||||
List<AvesEntry>? fixedSelection;
|
||||
|
||||
List<AvesEntry> _filteredSortedEntries = [];
|
||||
|
||||
|
@ -38,6 +39,7 @@ class CollectionLens with ChangeNotifier {
|
|||
Iterable<CollectionFilter?>? filters,
|
||||
this.id,
|
||||
this.listenToSource = true,
|
||||
this.fixedSelection,
|
||||
}) : filters = (filters ?? {}).whereNotNull().toSet(),
|
||||
sectionFactor = settings.collectionSectionFactor,
|
||||
sortFactor = settings.collectionSortFactor {
|
||||
|
@ -118,7 +120,7 @@ class CollectionLens with ChangeNotifier {
|
|||
final bool groupBursts = true;
|
||||
|
||||
void _applyFilters() {
|
||||
final entries = source.visibleEntries;
|
||||
final entries = fixedSelection ?? source.visibleEntries;
|
||||
_filteredSortedEntries = List.of(filters.isEmpty ? entries : entries.where((entry) => filters.every((filter) => filter.test(entry))));
|
||||
|
||||
if (groupBursts) {
|
||||
|
|
|
@ -69,7 +69,7 @@ class Durations {
|
|||
static const softKeyboardDisplayDelay = Duration(milliseconds: 300);
|
||||
static const searchDebounceDelay = Duration(milliseconds: 250);
|
||||
static const contentChangeDebounceDelay = Duration(milliseconds: 1000);
|
||||
static const mapScrollDebounceDelay = Duration(milliseconds: 150);
|
||||
static const mapInfoDebounceDelay = Duration(milliseconds: 150);
|
||||
static const mapIdleDebounceDelay = Duration(milliseconds: 100);
|
||||
|
||||
// app life
|
||||
|
|
27
lib/utils/geo_utils.dart
Normal file
27
lib/utils/geo_utils.dart
Normal file
|
@ -0,0 +1,27 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
LatLng getLatLngCenter(List<LatLng> points) {
|
||||
double x = 0;
|
||||
double y = 0;
|
||||
double z = 0;
|
||||
|
||||
points.forEach((point) {
|
||||
final lat = point.latitudeInRad;
|
||||
final lng = point.longitudeInRad;
|
||||
x += cos(lat) * cos(lng);
|
||||
y += cos(lat) * sin(lng);
|
||||
z += sin(lat);
|
||||
});
|
||||
|
||||
final pointCount = points.length;
|
||||
x /= pointCount;
|
||||
y /= pointCount;
|
||||
z /= pointCount;
|
||||
|
||||
final lng = atan2(y, x);
|
||||
final hyp = sqrt(x * x + y * y);
|
||||
final lat = atan2(z, hyp);
|
||||
return LatLng(radianToDeg(lat), radianToDeg(lng));
|
||||
}
|
|
@ -1,11 +1,5 @@
|
|||
import 'dart:math';
|
||||
|
||||
const double _piOver180 = pi / 180.0;
|
||||
|
||||
double toDegrees(num radians) => radians / _piOver180;
|
||||
|
||||
double toRadians(num degrees) => degrees * _piOver180;
|
||||
|
||||
int highestPowerOf2(num x) => x < 1 ? 0 : pow(2, (log(x) / ln2).floor()).toInt();
|
||||
|
||||
int smallestPowerOf2(num x) => x < 1 ? 1 : pow(2, (log(x) / ln2).ceil()).toInt();
|
||||
|
|
|
@ -242,9 +242,10 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
]
|
||||
];
|
||||
},
|
||||
onSelected: (action) {
|
||||
onSelected: (action) async {
|
||||
// wait for the popup menu to hide before proceeding with the action
|
||||
Future.delayed(Durations.popupMenuAnimation * timeDilation, () => _onCollectionActionSelected(action));
|
||||
await Future.delayed(Durations.popupMenuAnimation * timeDilation);
|
||||
await _onCollectionActionSelected(action);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
|
@ -244,14 +244,19 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
|||
|
||||
void _goToMap(BuildContext context) {
|
||||
final selection = context.read<Selection<AvesEntry>>();
|
||||
final entries = selection.isSelecting ? _getExpandedSelectedItems(selection) : context.read<CollectionLens>().sortedEntries;
|
||||
final collection = context.read<CollectionLens>();
|
||||
final entries = (selection.isSelecting ? _getExpandedSelectedItems(selection) : collection.sortedEntries);
|
||||
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
settings: const RouteSettings(name: MapPage.routeName),
|
||||
builder: (context) => MapPage(
|
||||
entries: entries.where((entry) => entry.hasGps).toList(),
|
||||
collection: CollectionLens(
|
||||
source: collection.source,
|
||||
filters: collection.filters,
|
||||
fixedSelection: entries.where((entry) => entry.hasGps).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
@ -54,11 +54,14 @@ class InteractiveThumbnail extends StatelessWidget {
|
|||
child: DecoratedThumbnail(
|
||||
entry: entry,
|
||||
tileExtent: tileExtent,
|
||||
collection: collection,
|
||||
// when the user is scrolling faster than we can retrieve the thumbnails,
|
||||
// the retrieval task queue can pile up for thumbnails that got disposed
|
||||
// in this case we pause the image retrieval task to get it out of the queue
|
||||
cancellableNotifier: isScrollingNotifier,
|
||||
// hero tag should include a collection identifier, so that it animates
|
||||
// between different views of the entry in the same collection (e.g. thumbnails <-> viewer)
|
||||
// but not between different collection instances, even with the same attributes (e.g. reloading collection page via drawer)
|
||||
heroTagger: () => Object.hashAll([collection.id, entry.uri]),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
@ -82,7 +82,8 @@ class AvesFilterChip extends StatefulWidget {
|
|||
);
|
||||
if (selectedAction != null) {
|
||||
// wait for the popup menu to hide before proceeding with the action
|
||||
Future.delayed(Durations.popupMenuAnimation * timeDilation, () => ChipActionDelegate().onActionSelected(context, filter, selectedAction));
|
||||
await Future.delayed(Durations.popupMenuAnimation * timeDilation);
|
||||
ChipActionDelegate().onActionSelected(context, filter, selectedAction);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,12 +4,14 @@ class EmptyContent extends StatelessWidget {
|
|||
final IconData? icon;
|
||||
final String text;
|
||||
final AlignmentGeometry alignment;
|
||||
final double fontSize;
|
||||
|
||||
const EmptyContent({
|
||||
Key? key,
|
||||
this.icon,
|
||||
required this.text,
|
||||
this.alignment = const FractionalOffset(.5, .35),
|
||||
this.fontSize = 22,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
|
@ -30,10 +32,11 @@ class EmptyContent extends StatelessWidget {
|
|||
],
|
||||
Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 22,
|
||||
fontSize: fontSize,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
@ -25,8 +25,6 @@ class MapButtonPanel extends StatelessWidget {
|
|||
final MapOpener? openMapPage;
|
||||
final VoidCallback? resetRotation;
|
||||
|
||||
static const double padding = 4;
|
||||
|
||||
const MapButtonPanel({
|
||||
Key? key,
|
||||
required this.boundsNotifier,
|
||||
|
@ -60,111 +58,117 @@ class MapButtonPanel extends StatelessWidget {
|
|||
break;
|
||||
}
|
||||
|
||||
final visualDensity = context.select<MapThemeData, VisualDensity?>((v) => v.visualDensity);
|
||||
final double padding = visualDensity == VisualDensity.compact ? 4 : 8;
|
||||
|
||||
return Positioned.fill(
|
||||
child: Align(
|
||||
alignment: AlignmentDirectional.centerEnd,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(padding),
|
||||
padding: EdgeInsets.all(padding),
|
||||
child: TooltipTheme(
|
||||
data: TooltipTheme.of(context).copyWith(
|
||||
preferBelow: false,
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
left: 0,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (navigationButton != null) ...[
|
||||
navigationButton,
|
||||
const SizedBox(height: padding),
|
||||
],
|
||||
ValueListenableBuilder<ZoomedBounds>(
|
||||
valueListenable: boundsNotifier,
|
||||
builder: (context, bounds, child) {
|
||||
final degrees = bounds.rotation;
|
||||
final opacity = degrees == 0 ? .0 : 1.0;
|
||||
return IgnorePointer(
|
||||
ignoring: opacity == 0,
|
||||
child: AnimatedOpacity(
|
||||
opacity: opacity,
|
||||
duration: Durations.viewerOverlayAnimation,
|
||||
child: MapOverlayButton(
|
||||
icon: Transform(
|
||||
origin: iconSize.center(Offset.zero),
|
||||
transform: Matrix4.rotationZ(degToRadian(degrees)),
|
||||
child: CustomPaint(
|
||||
painter: CompassPainter(
|
||||
color: iconTheme.color!,
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
left: 0,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (navigationButton != null) ...[
|
||||
navigationButton,
|
||||
SizedBox(height: padding),
|
||||
],
|
||||
ValueListenableBuilder<ZoomedBounds>(
|
||||
valueListenable: boundsNotifier,
|
||||
builder: (context, bounds, child) {
|
||||
final degrees = bounds.rotation;
|
||||
final opacity = degrees == 0 ? .0 : 1.0;
|
||||
return IgnorePointer(
|
||||
ignoring: opacity == 0,
|
||||
child: AnimatedOpacity(
|
||||
opacity: opacity,
|
||||
duration: Durations.viewerOverlayAnimation,
|
||||
child: MapOverlayButton(
|
||||
icon: Transform(
|
||||
origin: iconSize.center(Offset.zero),
|
||||
transform: Matrix4.rotationZ(degToRadian(degrees)),
|
||||
child: CustomPaint(
|
||||
painter: CompassPainter(
|
||||
color: iconTheme.color!,
|
||||
),
|
||||
size: iconSize,
|
||||
),
|
||||
size: iconSize,
|
||||
),
|
||||
onPressed: () => resetRotation?.call(),
|
||||
tooltip: context.l10n.mapPointNorthUpTooltip,
|
||||
),
|
||||
onPressed: () => resetRotation?.call(),
|
||||
tooltip: context.l10n.mapPointNorthUpTooltip,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 0,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
MapOverlayButton(
|
||||
icon: const Icon(AIcons.layers),
|
||||
onPressed: () async {
|
||||
final hasPlayServices = await availability.hasPlayServices;
|
||||
final availableStyles = EntryMapStyle.values.where((style) => !style.isGoogleMaps || hasPlayServices);
|
||||
final preferredStyle = settings.infoMapStyle;
|
||||
final initialStyle = availableStyles.contains(preferredStyle) ? preferredStyle : availableStyles.first;
|
||||
final style = await showDialog<EntryMapStyle>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AvesSelectionDialog<EntryMapStyle>(
|
||||
initialValue: initialStyle,
|
||||
options: Map.fromEntries(availableStyles.map((v) => MapEntry(v, v.getName(context)))),
|
||||
title: context.l10n.mapStyleTitle,
|
||||
);
|
||||
},
|
||||
);
|
||||
// wait for the dialog to hide as applying the change may block the UI
|
||||
await Future.delayed(Durations.dialogTransitionAnimation * timeDilation);
|
||||
if (style != null && style != settings.infoMapStyle) {
|
||||
settings.infoMapStyle = style;
|
||||
}
|
||||
},
|
||||
tooltip: context.l10n.mapStyleTooltip,
|
||||
),
|
||||
],
|
||||
Positioned(
|
||||
right: 0,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
MapOverlayButton(
|
||||
icon: const Icon(AIcons.layers),
|
||||
onPressed: () async {
|
||||
final hasPlayServices = await availability.hasPlayServices;
|
||||
final availableStyles = EntryMapStyle.values.where((style) => !style.isGoogleMaps || hasPlayServices);
|
||||
final preferredStyle = settings.infoMapStyle;
|
||||
final initialStyle = availableStyles.contains(preferredStyle) ? preferredStyle : availableStyles.first;
|
||||
final style = await showDialog<EntryMapStyle>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AvesSelectionDialog<EntryMapStyle>(
|
||||
initialValue: initialStyle,
|
||||
options: Map.fromEntries(availableStyles.map((v) => MapEntry(v, v.getName(context)))),
|
||||
title: context.l10n.mapStyleTitle,
|
||||
);
|
||||
},
|
||||
);
|
||||
// wait for the dialog to hide as applying the change may block the UI
|
||||
await Future.delayed(Durations.dialogTransitionAnimation * timeDilation);
|
||||
if (style != null && style != settings.infoMapStyle) {
|
||||
settings.infoMapStyle = style;
|
||||
}
|
||||
},
|
||||
tooltip: context.l10n.mapStyleTooltip,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
MapOverlayButton(
|
||||
icon: const Icon(AIcons.zoomIn),
|
||||
onPressed: zoomBy != null ? () => zoomBy?.call(1) : null,
|
||||
tooltip: context.l10n.mapZoomInTooltip,
|
||||
),
|
||||
const SizedBox(height: padding),
|
||||
MapOverlayButton(
|
||||
icon: const Icon(AIcons.zoomOut),
|
||||
onPressed: zoomBy != null ? () => zoomBy?.call(-1) : null,
|
||||
tooltip: context.l10n.mapZoomOutTooltip,
|
||||
),
|
||||
],
|
||||
Positioned(
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
MapOverlayButton(
|
||||
icon: const Icon(AIcons.zoomIn),
|
||||
onPressed: zoomBy != null ? () => zoomBy?.call(1) : null,
|
||||
tooltip: context.l10n.mapZoomInTooltip,
|
||||
),
|
||||
SizedBox(height: padding),
|
||||
MapOverlayButton(
|
||||
icon: const Icon(AIcons.zoomOut),
|
||||
onPressed: zoomBy != null ? () => zoomBy?.call(-1) : null,
|
||||
tooltip: context.l10n.mapZoomOutTooltip,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -187,24 +191,33 @@ class MapOverlayButton extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final visualDensity = context.select<MapThemeData, VisualDensity?>((v) => v.visualDensity);
|
||||
final blurred = settings.enableOverlayBlurEffect;
|
||||
return BlurredOval(
|
||||
enabled: blurred,
|
||||
child: Material(
|
||||
type: MaterialType.circle,
|
||||
color: overlayBackgroundColor(blurred: blurred),
|
||||
child: Ink(
|
||||
decoration: BoxDecoration(
|
||||
border: AvesBorder.border,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: IconButton(
|
||||
iconSize: 20,
|
||||
visualDensity: visualDensity,
|
||||
icon: icon,
|
||||
onPressed: onPressed,
|
||||
tooltip: tooltip,
|
||||
return Selector<MapThemeData, Animation<double>>(
|
||||
selector: (context, v) => v.scale,
|
||||
builder: (context, scale, child) => ScaleTransition(
|
||||
scale: scale,
|
||||
child: child,
|
||||
),
|
||||
child: BlurredOval(
|
||||
enabled: blurred,
|
||||
child: Material(
|
||||
type: MaterialType.circle,
|
||||
color: overlayBackgroundColor(blurred: blurred),
|
||||
child: Ink(
|
||||
decoration: BoxDecoration(
|
||||
border: AvesBorder.border,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Selector<MapThemeData, VisualDensity?>(
|
||||
selector: (context, v) => v.visualDensity,
|
||||
builder: (context, visualDensity, child) => IconButton(
|
||||
iconSize: 20,
|
||||
visualDensity: visualDensity,
|
||||
icon: icon,
|
||||
onPressed: onPressed,
|
||||
tooltip: tooltip,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:aves/widgets/common/map/zoomed_bounds.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
class AvesMapController {
|
||||
|
@ -7,13 +8,17 @@ class AvesMapController {
|
|||
|
||||
Stream<dynamic> get _events => _streamController.stream;
|
||||
|
||||
Stream<MapControllerMoveEvent> get moveEvents => _events.where((event) => event is MapControllerMoveEvent).cast<MapControllerMoveEvent>();
|
||||
Stream<MapControllerMoveEvent> get moveCommands => _events.where((event) => event is MapControllerMoveEvent).cast<MapControllerMoveEvent>();
|
||||
|
||||
Stream<MapIdleUpdate> get idleUpdates => _events.where((event) => event is MapIdleUpdate).cast<MapIdleUpdate>();
|
||||
|
||||
void dispose() {
|
||||
_streamController.close();
|
||||
}
|
||||
|
||||
void moveTo(LatLng latLng) => _streamController.add(MapControllerMoveEvent(latLng));
|
||||
|
||||
void notifyIdle(ZoomedBounds bounds) => _streamController.add(MapIdleUpdate(bounds));
|
||||
}
|
||||
|
||||
class MapControllerMoveEvent {
|
||||
|
@ -21,3 +26,9 @@ class MapControllerMoveEvent {
|
|||
|
||||
MapControllerMoveEvent(this.latLng);
|
||||
}
|
||||
|
||||
class MapIdleUpdate {
|
||||
final ZoomedBounds bounds;
|
||||
|
||||
MapIdleUpdate(this.bounds);
|
||||
}
|
||||
|
|
|
@ -30,12 +30,14 @@ class GeoMap extends StatefulWidget {
|
|||
final List<AvesEntry> entries;
|
||||
final AvesEntry? initialEntry;
|
||||
final ValueNotifier<bool> isAnimatingNotifier;
|
||||
final ValueNotifier<AvesEntry?>? dotEntryNotifier;
|
||||
final UserZoomChangeCallback? onUserZoomChange;
|
||||
final VoidCallback? onMapTap;
|
||||
final MarkerTapCallback? onMarkerTap;
|
||||
final MapOpener? openMapPage;
|
||||
|
||||
static const markerImageExtent = 48.0;
|
||||
static const pointerSize = Size(8, 6);
|
||||
static const markerArrowSize = Size(8, 6);
|
||||
|
||||
const GeoMap({
|
||||
Key? key,
|
||||
|
@ -43,7 +45,9 @@ class GeoMap extends StatefulWidget {
|
|||
required this.entries,
|
||||
this.initialEntry,
|
||||
required this.isAnimatingNotifier,
|
||||
this.dotEntryNotifier,
|
||||
this.onUserZoomChange,
|
||||
this.onMapTap,
|
||||
this.onMarkerTap,
|
||||
this.openMapPage,
|
||||
}) : super(key: key);
|
||||
|
@ -126,7 +130,7 @@ class _GeoMapState extends State<GeoMap> {
|
|||
entry: key.entry,
|
||||
count: key.count,
|
||||
extent: GeoMap.markerImageExtent,
|
||||
pointerSize: GeoMap.pointerSize,
|
||||
arrowSize: GeoMap.markerArrowSize,
|
||||
progressive: progressive,
|
||||
);
|
||||
|
||||
|
@ -139,7 +143,9 @@ class _GeoMapState extends State<GeoMap> {
|
|||
style: mapStyle,
|
||||
markerClusterBuilder: _buildMarkerClusters,
|
||||
markerWidgetBuilder: _buildMarkerWidget,
|
||||
dotEntryNotifier: widget.dotEntryNotifier,
|
||||
onUserZoomChange: widget.onUserZoomChange,
|
||||
onMapTap: widget.onMapTap,
|
||||
onMarkerTap: _onMarkerTap,
|
||||
openMapPage: widget.openMapPage,
|
||||
)
|
||||
|
@ -151,11 +157,17 @@ class _GeoMapState extends State<GeoMap> {
|
|||
style: mapStyle,
|
||||
markerClusterBuilder: _buildMarkerClusters,
|
||||
markerWidgetBuilder: _buildMarkerWidget,
|
||||
dotEntryNotifier: widget.dotEntryNotifier,
|
||||
markerSize: Size(
|
||||
GeoMap.markerImageExtent + ImageMarker.outerBorderWidth * 2,
|
||||
GeoMap.markerImageExtent + ImageMarker.outerBorderWidth * 2 + GeoMap.pointerSize.height,
|
||||
GeoMap.markerImageExtent + ImageMarker.outerBorderWidth * 2 + GeoMap.markerArrowSize.height,
|
||||
),
|
||||
dotMarkerSize: const Size(
|
||||
DotMarker.diameter + ImageMarker.outerBorderWidth * 2,
|
||||
DotMarker.diameter + ImageMarker.outerBorderWidth * 2,
|
||||
),
|
||||
onUserZoomChange: widget.onUserZoomChange,
|
||||
onMapTap: widget.onMapTap,
|
||||
onMarkerTap: _onMarkerTap,
|
||||
openMapPage: widget.openMapPage,
|
||||
);
|
||||
|
@ -170,7 +182,11 @@ class _GeoMapState extends State<GeoMap> {
|
|||
child: child,
|
||||
)
|
||||
: Expanded(child: child),
|
||||
Attribution(style: mapStyle),
|
||||
SafeArea(
|
||||
top: false,
|
||||
bottom: false,
|
||||
child: Attribution(style: mapStyle),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
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';
|
||||
|
@ -10,6 +11,7 @@ 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/marker.dart';
|
||||
import 'package:aves/widgets/common/map/theme.dart';
|
||||
import 'package:aves/widgets/common/map/zoomed_bounds.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
@ -24,7 +26,9 @@ class EntryGoogleMap extends StatefulWidget {
|
|||
final EntryMapStyle style;
|
||||
final MarkerClusterBuilder markerClusterBuilder;
|
||||
final MarkerWidgetBuilder markerWidgetBuilder;
|
||||
final ValueNotifier<AvesEntry?>? dotEntryNotifier;
|
||||
final UserZoomChangeCallback? onUserZoomChange;
|
||||
final VoidCallback? onMapTap;
|
||||
final void Function(GeoEntry geoEntry)? onMarkerTap;
|
||||
final MapOpener? openMapPage;
|
||||
|
||||
|
@ -37,7 +41,9 @@ class EntryGoogleMap extends StatefulWidget {
|
|||
required this.style,
|
||||
required this.markerClusterBuilder,
|
||||
required this.markerWidgetBuilder,
|
||||
required this.dotEntryNotifier,
|
||||
this.onUserZoomChange,
|
||||
this.onMapTap,
|
||||
this.onMarkerTap,
|
||||
this.openMapPage,
|
||||
}) : super(key: key);
|
||||
|
@ -52,6 +58,7 @@ class _EntryGoogleMapState extends State<EntryGoogleMap> with WidgetsBindingObse
|
|||
Map<MarkerKey, GeoEntry> _geoEntryByMarkerKey = {};
|
||||
final Map<MarkerKey, Uint8List> _markerBitmaps = {};
|
||||
final AChangeNotifier _markerBitmapChangeNotifier = AChangeNotifier();
|
||||
Uint8List? _dotMarkerBitmap;
|
||||
|
||||
ValueNotifier<ZoomedBounds> get boundsNotifier => widget.boundsNotifier;
|
||||
|
||||
|
@ -84,7 +91,7 @@ class _EntryGoogleMapState extends State<EntryGoogleMap> with WidgetsBindingObse
|
|||
void _registerWidget(EntryGoogleMap widget) {
|
||||
final avesMapController = widget.controller;
|
||||
if (avesMapController != null) {
|
||||
_subscriptions.add(avesMapController.moveEvents.listen((event) => _moveTo(_toGoogleLatLng(event.latLng))));
|
||||
_subscriptions.add(avesMapController.moveCommands.listen((event) => _moveTo(_toGoogleLatLng(event.latLng))));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -113,6 +120,11 @@ class _EntryGoogleMapState extends State<EntryGoogleMap> with WidgetsBindingObse
|
|||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
MarkerGeneratorWidget<Key>(
|
||||
markers: const [DotMarker(key: Key('dot'))],
|
||||
isReadyToRender: (key) => true,
|
||||
onRendered: (key, bitmap) => _dotMarkerBitmap = bitmap,
|
||||
),
|
||||
MarkerGeneratorWidget<MarkerKey>(
|
||||
markers: _geoEntryByMarkerKey.keys.map(widget.markerWidgetBuilder).toList(),
|
||||
isReadyToRender: (key) => key.entry.isThumbnailReady(extent: GeoMap.markerImageExtent),
|
||||
|
@ -154,43 +166,60 @@ class _EntryGoogleMapState extends State<EntryGoogleMap> with WidgetsBindingObse
|
|||
});
|
||||
|
||||
final interactive = context.select<MapThemeData, bool>((v) => v.interactive);
|
||||
return GoogleMap(
|
||||
initialCameraPosition: CameraPosition(
|
||||
target: _toGoogleLatLng(bounds.center),
|
||||
zoom: bounds.zoom,
|
||||
),
|
||||
onMapCreated: (controller) async {
|
||||
_googleMapController = controller;
|
||||
final zoom = await controller.getZoomLevel();
|
||||
await _updateVisibleRegion(zoom: zoom, rotation: 0);
|
||||
setState(() {});
|
||||
},
|
||||
// compass disabled to use provider agnostic controls
|
||||
compassEnabled: false,
|
||||
mapToolbarEnabled: false,
|
||||
mapType: _toMapType(widget.style),
|
||||
minMaxZoomPreference: MinMaxZoomPreference(widget.minZoom, widget.maxZoom),
|
||||
rotateGesturesEnabled: true,
|
||||
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(zoom: position.zoom, rotation: -position.bearing),
|
||||
onCameraIdle: _updateClusters,
|
||||
);
|
||||
return ValueListenableBuilder<AvesEntry?>(
|
||||
valueListenable: widget.dotEntryNotifier ?? ValueNotifier(null),
|
||||
builder: (context, dotEntry, child) {
|
||||
return GoogleMap(
|
||||
initialCameraPosition: CameraPosition(
|
||||
target: _toGoogleLatLng(bounds.center),
|
||||
zoom: bounds.zoom,
|
||||
),
|
||||
onMapCreated: (controller) async {
|
||||
_googleMapController = controller;
|
||||
final zoom = await controller.getZoomLevel();
|
||||
await _updateVisibleRegion(zoom: zoom, rotation: 0);
|
||||
setState(() {});
|
||||
},
|
||||
// compass disabled to use provider agnostic controls
|
||||
compassEnabled: false,
|
||||
mapToolbarEnabled: false,
|
||||
mapType: _toMapType(widget.style),
|
||||
minMaxZoomPreference: MinMaxZoomPreference(widget.minZoom, widget.maxZoom),
|
||||
rotateGesturesEnabled: true,
|
||||
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,
|
||||
if (dotEntry != null && _dotMarkerBitmap != null)
|
||||
Marker(
|
||||
markerId: const MarkerId('dot'),
|
||||
anchor: const Offset(.5, .5),
|
||||
consumeTapEvents: true,
|
||||
icon: BitmapDescriptor.fromBytes(_dotMarkerBitmap!),
|
||||
position: _toGoogleLatLng(dotEntry.latLng!),
|
||||
zIndex: 1,
|
||||
)
|
||||
},
|
||||
onCameraMove: (position) => _updateVisibleRegion(zoom: position.zoom, rotation: -position.bearing),
|
||||
onCameraIdle: _onIdle,
|
||||
onTap: (position) => widget.onMapTap?.call(),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _updateClusters() {
|
||||
void _onIdle() {
|
||||
if (!mounted) return;
|
||||
widget.controller?.notifyIdle(bounds);
|
||||
setState(() => _geoEntryByMarkerKey = widget.markerClusterBuilder());
|
||||
}
|
||||
|
||||
|
@ -199,11 +228,11 @@ class _EntryGoogleMapState extends State<EntryGoogleMap> with WidgetsBindingObse
|
|||
|
||||
final bounds = await _googleMapController?.getVisibleRegion();
|
||||
if (bounds != null && (bounds.northeast != uninitializedLatLng || bounds.southwest != uninitializedLatLng)) {
|
||||
final sw = bounds.southwest;
|
||||
final ne = bounds.northeast;
|
||||
boundsNotifier.value = ZoomedBounds(
|
||||
west: bounds.southwest.longitude,
|
||||
south: bounds.southwest.latitude,
|
||||
east: bounds.northeast.longitude,
|
||||
north: bounds.northeast.latitude,
|
||||
sw: ll.LatLng(sw.latitude, sw.longitude),
|
||||
ne: ll.LatLng(ne.latitude, ne.longitude),
|
||||
zoom: zoom,
|
||||
rotation: rotation,
|
||||
);
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/settings/enums.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/utils/debouncer.dart';
|
||||
|
@ -11,6 +12,7 @@ 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/marker.dart';
|
||||
import 'package:aves/widgets/common/map/theme.dart';
|
||||
import 'package:aves/widgets/common/map/zoomed_bounds.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
@ -25,8 +27,10 @@ class EntryLeafletMap extends StatefulWidget {
|
|||
final EntryMapStyle style;
|
||||
final MarkerClusterBuilder markerClusterBuilder;
|
||||
final MarkerWidgetBuilder markerWidgetBuilder;
|
||||
final Size markerSize;
|
||||
final ValueNotifier<AvesEntry?>? dotEntryNotifier;
|
||||
final Size markerSize, dotMarkerSize;
|
||||
final UserZoomChangeCallback? onUserZoomChange;
|
||||
final VoidCallback? onMapTap;
|
||||
final void Function(GeoEntry geoEntry)? onMarkerTap;
|
||||
final MapOpener? openMapPage;
|
||||
|
||||
|
@ -39,8 +43,11 @@ class EntryLeafletMap extends StatefulWidget {
|
|||
required this.style,
|
||||
required this.markerClusterBuilder,
|
||||
required this.markerWidgetBuilder,
|
||||
required this.dotEntryNotifier,
|
||||
required this.markerSize,
|
||||
required this.dotMarkerSize,
|
||||
this.onUserZoomChange,
|
||||
this.onMapTap,
|
||||
this.onMarkerTap,
|
||||
this.openMapPage,
|
||||
}) : super(key: key);
|
||||
|
@ -85,7 +92,7 @@ class _EntryLeafletMapState extends State<EntryLeafletMap> with TickerProviderSt
|
|||
void _registerWidget(EntryLeafletMap widget) {
|
||||
final avesMapController = widget.controller;
|
||||
if (avesMapController != null) {
|
||||
_subscriptions.add(avesMapController.moveEvents.listen((event) => _moveTo(event.latLng)));
|
||||
_subscriptions.add(avesMapController.moveCommands.listen((event) => _moveTo(event.latLng)));
|
||||
}
|
||||
_subscriptions.add(_leafletMapController.mapEventStream.listen((event) => _updateVisibleRegion()));
|
||||
boundsNotifier.addListener(_onBoundsChange);
|
||||
|
@ -117,6 +124,10 @@ class _EntryLeafletMapState extends State<EntryLeafletMap> with TickerProviderSt
|
|||
|
||||
Widget _buildMap() {
|
||||
final markerSize = widget.markerSize;
|
||||
final dotMarkerSize = widget.dotMarkerSize;
|
||||
|
||||
final interactive = context.select<MapThemeData, bool>((v) => v.interactive);
|
||||
|
||||
final markers = _geoEntryByMarkerKey.entries.map((kv) {
|
||||
final markerKey = kv.key;
|
||||
final geoEntry = kv.value;
|
||||
|
@ -125,6 +136,9 @@ class _EntryLeafletMapState extends State<EntryLeafletMap> with TickerProviderSt
|
|||
point: latLng,
|
||||
builder: (context) => GestureDetector(
|
||||
onTap: () => widget.onMarkerTap?.call(geoEntry),
|
||||
// marker tap handling prevents the default handling of focal zoom on double tap,
|
||||
// so we reimplement the double tap gesture here
|
||||
onDoubleTap: interactive ? () => _zoomBy(1, focalPoint: latLng) : null,
|
||||
child: widget.markerWidgetBuilder(markerKey),
|
||||
),
|
||||
width: markerSize.width,
|
||||
|
@ -133,7 +147,6 @@ class _EntryLeafletMapState extends State<EntryLeafletMap> with TickerProviderSt
|
|||
);
|
||||
}).toList();
|
||||
|
||||
final interactive = context.select<MapThemeData, bool>((v) => v.interactive);
|
||||
return FlutterMap(
|
||||
options: MapOptions(
|
||||
center: bounds.center,
|
||||
|
@ -141,6 +154,7 @@ class _EntryLeafletMapState extends State<EntryLeafletMap> with TickerProviderSt
|
|||
minZoom: widget.minZoom,
|
||||
maxZoom: widget.maxZoom,
|
||||
interactiveFlags: interactive ? InteractiveFlag.all : InteractiveFlag.none,
|
||||
onTap: (point) => widget.onMapTap?.call(),
|
||||
controller: _leafletMapController,
|
||||
),
|
||||
mapController: _leafletMapController,
|
||||
|
@ -158,6 +172,22 @@ class _EntryLeafletMapState extends State<EntryLeafletMap> with TickerProviderSt
|
|||
rotateAlignment: Alignment.bottomCenter,
|
||||
),
|
||||
),
|
||||
ValueListenableBuilder<AvesEntry?>(
|
||||
valueListenable: widget.dotEntryNotifier ?? ValueNotifier(null),
|
||||
builder: (context, dotEntry, child) => MarkerLayerWidget(
|
||||
options: MarkerLayerOptions(
|
||||
markers: [
|
||||
if (dotEntry != null)
|
||||
Marker(
|
||||
point: dotEntry.latLng!,
|
||||
builder: (context) => const DotMarker(),
|
||||
width: dotMarkerSize.width,
|
||||
height: dotMarkerSize.height,
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
@ -175,10 +205,11 @@ class _EntryLeafletMapState extends State<EntryLeafletMap> with TickerProviderSt
|
|||
}
|
||||
}
|
||||
|
||||
void _onBoundsChange() => _debouncer(_updateClusters);
|
||||
void _onBoundsChange() => _debouncer(_onIdle);
|
||||
|
||||
void _updateClusters() {
|
||||
void _onIdle() {
|
||||
if (!mounted) return;
|
||||
widget.controller?.notifyIdle(bounds);
|
||||
setState(() => _geoEntryByMarkerKey = widget.markerClusterBuilder());
|
||||
}
|
||||
|
||||
|
@ -186,10 +217,8 @@ class _EntryLeafletMapState extends State<EntryLeafletMap> with TickerProviderSt
|
|||
final bounds = _leafletMapController.bounds;
|
||||
if (bounds != null) {
|
||||
boundsNotifier.value = ZoomedBounds(
|
||||
west: bounds.west,
|
||||
south: bounds.south,
|
||||
east: bounds.east,
|
||||
north: bounds.north,
|
||||
sw: bounds.southWest!,
|
||||
ne: bounds.northEast!,
|
||||
zoom: _leafletMapController.zoom,
|
||||
rotation: _leafletMapController.rotation,
|
||||
);
|
||||
|
@ -201,12 +230,15 @@ class _EntryLeafletMapState extends State<EntryLeafletMap> with TickerProviderSt
|
|||
await _animateCamera((animation) => _leafletMapController.rotate(rotationTween.evaluate(animation)));
|
||||
}
|
||||
|
||||
Future<void> _zoomBy(double amount) async {
|
||||
Future<void> _zoomBy(double amount, {LatLng? focalPoint}) async {
|
||||
final endZoom = (_leafletMapController.zoom + amount).clamp(widget.minZoom, widget.maxZoom);
|
||||
widget.onUserZoomChange?.call(endZoom);
|
||||
|
||||
final center = _leafletMapController.center;
|
||||
final centerTween = LatLngTween(begin: center, end: focalPoint ?? center);
|
||||
|
||||
final zoomTween = Tween<double>(begin: _leafletMapController.zoom, end: endZoom);
|
||||
await _animateCamera((animation) => _leafletMapController.move(_leafletMapController.center, zoomTween.evaluate(animation)));
|
||||
await _animateCamera((animation) => _leafletMapController.move(centerTween.evaluate(animation)!, zoomTween.evaluate(animation)));
|
||||
}
|
||||
|
||||
Future<void> _moveTo(LatLng point) async {
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:aves/utils/math_utils.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
class ScaleBarUtils {
|
||||
|
@ -15,8 +14,8 @@ class ScaleBarUtils {
|
|||
var aSquared = a * a;
|
||||
var bSquared = b * b;
|
||||
var f = mFlattening;
|
||||
var phi1 = toRadians(start.latitude);
|
||||
var alpha1 = toRadians(startBearing);
|
||||
var phi1 = degToRadian(start.latitude);
|
||||
var alpha1 = degToRadian(startBearing);
|
||||
var cosAlpha1 = cos(alpha1);
|
||||
var sinAlpha1 = sin(alpha1);
|
||||
var s = distance;
|
||||
|
@ -103,8 +102,8 @@ class ScaleBarUtils {
|
|||
// cosSigma * cosAlpha1);
|
||||
|
||||
// build result
|
||||
var latitude = toDegrees(phi2);
|
||||
var longitude = start.longitude + toDegrees(L);
|
||||
var latitude = radianToDeg(phi2);
|
||||
var longitude = start.longitude + radianToDeg(L);
|
||||
|
||||
// if ((endBearing != null) && (endBearing.length > 0)) {
|
||||
// endBearing[0] = toDegrees(alpha2);
|
||||
|
|
|
@ -8,7 +8,7 @@ class ImageMarker extends StatelessWidget {
|
|||
final AvesEntry? entry;
|
||||
final int? count;
|
||||
final double extent;
|
||||
final Size pointerSize;
|
||||
final Size arrowSize;
|
||||
final bool progressive;
|
||||
|
||||
static const double outerBorderRadiusDim = 8;
|
||||
|
@ -25,7 +25,7 @@ class ImageMarker extends StatelessWidget {
|
|||
required this.entry,
|
||||
required this.count,
|
||||
required this.extent,
|
||||
required this.pointerSize,
|
||||
required this.arrowSize,
|
||||
required this.progressive,
|
||||
}) : super(key: key);
|
||||
|
||||
|
@ -106,14 +106,14 @@ class ImageMarker extends StatelessWidget {
|
|||
}
|
||||
|
||||
return CustomPaint(
|
||||
foregroundPainter: MarkerPointerPainter(
|
||||
foregroundPainter: _MarkerArrowPainter(
|
||||
color: innerBorderColor,
|
||||
outlineColor: outerBorderColor,
|
||||
outlineWidth: outerBorderWidth,
|
||||
size: pointerSize,
|
||||
size: arrowSize,
|
||||
),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(bottom: pointerSize.height),
|
||||
padding: EdgeInsets.only(bottom: arrowSize.height),
|
||||
child: Container(
|
||||
decoration: outerDecoration,
|
||||
child: child,
|
||||
|
@ -123,12 +123,12 @@ class ImageMarker extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
class MarkerPointerPainter extends CustomPainter {
|
||||
class _MarkerArrowPainter extends CustomPainter {
|
||||
final Color color, outlineColor;
|
||||
final double outlineWidth;
|
||||
final Size size;
|
||||
|
||||
const MarkerPointerPainter({
|
||||
const _MarkerArrowPainter({
|
||||
required this.color,
|
||||
required this.outlineColor,
|
||||
required this.outlineWidth,
|
||||
|
@ -137,12 +137,12 @@ class MarkerPointerPainter extends CustomPainter {
|
|||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final pointerWidth = this.size.width;
|
||||
final pointerHeight = this.size.height;
|
||||
final triangleWidth = this.size.width;
|
||||
final triangleHeight = this.size.height;
|
||||
|
||||
final bottomCenter = Offset(size.width / 2, size.height);
|
||||
final topLeft = bottomCenter + Offset(-pointerWidth / 2, -pointerHeight);
|
||||
final topRight = bottomCenter + Offset(pointerWidth / 2, -pointerHeight);
|
||||
final topLeft = bottomCenter + Offset(-triangleWidth / 2, -triangleHeight);
|
||||
final topRight = bottomCenter + Offset(triangleWidth / 2, -triangleHeight);
|
||||
|
||||
canvas.drawPath(
|
||||
Path()
|
||||
|
@ -165,3 +165,48 @@ class MarkerPointerPainter extends CustomPainter {
|
|||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
}
|
||||
|
||||
class DotMarker extends StatelessWidget {
|
||||
const DotMarker({Key? key}) : super(key: key);
|
||||
|
||||
static const double diameter = 16;
|
||||
static const double outerBorderRadiusDim = diameter;
|
||||
static const outerBorderRadius = BorderRadius.all(Radius.circular(outerBorderRadiusDim));
|
||||
static const innerRadius = Radius.circular(outerBorderRadiusDim - ImageMarker.outerBorderWidth);
|
||||
static const innerBorderRadius = BorderRadius.all(innerRadius);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const outerDecoration = BoxDecoration(
|
||||
border: Border.fromBorderSide(BorderSide(
|
||||
color: ImageMarker.outerBorderColor,
|
||||
width: ImageMarker.outerBorderWidth,
|
||||
)),
|
||||
borderRadius: outerBorderRadius,
|
||||
);
|
||||
|
||||
const innerDecoration = BoxDecoration(
|
||||
border: Border.fromBorderSide(BorderSide(
|
||||
color: ImageMarker.innerBorderColor,
|
||||
width: ImageMarker.innerBorderWidth,
|
||||
)),
|
||||
borderRadius: innerBorderRadius,
|
||||
);
|
||||
|
||||
return Container(
|
||||
decoration: outerDecoration,
|
||||
child: DecoratedBox(
|
||||
decoration: innerDecoration,
|
||||
position: DecorationPosition.foreground,
|
||||
child: ClipRRect(
|
||||
borderRadius: innerBorderRadius,
|
||||
child: Container(
|
||||
width: diameter,
|
||||
height: diameter,
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ enum MapNavigationButton { back, map }
|
|||
class MapTheme extends StatelessWidget {
|
||||
final bool interactive;
|
||||
final MapNavigationButton navigationButton;
|
||||
final Animation<double> scale;
|
||||
final VisualDensity? visualDensity;
|
||||
final double? mapHeight;
|
||||
final Widget child;
|
||||
|
@ -15,6 +16,7 @@ class MapTheme extends StatelessWidget {
|
|||
Key? key,
|
||||
required this.interactive,
|
||||
required this.navigationButton,
|
||||
this.scale = kAlwaysCompleteAnimation,
|
||||
this.visualDensity,
|
||||
this.mapHeight,
|
||||
required this.child,
|
||||
|
@ -27,10 +29,9 @@ class MapTheme extends StatelessWidget {
|
|||
return MapThemeData(
|
||||
interactive: interactive,
|
||||
navigationButton: navigationButton,
|
||||
scale: scale,
|
||||
visualDensity: visualDensity,
|
||||
mapHeight: mapHeight,
|
||||
// TODO TLAD use settings?
|
||||
// showLocation: showBackButton ?? settings.showThumbnailLocation,
|
||||
);
|
||||
},
|
||||
child: child,
|
||||
|
@ -41,13 +42,15 @@ class MapTheme extends StatelessWidget {
|
|||
class MapThemeData {
|
||||
final bool interactive;
|
||||
final MapNavigationButton navigationButton;
|
||||
final Animation<double> scale;
|
||||
final VisualDensity? visualDensity;
|
||||
final double? mapHeight;
|
||||
|
||||
const MapThemeData({
|
||||
required this.interactive,
|
||||
required this.navigationButton,
|
||||
this.visualDensity,
|
||||
this.mapHeight,
|
||||
required this.scale,
|
||||
required this.visualDensity,
|
||||
required this.mapHeight,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,25 +1,26 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:aves/utils/geo_utils.dart';
|
||||
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, rotation;
|
||||
final LatLng sw, ne;
|
||||
final double zoom, rotation;
|
||||
|
||||
List<double> get boundingBox => [west, south, east, north];
|
||||
// returns [southwestLng, southwestLat, northeastLng, northeastLat], as expected by Fluster
|
||||
List<double> get boundingBox => [sw.longitude, sw.latitude, ne.longitude, ne.latitude];
|
||||
|
||||
LatLng get center => LatLng((north + south) / 2, (east + west) / 2);
|
||||
LatLng get center => getLatLngCenter([sw, ne]);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [west, south, east, north, zoom, rotation];
|
||||
List<Object?> get props => [sw, ne, zoom, rotation];
|
||||
|
||||
const ZoomedBounds({
|
||||
required this.west,
|
||||
required this.south,
|
||||
required this.east,
|
||||
required this.north,
|
||||
required this.sw,
|
||||
required this.ne,
|
||||
required this.zoom,
|
||||
required this.rotation,
|
||||
});
|
||||
|
@ -55,12 +56,20 @@ class ZoomedBounds extends Equatable {
|
|||
}
|
||||
}
|
||||
return ZoomedBounds(
|
||||
west: west,
|
||||
south: south,
|
||||
east: east,
|
||||
north: north,
|
||||
sw: LatLng(south, west),
|
||||
ne: LatLng(north, east),
|
||||
zoom: zoom,
|
||||
rotation: 0,
|
||||
);
|
||||
}
|
||||
|
||||
bool contains(LatLng point) {
|
||||
final lat = point.latitude;
|
||||
final lng = point.longitude;
|
||||
final south = sw.latitude;
|
||||
final north = ne.latitude;
|
||||
final west = sw.longitude;
|
||||
final east = ne.longitude;
|
||||
return (south <= lat && lat <= north) && (west <= east ? (west <= lng && lng <= east) : (west <= lng || lng <= east));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/widgets/common/fx/borders.dart';
|
||||
import 'package:aves/widgets/common/grid/overlay.dart';
|
||||
import 'package:aves/widgets/common/thumbnail/image.dart';
|
||||
|
@ -9,9 +8,9 @@ import 'package:flutter/material.dart';
|
|||
class DecoratedThumbnail extends StatelessWidget {
|
||||
final AvesEntry entry;
|
||||
final double tileExtent;
|
||||
final CollectionLens? collection;
|
||||
final ValueNotifier<bool>? cancellableNotifier;
|
||||
final bool selectable, highlightable, hero;
|
||||
final bool selectable, highlightable;
|
||||
final Object? Function()? heroTagger;
|
||||
|
||||
static final Color borderColor = Colors.grey.shade700;
|
||||
static final double borderWidth = AvesBorder.borderWidth;
|
||||
|
@ -20,27 +19,22 @@ class DecoratedThumbnail extends StatelessWidget {
|
|||
Key? key,
|
||||
required this.entry,
|
||||
required this.tileExtent,
|
||||
this.collection,
|
||||
this.cancellableNotifier,
|
||||
this.selectable = true,
|
||||
this.highlightable = true,
|
||||
this.hero = true,
|
||||
this.heroTagger,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final imageExtent = tileExtent - borderWidth * 2;
|
||||
|
||||
// hero tag should include a collection identifier, so that it animates
|
||||
// between different views of the entry in the same collection (e.g. thumbnails <-> viewer)
|
||||
// but not between different collection instances, even with the same attributes (e.g. reloading collection page via drawer)
|
||||
final heroTag = hero ? Object.hashAll([collection?.id, entry.uri]) : null;
|
||||
final isSvg = entry.isSvg;
|
||||
Widget child = ThumbnailImage(
|
||||
entry: entry,
|
||||
extent: imageExtent,
|
||||
cancellableNotifier: cancellableNotifier,
|
||||
heroTag: heroTag,
|
||||
heroTag: heroTagger?.call(),
|
||||
);
|
||||
|
||||
child = Stack(
|
||||
|
|
|
@ -12,6 +12,9 @@ class ThumbnailScroller extends StatefulWidget {
|
|||
final int entryCount;
|
||||
final AvesEntry? Function(int index) entryBuilder;
|
||||
final ValueNotifier<int?> indexNotifier;
|
||||
final void Function(int index)? onTap;
|
||||
final Object? Function(AvesEntry entry)? heroTagger;
|
||||
final bool highlightable;
|
||||
|
||||
const ThumbnailScroller({
|
||||
Key? key,
|
||||
|
@ -19,6 +22,9 @@ class ThumbnailScroller extends StatefulWidget {
|
|||
required this.entryCount,
|
||||
required this.entryBuilder,
|
||||
required this.indexNotifier,
|
||||
this.onTap,
|
||||
this.heroTagger,
|
||||
this.highlightable = false,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
|
@ -98,7 +104,10 @@ class _ThumbnailScrollerState extends State<ThumbnailScroller> {
|
|||
return Stack(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => indexNotifier.value = page,
|
||||
onTap: () {
|
||||
indexNotifier.value = page;
|
||||
widget.onTap?.call(page);
|
||||
},
|
||||
child: DecoratedThumbnail(
|
||||
entry: pageEntry,
|
||||
tileExtent: extent,
|
||||
|
@ -107,8 +116,8 @@ class _ThumbnailScrollerState extends State<ThumbnailScroller> {
|
|||
// so we cancel these requests when possible
|
||||
cancellableNotifier: _cancellableNotifier,
|
||||
selectable: false,
|
||||
highlightable: false,
|
||||
hero: false,
|
||||
highlightable: widget.highlightable,
|
||||
heroTagger: () => widget.heroTagger?.call(pageEntry),
|
||||
),
|
||||
),
|
||||
IgnorePointer(
|
||||
|
@ -123,7 +132,7 @@ class _ThumbnailScrollerState extends State<ThumbnailScroller> {
|
|||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
|
|
@ -159,13 +159,14 @@ class AlbumPickAppBar extends StatelessWidget {
|
|||
),
|
||||
];
|
||||
},
|
||||
onSelected: (action) {
|
||||
onSelected: (action) async {
|
||||
// remove focus, if any, to prevent the keyboard from showing up
|
||||
// after the user is done with the popup menu
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
|
||||
// wait for the popup menu to hide before proceeding with the action
|
||||
Future.delayed(Durations.popupMenuAnimation * timeDilation, () => actionDelegate.onActionSelected(context, {}, action));
|
||||
await Future.delayed(Durations.popupMenuAnimation * timeDilation);
|
||||
actionDelegate.onActionSelected(context, {}, action);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
|
|
@ -4,6 +4,7 @@ import 'package:aves/model/entry.dart';
|
|||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/selection.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/model/source/enums.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
|
@ -142,7 +143,10 @@ abstract class ChipSetActionDelegate<T extends CollectionFilter> with FeedbackMi
|
|||
MaterialPageRoute(
|
||||
settings: const RouteSettings(name: MapPage.routeName),
|
||||
builder: (context) => MapPage(
|
||||
entries: _selectedEntries(context, filters).where((entry) => entry.hasGps).toList()..sort(AvesEntry.compareByDate),
|
||||
collection: CollectionLens(
|
||||
source: context.read<CollectionSource>(),
|
||||
fixedSelection: _selectedEntries(context, filters).where((entry) => entry.hasGps).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
@ -225,9 +225,10 @@ class _FilterGridAppBarState<T extends CollectionFilter> extends State<FilterGri
|
|||
|
||||
return menuItems;
|
||||
},
|
||||
onSelected: (action) {
|
||||
onSelected: (action) async {
|
||||
// wait for the popup menu to hide before proceeding with the action
|
||||
Future.delayed(Durations.popupMenuAnimation * timeDilation, () => applyAction(action));
|
||||
await Future.delayed(Durations.popupMenuAnimation * timeDilation);
|
||||
applyAction(action);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
|
159
lib/widgets/map/map_info_row.dart
Normal file
159
lib/widgets/map/map_info_row.dart
Normal file
|
@ -0,0 +1,159 @@
|
|||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/settings/coordinate_format.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:aves/services/geocoding_service.dart';
|
||||
import 'package:aves/theme/format.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/utils/constants.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/common/map/marker.dart';
|
||||
import 'package:decorated_icon/decorated_icon.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class MapInfoRow extends StatelessWidget {
|
||||
final ValueNotifier<AvesEntry?> entryNotifier;
|
||||
|
||||
static const double iconPadding = 8.0;
|
||||
static const double iconSize = 16.0;
|
||||
static const double _interRowPadding = 2.0;
|
||||
|
||||
const MapInfoRow({
|
||||
Key? key,
|
||||
required this.entryNotifier,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final orientation = context.select<MediaQueryData, Orientation>((v) => v.orientation);
|
||||
|
||||
return ValueListenableBuilder<AvesEntry?>(
|
||||
valueListenable: entryNotifier,
|
||||
builder: (context, entry, child) {
|
||||
final content = orientation == Orientation.portrait
|
||||
? [
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_AddressRow(entry: entry),
|
||||
const SizedBox(height: _interRowPadding),
|
||||
_buildDate(context, entry),
|
||||
],
|
||||
),
|
||||
),
|
||||
]
|
||||
: [
|
||||
_buildDate(context, entry),
|
||||
Expanded(
|
||||
child: _AddressRow(entry: entry),
|
||||
),
|
||||
];
|
||||
|
||||
return Opacity(
|
||||
opacity: entry != null ? 1 : 0,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(width: iconPadding),
|
||||
const DotMarker(),
|
||||
...content,
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDate(BuildContext context, AvesEntry? entry) {
|
||||
final locale = context.l10n.localeName;
|
||||
final date = entry?.bestDate;
|
||||
final dateText = date != null ? formatDateTime(date, locale) : Constants.overlayUnknown;
|
||||
return Row(
|
||||
children: [
|
||||
const SizedBox(width: iconPadding),
|
||||
const DecoratedIcon(AIcons.date, shadows: Constants.embossShadows, size: iconSize),
|
||||
const SizedBox(width: iconPadding),
|
||||
Text(
|
||||
dateText,
|
||||
strutStyle: Constants.overflowStrutStyle,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AddressRow extends StatefulWidget {
|
||||
final AvesEntry? entry;
|
||||
|
||||
const _AddressRow({
|
||||
Key? key,
|
||||
required this.entry,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
_AddressRowState createState() => _AddressRowState();
|
||||
}
|
||||
|
||||
class _AddressRowState extends State<_AddressRow> {
|
||||
final ValueNotifier<String?> _addressLineNotifier = ValueNotifier(null);
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _AddressRow oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
final entry = widget.entry;
|
||||
if (oldWidget.entry != entry) {
|
||||
_getAddressLine(entry).then((addressLine) {
|
||||
if (mounted && entry == widget.entry) {
|
||||
_addressLineNotifier.value = addressLine;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final entry = widget.entry;
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(width: MapInfoRow.iconPadding),
|
||||
const DecoratedIcon(AIcons.location, shadows: Constants.embossShadows, size: MapInfoRow.iconSize),
|
||||
const SizedBox(width: MapInfoRow.iconPadding),
|
||||
Expanded(
|
||||
child: ValueListenableBuilder<String?>(
|
||||
valueListenable: _addressLineNotifier,
|
||||
builder: (context, addressLine, child) {
|
||||
final location = addressLine ??
|
||||
(entry == null
|
||||
? Constants.overlayUnknown
|
||||
: entry.hasAddress
|
||||
? entry.shortAddress
|
||||
: settings.coordinateFormat.format(entry.latLng!));
|
||||
return Text(
|
||||
location,
|
||||
strutStyle: Constants.overflowStrutStyle,
|
||||
softWrap: false,
|
||||
overflow: TextOverflow.fade,
|
||||
maxLines: 1,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> _getAddressLine(AvesEntry? entry) async {
|
||||
if (entry != null && await availability.canLocatePlaces) {
|
||||
final addresses = await GeocodingService.getAddress(entry.latLng!, entry.geocoderLocale);
|
||||
if (addresses.isNotEmpty) {
|
||||
final address = addresses.first;
|
||||
return address.addressLine;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
|
@ -1,68 +1,152 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/highlight.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/theme/durations.dart';
|
||||
import 'package:aves/utils/debouncer.dart';
|
||||
import 'package:aves/widgets/common/behaviour/routes.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/common/identity/empty.dart';
|
||||
import 'package:aves/widgets/common/map/controller.dart';
|
||||
import 'package:aves/widgets/common/map/geo_map.dart';
|
||||
import 'package:aves/widgets/common/map/theme.dart';
|
||||
import 'package:aves/widgets/common/map/zoomed_bounds.dart';
|
||||
import 'package:aves/widgets/common/providers/highlight_info_provider.dart';
|
||||
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
||||
import 'package:aves/widgets/common/thumbnail/scroller.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:aves/widgets/map/map_info_row.dart';
|
||||
import 'package:aves/widgets/viewer/entry_viewer_page.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class MapPage extends StatefulWidget {
|
||||
class MapPage extends StatelessWidget {
|
||||
static const routeName = '/collection/map';
|
||||
|
||||
final List<AvesEntry> entries;
|
||||
final CollectionLens collection;
|
||||
final AvesEntry? initialEntry;
|
||||
|
||||
const MapPage({
|
||||
Key? key,
|
||||
required this.entries,
|
||||
required this.collection,
|
||||
this.initialEntry,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
_MapPageState createState() => _MapPageState();
|
||||
Widget build(BuildContext context) {
|
||||
// do not rely on the `HighlightInfoProvider` app level
|
||||
// as the map can be stacked on top of other pages
|
||||
// that catch highlight events and will not let it bubble up
|
||||
return HighlightInfoProvider(
|
||||
child: MediaQueryDataProvider(
|
||||
child: Scaffold(
|
||||
body: SafeArea(
|
||||
left: false,
|
||||
top: false,
|
||||
right: false,
|
||||
bottom: true,
|
||||
child: MapPageContent(
|
||||
collection: collection,
|
||||
initialEntry: initialEntry,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MapPageState extends State<MapPage> {
|
||||
final AvesMapController _mapController = AvesMapController();
|
||||
late final ValueNotifier<bool> _isAnimatingNotifier;
|
||||
final ValueNotifier<int> _selectedIndexNotifier = ValueNotifier(0);
|
||||
final Debouncer _debouncer = Debouncer(delay: Durations.mapScrollDebounceDelay);
|
||||
class MapPageContent extends StatefulWidget {
|
||||
final CollectionLens collection;
|
||||
final AvesEntry? initialEntry;
|
||||
|
||||
List<AvesEntry> get entries => widget.entries;
|
||||
const MapPageContent({
|
||||
Key? key,
|
||||
required this.collection,
|
||||
this.initialEntry,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
_MapPageContentState createState() => _MapPageContentState();
|
||||
}
|
||||
|
||||
class _MapPageContentState extends State<MapPageContent> with SingleTickerProviderStateMixin {
|
||||
final List<StreamSubscription> _subscriptions = [];
|
||||
final AvesMapController _mapController = AvesMapController();
|
||||
late final ValueNotifier<bool> _isPageAnimatingNotifier;
|
||||
final ValueNotifier<int?> _selectedIndexNotifier = ValueNotifier(0);
|
||||
final ValueNotifier<CollectionLens?> _regionCollectionNotifier = ValueNotifier(null);
|
||||
final ValueNotifier<AvesEntry?> _dotEntryNotifier = ValueNotifier(null), _infoEntryNotifier = ValueNotifier(null);
|
||||
final Debouncer _infoDebouncer = Debouncer(delay: Durations.mapInfoDebounceDelay);
|
||||
final ValueNotifier<bool> _overlayVisible = ValueNotifier(true);
|
||||
late AnimationController _overlayAnimationController;
|
||||
late Animation<double> _overlayScale, _scrollerSize;
|
||||
|
||||
List<AvesEntry> get entries => widget.collection.sortedEntries;
|
||||
|
||||
CollectionLens? get regionCollection => _regionCollectionNotifier.value;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
if (settings.infoMapStyle.isGoogleMaps) {
|
||||
_isAnimatingNotifier = ValueNotifier(true);
|
||||
_isPageAnimatingNotifier = ValueNotifier(true);
|
||||
Future.delayed(Durations.pageTransitionAnimation * timeDilation).then((_) {
|
||||
if (!mounted) return;
|
||||
_isAnimatingNotifier.value = false;
|
||||
_isPageAnimatingNotifier.value = false;
|
||||
});
|
||||
} else {
|
||||
_isAnimatingNotifier = ValueNotifier(false);
|
||||
_isPageAnimatingNotifier = ValueNotifier(false);
|
||||
}
|
||||
|
||||
final initialEntry = widget.initialEntry;
|
||||
if (initialEntry != null) {
|
||||
final index = entries.indexOf(initialEntry);
|
||||
if (index != -1) {
|
||||
_selectedIndexNotifier.value = index;
|
||||
}
|
||||
}
|
||||
_dotEntryNotifier.addListener(_updateInfoEntry);
|
||||
|
||||
_overlayAnimationController = AnimationController(
|
||||
duration: Durations.viewerOverlayAnimation,
|
||||
vsync: this,
|
||||
);
|
||||
_overlayScale = CurvedAnimation(
|
||||
parent: _overlayAnimationController,
|
||||
curve: Curves.easeOutBack,
|
||||
);
|
||||
_scrollerSize = CurvedAnimation(
|
||||
parent: _overlayAnimationController,
|
||||
curve: Curves.easeOutQuad,
|
||||
);
|
||||
_overlayVisible.addListener(_onOverlayVisibleChange);
|
||||
|
||||
_subscriptions.add(_mapController.idleUpdates.listen((event) => _onIdle(event.bounds)));
|
||||
|
||||
_selectedIndexNotifier.addListener(_onThumbnailIndexChange);
|
||||
Future.delayed(Durations.pageTransitionAnimation * timeDilation + const Duration(seconds: 1), () {
|
||||
final regionEntries = regionCollection?.sortedEntries ?? [];
|
||||
final initialEntry = widget.initialEntry ?? regionEntries.firstOrNull;
|
||||
if (initialEntry != null) {
|
||||
final index = regionEntries.indexOf(initialEntry);
|
||||
if (index != -1) {
|
||||
_selectedIndexNotifier.value = index;
|
||||
}
|
||||
_onEntrySelected(initialEntry);
|
||||
}
|
||||
});
|
||||
|
||||
WidgetsBinding.instance!.addPostFrameCallback((_) => _onOverlayVisibleChange(animate: false));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_subscriptions
|
||||
..forEach((sub) => sub.cancel())
|
||||
..clear();
|
||||
_dotEntryNotifier.removeListener(_updateInfoEntry);
|
||||
_overlayAnimationController.dispose();
|
||||
_overlayVisible.removeListener(_onOverlayVisibleChange);
|
||||
_mapController.dispose();
|
||||
_selectedIndexNotifier.removeListener(_onThumbnailIndexChange);
|
||||
super.dispose();
|
||||
|
@ -70,52 +154,211 @@ class _MapPageState extends State<MapPage> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MediaQueryDataProvider(
|
||||
child: Scaffold(
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: MapTheme(
|
||||
interactive: true,
|
||||
navigationButton: MapNavigationButton.back,
|
||||
child: GeoMap(
|
||||
controller: _mapController,
|
||||
entries: entries,
|
||||
initialEntry: widget.initialEntry,
|
||||
isAnimatingNotifier: _isAnimatingNotifier,
|
||||
onMarkerTap: (markerEntry, getClusterEntries) {
|
||||
final index = entries.indexOf(markerEntry);
|
||||
if (_selectedIndexNotifier.value != index) {
|
||||
_selectedIndexNotifier.value = index;
|
||||
} else {
|
||||
_moveToEntry(markerEntry);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
Selector<MediaQueryData, double>(
|
||||
selector: (c, mq) => mq.size.width,
|
||||
builder: (c, mqWidth, child) {
|
||||
return ThumbnailScroller(
|
||||
availableWidth: mqWidth,
|
||||
entryCount: entries.length,
|
||||
entryBuilder: (index) => entries[index],
|
||||
indexNotifier: _selectedIndexNotifier,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
return Selector<Settings, EntryMapStyle>(
|
||||
selector: (context, s) => s.infoMapStyle,
|
||||
builder: (context, mapStyle, child) {
|
||||
late Widget scroller;
|
||||
if (mapStyle.isGoogleMaps) {
|
||||
// the Google map widget is too heavy for a smooth resizing animation
|
||||
// so we just toggle visibility when overlay animation is done
|
||||
scroller = ValueListenableBuilder<double>(
|
||||
valueListenable: _overlayAnimationController,
|
||||
builder: (context, animation, child) {
|
||||
return Visibility(
|
||||
visible: !_overlayAnimationController.isDismissed,
|
||||
child: child!,
|
||||
);
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
} else {
|
||||
// the Leaflet map widget is light enough for a smooth resizing animation
|
||||
scroller = FadeTransition(
|
||||
opacity: _scrollerSize,
|
||||
child: SizeTransition(
|
||||
sizeFactor: _scrollerSize,
|
||||
axisAlignment: 1.0,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(child: _buildMap()),
|
||||
scroller,
|
||||
],
|
||||
);
|
||||
},
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Divider(),
|
||||
_buildScroller(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onThumbnailIndexChange() => _moveToEntry(widget.entries[_selectedIndexNotifier.value]);
|
||||
Widget _buildMap() {
|
||||
return MapTheme(
|
||||
interactive: true,
|
||||
navigationButton: MapNavigationButton.back,
|
||||
scale: _overlayScale,
|
||||
child: GeoMap(
|
||||
controller: _mapController,
|
||||
entries: entries,
|
||||
initialEntry: widget.initialEntry,
|
||||
isAnimatingNotifier: _isPageAnimatingNotifier,
|
||||
dotEntryNotifier: _dotEntryNotifier,
|
||||
onMapTap: _toggleOverlay,
|
||||
onMarkerTap: (markerEntry, getClusterEntries) async {
|
||||
final index = regionCollection?.sortedEntries.indexOf(markerEntry);
|
||||
if (index != null && _selectedIndexNotifier.value != index) {
|
||||
_selectedIndexNotifier.value = index;
|
||||
}
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
context.read<HighlightInfo>().set(markerEntry);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _moveToEntry(AvesEntry entry) => _debouncer(() => _mapController.moveTo(entry.latLng!));
|
||||
Widget _buildScroller() {
|
||||
return Stack(
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SafeArea(
|
||||
top: false,
|
||||
bottom: false,
|
||||
child: MapInfoRow(entryNotifier: _infoEntryNotifier),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Selector<MediaQueryData, double>(
|
||||
selector: (c, mq) => mq.size.width,
|
||||
builder: (c, mqWidth, child) => ValueListenableBuilder<CollectionLens?>(
|
||||
valueListenable: _regionCollectionNotifier,
|
||||
builder: (context, regionCollection, child) {
|
||||
final regionEntries = regionCollection?.sortedEntries ?? [];
|
||||
return ThumbnailScroller(
|
||||
availableWidth: mqWidth,
|
||||
entryCount: regionEntries.length,
|
||||
entryBuilder: (index) => regionEntries[index],
|
||||
indexNotifier: _selectedIndexNotifier,
|
||||
onTap: _onThumbnailTap,
|
||||
heroTagger: (entry) => Object.hashAll([regionCollection?.id, entry.uri]),
|
||||
highlightable: true,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Positioned.fill(
|
||||
child: ValueListenableBuilder<CollectionLens?>(
|
||||
valueListenable: _regionCollectionNotifier,
|
||||
builder: (context, regionCollection, child) {
|
||||
return regionCollection != null && regionCollection.isEmpty
|
||||
? EmptyContent(
|
||||
text: context.l10n.mapEmptyRegion,
|
||||
fontSize: 18,
|
||||
)
|
||||
: const SizedBox();
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _onIdle(ZoomedBounds bounds) {
|
||||
AvesEntry? selectedEntry;
|
||||
if (regionCollection != null) {
|
||||
final regionEntries = regionCollection!.sortedEntries;
|
||||
final selectedIndex = _selectedIndexNotifier.value;
|
||||
selectedEntry = selectedIndex != null && selectedIndex < regionEntries.length ? regionEntries[selectedIndex] : null;
|
||||
}
|
||||
|
||||
_regionCollectionNotifier.value = CollectionLens(
|
||||
source: widget.collection.source,
|
||||
listenToSource: false,
|
||||
fixedSelection: entries.where((entry) => bounds.contains(entry.latLng!)).toList(),
|
||||
);
|
||||
|
||||
// get entries from the new collection, so the entry order is the same
|
||||
// as the one used by the thumbnail scroller (considering sort/section/group)
|
||||
final regionEntries = regionCollection!.sortedEntries;
|
||||
final selectedIndex = (selectedEntry != null && regionEntries.contains(selectedEntry))
|
||||
? regionEntries.indexOf(selectedEntry)
|
||||
: regionEntries.isEmpty
|
||||
? null
|
||||
: 0;
|
||||
_selectedIndexNotifier.value = selectedIndex;
|
||||
// force update, as the region entries may change without a change of index
|
||||
_onThumbnailIndexChange();
|
||||
}
|
||||
|
||||
AvesEntry? _getRegionEntry(int? index) {
|
||||
if (index != null && regionCollection != null) {
|
||||
final regionEntries = regionCollection!.sortedEntries;
|
||||
if (index < regionEntries.length) {
|
||||
return regionEntries[index];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void _onThumbnailTap(int index) => _goToViewer(_getRegionEntry(index));
|
||||
|
||||
void _onThumbnailIndexChange() => _onEntrySelected(_getRegionEntry(_selectedIndexNotifier.value));
|
||||
|
||||
void _onEntrySelected(AvesEntry? selectedEntry) => _dotEntryNotifier.value = selectedEntry;
|
||||
|
||||
void _updateInfoEntry() {
|
||||
final selectedEntry = _dotEntryNotifier.value;
|
||||
if (_infoEntryNotifier.value == null || selectedEntry == null) {
|
||||
_infoEntryNotifier.value = selectedEntry;
|
||||
} else {
|
||||
_infoDebouncer(() => _infoEntryNotifier.value = selectedEntry);
|
||||
}
|
||||
}
|
||||
|
||||
void _goToViewer(AvesEntry? initialEntry) {
|
||||
if (initialEntry == null) return;
|
||||
|
||||
Navigator.push(
|
||||
context,
|
||||
TransparentMaterialPageRoute(
|
||||
settings: const RouteSettings(name: EntryViewerPage.routeName),
|
||||
pageBuilder: (c, a, sa) {
|
||||
return EntryViewerPage(
|
||||
collection: regionCollection,
|
||||
initialEntry: initialEntry,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// overlay
|
||||
|
||||
void _toggleOverlay() => _overlayVisible.value = !_overlayVisible.value;
|
||||
|
||||
Future<void> _onOverlayVisibleChange({bool animate = true}) async {
|
||||
if (_overlayVisible.value) {
|
||||
if (animate) {
|
||||
await _overlayAnimationController.forward();
|
||||
} else {
|
||||
_overlayAnimationController.value = _overlayAnimationController.upperBound;
|
||||
}
|
||||
} else {
|
||||
if (animate) {
|
||||
await _overlayAnimationController.reverse();
|
||||
} else {
|
||||
_overlayAnimationController.reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -56,9 +56,10 @@ class _SettingsPageState extends State<SettingsPage> with FeedbackMixin {
|
|||
),
|
||||
];
|
||||
},
|
||||
onSelected: (action) {
|
||||
onSelected: (action) async {
|
||||
// wait for the popup menu to hide before proceeding with the action
|
||||
Future.delayed(Durations.popupMenuAnimation * timeDilation, () => _onActionSelected(action));
|
||||
await Future.delayed(Durations.popupMenuAnimation * timeDilation);
|
||||
_onActionSelected(action);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
|
|
@ -60,9 +60,10 @@ class InfoAppBar extends StatelessWidget {
|
|||
),
|
||||
];
|
||||
},
|
||||
onSelected: (action) {
|
||||
onSelected: (action) async {
|
||||
// wait for the popup menu to hide before proceeding with the action
|
||||
Future.delayed(Durations.popupMenuAnimation * timeDilation, () => EntryInfoActionDelegate(entry).onActionSelected(context, action));
|
||||
await Future.delayed(Durations.popupMenuAnimation * timeDilation);
|
||||
EntryInfoActionDelegate(entry).onActionSelected(context, action);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
|
|
@ -93,6 +93,7 @@ class _LocationSectionState extends State<LocationSection> {
|
|||
entries: [entry],
|
||||
isAnimatingNotifier: widget.isScrollingNotifier,
|
||||
onUserZoomChange: (zoom) => settings.infoMapZoom = zoom,
|
||||
onMarkerTap: collection != null ? (_, __) => _openMapPage(context) : null,
|
||||
openMapPage: collection != null ? _openMapPage : null,
|
||||
),
|
||||
),
|
||||
|
@ -116,13 +117,18 @@ class _LocationSectionState extends State<LocationSection> {
|
|||
}
|
||||
|
||||
void _openMapPage(BuildContext context) {
|
||||
final entries = (collection?.sortedEntries ?? []).where((entry) => entry.hasGps).toList();
|
||||
final baseCollection = collection;
|
||||
if (baseCollection == null) return;
|
||||
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
settings: const RouteSettings(name: MapPage.routeName),
|
||||
builder: (context) => MapPage(
|
||||
entries: entries,
|
||||
collection: CollectionLens(
|
||||
source: baseCollection.source,
|
||||
fixedSelection: baseCollection.sortedEntries.where((entry) => entry.hasGps).toList(),
|
||||
),
|
||||
initialEntry: entry,
|
||||
),
|
||||
),
|
||||
|
|
|
@ -233,9 +233,10 @@ class _ButtonRow extends StatelessWidget {
|
|||
child: MenuIconTheme(
|
||||
child: AvesPopupMenuButton<VideoAction>(
|
||||
itemBuilder: (context) => menuActions.map((action) => _buildPopupMenuItem(context, action)).toList(),
|
||||
onSelected: (action) {
|
||||
onSelected: (action) async {
|
||||
// wait for the popup menu to hide before proceeding with the action
|
||||
Future.delayed(Durations.popupMenuAnimation * timeDilation, () => onActionSelected(action));
|
||||
await Future.delayed(Durations.popupMenuAnimation * timeDilation);
|
||||
onActionSelected(action);
|
||||
},
|
||||
onMenuOpened: onActionMenuOpened,
|
||||
),
|
||||
|
|
|
@ -12,6 +12,7 @@ import 'package:aves/widgets/viewer/visual/entry_page_view.dart';
|
|||
import 'package:aves/widgets/viewer/visual/state.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
class RasterImageView extends StatefulWidget {
|
||||
|
@ -156,7 +157,7 @@ class _RasterImageViewState extends State<RasterImageView> {
|
|||
_tileTransform = Matrix4.identity()
|
||||
..translate(entry.width / 2.0, entry.height / 2.0)
|
||||
..scale(isFlipped ? -1.0 : 1.0, 1.0, 1.0)
|
||||
..rotateZ(-toRadians(rotationDegrees.toDouble()))
|
||||
..rotateZ(-degToRadian(rotationDegrees.toDouble()))
|
||||
..translate(-_displaySize.width / 2.0, -_displaySize.height / 2.0);
|
||||
}
|
||||
_isTilingInitialized = true;
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/utils/math_utils.dart';
|
||||
import 'package:aves/widgets/common/basic/outlined_text.dart';
|
||||
import 'package:aves/widgets/viewer/video/controller.dart';
|
||||
import 'package:aves/widgets/viewer/visual/state.dart';
|
||||
|
@ -9,6 +8,7 @@ import 'package:aves/widgets/viewer/visual/subtitle/style.dart';
|
|||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:latlong2/latlong.dart' as angles;
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class VideoSubtitles extends StatelessWidget {
|
||||
|
@ -186,9 +186,9 @@ class VideoSubtitles extends StatelessWidget {
|
|||
if (extraStyle.rotating) {
|
||||
// for perspective
|
||||
transform.setEntry(3, 2, 0.001);
|
||||
final x = -toRadians(extraStyle.rotationX ?? 0);
|
||||
final y = -toRadians(extraStyle.rotationY ?? 0);
|
||||
final z = -toRadians(extraStyle.rotationZ ?? 0);
|
||||
final x = -angles.degToRadian(extraStyle.rotationX ?? 0);
|
||||
final y = -angles.degToRadian(extraStyle.rotationY ?? 0);
|
||||
final z = -angles.degToRadian(extraStyle.rotationZ ?? 0);
|
||||
if (x != 0) transform.rotateX(x);
|
||||
if (y != 0) transform.rotateY(y);
|
||||
if (z != 0) transform.rotateZ(z);
|
||||
|
|
10
test/utils/geo_utils_test.dart
Normal file
10
test/utils/geo_utils_test.dart
Normal file
|
@ -0,0 +1,10 @@
|
|||
import 'package:aves/utils/geo_utils.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
test('bounds center', () {
|
||||
expect(getLatLngCenter([LatLng(10, 30), LatLng(30, 50)]), LatLng(20.28236664671092, 39.351653000319956));
|
||||
expect(getLatLngCenter([LatLng(10, -179), LatLng(30, 179)]), LatLng(20.00279344048298, -179.9358157370226));
|
||||
});
|
||||
}
|
|
@ -1,19 +1,7 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:aves/utils/math_utils.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
test('convert angles in radians to degrees', () {
|
||||
expect(toDegrees(pi), 180);
|
||||
expect(toDegrees(-pi / 2), -90);
|
||||
});
|
||||
|
||||
test('convert angles in degrees to radians', () {
|
||||
expect(toRadians(180), pi);
|
||||
expect(toRadians(-270), pi * -3 / 2);
|
||||
});
|
||||
|
||||
test('highest power of 2 that is smaller than or equal to the number', () {
|
||||
expect(highestPowerOf2(1024), 1024);
|
||||
expect(highestPowerOf2(42), 32);
|
||||
|
|
Loading…
Reference in a new issue