map: update on entry removal

This commit is contained in:
Thibault Deckers 2021-10-08 18:31:01 +09:00
parent d0c62a602b
commit 8df538e7f7
8 changed files with 152 additions and 82 deletions

View file

@ -46,8 +46,8 @@ class CollectionLens with ChangeNotifier {
id ??= hashCode; id ??= hashCode;
if (listenToSource) { if (listenToSource) {
final sourceEvents = source.eventBus; final sourceEvents = source.eventBus;
_subscriptions.add(sourceEvents.on<EntryAddedEvent>().listen((e) => onEntryAdded(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<EntryRemovedEvent>().listen((e) => _onEntryRemoved(e.entries)));
_subscriptions.add(sourceEvents.on<EntryMovedEvent>().listen((e) => _refresh())); _subscriptions.add(sourceEvents.on<EntryMovedEvent>().listen((e) => _refresh()));
_subscriptions.add(sourceEvents.on<EntryRefreshedEvent>().listen((e) => _refresh())); _subscriptions.add(sourceEvents.on<EntryRefreshedEvent>().listen((e) => _refresh()));
_subscriptions.add(sourceEvents.on<FilterVisibilityChangedEvent>().listen((e) => _refresh())); _subscriptions.add(sourceEvents.on<FilterVisibilityChangedEvent>().listen((e) => _refresh()));
@ -73,6 +73,20 @@ class CollectionLens with ChangeNotifier {
super.dispose(); 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; bool get isEmpty => _filteredSortedEntries.isEmpty;
int get entryCount => _filteredSortedEntries.length; int get entryCount => _filteredSortedEntries.length;
@ -103,16 +117,16 @@ class CollectionLens with ChangeNotifier {
filters.removeWhere((old) => old.category == filter.category); filters.removeWhere((old) => old.category == filter.category);
} }
filters.add(filter); filters.add(filter);
onFilterChanged(); _onFilterChanged();
} }
void removeFilter(CollectionFilter filter) { void removeFilter(CollectionFilter filter) {
if (!filters.contains(filter)) return; if (!filters.contains(filter)) return;
filters.remove(filter); filters.remove(filter);
onFilterChanged(); _onFilterChanged();
} }
void onFilterChanged() { void _onFilterChanged() {
_refresh(); _refresh();
filterChangeNotifier.notifyListeners(); filterChangeNotifier.notifyListeners();
} }
@ -229,11 +243,11 @@ class CollectionLens with ChangeNotifier {
} }
} }
void onEntryAdded(Set<AvesEntry>? entries) { void _onEntryAdded(Set<AvesEntry>? entries) {
_refresh(); _refresh();
} }
void onEntryRemoved(Set<AvesEntry> entries) { void _onEntryRemoved(Set<AvesEntry> entries) {
if (groupBursts) { if (groupBursts) {
// find impacted burst groups // find impacted burst groups
final obsoleteBurstEntries = <AvesEntry>{}; final obsoleteBurstEntries = <AvesEntry>{};
@ -256,6 +270,7 @@ class CollectionLens with ChangeNotifier {
// we should remove obsolete entries and sections // we should remove obsolete entries and sections
// but do not apply sort/section // but do not apply sort/section
// as section order change would surprise the user while browsing // as section order change would surprise the user while browsing
fixedSelection?.removeWhere(entries.contains);
_filteredSortedEntries.removeWhere(entries.contains); _filteredSortedEntries.removeWhere(entries.contains);
_sortedEntries?.removeWhere(entries.contains); _sortedEntries?.removeWhere(entries.contains);
sections.forEach((key, sectionEntries) => sectionEntries.removeWhere(entries.contains)); sections.forEach((key, sectionEntries) => sectionEntries.removeWhere(entries.contains));

View file

@ -288,6 +288,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
MaterialPageRoute( MaterialPageRoute(
settings: const RouteSettings(name: MapPage.routeName), settings: const RouteSettings(name: MapPage.routeName),
builder: (context) => MapPage( builder: (context) => MapPage(
// need collection with fresh ID to prevent hero from scroller on Map page to Collection page
collection: CollectionLens( collection: CollectionLens(
source: collection.source, source: collection.source,
filters: collection.filters, filters: collection.filters,

View file

@ -73,10 +73,7 @@ class InteractiveThumbnail extends StatelessWidget {
TransparentMaterialPageRoute( TransparentMaterialPageRoute(
settings: const RouteSettings(name: EntryViewerPage.routeName), settings: const RouteSettings(name: EntryViewerPage.routeName),
pageBuilder: (context, a, sa) { pageBuilder: (context, a, sa) {
final viewerCollection = CollectionLens( final viewerCollection = collection.copyWith(
source: collection.source,
filters: collection.filters,
id: collection.id,
listenToSource: false, listenToSource: false,
); );
assert(viewerCollection.sortedEntries.map((e) => e.contentId).contains(entry.contentId)); assert(viewerCollection.sortedEntries.map((e) => e.contentId).contains(entry.contentId));

View file

@ -6,6 +6,7 @@ import 'package:aves/model/settings/map_style.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/services/common/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/utils/change_notifier.dart';
import 'package:aves/utils/constants.dart'; import 'package:aves/utils/constants.dart';
import 'package:aves/utils/math_utils.dart'; import 'package:aves/utils/math_utils.dart';
import 'package:aves/widgets/common/map/attribution.dart'; import 'package:aves/widgets/common/map/attribution.dart';
@ -27,6 +28,7 @@ import 'package:provider/provider.dart';
class GeoMap extends StatefulWidget { class GeoMap extends StatefulWidget {
final AvesMapController? controller; final AvesMapController? controller;
final Listenable? collectionListenable;
final List<AvesEntry> entries; final List<AvesEntry> entries;
final AvesEntry? initialEntry; final AvesEntry? initialEntry;
final ValueNotifier<bool> isAnimatingNotifier; final ValueNotifier<bool> isAnimatingNotifier;
@ -42,6 +44,7 @@ class GeoMap extends StatefulWidget {
const GeoMap({ const GeoMap({
Key? key, Key? key,
this.controller, this.controller,
this.collectionListenable,
required this.entries, required this.entries,
this.initialEntry, this.initialEntry,
required this.isAnimatingNotifier, required this.isAnimatingNotifier,
@ -63,8 +66,9 @@ class _GeoMapState extends State<GeoMap> {
// so we prevent loading it while scrolling or animating // so we prevent loading it while scrolling or animating
bool _googleMapsLoaded = false; bool _googleMapsLoaded = false;
late final ValueNotifier<ZoomedBounds> _boundsNotifier; late final ValueNotifier<ZoomedBounds> _boundsNotifier;
late final Fluster<GeoEntry> _defaultMarkerCluster; Fluster<GeoEntry>? _defaultMarkerCluster;
Fluster<GeoEntry>? _slowMarkerCluster; Fluster<GeoEntry>? _slowMarkerCluster;
final AChangeNotifier _clusterChangeNotifier = AChangeNotifier();
List<AvesEntry> get entries => widget.entries; List<AvesEntry> get entries => widget.entries;
@ -84,7 +88,29 @@ class _GeoMapState extends State<GeoMap> {
_boundsNotifier = ValueNotifier(bounds.copyWith( _boundsNotifier = ValueNotifier(bounds.copyWith(
zoom: max(bounds.zoom, minInitialZoom), 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 @override
@ -99,7 +125,7 @@ class _GeoMapState extends State<GeoMap> {
return {geoEntry.entry!}; return {geoEntry.entry!};
} }
var points = _defaultMarkerCluster.points(clusterId); var points = _defaultMarkerCluster?.points(clusterId) ?? [];
if (points.length != geoEntry.pointsSize) { if (points.length != geoEntry.pointsSize) {
// `Fluster.points()` method does not always return all the points contained in a cluster // `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`) // 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 Widget child = isGoogleMaps
? EntryGoogleMap( ? EntryGoogleMap(
controller: widget.controller, controller: widget.controller,
clusterListenable: _clusterChangeNotifier,
boundsNotifier: _boundsNotifier, boundsNotifier: _boundsNotifier,
minZoom: 0, minZoom: 0,
maxZoom: 20, maxZoom: 20,
@ -158,6 +185,7 @@ class _GeoMapState extends State<GeoMap> {
) )
: EntryLeafletMap( : EntryLeafletMap(
controller: widget.controller, controller: widget.controller,
clusterListenable: _clusterChangeNotifier,
boundsNotifier: _boundsNotifier, boundsNotifier: _boundsNotifier,
minZoom: 2, minZoom: 2,
maxZoom: 16, 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}) { Fluster<GeoEntry> _buildFluster({int nodeSize = 64}) {
final markers = entries.map((entry) { final markers = entries.map((entry) {
final latLng = entry.latLng!; final latLng = entry.latLng!;
@ -266,7 +300,7 @@ class _GeoMapState extends State<GeoMap> {
Map<MarkerKey, GeoEntry> _buildMarkerClusters() { Map<MarkerKey, GeoEntry> _buildMarkerClusters() {
final bounds = _boundsNotifier.value; 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) { return Map.fromEntries(geoEntries.map((v) {
if (v.isCluster!) { if (v.isCluster!) {
final uri = v.childMarkerId; final uri = v.childMarkerId;

View file

@ -21,6 +21,7 @@ import 'package:provider/provider.dart';
class EntryGoogleMap extends StatefulWidget { class EntryGoogleMap extends StatefulWidget {
final AvesMapController? controller; final AvesMapController? controller;
final Listenable clusterListenable;
final ValueNotifier<ZoomedBounds> boundsNotifier; final ValueNotifier<ZoomedBounds> boundsNotifier;
final double? minZoom, maxZoom; final double? minZoom, maxZoom;
final EntryMapStyle style; final EntryMapStyle style;
@ -35,6 +36,7 @@ class EntryGoogleMap extends StatefulWidget {
const EntryGoogleMap({ const EntryGoogleMap({
Key? key, Key? key,
this.controller, this.controller,
required this.clusterListenable,
required this.boundsNotifier, required this.boundsNotifier,
this.minZoom, this.minZoom,
this.maxZoom, this.maxZoom,
@ -93,9 +95,11 @@ class _EntryGoogleMapState extends State<EntryGoogleMap> with WidgetsBindingObse
if (avesMapController != null) { if (avesMapController != null) {
_subscriptions.add(avesMapController.moveCommands.listen((event) => _moveTo(_toGoogleLatLng(event.latLng)))); _subscriptions.add(avesMapController.moveCommands.listen((event) => _moveTo(_toGoogleLatLng(event.latLng))));
} }
widget.clusterListenable.addListener(_updateMarkers);
} }
void _unregisterWidget(EntryGoogleMap widget) { void _unregisterWidget(EntryGoogleMap widget) {
widget.clusterListenable.removeListener(_updateMarkers);
_subscriptions _subscriptions
..forEach((sub) => sub.cancel()) ..forEach((sub) => sub.cancel())
..clear(); ..clear();
@ -167,53 +171,54 @@ class _EntryGoogleMapState extends State<EntryGoogleMap> with WidgetsBindingObse
final interactive = context.select<MapThemeData, bool>((v) => v.interactive); final interactive = context.select<MapThemeData, bool>((v) => v.interactive);
return ValueListenableBuilder<AvesEntry?>( return ValueListenableBuilder<AvesEntry?>(
valueListenable: widget.dotEntryNotifier ?? ValueNotifier(null), valueListenable: widget.dotEntryNotifier ?? ValueNotifier(null),
builder: (context, dotEntry, child) { builder: (context, dotEntry, child) {
return GoogleMap( return GoogleMap(
initialCameraPosition: CameraPosition( initialCameraPosition: CameraPosition(
bearing: -bounds.rotation, bearing: -bounds.rotation,
target: _toGoogleLatLng(bounds.center), target: _toGoogleLatLng(bounds.center),
zoom: bounds.zoom, zoom: bounds.zoom,
), ),
onMapCreated: (controller) async { onMapCreated: (controller) async {
_googleMapController = controller; _googleMapController = controller;
final zoom = await controller.getZoomLevel(); final zoom = await controller.getZoomLevel();
await _updateVisibleRegion(zoom: zoom, rotation: bounds.rotation); await _updateVisibleRegion(zoom: zoom, rotation: bounds.rotation);
setState(() {}); setState(() {});
}, },
// compass disabled to use provider agnostic controls // compass disabled to use provider agnostic controls
compassEnabled: false, compassEnabled: false,
mapToolbarEnabled: false, mapToolbarEnabled: false,
mapType: _toMapType(widget.style), mapType: _toMapType(widget.style),
minMaxZoomPreference: MinMaxZoomPreference(widget.minZoom, widget.maxZoom), minMaxZoomPreference: MinMaxZoomPreference(widget.minZoom, widget.maxZoom),
rotateGesturesEnabled: true, rotateGesturesEnabled: true,
scrollGesturesEnabled: interactive, scrollGesturesEnabled: interactive,
// zoom controls disabled to use provider agnostic controls // zoom controls disabled to use provider agnostic controls
zoomControlsEnabled: false, zoomControlsEnabled: false,
zoomGesturesEnabled: interactive, zoomGesturesEnabled: interactive,
// lite mode disabled because it lacks camera animation // lite mode disabled because it lacks camera animation
liteModeEnabled: false, liteModeEnabled: false,
// tilt disabled to match leaflet // tilt disabled to match leaflet
tiltGesturesEnabled: false, tiltGesturesEnabled: false,
myLocationEnabled: false, myLocationEnabled: false,
myLocationButtonEnabled: false, myLocationButtonEnabled: false,
markers: { markers: {
...markers, ...markers,
if (dotEntry != null && _dotMarkerBitmap != null) if (dotEntry != null && _dotMarkerBitmap != null)
Marker( Marker(
markerId: const MarkerId('dot'), markerId: const MarkerId('dot'),
anchor: const Offset(.5, .5), anchor: const Offset(.5, .5),
consumeTapEvents: true, consumeTapEvents: true,
icon: BitmapDescriptor.fromBytes(_dotMarkerBitmap!), icon: BitmapDescriptor.fromBytes(_dotMarkerBitmap!),
position: _toGoogleLatLng(dotEntry.latLng!), position: _toGoogleLatLng(dotEntry.latLng!),
zIndex: 1, zIndex: 1,
) )
}, },
onCameraMove: (position) => _updateVisibleRegion(zoom: position.zoom, rotation: -position.bearing), onCameraMove: (position) => _updateVisibleRegion(zoom: position.zoom, rotation: -position.bearing),
onCameraIdle: _onIdle, onCameraIdle: _onIdle,
onTap: (position) => widget.onMapTap?.call(), onTap: (position) => widget.onMapTap?.call(),
); );
}); },
);
}, },
); );
} }
@ -221,6 +226,10 @@ class _EntryGoogleMapState extends State<EntryGoogleMap> with WidgetsBindingObse
void _onIdle() { void _onIdle() {
if (!mounted) return; if (!mounted) return;
widget.controller?.notifyIdle(bounds); widget.controller?.notifyIdle(bounds);
_updateMarkers();
}
void _updateMarkers() {
setState(() => _geoEntryByMarkerKey = widget.markerClusterBuilder()); setState(() => _geoEntryByMarkerKey = widget.markerClusterBuilder());
} }

View file

@ -23,6 +23,7 @@ import 'package:provider/provider.dart';
class EntryLeafletMap extends StatefulWidget { class EntryLeafletMap extends StatefulWidget {
final AvesMapController? controller; final AvesMapController? controller;
final Listenable clusterListenable;
final ValueNotifier<ZoomedBounds> boundsNotifier; final ValueNotifier<ZoomedBounds> boundsNotifier;
final double minZoom, maxZoom; final double minZoom, maxZoom;
final EntryMapStyle style; final EntryMapStyle style;
@ -38,6 +39,7 @@ class EntryLeafletMap extends StatefulWidget {
const EntryLeafletMap({ const EntryLeafletMap({
Key? key, Key? key,
this.controller, this.controller,
required this.clusterListenable,
required this.boundsNotifier, required this.boundsNotifier,
this.minZoom = 0, this.minZoom = 0,
this.maxZoom = 22, 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(avesMapController.moveCommands.listen((event) => _moveTo(event.latLng)));
} }
_subscriptions.add(_leafletMapController.mapEventStream.listen((event) => _updateVisibleRegion())); _subscriptions.add(_leafletMapController.mapEventStream.listen((event) => _updateVisibleRegion()));
boundsNotifier.addListener(_onBoundsChange); widget.clusterListenable.addListener(_updateMarkers);
widget.boundsNotifier.addListener(_onBoundsChange);
} }
void _unregisterWidget(EntryLeafletMap widget) { void _unregisterWidget(EntryLeafletMap widget) {
boundsNotifier.removeListener(_onBoundsChange); widget.clusterListenable.removeListener(_updateMarkers);
widget.boundsNotifier.removeListener(_onBoundsChange);
_subscriptions _subscriptions
..forEach((sub) => sub.cancel()) ..forEach((sub) => sub.cancel())
..clear(); ..clear();
@ -216,6 +220,10 @@ class _EntryLeafletMapState extends State<EntryLeafletMap> with TickerProviderSt
void _onIdle() { void _onIdle() {
if (!mounted) return; if (!mounted) return;
widget.controller?.notifyIdle(bounds); widget.controller?.notifyIdle(bounds);
_updateMarkers();
}
void _updateMarkers() {
setState(() => _geoEntryByMarkerKey = widget.markerClusterBuilder()); setState(() => _geoEntryByMarkerKey = widget.markerClusterBuilder());
} }

View file

@ -221,6 +221,7 @@ class _MapPageContentState extends State<MapPageContent> with SingleTickerProvid
// key is expected by test driver // key is expected by test driver
key: const Key('map_view'), key: const Key('map_view'),
controller: _mapController, controller: _mapController,
collectionListenable: openingCollection,
entries: openingCollection.sortedEntries, entries: openingCollection.sortedEntries,
initialEntry: widget.initialEntry, initialEntry: widget.initialEntry,
isAnimatingNotifier: _isPageAnimatingNotifier, isAnimatingNotifier: _isPageAnimatingNotifier,
@ -255,15 +256,21 @@ class _MapPageContentState extends State<MapPageContent> with SingleTickerProvid
builder: (context, mqWidth, child) => ValueListenableBuilder<CollectionLens?>( builder: (context, mqWidth, child) => ValueListenableBuilder<CollectionLens?>(
valueListenable: _regionCollectionNotifier, valueListenable: _regionCollectionNotifier,
builder: (context, regionCollection, child) { builder: (context, regionCollection, child) {
final regionEntries = regionCollection?.sortedEntries ?? []; return AnimatedBuilder(
return ThumbnailScroller( // update when entries are added/removed
availableWidth: mqWidth, animation: regionCollection ?? ChangeNotifier(),
entryCount: regionEntries.length, builder: (context, child) {
entryBuilder: (index) => index < regionEntries.length ? regionEntries[index] : null, final regionEntries = regionCollection?.sortedEntries ?? [];
indexNotifier: _selectedIndexNotifier, return ThumbnailScroller(
onTap: _onThumbnailTap, availableWidth: mqWidth,
heroTagger: (entry) => Object.hashAll([regionCollection?.id, entry.uri]), entryCount: regionEntries.length,
highlightable: true, 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; selectedEntry = selectedIndex != null && selectedIndex < regionEntries.length ? regionEntries[selectedIndex] : null;
} }
_regionCollectionNotifier.value = CollectionLens( _regionCollectionNotifier.value = openingCollection.copyWith(
source: openingCollection.source, filters: {
listenToSource: false,
fixedSelection: openingCollection.sortedEntries,
filters: [
...openingCollection.filters.whereNot((v) => v is CoordinateFilter), ...openingCollection.filters.whereNot((v) => v is CoordinateFilter),
CoordinateFilter(bounds.sw, bounds.ne), CoordinateFilter(bounds.sw, bounds.ne),
], },
); );
// get entries from the new collection, so the entry order is the same // 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), settings: const RouteSettings(name: EntryViewerPage.routeName),
pageBuilder: (context, a, sa) { pageBuilder: (context, a, sa) {
return EntryViewerPage( return EntryViewerPage(
collection: regionCollection, collection: regionCollection?.copyWith(
listenToSource: false,
),
initialEntry: initialEntry, initialEntry: initialEntry,
); );
}, },

View file

@ -126,8 +126,8 @@ class _LocationSectionState extends State<LocationSection> {
MaterialPageRoute( MaterialPageRoute(
settings: const RouteSettings(name: MapPage.routeName), settings: const RouteSettings(name: MapPage.routeName),
builder: (context) => MapPage( builder: (context) => MapPage(
collection: CollectionLens( collection: baseCollection.copyWith(
source: baseCollection.source, listenToSource: true,
fixedSelection: baseCollection.sortedEntries.where((entry) => entry.hasGps).toList(), fixedSelection: baseCollection.sortedEntries.where((entry) => entry.hasGps).toList(),
), ),
initialEntry: entry, initialEntry: entry,