map browse prep
This commit is contained in:
parent
c0af01578a
commit
fd33904658
19 changed files with 253 additions and 150 deletions
|
@ -43,8 +43,8 @@ class Durations {
|
||||||
static const viewerVerticalPageScrollAnimation = Duration(milliseconds: 500);
|
static const viewerVerticalPageScrollAnimation = Duration(milliseconds: 500);
|
||||||
static const viewerOverlayAnimation = Duration(milliseconds: 200);
|
static const viewerOverlayAnimation = Duration(milliseconds: 200);
|
||||||
static const viewerOverlayChangeAnimation = Duration(milliseconds: 150);
|
static const viewerOverlayChangeAnimation = Duration(milliseconds: 150);
|
||||||
static const viewerOverlayPageScrollAnimation = Duration(milliseconds: 200);
|
static const thumbnailScrollerScrollAnimation = Duration(milliseconds: 200);
|
||||||
static const viewerOverlayPageShadeAnimation = Duration(milliseconds: 150);
|
static const thumbnailScrollerShadeAnimation = Duration(milliseconds: 150);
|
||||||
static const viewerVideoPlayerTransition = Duration(milliseconds: 500);
|
static const viewerVideoPlayerTransition = Duration(milliseconds: 500);
|
||||||
|
|
||||||
// info animations
|
// info animations
|
||||||
|
|
|
@ -13,7 +13,6 @@ import 'package:aves/widgets/collection/app_bar.dart';
|
||||||
import 'package:aves/widgets/collection/draggable_thumb_label.dart';
|
import 'package:aves/widgets/collection/draggable_thumb_label.dart';
|
||||||
import 'package:aves/widgets/collection/grid/section_layout.dart';
|
import 'package:aves/widgets/collection/grid/section_layout.dart';
|
||||||
import 'package:aves/widgets/collection/grid/thumbnail.dart';
|
import 'package:aves/widgets/collection/grid/thumbnail.dart';
|
||||||
import 'package:aves/widgets/collection/thumbnail/decorated.dart';
|
|
||||||
import 'package:aves/widgets/common/basic/draggable_scrollbar.dart';
|
import 'package:aves/widgets/common/basic/draggable_scrollbar.dart';
|
||||||
import 'package:aves/widgets/common/basic/insets.dart';
|
import 'package:aves/widgets/common/basic/insets.dart';
|
||||||
import 'package:aves/widgets/common/behaviour/sloppy_scroll_physics.dart';
|
import 'package:aves/widgets/common/behaviour/sloppy_scroll_physics.dart';
|
||||||
|
@ -27,6 +26,7 @@ import 'package:aves/widgets/common/identity/empty.dart';
|
||||||
import 'package:aves/widgets/common/identity/scroll_thumb.dart';
|
import 'package:aves/widgets/common/identity/scroll_thumb.dart';
|
||||||
import 'package:aves/widgets/common/providers/tile_extent_controller_provider.dart';
|
import 'package:aves/widgets/common/providers/tile_extent_controller_provider.dart';
|
||||||
import 'package:aves/widgets/common/scaling.dart';
|
import 'package:aves/widgets/common/scaling.dart';
|
||||||
|
import 'package:aves/widgets/common/thumbnail/decorated.dart';
|
||||||
import 'package:aves/widgets/common/tile_extent_controller.dart';
|
import 'package:aves/widgets/common/tile_extent_controller.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
|
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
|
||||||
|
|
|
@ -3,9 +3,9 @@ import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/selection.dart';
|
import 'package:aves/model/selection.dart';
|
||||||
import 'package:aves/model/source/collection_lens.dart';
|
import 'package:aves/model/source/collection_lens.dart';
|
||||||
import 'package:aves/services/viewer_service.dart';
|
import 'package:aves/services/viewer_service.dart';
|
||||||
import 'package:aves/widgets/collection/thumbnail/decorated.dart';
|
|
||||||
import 'package:aves/widgets/common/behaviour/routes.dart';
|
import 'package:aves/widgets/common/behaviour/routes.dart';
|
||||||
import 'package:aves/widgets/common/scaling.dart';
|
import 'package:aves/widgets/common/scaling.dart';
|
||||||
|
import 'package:aves/widgets/common/thumbnail/decorated.dart';
|
||||||
import 'package:aves/widgets/viewer/entry_viewer_page.dart';
|
import 'package:aves/widgets/viewer/entry_viewer_page.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
|
@ -27,6 +27,7 @@ class GeoMap extends StatefulWidget {
|
||||||
final double? mapHeight;
|
final double? mapHeight;
|
||||||
final ValueNotifier<bool> isAnimatingNotifier;
|
final ValueNotifier<bool> isAnimatingNotifier;
|
||||||
final UserZoomChangeCallback? onUserZoomChange;
|
final UserZoomChangeCallback? onUserZoomChange;
|
||||||
|
final GeoEntryTapCallback? onEntryTap;
|
||||||
|
|
||||||
static const markerImageExtent = 48.0;
|
static const markerImageExtent = 48.0;
|
||||||
static const pointerSize = Size(8, 6);
|
static const pointerSize = Size(8, 6);
|
||||||
|
@ -38,6 +39,7 @@ class GeoMap extends StatefulWidget {
|
||||||
this.mapHeight,
|
this.mapHeight,
|
||||||
required this.isAnimatingNotifier,
|
required this.isAnimatingNotifier,
|
||||||
this.onUserZoomChange,
|
this.onUserZoomChange,
|
||||||
|
this.onEntryTap,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -130,6 +132,7 @@ class _GeoMapState extends State<GeoMap> with TickerProviderStateMixin {
|
||||||
markerCluster: markerCluster,
|
markerCluster: markerCluster,
|
||||||
markerEntries: entries,
|
markerEntries: entries,
|
||||||
onUserZoomChange: widget.onUserZoomChange,
|
onUserZoomChange: widget.onUserZoomChange,
|
||||||
|
onEntryTap: widget.onEntryTap,
|
||||||
)
|
)
|
||||||
: EntryLeafletMap(
|
: EntryLeafletMap(
|
||||||
boundsNotifier: boundsNotifier,
|
boundsNotifier: boundsNotifier,
|
||||||
|
@ -143,6 +146,7 @@ class _GeoMapState extends State<GeoMap> with TickerProviderStateMixin {
|
||||||
GeoMap.markerImageExtent + ImageMarker.outerBorderWidth * 2 + GeoMap.pointerSize.height,
|
GeoMap.markerImageExtent + ImageMarker.outerBorderWidth * 2 + GeoMap.pointerSize.height,
|
||||||
),
|
),
|
||||||
onUserZoomChange: widget.onUserZoomChange,
|
onUserZoomChange: widget.onUserZoomChange,
|
||||||
|
onEntryTap: widget.onEntryTap,
|
||||||
);
|
);
|
||||||
|
|
||||||
child = Column(
|
child = Column(
|
||||||
|
@ -214,3 +218,4 @@ class MarkerKey extends LocalKey with EquatableMixin {
|
||||||
|
|
||||||
typedef EntryMarkerBuilder = Widget Function(MarkerKey key);
|
typedef EntryMarkerBuilder = Widget Function(MarkerKey key);
|
||||||
typedef UserZoomChangeCallback = void Function(double zoom);
|
typedef UserZoomChangeCallback = void Function(double zoom);
|
||||||
|
typedef GeoEntryTapCallback = void Function(List<GeoEntry> geoEntries);
|
||||||
|
|
|
@ -25,6 +25,7 @@ class EntryGoogleMap extends StatefulWidget {
|
||||||
final Fluster<GeoEntry> markerCluster;
|
final Fluster<GeoEntry> markerCluster;
|
||||||
final List<AvesEntry> markerEntries;
|
final List<AvesEntry> markerEntries;
|
||||||
final UserZoomChangeCallback? onUserZoomChange;
|
final UserZoomChangeCallback? onUserZoomChange;
|
||||||
|
final GeoEntryTapCallback? onEntryTap;
|
||||||
|
|
||||||
const EntryGoogleMap({
|
const EntryGoogleMap({
|
||||||
Key? key,
|
Key? key,
|
||||||
|
@ -35,6 +36,7 @@ class EntryGoogleMap extends StatefulWidget {
|
||||||
required this.markerCluster,
|
required this.markerCluster,
|
||||||
required this.markerEntries,
|
required this.markerEntries,
|
||||||
this.onUserZoomChange,
|
this.onUserZoomChange,
|
||||||
|
this.onEntryTap,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -93,8 +95,8 @@ class _EntryGoogleMapState extends State<EntryGoogleMap> with WidgetsBindingObse
|
||||||
valueListenable: boundsNotifier,
|
valueListenable: boundsNotifier,
|
||||||
builder: (context, visibleRegion, child) {
|
builder: (context, visibleRegion, child) {
|
||||||
final allEntries = widget.markerEntries;
|
final allEntries = widget.markerEntries;
|
||||||
final clusters = visibleRegion != null ? widget.markerCluster.clusters(visibleRegion.boundingBox, visibleRegion.zoom.round()) : <GeoEntry>[];
|
final geoEntries = visibleRegion != null ? widget.markerCluster.clusters(visibleRegion.boundingBox, visibleRegion.zoom.round()) : <GeoEntry>[];
|
||||||
final clusterByMarkerKey = Map.fromEntries(clusters.map((v) {
|
final geoEntryByMarkerKey = Map.fromEntries(geoEntries.map((v) {
|
||||||
if (v.isCluster!) {
|
if (v.isCluster!) {
|
||||||
final uri = v.childMarkerId;
|
final uri = v.childMarkerId;
|
||||||
final entry = allEntries.firstWhere((v) => v.uri == uri);
|
final entry = allEntries.firstWhere((v) => v.uri == uri);
|
||||||
|
@ -106,7 +108,7 @@ class _EntryGoogleMapState extends State<EntryGoogleMap> with WidgetsBindingObse
|
||||||
return Stack(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
MarkerGeneratorWidget<MarkerKey>(
|
MarkerGeneratorWidget<MarkerKey>(
|
||||||
markers: clusterByMarkerKey.keys.map(widget.markerBuilder).toList(),
|
markers: geoEntryByMarkerKey.keys.map(widget.markerBuilder).toList(),
|
||||||
isReadyToRender: (key) => key.entry.isThumbnailReady(extent: GeoMap.markerImageExtent),
|
isReadyToRender: (key) => key.entry.isThumbnailReady(extent: GeoMap.markerImageExtent),
|
||||||
onRendered: (key, bitmap) {
|
onRendered: (key, bitmap) {
|
||||||
_markerBitmaps[key] = bitmap;
|
_markerBitmaps[key] = bitmap;
|
||||||
|
@ -115,7 +117,7 @@ class _EntryGoogleMapState extends State<EntryGoogleMap> with WidgetsBindingObse
|
||||||
),
|
),
|
||||||
MapDecorator(
|
MapDecorator(
|
||||||
interactive: widget.interactive,
|
interactive: widget.interactive,
|
||||||
child: _buildMap(clusterByMarkerKey),
|
child: _buildMap(geoEntryByMarkerKey),
|
||||||
),
|
),
|
||||||
MapButtonPanel(
|
MapButtonPanel(
|
||||||
latLng: bounds.center,
|
latLng: bounds.center,
|
||||||
|
@ -127,19 +129,26 @@ class _EntryGoogleMapState extends State<EntryGoogleMap> with WidgetsBindingObse
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildMap(Map<MarkerKey, GeoEntry> clusterByMarkerKey) {
|
Widget _buildMap(Map<MarkerKey, GeoEntry> geoEntryByMarkerKey) {
|
||||||
return AnimatedBuilder(
|
return AnimatedBuilder(
|
||||||
animation: _markerBitmapChangeNotifier,
|
animation: _markerBitmapChangeNotifier,
|
||||||
builder: (context, child) {
|
builder: (context, child) {
|
||||||
final markers = <Marker>{};
|
final markers = <Marker>{};
|
||||||
clusterByMarkerKey.forEach((markerKey, cluster) {
|
final onTap = widget.onEntryTap;
|
||||||
|
geoEntryByMarkerKey.forEach((markerKey, geoEntry) {
|
||||||
final bytes = _markerBitmaps[markerKey];
|
final bytes = _markerBitmaps[markerKey];
|
||||||
if (bytes != null) {
|
if (bytes != null) {
|
||||||
final latLng = LatLng(cluster.latitude!, cluster.longitude!);
|
final latLng = LatLng(geoEntry.latitude!, geoEntry.longitude!);
|
||||||
markers.add(Marker(
|
markers.add(Marker(
|
||||||
markerId: MarkerId(cluster.markerId!),
|
markerId: MarkerId(geoEntry.markerId!),
|
||||||
icon: BitmapDescriptor.fromBytes(bytes),
|
icon: BitmapDescriptor.fromBytes(bytes),
|
||||||
position: latLng,
|
position: latLng,
|
||||||
|
onTap: onTap != null
|
||||||
|
? () {
|
||||||
|
final clusterId = geoEntry.clusterId;
|
||||||
|
onTap(clusterId != null ? widget.markerCluster.points(clusterId) : [geoEntry]);
|
||||||
|
}
|
||||||
|
: null,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -24,6 +24,7 @@ class EntryLeafletMap extends StatefulWidget {
|
||||||
final List<AvesEntry> markerEntries;
|
final List<AvesEntry> markerEntries;
|
||||||
final Size markerSize;
|
final Size markerSize;
|
||||||
final UserZoomChangeCallback? onUserZoomChange;
|
final UserZoomChangeCallback? onUserZoomChange;
|
||||||
|
final GeoEntryTapCallback? onEntryTap;
|
||||||
|
|
||||||
const EntryLeafletMap({
|
const EntryLeafletMap({
|
||||||
Key? key,
|
Key? key,
|
||||||
|
@ -35,6 +36,7 @@ class EntryLeafletMap extends StatefulWidget {
|
||||||
required this.markerEntries,
|
required this.markerEntries,
|
||||||
required this.markerSize,
|
required this.markerSize,
|
||||||
this.onUserZoomChange,
|
this.onUserZoomChange,
|
||||||
|
this.onEntryTap,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -80,8 +82,8 @@ class _EntryLeafletMapState extends State<EntryLeafletMap> with TickerProviderSt
|
||||||
valueListenable: boundsNotifier,
|
valueListenable: boundsNotifier,
|
||||||
builder: (context, visibleRegion, child) {
|
builder: (context, visibleRegion, child) {
|
||||||
final allEntries = widget.markerEntries;
|
final allEntries = widget.markerEntries;
|
||||||
final clusters = visibleRegion != null ? widget.markerCluster.clusters(visibleRegion.boundingBox, visibleRegion.zoom.round()) : <GeoEntry>[];
|
final geoEntries = visibleRegion != null ? widget.markerCluster.clusters(visibleRegion.boundingBox, visibleRegion.zoom.round()) : <GeoEntry>[];
|
||||||
final clusterByMarkerKey = Map.fromEntries(clusters.map((v) {
|
final geoEntryByMarkerKey = Map.fromEntries(geoEntries.map((v) {
|
||||||
if (v.isCluster!) {
|
if (v.isCluster!) {
|
||||||
final uri = v.childMarkerId;
|
final uri = v.childMarkerId;
|
||||||
final entry = allEntries.firstWhere((v) => v.uri == uri);
|
final entry = allEntries.firstWhere((v) => v.uri == uri);
|
||||||
|
@ -94,7 +96,7 @@ class _EntryLeafletMapState extends State<EntryLeafletMap> with TickerProviderSt
|
||||||
children: [
|
children: [
|
||||||
MapDecorator(
|
MapDecorator(
|
||||||
interactive: widget.interactive,
|
interactive: widget.interactive,
|
||||||
child: _buildMap(clusterByMarkerKey),
|
child: _buildMap(geoEntryByMarkerKey),
|
||||||
),
|
),
|
||||||
MapButtonPanel(
|
MapButtonPanel(
|
||||||
latLng: bounds.center,
|
latLng: bounds.center,
|
||||||
|
@ -106,16 +108,20 @@ class _EntryLeafletMapState extends State<EntryLeafletMap> with TickerProviderSt
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildMap(Map<MarkerKey, GeoEntry> clusterByMarkerKey) {
|
Widget _buildMap(Map<MarkerKey, GeoEntry> geoEntryByMarkerKey) {
|
||||||
final markerSize = widget.markerSize;
|
final markerSize = widget.markerSize;
|
||||||
final markers = clusterByMarkerKey.entries.map((kv) {
|
final markers = geoEntryByMarkerKey.entries.map((kv) {
|
||||||
final markerKey = kv.key;
|
final markerKey = kv.key;
|
||||||
final cluster = kv.value;
|
final geoEntry = kv.value;
|
||||||
final latLng = LatLng(cluster.latitude!, cluster.longitude!);
|
final latLng = LatLng(geoEntry.latitude!, geoEntry.longitude!);
|
||||||
return Marker(
|
return Marker(
|
||||||
point: latLng,
|
point: latLng,
|
||||||
builder: (context) => GestureDetector(
|
builder: (context) => GestureDetector(
|
||||||
onTap: () => _moveTo(latLng),
|
onTap: () {
|
||||||
|
final clusterId = geoEntry.clusterId;
|
||||||
|
widget.onEntryTap?.call(clusterId != null ? widget.markerCluster.points(clusterId) : [geoEntry]);
|
||||||
|
_moveTo(latLng);
|
||||||
|
},
|
||||||
child: widget.markerBuilder(markerKey),
|
child: widget.markerBuilder(markerKey),
|
||||||
),
|
),
|
||||||
width: markerSize.width,
|
width: markerSize.width,
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/widgets/collection/thumbnail/image.dart';
|
import 'package:aves/widgets/common/thumbnail/image.dart';
|
||||||
import 'package:custom_rounded_rectangle_border/custom_rounded_rectangle_border.dart';
|
import 'package:custom_rounded_rectangle_border/custom_rounded_rectangle_border.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/rendering.dart';
|
import 'package:flutter/rendering.dart';
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/source/collection_lens.dart';
|
import 'package:aves/model/source/collection_lens.dart';
|
||||||
import 'package:aves/widgets/collection/thumbnail/image.dart';
|
|
||||||
import 'package:aves/widgets/collection/thumbnail/overlay.dart';
|
|
||||||
import 'package:aves/widgets/common/fx/borders.dart';
|
import 'package:aves/widgets/common/fx/borders.dart';
|
||||||
import 'package:aves/widgets/common/grid/overlay.dart';
|
import 'package:aves/widgets/common/grid/overlay.dart';
|
||||||
|
import 'package:aves/widgets/common/thumbnail/image.dart';
|
||||||
|
import 'package:aves/widgets/common/thumbnail/overlay.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class DecoratedThumbnail extends StatelessWidget {
|
class DecoratedThumbnail extends StatelessWidget {
|
|
@ -8,11 +8,11 @@ import 'package:aves/model/settings/entry_background.dart';
|
||||||
import 'package:aves/model/settings/enums.dart';
|
import 'package:aves/model/settings/enums.dart';
|
||||||
import 'package:aves/model/settings/settings.dart';
|
import 'package:aves/model/settings/settings.dart';
|
||||||
import 'package:aves/services/services.dart';
|
import 'package:aves/services/services.dart';
|
||||||
import 'package:aves/widgets/collection/thumbnail/error.dart';
|
|
||||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||||
import 'package:aves/widgets/common/fx/checkered_decoration.dart';
|
import 'package:aves/widgets/common/fx/checkered_decoration.dart';
|
||||||
import 'package:aves/widgets/common/fx/transition_image.dart';
|
import 'package:aves/widgets/common/fx/transition_image.dart';
|
||||||
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
||||||
|
import 'package:aves/widgets/common/thumbnail/error.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
152
lib/widgets/common/thumbnail/scroller.dart
Normal file
152
lib/widgets/common/thumbnail/scroller.dart
Normal file
|
@ -0,0 +1,152 @@
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:aves/model/entry.dart';
|
||||||
|
import 'package:aves/theme/durations.dart';
|
||||||
|
import 'package:aves/widgets/common/grid/theme.dart';
|
||||||
|
import 'package:aves/widgets/common/thumbnail/decorated.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class ThumbnailScroller extends StatefulWidget {
|
||||||
|
final double availableWidth;
|
||||||
|
final int entryCount;
|
||||||
|
final AvesEntry? Function(int index) entryBuilder;
|
||||||
|
final int? initialIndex;
|
||||||
|
final bool Function(int page) isCurrentIndex;
|
||||||
|
final void Function(int index) onIndexChange;
|
||||||
|
|
||||||
|
const ThumbnailScroller({
|
||||||
|
Key? key,
|
||||||
|
required this.availableWidth,
|
||||||
|
required this.entryCount,
|
||||||
|
required this.entryBuilder,
|
||||||
|
required this.initialIndex,
|
||||||
|
required this.isCurrentIndex,
|
||||||
|
required this.onIndexChange,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
_ThumbnailScrollerState createState() => _ThumbnailScrollerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ThumbnailScrollerState extends State<ThumbnailScroller> {
|
||||||
|
final _cancellableNotifier = ValueNotifier(true);
|
||||||
|
late ScrollController _scrollController;
|
||||||
|
bool _syncScroll = true;
|
||||||
|
|
||||||
|
static const double extent = 48;
|
||||||
|
static const double separatorWidth = 2;
|
||||||
|
|
||||||
|
int get entryCount => widget.entryCount;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_registerWidget();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant ThumbnailScroller oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
|
||||||
|
if (oldWidget.initialIndex != widget.initialIndex) {
|
||||||
|
_unregisterWidget();
|
||||||
|
_registerWidget();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_unregisterWidget();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _registerWidget() {
|
||||||
|
final scrollOffset = indexToScrollOffset(widget.initialIndex ?? 0);
|
||||||
|
_scrollController = ScrollController(initialScrollOffset: scrollOffset);
|
||||||
|
_scrollController.addListener(_onScrollChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _unregisterWidget() {
|
||||||
|
_scrollController.removeListener(_onScrollChange);
|
||||||
|
_scrollController.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final marginWidth = max(0.0, (widget.availableWidth - extent) / 2 - separatorWidth);
|
||||||
|
final horizontalMargin = SizedBox(width: marginWidth);
|
||||||
|
const separator = SizedBox(width: separatorWidth);
|
||||||
|
|
||||||
|
return GridTheme(
|
||||||
|
extent: extent,
|
||||||
|
showLocation: false,
|
||||||
|
child: SizedBox(
|
||||||
|
height: extent,
|
||||||
|
child: ListView.separated(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
controller: _scrollController,
|
||||||
|
// default padding in scroll direction matches `MediaQuery.viewPadding`,
|
||||||
|
// but we already accommodate for it, so make sure horizontal padding is 0
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
if (index == 0 || index == entryCount + 1) return horizontalMargin;
|
||||||
|
final page = index - 1;
|
||||||
|
final pageEntry = widget.entryBuilder(page);
|
||||||
|
if (pageEntry == null) return const SizedBox();
|
||||||
|
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => _goTo(page),
|
||||||
|
child: DecoratedThumbnail(
|
||||||
|
entry: pageEntry,
|
||||||
|
tileExtent: extent,
|
||||||
|
// the retrieval task queue can pile up for thumbnails of heavy pages
|
||||||
|
// (e.g. thumbnails of 15MP HEIF images inside 100MB+ HEIC containers)
|
||||||
|
// so we cancel these requests when possible
|
||||||
|
cancellableNotifier: _cancellableNotifier,
|
||||||
|
selectable: false,
|
||||||
|
highlightable: false,
|
||||||
|
hero: false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IgnorePointer(
|
||||||
|
child: AnimatedContainer(
|
||||||
|
color: widget.isCurrentIndex(page) ? Colors.transparent : Colors.black45,
|
||||||
|
width: extent,
|
||||||
|
height: extent,
|
||||||
|
duration: Durations.thumbnailScrollerShadeAnimation,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
separatorBuilder: (context, index) => separator,
|
||||||
|
itemCount: entryCount + 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _goTo(int index) async {
|
||||||
|
_syncScroll = false;
|
||||||
|
widget.onIndexChange(index);
|
||||||
|
await _scrollController.animateTo(
|
||||||
|
indexToScrollOffset(index),
|
||||||
|
duration: Durations.thumbnailScrollerScrollAnimation,
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
);
|
||||||
|
_syncScroll = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onScrollChange() {
|
||||||
|
if (_syncScroll) {
|
||||||
|
widget.onIndexChange(scrollOffsetToIndex(_scrollController.offset));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
double indexToScrollOffset(int index) => index * (extent + separatorWidth);
|
||||||
|
|
||||||
|
int scrollOffsetToIndex(double offset) => (offset / (extent + separatorWidth)).round();
|
||||||
|
}
|
|
@ -2,9 +2,9 @@ import 'package:aves/model/covers.dart';
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/filters/filters.dart';
|
import 'package:aves/model/filters/filters.dart';
|
||||||
import 'package:aves/model/source/collection_lens.dart';
|
import 'package:aves/model/source/collection_lens.dart';
|
||||||
import 'package:aves/widgets/collection/thumbnail/image.dart';
|
|
||||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||||
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
||||||
|
import 'package:aves/widgets/common/thumbnail/image.dart';
|
||||||
import 'package:aves/widgets/dialogs/item_pick_dialog.dart';
|
import 'package:aves/widgets/dialogs/item_pick_dialog.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
|
@ -13,8 +13,8 @@ import 'package:aves/theme/durations.dart';
|
||||||
import 'package:aves/theme/icons.dart';
|
import 'package:aves/theme/icons.dart';
|
||||||
import 'package:aves/utils/android_file_utils.dart';
|
import 'package:aves/utils/android_file_utils.dart';
|
||||||
import 'package:aves/utils/constants.dart';
|
import 'package:aves/utils/constants.dart';
|
||||||
import 'package:aves/widgets/collection/thumbnail/image.dart';
|
|
||||||
import 'package:aves/widgets/common/identity/aves_filter_chip.dart';
|
import 'package:aves/widgets/common/identity/aves_filter_chip.dart';
|
||||||
|
import 'package:aves/widgets/common/thumbnail/image.dart';
|
||||||
import 'package:aves/widgets/filter_grids/common/filter_grid_page.dart';
|
import 'package:aves/widgets/filter_grids/common/filter_grid_page.dart';
|
||||||
import 'package:decorated_icon/decorated_icon.dart';
|
import 'package:decorated_icon/decorated_icon.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
|
@ -5,9 +5,11 @@ import 'package:aves/theme/durations.dart';
|
||||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||||
import 'package:aves/widgets/common/map/geo_map.dart';
|
import 'package:aves/widgets/common/map/geo_map.dart';
|
||||||
import 'package:aves/widgets/common/providers/media_query_data_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:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/scheduler.dart';
|
import 'package:flutter/scheduler.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
class MapPage extends StatefulWidget {
|
class MapPage extends StatefulWidget {
|
||||||
static const routeName = '/collection/map';
|
static const routeName = '/collection/map';
|
||||||
|
@ -25,6 +27,9 @@ class MapPage extends StatefulWidget {
|
||||||
|
|
||||||
class _MapPageState extends State<MapPage> {
|
class _MapPageState extends State<MapPage> {
|
||||||
late final ValueNotifier<bool> _isAnimatingNotifier;
|
late final ValueNotifier<bool> _isAnimatingNotifier;
|
||||||
|
int _selectedIndex = 0;
|
||||||
|
|
||||||
|
List<AvesEntry> get entries => widget.entries;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
@ -48,10 +53,34 @@ class _MapPageState extends State<MapPage> {
|
||||||
title: Text(context.l10n.mapPageTitle),
|
title: Text(context.l10n.mapPageTitle),
|
||||||
),
|
),
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
child: GeoMap(
|
child: GeoMap(
|
||||||
entries: widget.entries,
|
entries: entries,
|
||||||
interactive: true,
|
interactive: true,
|
||||||
isAnimatingNotifier: _isAnimatingNotifier,
|
isAnimatingNotifier: _isAnimatingNotifier,
|
||||||
|
onEntryTap: (geoEntries) {
|
||||||
|
debugPrint('TLAD count=${geoEntries.length} entry=${geoEntries.first.entry}');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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],
|
||||||
|
initialIndex: _selectedIndex,
|
||||||
|
isCurrentIndex: (index) => _selectedIndex == index,
|
||||||
|
onIndexChange: (index) => _selectedIndex = index,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
@ -107,7 +107,7 @@ class _ViewerBottomOverlayState extends State<ViewerBottomOverlay> {
|
||||||
_lastDetails = snapshot.data;
|
_lastDetails = snapshot.data;
|
||||||
_lastEntry = entry;
|
_lastEntry = entry;
|
||||||
}
|
}
|
||||||
if (_lastEntry == null) return const SizedBox.shrink();
|
if (_lastEntry == null) return const SizedBox();
|
||||||
final mainEntry = _lastEntry!;
|
final mainEntry = _lastEntry!;
|
||||||
|
|
||||||
Widget _buildContent({AvesEntry? pageEntry}) => _BottomOverlayContent(
|
Widget _buildContent({AvesEntry? pageEntry}) => _BottomOverlayContent(
|
||||||
|
@ -261,7 +261,7 @@ class _BottomOverlayContent extends AnimatedWidget {
|
||||||
padding: const EdgeInsets.only(top: _interRowPadding),
|
padding: const EdgeInsets.only(top: _interRowPadding),
|
||||||
child: _LocationRow(entry: pageEntry),
|
child: _LocationRow(entry: pageEntry),
|
||||||
)
|
)
|
||||||
: const SizedBox.shrink(),
|
: const SizedBox(),
|
||||||
);
|
);
|
||||||
|
|
||||||
Widget _buildSoloShootingRow(double subRowWidth, bool hasShootingDetails) => AnimatedSwitcher(
|
Widget _buildSoloShootingRow(double subRowWidth, bool hasShootingDetails) => AnimatedSwitcher(
|
||||||
|
@ -275,7 +275,7 @@ class _BottomOverlayContent extends AnimatedWidget {
|
||||||
width: subRowWidth,
|
width: subRowWidth,
|
||||||
child: _ShootingRow(details!),
|
child: _ShootingRow(details!),
|
||||||
)
|
)
|
||||||
: const SizedBox.shrink(),
|
: const SizedBox(),
|
||||||
);
|
);
|
||||||
|
|
||||||
Widget _buildDuoShootingRow(double subRowWidth, bool hasShootingDetails) => AnimatedSwitcher(
|
Widget _buildDuoShootingRow(double subRowWidth, bool hasShootingDetails) => AnimatedSwitcher(
|
||||||
|
@ -291,7 +291,7 @@ class _BottomOverlayContent extends AnimatedWidget {
|
||||||
width: subRowWidth,
|
width: subRowWidth,
|
||||||
child: _ShootingRow(details!),
|
child: _ShootingRow(details!),
|
||||||
)
|
)
|
||||||
: const SizedBox.shrink(),
|
: const SizedBox(),
|
||||||
);
|
);
|
||||||
|
|
||||||
static Widget _soloTransition(Widget child, Animation<double> animation) => FadeTransition(
|
static Widget _soloTransition(Widget child, Animation<double> animation) => FadeTransition(
|
||||||
|
|
|
@ -1,12 +1,7 @@
|
||||||
import 'dart:math';
|
|
||||||
|
|
||||||
import 'package:aves/model/multipage.dart';
|
import 'package:aves/model/multipage.dart';
|
||||||
import 'package:aves/theme/durations.dart';
|
import 'package:aves/widgets/common/thumbnail/scroller.dart';
|
||||||
import 'package:aves/widgets/collection/thumbnail/decorated.dart';
|
|
||||||
import 'package:aves/widgets/common/grid/theme.dart';
|
|
||||||
import 'package:aves/widgets/viewer/multipage/controller.dart';
|
import 'package:aves/widgets/viewer/multipage/controller.dart';
|
||||||
import 'package:aves/widgets/viewer/visual/conductor.dart';
|
import 'package:aves/widgets/viewer/visual/conductor.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
@ -25,18 +20,10 @@ class MultiPageOverlay extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MultiPageOverlayState extends State<MultiPageOverlay> {
|
class _MultiPageOverlayState extends State<MultiPageOverlay> {
|
||||||
final _cancellableNotifier = ValueNotifier(true);
|
|
||||||
late ScrollController _scrollController;
|
|
||||||
bool _syncScroll = true;
|
|
||||||
int? _initControllerPage;
|
int? _initControllerPage;
|
||||||
|
|
||||||
static const double extent = 48;
|
|
||||||
static const double separatorWidth = 2;
|
|
||||||
|
|
||||||
MultiPageController get controller => widget.controller;
|
MultiPageController get controller => widget.controller;
|
||||||
|
|
||||||
double get availableWidth => widget.availableWidth;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
@ -48,23 +35,12 @@ class _MultiPageOverlayState extends State<MultiPageOverlay> {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
|
|
||||||
if (oldWidget.controller != controller) {
|
if (oldWidget.controller != controller) {
|
||||||
_unregisterWidget();
|
|
||||||
_registerWidget();
|
_registerWidget();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_unregisterWidget();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _registerWidget() {
|
void _registerWidget() {
|
||||||
_initControllerPage = controller.page;
|
_initControllerPage = controller.page;
|
||||||
final scrollOffset = pageToScrollOffset(_initControllerPage ?? 0);
|
|
||||||
_scrollController = ScrollController(initialScrollOffset: scrollOffset);
|
|
||||||
_scrollController.addListener(_onScrollChange);
|
|
||||||
|
|
||||||
if (_initControllerPage == null) {
|
if (_initControllerPage == null) {
|
||||||
_correctDefaultPageScroll();
|
_correctDefaultPageScroll();
|
||||||
}
|
}
|
||||||
|
@ -75,79 +51,26 @@ class _MultiPageOverlayState extends State<MultiPageOverlay> {
|
||||||
void _correctDefaultPageScroll() async {
|
void _correctDefaultPageScroll() async {
|
||||||
await controller.infoStream.first;
|
await controller.infoStream.first;
|
||||||
if (_initControllerPage == null) {
|
if (_initControllerPage == null) {
|
||||||
_initControllerPage = controller.page;
|
setState(() => _initControllerPage = controller.page);
|
||||||
if (_initControllerPage != null && _initControllerPage != 0) {
|
|
||||||
WidgetsBinding.instance!.addPostFrameCallback((_) => _goToPage(_initControllerPage!));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
void _unregisterWidget() {
|
|
||||||
_scrollController.removeListener(_onScrollChange);
|
|
||||||
_scrollController.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final marginWidth = max(0.0, (availableWidth - extent) / 2 - separatorWidth);
|
return StreamBuilder<MultiPageInfo?>(
|
||||||
final horizontalMargin = SizedBox(width: marginWidth);
|
|
||||||
const separator = SizedBox(width: separatorWidth);
|
|
||||||
|
|
||||||
return GridTheme(
|
|
||||||
extent: extent,
|
|
||||||
showLocation: false,
|
|
||||||
child: StreamBuilder<MultiPageInfo?>(
|
|
||||||
stream: controller.infoStream,
|
stream: controller.infoStream,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
final multiPageInfo = controller.info;
|
final multiPageInfo = controller.info;
|
||||||
final pageCount = multiPageInfo?.pageCount ?? 0;
|
return ThumbnailScroller(
|
||||||
return SizedBox(
|
|
||||||
height: extent,
|
|
||||||
child: ListView.separated(
|
|
||||||
key: ValueKey(multiPageInfo),
|
key: ValueKey(multiPageInfo),
|
||||||
scrollDirection: Axis.horizontal,
|
availableWidth: widget.availableWidth,
|
||||||
controller: _scrollController,
|
entryCount: multiPageInfo?.pageCount ?? 0,
|
||||||
// default padding in scroll direction matches `MediaQuery.viewPadding`,
|
entryBuilder: (page) => multiPageInfo?.getPageEntryByIndex(page),
|
||||||
// but we already accommodate for it, so make sure horizontal padding is 0
|
initialIndex: _initControllerPage,
|
||||||
padding: EdgeInsets.zero,
|
isCurrentIndex: (page) => controller.page == page,
|
||||||
itemBuilder: (context, index) {
|
onIndexChange: _setPage,
|
||||||
if (index == 0 || index == pageCount + 1) return horizontalMargin;
|
|
||||||
final page = index - 1;
|
|
||||||
final pageEntry = multiPageInfo!.getPageEntryByIndex(page);
|
|
||||||
|
|
||||||
return Stack(
|
|
||||||
children: [
|
|
||||||
GestureDetector(
|
|
||||||
onTap: () => _goToPage(page),
|
|
||||||
child: DecoratedThumbnail(
|
|
||||||
entry: pageEntry,
|
|
||||||
tileExtent: extent,
|
|
||||||
// the retrieval task queue can pile up for thumbnails of heavy pages
|
|
||||||
// (e.g. thumbnails of 15MP HEIF images inside 100MB+ HEIC containers)
|
|
||||||
// so we cancel these requests when possible
|
|
||||||
cancellableNotifier: _cancellableNotifier,
|
|
||||||
selectable: false,
|
|
||||||
highlightable: false,
|
|
||||||
hero: false,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
IgnorePointer(
|
|
||||||
child: AnimatedContainer(
|
|
||||||
color: controller.page == page ? Colors.transparent : Colors.black45,
|
|
||||||
width: extent,
|
|
||||||
height: extent,
|
|
||||||
duration: Durations.viewerOverlayPageShadeAnimation,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
separatorBuilder: (context, index) => separator,
|
|
||||||
itemCount: pageCount + 2,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -159,25 +82,4 @@ class _MultiPageOverlayState extends State<MultiPageOverlay> {
|
||||||
controller.page = newPage;
|
controller.page = newPage;
|
||||||
context.read<ViewStateConductor>().reset(oldPageEntry);
|
context.read<ViewStateConductor>().reset(oldPageEntry);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _goToPage(int page) async {
|
|
||||||
_syncScroll = false;
|
|
||||||
_setPage(page);
|
|
||||||
await _scrollController.animateTo(
|
|
||||||
pageToScrollOffset(page),
|
|
||||||
duration: Durations.viewerOverlayPageScrollAnimation,
|
|
||||||
curve: Curves.easeOutCubic,
|
|
||||||
);
|
|
||||||
_syncScroll = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onScrollChange() {
|
|
||||||
if (_syncScroll) {
|
|
||||||
_setPage(scrollOffsetToPage(_scrollController.offset));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
double pageToScrollOffset(int page) => page * (extent + separatorWidth);
|
|
||||||
|
|
||||||
int scrollOffsetToPage(double offset) => (offset / (extent + separatorWidth)).round();
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,13 +3,13 @@ import 'dart:async';
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/settings/settings.dart';
|
import 'package:aves/model/settings/settings.dart';
|
||||||
import 'package:aves/theme/durations.dart';
|
import 'package:aves/theme/durations.dart';
|
||||||
import 'package:aves/widgets/collection/thumbnail/image.dart';
|
|
||||||
import 'package:aves/widgets/common/magnifier/controller/controller.dart';
|
import 'package:aves/widgets/common/magnifier/controller/controller.dart';
|
||||||
import 'package:aves/widgets/common/magnifier/controller/state.dart';
|
import 'package:aves/widgets/common/magnifier/controller/state.dart';
|
||||||
import 'package:aves/widgets/common/magnifier/magnifier.dart';
|
import 'package:aves/widgets/common/magnifier/magnifier.dart';
|
||||||
import 'package:aves/widgets/common/magnifier/scale/scale_boundaries.dart';
|
import 'package:aves/widgets/common/magnifier/scale/scale_boundaries.dart';
|
||||||
import 'package:aves/widgets/common/magnifier/scale/scale_level.dart';
|
import 'package:aves/widgets/common/magnifier/scale/scale_level.dart';
|
||||||
import 'package:aves/widgets/common/magnifier/scale/state.dart';
|
import 'package:aves/widgets/common/magnifier/scale/state.dart';
|
||||||
|
import 'package:aves/widgets/common/thumbnail/image.dart';
|
||||||
import 'package:aves/widgets/viewer/hero.dart';
|
import 'package:aves/widgets/viewer/hero.dart';
|
||||||
import 'package:aves/widgets/viewer/overlay/notifications.dart';
|
import 'package:aves/widgets/viewer/overlay/notifications.dart';
|
||||||
import 'package:aves/widgets/viewer/video/conductor.dart';
|
import 'package:aves/widgets/viewer/video/conductor.dart';
|
||||||
|
|
Loading…
Reference in a new issue