map: update on entry removal
This commit is contained in:
parent
d0c62a602b
commit
8df538e7f7
8 changed files with 152 additions and 82 deletions
|
@ -46,8 +46,8 @@ class CollectionLens with ChangeNotifier {
|
|||
id ??= hashCode;
|
||||
if (listenToSource) {
|
||||
final sourceEvents = source.eventBus;
|
||||
_subscriptions.add(sourceEvents.on<EntryAddedEvent>().listen((e) => onEntryAdded(e.entries)));
|
||||
_subscriptions.add(sourceEvents.on<EntryRemovedEvent>().listen((e) => onEntryRemoved(e.entries)));
|
||||
_subscriptions.add(sourceEvents.on<EntryAddedEvent>().listen((e) => _onEntryAdded(e.entries)));
|
||||
_subscriptions.add(sourceEvents.on<EntryRemovedEvent>().listen((e) => _onEntryRemoved(e.entries)));
|
||||
_subscriptions.add(sourceEvents.on<EntryMovedEvent>().listen((e) => _refresh()));
|
||||
_subscriptions.add(sourceEvents.on<EntryRefreshedEvent>().listen((e) => _refresh()));
|
||||
_subscriptions.add(sourceEvents.on<FilterVisibilityChangedEvent>().listen((e) => _refresh()));
|
||||
|
@ -73,6 +73,20 @@ class CollectionLens with ChangeNotifier {
|
|||
super.dispose();
|
||||
}
|
||||
|
||||
CollectionLens copyWith({
|
||||
CollectionSource? source,
|
||||
Set<CollectionFilter>? filters,
|
||||
bool? listenToSource,
|
||||
List<AvesEntry>? fixedSelection,
|
||||
}) =>
|
||||
CollectionLens(
|
||||
source: source ?? this.source,
|
||||
filters: filters ?? this.filters,
|
||||
id: id,
|
||||
listenToSource: listenToSource ?? this.listenToSource,
|
||||
fixedSelection: fixedSelection ?? this.fixedSelection,
|
||||
);
|
||||
|
||||
bool get isEmpty => _filteredSortedEntries.isEmpty;
|
||||
|
||||
int get entryCount => _filteredSortedEntries.length;
|
||||
|
@ -103,16 +117,16 @@ class CollectionLens with ChangeNotifier {
|
|||
filters.removeWhere((old) => old.category == filter.category);
|
||||
}
|
||||
filters.add(filter);
|
||||
onFilterChanged();
|
||||
_onFilterChanged();
|
||||
}
|
||||
|
||||
void removeFilter(CollectionFilter filter) {
|
||||
if (!filters.contains(filter)) return;
|
||||
filters.remove(filter);
|
||||
onFilterChanged();
|
||||
_onFilterChanged();
|
||||
}
|
||||
|
||||
void onFilterChanged() {
|
||||
void _onFilterChanged() {
|
||||
_refresh();
|
||||
filterChangeNotifier.notifyListeners();
|
||||
}
|
||||
|
@ -229,11 +243,11 @@ class CollectionLens with ChangeNotifier {
|
|||
}
|
||||
}
|
||||
|
||||
void onEntryAdded(Set<AvesEntry>? entries) {
|
||||
void _onEntryAdded(Set<AvesEntry>? entries) {
|
||||
_refresh();
|
||||
}
|
||||
|
||||
void onEntryRemoved(Set<AvesEntry> entries) {
|
||||
void _onEntryRemoved(Set<AvesEntry> entries) {
|
||||
if (groupBursts) {
|
||||
// find impacted burst groups
|
||||
final obsoleteBurstEntries = <AvesEntry>{};
|
||||
|
@ -256,6 +270,7 @@ class CollectionLens with ChangeNotifier {
|
|||
// we should remove obsolete entries and sections
|
||||
// but do not apply sort/section
|
||||
// as section order change would surprise the user while browsing
|
||||
fixedSelection?.removeWhere(entries.contains);
|
||||
_filteredSortedEntries.removeWhere(entries.contains);
|
||||
_sortedEntries?.removeWhere(entries.contains);
|
||||
sections.forEach((key, sectionEntries) => sectionEntries.removeWhere(entries.contains));
|
||||
|
|
|
@ -288,6 +288,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
|||
MaterialPageRoute(
|
||||
settings: const RouteSettings(name: MapPage.routeName),
|
||||
builder: (context) => MapPage(
|
||||
// need collection with fresh ID to prevent hero from scroller on Map page to Collection page
|
||||
collection: CollectionLens(
|
||||
source: collection.source,
|
||||
filters: collection.filters,
|
||||
|
|
|
@ -73,10 +73,7 @@ class InteractiveThumbnail extends StatelessWidget {
|
|||
TransparentMaterialPageRoute(
|
||||
settings: const RouteSettings(name: EntryViewerPage.routeName),
|
||||
pageBuilder: (context, a, sa) {
|
||||
final viewerCollection = CollectionLens(
|
||||
source: collection.source,
|
||||
filters: collection.filters,
|
||||
id: collection.id,
|
||||
final viewerCollection = collection.copyWith(
|
||||
listenToSource: false,
|
||||
);
|
||||
assert(viewerCollection.sortedEntries.map((e) => e.contentId).contains(entry.contentId));
|
||||
|
|
|
@ -6,6 +6,7 @@ import 'package:aves/model/settings/map_style.dart';
|
|||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/utils/change_notifier.dart';
|
||||
import 'package:aves/utils/constants.dart';
|
||||
import 'package:aves/utils/math_utils.dart';
|
||||
import 'package:aves/widgets/common/map/attribution.dart';
|
||||
|
@ -27,6 +28,7 @@ import 'package:provider/provider.dart';
|
|||
|
||||
class GeoMap extends StatefulWidget {
|
||||
final AvesMapController? controller;
|
||||
final Listenable? collectionListenable;
|
||||
final List<AvesEntry> entries;
|
||||
final AvesEntry? initialEntry;
|
||||
final ValueNotifier<bool> isAnimatingNotifier;
|
||||
|
@ -42,6 +44,7 @@ class GeoMap extends StatefulWidget {
|
|||
const GeoMap({
|
||||
Key? key,
|
||||
this.controller,
|
||||
this.collectionListenable,
|
||||
required this.entries,
|
||||
this.initialEntry,
|
||||
required this.isAnimatingNotifier,
|
||||
|
@ -63,8 +66,9 @@ class _GeoMapState extends State<GeoMap> {
|
|||
// so we prevent loading it while scrolling or animating
|
||||
bool _googleMapsLoaded = false;
|
||||
late final ValueNotifier<ZoomedBounds> _boundsNotifier;
|
||||
late final Fluster<GeoEntry> _defaultMarkerCluster;
|
||||
Fluster<GeoEntry>? _defaultMarkerCluster;
|
||||
Fluster<GeoEntry>? _slowMarkerCluster;
|
||||
final AChangeNotifier _clusterChangeNotifier = AChangeNotifier();
|
||||
|
||||
List<AvesEntry> get entries => widget.entries;
|
||||
|
||||
|
@ -84,7 +88,29 @@ class _GeoMapState extends State<GeoMap> {
|
|||
_boundsNotifier = ValueNotifier(bounds.copyWith(
|
||||
zoom: max(bounds.zoom, minInitialZoom),
|
||||
));
|
||||
_defaultMarkerCluster = _buildFluster();
|
||||
_registerWidget(widget);
|
||||
_onCollectionChanged();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant GeoMap oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
_unregisterWidget(oldWidget);
|
||||
_registerWidget(widget);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_unregisterWidget(widget);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _registerWidget(GeoMap widget) {
|
||||
widget.collectionListenable?.addListener(_onCollectionChanged);
|
||||
}
|
||||
|
||||
void _unregisterWidget(GeoMap widget) {
|
||||
widget.collectionListenable?.removeListener(_onCollectionChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -99,7 +125,7 @@ class _GeoMapState extends State<GeoMap> {
|
|||
return {geoEntry.entry!};
|
||||
}
|
||||
|
||||
var points = _defaultMarkerCluster.points(clusterId);
|
||||
var points = _defaultMarkerCluster?.points(clusterId) ?? [];
|
||||
if (points.length != geoEntry.pointsSize) {
|
||||
// `Fluster.points()` method does not always return all the points contained in a cluster
|
||||
// the higher `nodeSize` is, the higher the chance to get all the points (i.e. as many as the cluster `pointsSize`)
|
||||
|
@ -144,6 +170,7 @@ class _GeoMapState extends State<GeoMap> {
|
|||
Widget child = isGoogleMaps
|
||||
? EntryGoogleMap(
|
||||
controller: widget.controller,
|
||||
clusterListenable: _clusterChangeNotifier,
|
||||
boundsNotifier: _boundsNotifier,
|
||||
minZoom: 0,
|
||||
maxZoom: 20,
|
||||
|
@ -158,6 +185,7 @@ class _GeoMapState extends State<GeoMap> {
|
|||
)
|
||||
: EntryLeafletMap(
|
||||
controller: widget.controller,
|
||||
clusterListenable: _clusterChangeNotifier,
|
||||
boundsNotifier: _boundsNotifier,
|
||||
minZoom: 2,
|
||||
maxZoom: 16,
|
||||
|
@ -237,6 +265,12 @@ class _GeoMapState extends State<GeoMap> {
|
|||
);
|
||||
}
|
||||
|
||||
void _onCollectionChanged() {
|
||||
_defaultMarkerCluster = _buildFluster();
|
||||
_slowMarkerCluster = null;
|
||||
_clusterChangeNotifier.notifyListeners();
|
||||
}
|
||||
|
||||
Fluster<GeoEntry> _buildFluster({int nodeSize = 64}) {
|
||||
final markers = entries.map((entry) {
|
||||
final latLng = entry.latLng!;
|
||||
|
@ -266,7 +300,7 @@ class _GeoMapState extends State<GeoMap> {
|
|||
|
||||
Map<MarkerKey, GeoEntry> _buildMarkerClusters() {
|
||||
final bounds = _boundsNotifier.value;
|
||||
final geoEntries = _defaultMarkerCluster.clusters(bounds.boundingBox, bounds.zoom.round());
|
||||
final geoEntries = _defaultMarkerCluster?.clusters(bounds.boundingBox, bounds.zoom.round()) ?? [];
|
||||
return Map.fromEntries(geoEntries.map((v) {
|
||||
if (v.isCluster!) {
|
||||
final uri = v.childMarkerId;
|
||||
|
|
|
@ -21,6 +21,7 @@ import 'package:provider/provider.dart';
|
|||
|
||||
class EntryGoogleMap extends StatefulWidget {
|
||||
final AvesMapController? controller;
|
||||
final Listenable clusterListenable;
|
||||
final ValueNotifier<ZoomedBounds> boundsNotifier;
|
||||
final double? minZoom, maxZoom;
|
||||
final EntryMapStyle style;
|
||||
|
@ -35,6 +36,7 @@ class EntryGoogleMap extends StatefulWidget {
|
|||
const EntryGoogleMap({
|
||||
Key? key,
|
||||
this.controller,
|
||||
required this.clusterListenable,
|
||||
required this.boundsNotifier,
|
||||
this.minZoom,
|
||||
this.maxZoom,
|
||||
|
@ -93,9 +95,11 @@ class _EntryGoogleMapState extends State<EntryGoogleMap> with WidgetsBindingObse
|
|||
if (avesMapController != null) {
|
||||
_subscriptions.add(avesMapController.moveCommands.listen((event) => _moveTo(_toGoogleLatLng(event.latLng))));
|
||||
}
|
||||
widget.clusterListenable.addListener(_updateMarkers);
|
||||
}
|
||||
|
||||
void _unregisterWidget(EntryGoogleMap widget) {
|
||||
widget.clusterListenable.removeListener(_updateMarkers);
|
||||
_subscriptions
|
||||
..forEach((sub) => sub.cancel())
|
||||
..clear();
|
||||
|
@ -167,53 +171,54 @@ class _EntryGoogleMapState extends State<EntryGoogleMap> with WidgetsBindingObse
|
|||
|
||||
final interactive = context.select<MapThemeData, bool>((v) => v.interactive);
|
||||
return ValueListenableBuilder<AvesEntry?>(
|
||||
valueListenable: widget.dotEntryNotifier ?? ValueNotifier(null),
|
||||
builder: (context, dotEntry, child) {
|
||||
return GoogleMap(
|
||||
initialCameraPosition: CameraPosition(
|
||||
bearing: -bounds.rotation,
|
||||
target: _toGoogleLatLng(bounds.center),
|
||||
zoom: bounds.zoom,
|
||||
),
|
||||
onMapCreated: (controller) async {
|
||||
_googleMapController = controller;
|
||||
final zoom = await controller.getZoomLevel();
|
||||
await _updateVisibleRegion(zoom: zoom, rotation: bounds.rotation);
|
||||
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(),
|
||||
);
|
||||
});
|
||||
valueListenable: widget.dotEntryNotifier ?? ValueNotifier(null),
|
||||
builder: (context, dotEntry, child) {
|
||||
return GoogleMap(
|
||||
initialCameraPosition: CameraPosition(
|
||||
bearing: -bounds.rotation,
|
||||
target: _toGoogleLatLng(bounds.center),
|
||||
zoom: bounds.zoom,
|
||||
),
|
||||
onMapCreated: (controller) async {
|
||||
_googleMapController = controller;
|
||||
final zoom = await controller.getZoomLevel();
|
||||
await _updateVisibleRegion(zoom: zoom, rotation: bounds.rotation);
|
||||
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(),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
@ -221,6 +226,10 @@ class _EntryGoogleMapState extends State<EntryGoogleMap> with WidgetsBindingObse
|
|||
void _onIdle() {
|
||||
if (!mounted) return;
|
||||
widget.controller?.notifyIdle(bounds);
|
||||
_updateMarkers();
|
||||
}
|
||||
|
||||
void _updateMarkers() {
|
||||
setState(() => _geoEntryByMarkerKey = widget.markerClusterBuilder());
|
||||
}
|
||||
|
||||
|
|
|
@ -23,6 +23,7 @@ import 'package:provider/provider.dart';
|
|||
|
||||
class EntryLeafletMap extends StatefulWidget {
|
||||
final AvesMapController? controller;
|
||||
final Listenable clusterListenable;
|
||||
final ValueNotifier<ZoomedBounds> boundsNotifier;
|
||||
final double minZoom, maxZoom;
|
||||
final EntryMapStyle style;
|
||||
|
@ -38,6 +39,7 @@ class EntryLeafletMap extends StatefulWidget {
|
|||
const EntryLeafletMap({
|
||||
Key? key,
|
||||
this.controller,
|
||||
required this.clusterListenable,
|
||||
required this.boundsNotifier,
|
||||
this.minZoom = 0,
|
||||
this.maxZoom = 22,
|
||||
|
@ -96,11 +98,13 @@ class _EntryLeafletMapState extends State<EntryLeafletMap> with TickerProviderSt
|
|||
_subscriptions.add(avesMapController.moveCommands.listen((event) => _moveTo(event.latLng)));
|
||||
}
|
||||
_subscriptions.add(_leafletMapController.mapEventStream.listen((event) => _updateVisibleRegion()));
|
||||
boundsNotifier.addListener(_onBoundsChange);
|
||||
widget.clusterListenable.addListener(_updateMarkers);
|
||||
widget.boundsNotifier.addListener(_onBoundsChange);
|
||||
}
|
||||
|
||||
void _unregisterWidget(EntryLeafletMap widget) {
|
||||
boundsNotifier.removeListener(_onBoundsChange);
|
||||
widget.clusterListenable.removeListener(_updateMarkers);
|
||||
widget.boundsNotifier.removeListener(_onBoundsChange);
|
||||
_subscriptions
|
||||
..forEach((sub) => sub.cancel())
|
||||
..clear();
|
||||
|
@ -216,6 +220,10 @@ class _EntryLeafletMapState extends State<EntryLeafletMap> with TickerProviderSt
|
|||
void _onIdle() {
|
||||
if (!mounted) return;
|
||||
widget.controller?.notifyIdle(bounds);
|
||||
_updateMarkers();
|
||||
}
|
||||
|
||||
void _updateMarkers() {
|
||||
setState(() => _geoEntryByMarkerKey = widget.markerClusterBuilder());
|
||||
}
|
||||
|
||||
|
|
|
@ -221,6 +221,7 @@ class _MapPageContentState extends State<MapPageContent> with SingleTickerProvid
|
|||
// key is expected by test driver
|
||||
key: const Key('map_view'),
|
||||
controller: _mapController,
|
||||
collectionListenable: openingCollection,
|
||||
entries: openingCollection.sortedEntries,
|
||||
initialEntry: widget.initialEntry,
|
||||
isAnimatingNotifier: _isPageAnimatingNotifier,
|
||||
|
@ -255,15 +256,21 @@ class _MapPageContentState extends State<MapPageContent> with SingleTickerProvid
|
|||
builder: (context, mqWidth, child) => ValueListenableBuilder<CollectionLens?>(
|
||||
valueListenable: _regionCollectionNotifier,
|
||||
builder: (context, regionCollection, child) {
|
||||
final regionEntries = regionCollection?.sortedEntries ?? [];
|
||||
return ThumbnailScroller(
|
||||
availableWidth: mqWidth,
|
||||
entryCount: regionEntries.length,
|
||||
entryBuilder: (index) => index < regionEntries.length ? regionEntries[index] : null,
|
||||
indexNotifier: _selectedIndexNotifier,
|
||||
onTap: _onThumbnailTap,
|
||||
heroTagger: (entry) => Object.hashAll([regionCollection?.id, entry.uri]),
|
||||
highlightable: true,
|
||||
return AnimatedBuilder(
|
||||
// update when entries are added/removed
|
||||
animation: regionCollection ?? ChangeNotifier(),
|
||||
builder: (context, child) {
|
||||
final regionEntries = regionCollection?.sortedEntries ?? [];
|
||||
return ThumbnailScroller(
|
||||
availableWidth: mqWidth,
|
||||
entryCount: regionEntries.length,
|
||||
entryBuilder: (index) => index < regionEntries.length ? regionEntries[index] : null,
|
||||
indexNotifier: _selectedIndexNotifier,
|
||||
onTap: _onThumbnailTap,
|
||||
heroTagger: (entry) => Object.hashAll([regionCollection?.id, entry.uri]),
|
||||
highlightable: true,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
|
@ -295,14 +302,11 @@ class _MapPageContentState extends State<MapPageContent> with SingleTickerProvid
|
|||
selectedEntry = selectedIndex != null && selectedIndex < regionEntries.length ? regionEntries[selectedIndex] : null;
|
||||
}
|
||||
|
||||
_regionCollectionNotifier.value = CollectionLens(
|
||||
source: openingCollection.source,
|
||||
listenToSource: false,
|
||||
fixedSelection: openingCollection.sortedEntries,
|
||||
filters: [
|
||||
_regionCollectionNotifier.value = openingCollection.copyWith(
|
||||
filters: {
|
||||
...openingCollection.filters.whereNot((v) => v is CoordinateFilter),
|
||||
CoordinateFilter(bounds.sw, bounds.ne),
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
// get entries from the new collection, so the entry order is the same
|
||||
|
@ -352,7 +356,9 @@ class _MapPageContentState extends State<MapPageContent> with SingleTickerProvid
|
|||
settings: const RouteSettings(name: EntryViewerPage.routeName),
|
||||
pageBuilder: (context, a, sa) {
|
||||
return EntryViewerPage(
|
||||
collection: regionCollection,
|
||||
collection: regionCollection?.copyWith(
|
||||
listenToSource: false,
|
||||
),
|
||||
initialEntry: initialEntry,
|
||||
);
|
||||
},
|
||||
|
|
|
@ -126,8 +126,8 @@ class _LocationSectionState extends State<LocationSection> {
|
|||
MaterialPageRoute(
|
||||
settings: const RouteSettings(name: MapPage.routeName),
|
||||
builder: (context) => MapPage(
|
||||
collection: CollectionLens(
|
||||
source: baseCollection.source,
|
||||
collection: baseCollection.copyWith(
|
||||
listenToSource: true,
|
||||
fixedSelection: baseCollection.sortedEntries.where((entry) => entry.hasGps).toList(),
|
||||
),
|
||||
initialEntry: entry,
|
||||
|
|
Loading…
Reference in a new issue