various perf related changes
This commit is contained in:
parent
29cb704391
commit
b539597c62
43 changed files with 552 additions and 444 deletions
|
@ -22,6 +22,7 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
|
||||||
return MultiFrameImageStreamCompleter(
|
return MultiFrameImageStreamCompleter(
|
||||||
codec: _loadAsync(key, decode),
|
codec: _loadAsync(key, decode),
|
||||||
scale: 1.0,
|
scale: 1.0,
|
||||||
|
debugLabel: kReleaseMode ? null : [key.uri, key.extent].join('-'),
|
||||||
informationCollector: () sync* {
|
informationCollector: () sync* {
|
||||||
yield ErrorDescription('uri=${key.uri}, pageId=${key.pageId}, mimeType=${key.mimeType}, extent=${key.extent}');
|
yield ErrorDescription('uri=${key.uri}, pageId=${key.pageId}, mimeType=${key.mimeType}, extent=${key.extent}');
|
||||||
},
|
},
|
||||||
|
|
|
@ -9,6 +9,18 @@ void main() {
|
||||||
// HttpClient.enableTimelineLogging = true; // enable network traffic logging
|
// HttpClient.enableTimelineLogging = true; // enable network traffic logging
|
||||||
// debugPrintGestureArenaDiagnostics = true;
|
// debugPrintGestureArenaDiagnostics = true;
|
||||||
|
|
||||||
|
// Invert oversized images (debug mode only)
|
||||||
|
// cf https://flutter.dev/docs/development/tools/devtools/inspector
|
||||||
|
// but unaware of device pixel ratio as of Flutter 2.2.1: https://github.com/flutter/flutter/issues/76208
|
||||||
|
//
|
||||||
|
// MaterialApp.checkerboardOffscreenLayers
|
||||||
|
// cf https://flutter.dev/docs/perf/rendering/ui-performance#checking-for-offscreen-layers
|
||||||
|
//
|
||||||
|
// MaterialApp.checkerboardRasterCacheImages
|
||||||
|
// cf https://flutter.dev/docs/perf/rendering/ui-performance#checking-for-non-cached-images
|
||||||
|
//
|
||||||
|
// flutter run --profile --trace-skia
|
||||||
|
|
||||||
Isolate.current.addErrorListener(RawReceivePort((pair) async {
|
Isolate.current.addErrorListener(RawReceivePort((pair) async {
|
||||||
final List<dynamic> errorAndStacktrace = pair;
|
final List<dynamic> errorAndStacktrace = pair;
|
||||||
await FirebaseCrashlytics.instance.recordError(
|
await FirebaseCrashlytics.instance.recordError(
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:math';
|
|
||||||
|
|
||||||
import 'package:aves/image_providers/thumbnail_provider.dart';
|
import 'package:aves/image_providers/thumbnail_provider.dart';
|
||||||
import 'package:aves/image_providers/uri_image_provider.dart';
|
import 'package:aves/image_providers/uri_image_provider.dart';
|
||||||
|
|
||||||
class EntryCache {
|
class EntryCache {
|
||||||
|
static final requestExtents = <double>{};
|
||||||
|
|
||||||
static Future<void> evict(
|
static Future<void> evict(
|
||||||
String uri,
|
String uri,
|
||||||
String mimeType,
|
String mimeType,
|
||||||
|
@ -34,10 +35,8 @@ class EntryCache {
|
||||||
isFlipped: oldIsFlipped,
|
isFlipped: oldIsFlipped,
|
||||||
)).evict();
|
)).evict();
|
||||||
|
|
||||||
// evict higher quality thumbnails (with powers of 2 from 32 to 1024 as specified extents)
|
|
||||||
final extents = List.generate(6, (index) => pow(2, index + 5).toDouble());
|
|
||||||
await Future.forEach<double>(
|
await Future.forEach<double>(
|
||||||
extents,
|
requestExtents,
|
||||||
(extent) => ThumbnailProvider(ThumbnailProviderKey(
|
(extent) => ThumbnailProvider(ThumbnailProviderKey(
|
||||||
uri: uri,
|
uri: uri,
|
||||||
mimeType: mimeType,
|
mimeType: mimeType,
|
||||||
|
|
|
@ -5,6 +5,7 @@ import 'package:aves/image_providers/region_provider.dart';
|
||||||
import 'package:aves/image_providers/thumbnail_provider.dart';
|
import 'package:aves/image_providers/thumbnail_provider.dart';
|
||||||
import 'package:aves/image_providers/uri_image_provider.dart';
|
import 'package:aves/image_providers/uri_image_provider.dart';
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
|
import 'package:aves/model/entry_cache.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
extension ExtraAvesEntry on AvesEntry {
|
extension ExtraAvesEntry on AvesEntry {
|
||||||
|
@ -13,11 +14,7 @@ extension ExtraAvesEntry on AvesEntry {
|
||||||
}
|
}
|
||||||
|
|
||||||
ThumbnailProviderKey _getThumbnailProviderKey(double extent) {
|
ThumbnailProviderKey _getThumbnailProviderKey(double extent) {
|
||||||
// we standardize the thumbnail loading dimension by taking the nearest larger power of 2
|
EntryCache.requestExtents.add(extent);
|
||||||
// so that there are less variants of the thumbnails to load and cache
|
|
||||||
// it increases the chance of cache hit when loading similarly sized columns (e.g. on orientation change)
|
|
||||||
final requestExtent = extent == 0 ? .0 : pow(2, (log(extent) / log(2)).ceil()).toDouble();
|
|
||||||
|
|
||||||
return ThumbnailProviderKey(
|
return ThumbnailProviderKey(
|
||||||
uri: uri,
|
uri: uri,
|
||||||
mimeType: mimeType,
|
mimeType: mimeType,
|
||||||
|
@ -25,7 +22,7 @@ extension ExtraAvesEntry on AvesEntry {
|
||||||
rotationDegrees: rotationDegrees,
|
rotationDegrees: rotationDegrees,
|
||||||
isFlipped: isFlipped,
|
isFlipped: isFlipped,
|
||||||
dateModifiedSecs: dateModifiedSecs ?? -1,
|
dateModifiedSecs: dateModifiedSecs ?? -1,
|
||||||
extent: requestExtent,
|
extent: extent,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -32,14 +32,13 @@ class Durations {
|
||||||
static const collectionOpOverlayAnimation = Duration(milliseconds: 300);
|
static const collectionOpOverlayAnimation = Duration(milliseconds: 300);
|
||||||
static const collectionScalingBackgroundAnimation = Duration(milliseconds: 200);
|
static const collectionScalingBackgroundAnimation = Duration(milliseconds: 200);
|
||||||
static const sectionHeaderAnimation = Duration(milliseconds: 200);
|
static const sectionHeaderAnimation = Duration(milliseconds: 200);
|
||||||
static const thumbnailTransition = Duration(milliseconds: 200);
|
|
||||||
static const thumbnailOverlayAnimation = Duration(milliseconds: 200);
|
static const thumbnailOverlayAnimation = Duration(milliseconds: 200);
|
||||||
|
|
||||||
// search animations
|
// search animations
|
||||||
static const filterRowExpandAnimation = Duration(milliseconds: 300);
|
static const filterRowExpandAnimation = Duration(milliseconds: 300);
|
||||||
|
|
||||||
// viewer animations
|
// viewer animations
|
||||||
static const viewerVerticalPageScrollAnimation = Duration(milliseconds: 300);
|
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 viewerOverlayPageScrollAnimation = Duration(milliseconds: 200);
|
||||||
|
@ -56,6 +55,7 @@ class Durations {
|
||||||
|
|
||||||
// delays & refresh intervals
|
// delays & refresh intervals
|
||||||
static const opToastDisplay = Duration(seconds: 3);
|
static const opToastDisplay = Duration(seconds: 3);
|
||||||
|
static const infoScrollMonitoringTimerDelay = Duration(milliseconds: 100);
|
||||||
static const collectionScrollMonitoringTimerDelay = Duration(milliseconds: 100);
|
static const collectionScrollMonitoringTimerDelay = Duration(milliseconds: 100);
|
||||||
static const collectionScalingCompleteNotificationDelay = Duration(milliseconds: 300);
|
static const collectionScalingCompleteNotificationDelay = Duration(milliseconds: 300);
|
||||||
static const highlightScrollInitDelay = Duration(milliseconds: 800);
|
static const highlightScrollInitDelay = Duration(milliseconds: 800);
|
||||||
|
|
|
@ -9,7 +9,7 @@ class Themes {
|
||||||
static final darkTheme = ThemeData(
|
static final darkTheme = ThemeData(
|
||||||
brightness: Brightness.dark,
|
brightness: Brightness.dark,
|
||||||
accentColor: _accentColor,
|
accentColor: _accentColor,
|
||||||
scaffoldBackgroundColor: Colors.grey[900],
|
scaffoldBackgroundColor: Colors.grey.shade900,
|
||||||
dialogBackgroundColor: Colors.grey[850],
|
dialogBackgroundColor: Colors.grey[850],
|
||||||
toggleableActiveColor: _accentColor,
|
toggleableActiveColor: _accentColor,
|
||||||
tooltipTheme: TooltipThemeData(
|
tooltipTheme: TooltipThemeData(
|
||||||
|
@ -31,7 +31,7 @@ class Themes {
|
||||||
onSecondary: Colors.white,
|
onSecondary: Colors.white,
|
||||||
),
|
),
|
||||||
snackBarTheme: SnackBarThemeData(
|
snackBarTheme: SnackBarThemeData(
|
||||||
backgroundColor: Colors.grey[800],
|
backgroundColor: Colors.grey.shade800,
|
||||||
contentTextStyle: TextStyle(
|
contentTextStyle: TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
),
|
),
|
||||||
|
|
|
@ -16,7 +16,7 @@ class Constants {
|
||||||
);
|
);
|
||||||
|
|
||||||
static const embossShadow = Shadow(
|
static const embossShadow = Shadow(
|
||||||
color: Colors.black87,
|
color: Colors.black,
|
||||||
offset: Offset(0.5, 1.0),
|
offset: Offset(0.5, 1.0),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -92,6 +92,8 @@ class _AvesAppState extends State<AvesApp> {
|
||||||
...AppLocalizations.localizationsDelegates,
|
...AppLocalizations.localizationsDelegates,
|
||||||
],
|
],
|
||||||
supportedLocales: AppLocalizations.supportedLocales,
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
// checkerboardRasterCacheImages: true,
|
||||||
|
// checkerboardOffscreenLayers: true,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
|
@ -75,19 +75,21 @@ class _CollectionGridContent extends StatelessWidget {
|
||||||
builder: (context, tileExtent, child) {
|
builder: (context, tileExtent, child) {
|
||||||
return ThumbnailTheme(
|
return ThumbnailTheme(
|
||||||
extent: tileExtent,
|
extent: tileExtent,
|
||||||
child: Selector<TileExtentController, Tuple2<double, int>>(
|
child: Selector<TileExtentController, Tuple3<double, int, double>>(
|
||||||
selector: (context, c) => Tuple2(c.viewportSize.width, c.columnCount),
|
selector: (context, c) => Tuple3(c.viewportSize.width, c.columnCount, c.spacing),
|
||||||
builder: (context, c, child) {
|
builder: (context, c, child) {
|
||||||
final scrollableWidth = c.item1;
|
final scrollableWidth = c.item1;
|
||||||
final columnCount = c.item2;
|
final columnCount = c.item2;
|
||||||
|
final tileSpacing = c.item3;
|
||||||
// do not listen for animation delay change
|
// do not listen for animation delay change
|
||||||
final controller = Provider.of<TileExtentController>(context, listen: false);
|
final controller = Provider.of<TileExtentController>(context, listen: false);
|
||||||
final tileAnimationDelay = controller.getTileAnimationDelay(Durations.staggeredAnimationPageTarget);
|
final tileAnimationDelay = controller.getTileAnimationDelay(Durations.staggeredAnimationPageTarget);
|
||||||
return SectionedEntryListLayoutProvider(
|
return SectionedEntryListLayoutProvider(
|
||||||
collection: collection,
|
collection: collection,
|
||||||
scrollableWidth: scrollableWidth,
|
scrollableWidth: scrollableWidth,
|
||||||
tileExtent: tileExtent,
|
|
||||||
columnCount: columnCount,
|
columnCount: columnCount,
|
||||||
|
spacing: tileSpacing,
|
||||||
|
tileExtent: tileExtent,
|
||||||
tileBuilder: (entry) => InteractiveThumbnail(
|
tileBuilder: (entry) => InteractiveThumbnail(
|
||||||
key: ValueKey(entry.contentId),
|
key: ValueKey(entry.contentId),
|
||||||
collection: collection,
|
collection: collection,
|
||||||
|
@ -204,7 +206,7 @@ class _CollectionScaler extends StatelessWidget {
|
||||||
extent: extent,
|
extent: extent,
|
||||||
child: DecoratedThumbnail(
|
child: DecoratedThumbnail(
|
||||||
entry: entry,
|
entry: entry,
|
||||||
extent: extent,
|
tileExtent: extent,
|
||||||
selectable: false,
|
selectable: false,
|
||||||
highlightable: false,
|
highlightable: false,
|
||||||
),
|
),
|
||||||
|
@ -317,7 +319,7 @@ class _CollectionScrollViewState extends State<_CollectionScrollView> {
|
||||||
// workaround to prevent scrolling the app bar away
|
// workaround to prevent scrolling the app bar away
|
||||||
// when there is no content and we use `SliverFillRemaining`
|
// when there is no content and we use `SliverFillRemaining`
|
||||||
physics: collection.isEmpty ? NeverScrollableScrollPhysics() : SloppyScrollPhysics(parent: AlwaysScrollableScrollPhysics()),
|
physics: collection.isEmpty ? NeverScrollableScrollPhysics() : SloppyScrollPhysics(parent: AlwaysScrollableScrollPhysics()),
|
||||||
cacheExtent: context.select<TileExtentController, double>((controller) => controller.effectiveExtentMax * 2),
|
cacheExtent: context.select<TileExtentController, double>((controller) => controller.effectiveExtentMax),
|
||||||
slivers: [
|
slivers: [
|
||||||
appBar,
|
appBar,
|
||||||
collection.isEmpty
|
collection.isEmpty
|
||||||
|
|
|
@ -22,12 +22,14 @@ class AlbumSectionHeader extends StatelessWidget {
|
||||||
if (directory != null) {
|
if (directory != null) {
|
||||||
albumIcon = IconUtils.getAlbumIcon(context: context, albumPath: directory!);
|
albumIcon = IconUtils.getAlbumIcon(context: context, albumPath: directory!);
|
||||||
if (albumIcon != null) {
|
if (albumIcon != null) {
|
||||||
albumIcon = Material(
|
albumIcon = RepaintBoundary(
|
||||||
type: MaterialType.circle,
|
child: Material(
|
||||||
elevation: 3,
|
type: MaterialType.circle,
|
||||||
color: Colors.transparent,
|
elevation: 3,
|
||||||
shadowColor: Colors.black,
|
color: Colors.transparent,
|
||||||
child: albumIcon,
|
shadowColor: Colors.black,
|
||||||
|
child: albumIcon,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,6 +12,7 @@ class SectionedEntryListLayoutProvider extends SectionedListLayoutProvider<AvesE
|
||||||
required this.collection,
|
required this.collection,
|
||||||
required double scrollableWidth,
|
required double scrollableWidth,
|
||||||
required int columnCount,
|
required int columnCount,
|
||||||
|
required double spacing,
|
||||||
required double tileExtent,
|
required double tileExtent,
|
||||||
required Widget Function(AvesEntry entry) tileBuilder,
|
required Widget Function(AvesEntry entry) tileBuilder,
|
||||||
required Duration tileAnimationDelay,
|
required Duration tileAnimationDelay,
|
||||||
|
@ -19,6 +20,7 @@ class SectionedEntryListLayoutProvider extends SectionedListLayoutProvider<AvesE
|
||||||
}) : super(
|
}) : super(
|
||||||
scrollableWidth: scrollableWidth,
|
scrollableWidth: scrollableWidth,
|
||||||
columnCount: columnCount,
|
columnCount: columnCount,
|
||||||
|
spacing: spacing,
|
||||||
tileExtent: tileExtent,
|
tileExtent: tileExtent,
|
||||||
tileBuilder: tileBuilder,
|
tileBuilder: tileBuilder,
|
||||||
tileAnimationDelay: tileAnimationDelay,
|
tileAnimationDelay: tileAnimationDelay,
|
||||||
|
|
|
@ -51,7 +51,7 @@ class InteractiveThumbnail extends StatelessWidget {
|
||||||
metaData: ScalerMetadata(entry),
|
metaData: ScalerMetadata(entry),
|
||||||
child: DecoratedThumbnail(
|
child: DecoratedThumbnail(
|
||||||
entry: entry,
|
entry: entry,
|
||||||
extent: tileExtent,
|
tileExtent: tileExtent,
|
||||||
collection: collection,
|
collection: collection,
|
||||||
// when the user is scrolling faster than we can retrieve the thumbnails,
|
// when the user is scrolling faster than we can retrieve the thumbnails,
|
||||||
// the retrieval task queue can pile up for thumbnails that got disposed
|
// the retrieval task queue can pile up for thumbnails that got disposed
|
||||||
|
|
|
@ -3,22 +3,23 @@ import 'package:aves/model/source/collection_lens.dart';
|
||||||
import 'package:aves/widgets/collection/thumbnail/overlay.dart';
|
import 'package:aves/widgets/collection/thumbnail/overlay.dart';
|
||||||
import 'package:aves/widgets/collection/thumbnail/raster.dart';
|
import 'package:aves/widgets/collection/thumbnail/raster.dart';
|
||||||
import 'package:aves/widgets/collection/thumbnail/vector.dart';
|
import 'package:aves/widgets/collection/thumbnail/vector.dart';
|
||||||
|
import 'package:aves/widgets/common/fx/borders.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class DecoratedThumbnail extends StatelessWidget {
|
class DecoratedThumbnail extends StatelessWidget {
|
||||||
final AvesEntry entry;
|
final AvesEntry entry;
|
||||||
final double extent;
|
final double tileExtent;
|
||||||
final CollectionLens? collection;
|
final CollectionLens? collection;
|
||||||
final ValueNotifier<bool>? cancellableNotifier;
|
final ValueNotifier<bool>? cancellableNotifier;
|
||||||
final bool selectable, highlightable;
|
final bool selectable, highlightable;
|
||||||
|
|
||||||
static final Color borderColor = Colors.grey.shade700;
|
static final Color borderColor = Colors.grey.shade700;
|
||||||
static const double borderWidth = .5;
|
static final double borderWidth = AvesBorder.borderWidth;
|
||||||
|
|
||||||
const DecoratedThumbnail({
|
const DecoratedThumbnail({
|
||||||
Key? key,
|
Key? key,
|
||||||
required this.entry,
|
required this.entry,
|
||||||
required this.extent,
|
required this.tileExtent,
|
||||||
this.collection,
|
this.collection,
|
||||||
this.cancellableNotifier,
|
this.cancellableNotifier,
|
||||||
this.selectable = true,
|
this.selectable = true,
|
||||||
|
@ -27,6 +28,8 @@ class DecoratedThumbnail extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final imageExtent = tileExtent - borderWidth * 2;
|
||||||
|
|
||||||
// hero tag should include a collection identifier, so that it animates
|
// 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)
|
// 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)
|
// but not between different collection instances, even with the same attributes (e.g. reloading collection page via drawer)
|
||||||
|
@ -35,12 +38,12 @@ class DecoratedThumbnail extends StatelessWidget {
|
||||||
var child = isSvg
|
var child = isSvg
|
||||||
? VectorImageThumbnail(
|
? VectorImageThumbnail(
|
||||||
entry: entry,
|
entry: entry,
|
||||||
extent: extent,
|
extent: imageExtent,
|
||||||
heroTag: heroTag,
|
heroTag: heroTag,
|
||||||
)
|
)
|
||||||
: RasterImageThumbnail(
|
: RasterImageThumbnail(
|
||||||
entry: entry,
|
entry: entry,
|
||||||
extent: extent,
|
extent: imageExtent,
|
||||||
cancellableNotifier: cancellableNotifier,
|
cancellableNotifier: cancellableNotifier,
|
||||||
heroTag: heroTag,
|
heroTag: heroTag,
|
||||||
);
|
);
|
||||||
|
@ -49,33 +52,21 @@ class DecoratedThumbnail extends StatelessWidget {
|
||||||
alignment: isSvg ? Alignment.center : AlignmentDirectional.bottomStart,
|
alignment: isSvg ? Alignment.center : AlignmentDirectional.bottomStart,
|
||||||
children: [
|
children: [
|
||||||
child,
|
child,
|
||||||
if (!isSvg)
|
if (!isSvg) ThumbnailEntryOverlay(entry: entry),
|
||||||
ThumbnailEntryOverlay(
|
if (selectable) ThumbnailSelectionOverlay(entry: entry),
|
||||||
entry: entry,
|
if (highlightable) ThumbnailHighlightOverlay(entry: entry),
|
||||||
extent: extent,
|
|
||||||
),
|
|
||||||
if (selectable)
|
|
||||||
ThumbnailSelectionOverlay(
|
|
||||||
entry: entry,
|
|
||||||
extent: extent,
|
|
||||||
),
|
|
||||||
if (highlightable)
|
|
||||||
ThumbnailHighlightOverlay(
|
|
||||||
entry: entry,
|
|
||||||
extent: extent,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
foregroundDecoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: borderColor,
|
color: borderColor,
|
||||||
width: borderWidth,
|
width: borderWidth,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
width: extent,
|
width: tileExtent,
|
||||||
height: extent,
|
height: tileExtent,
|
||||||
child: child,
|
child: child,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -41,12 +41,12 @@ class _ErrorThumbnailState extends State<ErrorThumbnail> {
|
||||||
return FutureBuilder<bool>(
|
return FutureBuilder<bool>(
|
||||||
future: _exists,
|
future: _exists,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (snapshot.connectionState != ConnectionState.done) return SizedBox();
|
Widget child;
|
||||||
final exists = snapshot.data!;
|
if (snapshot.connectionState != ConnectionState.done) {
|
||||||
return Container(
|
child = SizedBox();
|
||||||
alignment: Alignment.center,
|
} else {
|
||||||
color: Colors.black,
|
final exists = snapshot.data!;
|
||||||
child: Tooltip(
|
child = Tooltip(
|
||||||
message: exists ? widget.tooltip : context.l10n.viewerErrorDoesNotExist,
|
message: exists ? widget.tooltip : context.l10n.viewerErrorDoesNotExist,
|
||||||
preferBelow: false,
|
preferBelow: false,
|
||||||
child: exists
|
child: exists
|
||||||
|
@ -63,7 +63,14 @@ class _ErrorThumbnailState extends State<ErrorThumbnail> {
|
||||||
size: extent / 2,
|
size: extent / 2,
|
||||||
color: color,
|
color: color,
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
|
}
|
||||||
|
return Container(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
color: Colors.black,
|
||||||
|
width: extent,
|
||||||
|
height: extent,
|
||||||
|
child: child,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,12 +14,10 @@ import 'package:provider/provider.dart';
|
||||||
|
|
||||||
class ThumbnailEntryOverlay extends StatelessWidget {
|
class ThumbnailEntryOverlay extends StatelessWidget {
|
||||||
final AvesEntry entry;
|
final AvesEntry entry;
|
||||||
final double extent;
|
|
||||||
|
|
||||||
const ThumbnailEntryOverlay({
|
const ThumbnailEntryOverlay({
|
||||||
Key? key,
|
Key? key,
|
||||||
required this.entry,
|
required this.entry,
|
||||||
required this.extent,
|
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -51,14 +49,12 @@ class ThumbnailEntryOverlay extends StatelessWidget {
|
||||||
|
|
||||||
class ThumbnailSelectionOverlay extends StatelessWidget {
|
class ThumbnailSelectionOverlay extends StatelessWidget {
|
||||||
final AvesEntry entry;
|
final AvesEntry entry;
|
||||||
final double extent;
|
|
||||||
|
|
||||||
static const duration = Durations.thumbnailOverlayAnimation;
|
static const duration = Durations.thumbnailOverlayAnimation;
|
||||||
|
|
||||||
const ThumbnailSelectionOverlay({
|
const ThumbnailSelectionOverlay({
|
||||||
Key? key,
|
Key? key,
|
||||||
required this.entry,
|
required this.entry,
|
||||||
required this.extent,
|
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -110,12 +106,10 @@ class ThumbnailSelectionOverlay extends StatelessWidget {
|
||||||
|
|
||||||
class ThumbnailHighlightOverlay extends StatefulWidget {
|
class ThumbnailHighlightOverlay extends StatefulWidget {
|
||||||
final AvesEntry entry;
|
final AvesEntry entry;
|
||||||
final double extent;
|
|
||||||
|
|
||||||
const ThumbnailHighlightOverlay({
|
const ThumbnailHighlightOverlay({
|
||||||
Key? key,
|
Key? key,
|
||||||
required this.entry,
|
required this.entry,
|
||||||
required this.extent,
|
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -138,7 +132,7 @@ class _ThumbnailHighlightOverlayState extends State<ThumbnailHighlightOverlay> {
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: Theme.of(context).accentColor,
|
color: Theme.of(context).accentColor,
|
||||||
width: widget.extent * .1,
|
width: context.select<ThumbnailThemeData, double>((t) => t.highlightBorderWidth),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
@ -1,7 +1,10 @@
|
||||||
|
import 'dart:math';
|
||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
import 'package:aves/image_providers/thumbnail_provider.dart';
|
import 'package:aves/image_providers/thumbnail_provider.dart';
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/entry_images.dart';
|
import 'package:aves/model/entry_images.dart';
|
||||||
import 'package:aves/theme/durations.dart';
|
import 'package:aves/services/services.dart';
|
||||||
import 'package:aves/widgets/collection/thumbnail/error.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/transition_image.dart';
|
import 'package:aves/widgets/common/fx/transition_image.dart';
|
||||||
|
@ -26,7 +29,12 @@ class RasterImageThumbnail extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _RasterImageThumbnailState extends State<RasterImageThumbnail> {
|
class _RasterImageThumbnailState extends State<RasterImageThumbnail> {
|
||||||
ThumbnailProvider? _fastThumbnailProvider, _sizedThumbnailProvider;
|
final _providers = <_ConditionalImageProvider>[];
|
||||||
|
_ProviderStream? _currentProviderStream;
|
||||||
|
ImageInfo? _lastImageInfo;
|
||||||
|
Object? _lastException;
|
||||||
|
late final ImageStreamListener _streamListener;
|
||||||
|
late DisposableBuildContext<State<RasterImageThumbnail>> _scrollAwareContext;
|
||||||
|
|
||||||
AvesEntry get entry => widget.entry;
|
AvesEntry get entry => widget.entry;
|
||||||
|
|
||||||
|
@ -35,6 +43,8 @@ class _RasterImageThumbnailState extends State<RasterImageThumbnail> {
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_streamListener = ImageStreamListener(_onImageLoad, onError: _onError);
|
||||||
|
_scrollAwareContext = DisposableBuildContext<State<RasterImageThumbnail>>(this);
|
||||||
_registerWidget(widget);
|
_registerWidget(widget);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -50,6 +60,7 @@ class _RasterImageThumbnailState extends State<RasterImageThumbnail> {
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_unregisterWidget(widget);
|
_unregisterWidget(widget);
|
||||||
|
_scrollAwareContext.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -61,66 +72,119 @@ class _RasterImageThumbnailState extends State<RasterImageThumbnail> {
|
||||||
void _unregisterWidget(RasterImageThumbnail widget) {
|
void _unregisterWidget(RasterImageThumbnail widget) {
|
||||||
widget.entry.imageChangeNotifier.removeListener(_onImageChanged);
|
widget.entry.imageChangeNotifier.removeListener(_onImageChanged);
|
||||||
_pauseProvider();
|
_pauseProvider();
|
||||||
|
_currentProviderStream?.stopListening();
|
||||||
|
_currentProviderStream = null;
|
||||||
|
_replaceImage(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _initProvider() {
|
void _initProvider() {
|
||||||
if (!entry.canDecode) return;
|
if (!entry.canDecode) return;
|
||||||
|
|
||||||
_fastThumbnailProvider = entry.getThumbnail();
|
_lastException = null;
|
||||||
_sizedThumbnailProvider = entry.getThumbnail(extent: extent);
|
_providers.clear();
|
||||||
|
_providers.addAll([
|
||||||
|
_ConditionalImageProvider(
|
||||||
|
ScrollAwareImageProvider(
|
||||||
|
context: _scrollAwareContext,
|
||||||
|
imageProvider: entry.getThumbnail(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_ConditionalImageProvider(
|
||||||
|
ScrollAwareImageProvider(
|
||||||
|
context: _scrollAwareContext,
|
||||||
|
imageProvider: entry.getThumbnail(extent: extent),
|
||||||
|
),
|
||||||
|
_needSizedProvider,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
_loadNextProvider();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _pauseProvider() {
|
void _loadNextProvider([ImageInfo? imageInfo]) {
|
||||||
if (widget.cancellableNotifier?.value ?? false) {
|
final nextIndex = _currentProviderStream == null ? 0 : (_providers.indexOf(_currentProviderStream!.provider) + 1);
|
||||||
_fastThumbnailProvider?.pause();
|
if (nextIndex < _providers.length) {
|
||||||
_sizedThumbnailProvider?.pause();
|
final provider = _providers[nextIndex];
|
||||||
|
if (provider.predicate?.call(imageInfo) ?? true) {
|
||||||
|
_currentProviderStream?.stopListening();
|
||||||
|
_currentProviderStream = _ProviderStream(provider, _streamListener);
|
||||||
|
_currentProviderStream!.startListening();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onImageLoad(ImageInfo imageInfo, bool synchronousCall) {
|
||||||
|
_replaceImage(imageInfo);
|
||||||
|
_loadNextProvider(imageInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _replaceImage(ImageInfo? imageInfo) {
|
||||||
|
_lastImageInfo?.dispose();
|
||||||
|
_lastImageInfo = imageInfo;
|
||||||
|
if (imageInfo != null) {
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onError(Object exception, StackTrace? stackTrace) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _lastException = exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _needSizedProvider(ImageInfo? currentImageInfo) {
|
||||||
|
if (currentImageInfo == null) return true;
|
||||||
|
final currentImage = currentImageInfo.image;
|
||||||
|
// directly uses `devicePixelRatio` as it never changes, to avoid visiting ancestors via `MediaQuery`
|
||||||
|
final sizedThreshold = extent * window.devicePixelRatio;
|
||||||
|
return sizedThreshold > min(currentImage.width, currentImage.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _pauseProvider() async {
|
||||||
|
if (widget.cancellableNotifier?.value ?? false) {
|
||||||
|
final key = await _currentProviderStream?.provider.provider.obtainKey(ImageConfiguration.empty);
|
||||||
|
if (key is ThumbnailProviderKey) {
|
||||||
|
imageFileService.cancelThumbnail(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Color? _backgroundColor;
|
||||||
|
|
||||||
|
Color get backgroundColor {
|
||||||
|
if (_backgroundColor == null) {
|
||||||
|
final rgb = 0x30 + entry.uri.hashCode % 0x20;
|
||||||
|
_backgroundColor = Color.fromARGB(0xFF, rgb, rgb, rgb);
|
||||||
|
}
|
||||||
|
return _backgroundColor!;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (!entry.canDecode) {
|
if (!entry.canDecode) {
|
||||||
return _buildError(context, context.l10n.errorUnsupportedMimeType(entry.mimeType), null);
|
return _buildError(context, context.l10n.errorUnsupportedMimeType(entry.mimeType), null);
|
||||||
|
} else if (_lastException != null) {
|
||||||
|
return _buildError(context, _lastException.toString(), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
final fastImage = Image(
|
// use `RawImage` instead of `Image`, using `ImageInfo` to check dimensions
|
||||||
key: ValueKey('LQ'),
|
// and have more control when chaining image providers
|
||||||
image: _fastThumbnailProvider!,
|
|
||||||
errorBuilder: _buildError,
|
final imageInfo = _lastImageInfo;
|
||||||
width: extent,
|
final image = imageInfo == null
|
||||||
height: extent,
|
? Container(
|
||||||
fit: BoxFit.cover,
|
color: backgroundColor,
|
||||||
);
|
|
||||||
final image = _sizedThumbnailProvider == null
|
|
||||||
? fastImage
|
|
||||||
: Image(
|
|
||||||
key: ValueKey('HQ'),
|
|
||||||
image: _sizedThumbnailProvider!,
|
|
||||||
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
|
|
||||||
if (wasSynchronouslyLoaded) return child;
|
|
||||||
return AnimatedSwitcher(
|
|
||||||
duration: Durations.thumbnailTransition,
|
|
||||||
transitionBuilder: (child, animation) {
|
|
||||||
var shouldFade = true;
|
|
||||||
if (child is Image && child.image == _fastThumbnailProvider) {
|
|
||||||
// directly show LQ thumbnail, only fade when switching from LQ to HQ
|
|
||||||
shouldFade = false;
|
|
||||||
}
|
|
||||||
return shouldFade
|
|
||||||
? FadeTransition(
|
|
||||||
opacity: animation,
|
|
||||||
child: child,
|
|
||||||
)
|
|
||||||
: child;
|
|
||||||
},
|
|
||||||
child: frame == null ? fastImage : child,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
errorBuilder: _buildError,
|
|
||||||
width: extent,
|
width: extent,
|
||||||
height: extent,
|
height: extent,
|
||||||
|
)
|
||||||
|
: RawImage(
|
||||||
|
image: imageInfo.image,
|
||||||
|
debugImageLabel: imageInfo.debugLabel,
|
||||||
|
width: extent,
|
||||||
|
height: extent,
|
||||||
|
scale: imageInfo.scale,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
);
|
);
|
||||||
|
|
||||||
return widget.heroTag != null
|
return widget.heroTag != null
|
||||||
? Hero(
|
? Hero(
|
||||||
tag: widget.heroTag!,
|
tag: widget.heroTag!,
|
||||||
|
@ -150,3 +214,22 @@ class _RasterImageThumbnailState extends State<RasterImageThumbnail> {
|
||||||
setState(() {});
|
setState(() {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _ConditionalImageProvider {
|
||||||
|
final ImageProvider provider;
|
||||||
|
final bool Function(ImageInfo?)? predicate;
|
||||||
|
|
||||||
|
const _ConditionalImageProvider(this.provider, [this.predicate]);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProviderStream {
|
||||||
|
final _ConditionalImageProvider provider;
|
||||||
|
final ImageStream _stream;
|
||||||
|
final ImageStreamListener listener;
|
||||||
|
|
||||||
|
_ProviderStream(this.provider, this.listener) : _stream = provider.provider.resolve(ImageConfiguration.empty);
|
||||||
|
|
||||||
|
void startListening() => _stream.addListener(listener);
|
||||||
|
|
||||||
|
void stopListening() => _stream.removeListener(listener);
|
||||||
|
}
|
||||||
|
|
|
@ -21,9 +21,11 @@ class ThumbnailTheme extends StatelessWidget {
|
||||||
update: (_, settings, __) {
|
update: (_, settings, __) {
|
||||||
final iconSize = min(28.0, (extent / 4)).roundToDouble();
|
final iconSize = min(28.0, (extent / 4)).roundToDouble();
|
||||||
final fontSize = (iconSize / 2).floorToDouble();
|
final fontSize = (iconSize / 2).floorToDouble();
|
||||||
|
final highlightBorderWidth = extent * .1;
|
||||||
return ThumbnailThemeData(
|
return ThumbnailThemeData(
|
||||||
iconSize: iconSize,
|
iconSize: iconSize,
|
||||||
fontSize: fontSize,
|
fontSize: fontSize,
|
||||||
|
highlightBorderWidth: highlightBorderWidth,
|
||||||
showLocation: showLocation ?? settings.showThumbnailLocation,
|
showLocation: showLocation ?? settings.showThumbnailLocation,
|
||||||
showRaw: settings.showThumbnailRaw,
|
showRaw: settings.showThumbnailRaw,
|
||||||
showVideoDuration: settings.showThumbnailVideoDuration,
|
showVideoDuration: settings.showThumbnailVideoDuration,
|
||||||
|
@ -35,12 +37,13 @@ class ThumbnailTheme extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class ThumbnailThemeData {
|
class ThumbnailThemeData {
|
||||||
final double iconSize, fontSize;
|
final double iconSize, fontSize, highlightBorderWidth;
|
||||||
final bool showLocation, showRaw, showVideoDuration;
|
final bool showLocation, showRaw, showVideoDuration;
|
||||||
|
|
||||||
const ThumbnailThemeData({
|
const ThumbnailThemeData({
|
||||||
required this.iconSize,
|
required this.iconSize,
|
||||||
required this.fontSize,
|
required this.fontSize,
|
||||||
|
required this.highlightBorderWidth,
|
||||||
required this.showLocation,
|
required this.showLocation,
|
||||||
required this.showRaw,
|
required this.showRaw,
|
||||||
required this.showVideoDuration,
|
required this.showVideoDuration,
|
||||||
|
|
|
@ -1,19 +1,17 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'dart:ui';
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
|
|
||||||
class AvesCircleBorder {
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class AvesBorder {
|
||||||
static const borderColor = Colors.white30;
|
static const borderColor = Colors.white30;
|
||||||
|
|
||||||
static double _borderWidth(BuildContext context) => context.read<MediaQueryData>().devicePixelRatio > 2 ? 0.5 : 1.0;
|
// directly uses `devicePixelRatio` as it never changes, to avoid visiting ancestors via `MediaQuery`
|
||||||
|
static double get borderWidth => window.devicePixelRatio > 2 ? 0.5 : 1.0;
|
||||||
|
|
||||||
static Border build(BuildContext context) {
|
static BorderSide get side => BorderSide(
|
||||||
return Border.fromBorderSide(buildSide(context));
|
color: borderColor,
|
||||||
}
|
width: borderWidth,
|
||||||
|
);
|
||||||
|
|
||||||
static BorderSide buildSide(BuildContext context) {
|
static Border get border => Border.fromBorderSide(side);
|
||||||
return BorderSide(
|
|
||||||
color: borderColor,
|
|
||||||
width: _borderWidth(context),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,7 +19,7 @@ abstract class SectionedListLayoutProvider<T> extends StatelessWidget {
|
||||||
const SectionedListLayoutProvider({
|
const SectionedListLayoutProvider({
|
||||||
required this.scrollableWidth,
|
required this.scrollableWidth,
|
||||||
required this.columnCount,
|
required this.columnCount,
|
||||||
this.spacing = 0,
|
required this.spacing,
|
||||||
required this.tileExtent,
|
required this.tileExtent,
|
||||||
required this.tileBuilder,
|
required this.tileBuilder,
|
||||||
required this.tileAnimationDelay,
|
required this.tileAnimationDelay,
|
||||||
|
@ -118,12 +118,13 @@ abstract class SectionedListLayoutProvider<T> extends StatelessWidget {
|
||||||
final children = <Widget>[];
|
final children = <Widget>[];
|
||||||
for (var i = minItemIndex; i < maxItemIndex; i++) {
|
for (var i = minItemIndex; i < maxItemIndex; i++) {
|
||||||
final itemGridIndex = sectionGridIndex + i - minItemIndex;
|
final itemGridIndex = sectionGridIndex + i - minItemIndex;
|
||||||
final item = tileBuilder(section[i]);
|
final item = RepaintBoundary(
|
||||||
if (i != minItemIndex) children.add(SizedBox(width: spacing));
|
child: tileBuilder(section[i]),
|
||||||
|
);
|
||||||
children.add(animate ? _buildAnimation(itemGridIndex, item) : item);
|
children.add(animate ? _buildAnimation(itemGridIndex, item) : item);
|
||||||
}
|
}
|
||||||
return Row(
|
return Wrap(
|
||||||
mainAxisSize: MainAxisSize.min,
|
spacing: spacing,
|
||||||
children: children,
|
children: children,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,6 +30,7 @@ class SectionedListSliver<T> extends StatelessWidget {
|
||||||
},
|
},
|
||||||
childCount: childCount,
|
childCount: childCount,
|
||||||
addAutomaticKeepAlives: false,
|
addAutomaticKeepAlives: false,
|
||||||
|
addRepaintBoundaries: false,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -53,7 +53,7 @@ class AvesExpansionTile extends StatelessWidget {
|
||||||
expandable: enabled,
|
expandable: enabled,
|
||||||
initiallyExpanded: initiallyExpanded,
|
initiallyExpanded: initiallyExpanded,
|
||||||
finalPadding: EdgeInsets.symmetric(vertical: 6.0),
|
finalPadding: EdgeInsets.symmetric(vertical: 6.0),
|
||||||
baseColor: Colors.grey[900],
|
baseColor: Colors.grey.shade900,
|
||||||
expandedColor: Colors.grey[850],
|
expandedColor: Colors.grey[850],
|
||||||
shadowColor: Theme.of(context).shadowColor,
|
shadowColor: Theme.of(context).shadowColor,
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|
|
@ -31,7 +31,7 @@ class VideoIcon extends StatelessWidget {
|
||||||
if (showDuration) {
|
if (showDuration) {
|
||||||
child = DefaultTextStyle(
|
child = DefaultTextStyle(
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.grey[200],
|
color: Colors.grey.shade200,
|
||||||
fontSize: thumbnailTheme.fontSize,
|
fontSize: thumbnailTheme.fontSize,
|
||||||
),
|
),
|
||||||
child: child,
|
child: child,
|
||||||
|
|
|
@ -11,7 +11,7 @@ class DebugTaskQueueOverlay extends StatelessWidget {
|
||||||
alignment: AlignmentDirectional.bottomStart,
|
alignment: AlignmentDirectional.bottomStart,
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
child: Container(
|
child: Container(
|
||||||
color: Colors.indigo[900]!.withAlpha(0xCC),
|
color: Colors.indigo.shade900.withAlpha(0xCC),
|
||||||
padding: EdgeInsets.all(8),
|
padding: EdgeInsets.all(8),
|
||||||
child: StreamBuilder<QueueState>(
|
child: StreamBuilder<QueueState>(
|
||||||
stream: servicePolicy.queueStream,
|
stream: servicePolicy.queueStream,
|
||||||
|
|
|
@ -58,7 +58,7 @@ class _AppDrawerState extends State<AppDrawer> {
|
||||||
albumListTile,
|
albumListTile,
|
||||||
countryListTile,
|
countryListTile,
|
||||||
tagListTile,
|
tagListTile,
|
||||||
if (kDebugMode) ...[
|
if (!kReleaseMode) ...[
|
||||||
Divider(),
|
Divider(),
|
||||||
debugTile,
|
debugTile,
|
||||||
],
|
],
|
||||||
|
|
|
@ -222,9 +222,9 @@ class _FilterGridContent<T extends CollectionFilter> extends StatelessWidget {
|
||||||
sections: visibleFilterSections,
|
sections: visibleFilterSections,
|
||||||
showHeaders: showHeaders,
|
showHeaders: showHeaders,
|
||||||
scrollableWidth: scrollableWidth,
|
scrollableWidth: scrollableWidth,
|
||||||
tileExtent: tileExtent,
|
|
||||||
columnCount: columnCount,
|
columnCount: columnCount,
|
||||||
spacing: tileSpacing,
|
spacing: tileSpacing,
|
||||||
|
tileExtent: tileExtent,
|
||||||
tileBuilder: (gridItem) {
|
tileBuilder: (gridItem) {
|
||||||
final filter = gridItem.filter;
|
final filter = gridItem.filter;
|
||||||
final entry = gridItem.entry;
|
final entry = gridItem.entry;
|
||||||
|
|
|
@ -11,7 +11,7 @@ class SectionedFilterListLayoutProvider<T extends CollectionFilter> extends Sect
|
||||||
required this.showHeaders,
|
required this.showHeaders,
|
||||||
required double scrollableWidth,
|
required double scrollableWidth,
|
||||||
required int columnCount,
|
required int columnCount,
|
||||||
double spacing = 0,
|
required double spacing,
|
||||||
required double tileExtent,
|
required double tileExtent,
|
||||||
required Widget Function(FilterGridItem<T> gridItem) tileBuilder,
|
required Widget Function(FilterGridItem<T> gridItem) tileBuilder,
|
||||||
required Duration tileAnimationDelay,
|
required Duration tileAnimationDelay,
|
||||||
|
|
|
@ -70,7 +70,7 @@ class _EntryBackgroundSelectorState extends State<EntryBackgroundSelector> {
|
||||||
width: radius * 2,
|
width: radius * 2,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: selected.isColor ? selected.color : null,
|
color: selected.isColor ? selected.color : null,
|
||||||
border: AvesCircleBorder.build(context),
|
border: AvesBorder.border,
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: child,
|
child: child,
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
|
import 'dart:async';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
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/theme/durations.dart';
|
||||||
import 'package:aves/widgets/common/magnifier/pan/scroll_physics.dart';
|
import 'package:aves/widgets/common/magnifier/pan/scroll_physics.dart';
|
||||||
import 'package:aves/widgets/viewer/entry_horizontal_pager.dart';
|
import 'package:aves/widgets/viewer/entry_horizontal_pager.dart';
|
||||||
import 'package:aves/widgets/viewer/info/info_page.dart';
|
import 'package:aves/widgets/viewer/info/info_page.dart';
|
||||||
|
@ -35,7 +37,8 @@ class ViewerVerticalPageView extends StatefulWidget {
|
||||||
|
|
||||||
class _ViewerVerticalPageViewState extends State<ViewerVerticalPageView> {
|
class _ViewerVerticalPageViewState extends State<ViewerVerticalPageView> {
|
||||||
final ValueNotifier<Color> _backgroundColorNotifier = ValueNotifier(Colors.black);
|
final ValueNotifier<Color> _backgroundColorNotifier = ValueNotifier(Colors.black);
|
||||||
final ValueNotifier<bool> _infoPageVisibleNotifier = ValueNotifier(false);
|
final ValueNotifier<bool> _isVerticallyScrollingNotifier = ValueNotifier(false);
|
||||||
|
Timer? _verticalScrollMonitoringTimer;
|
||||||
AvesEntry? _oldEntry;
|
AvesEntry? _oldEntry;
|
||||||
|
|
||||||
CollectionLens? get collection => widget.collection;
|
CollectionLens? get collection => widget.collection;
|
||||||
|
@ -60,6 +63,7 @@ class _ViewerVerticalPageViewState extends State<ViewerVerticalPageView> {
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_unregisterWidget(widget);
|
_unregisterWidget(widget);
|
||||||
|
_stopScrollMonitoringTimer();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -77,32 +81,47 @@ class _ViewerVerticalPageViewState extends State<ViewerVerticalPageView> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final pages = [
|
// fake page for opacity transition between collection and viewer
|
||||||
// fake page for opacity transition between collection and viewer
|
final transitionPage = SizedBox();
|
||||||
SizedBox(),
|
|
||||||
hasCollection
|
final imagePage = hasCollection
|
||||||
? MultiEntryScroller(
|
? MultiEntryScroller(
|
||||||
collection: collection!,
|
collection: collection!,
|
||||||
pageController: widget.horizontalPager,
|
pageController: widget.horizontalPager,
|
||||||
onPageChanged: widget.onHorizontalPageChanged,
|
onPageChanged: widget.onHorizontalPageChanged,
|
||||||
onViewDisposed: widget.onViewDisposed,
|
onViewDisposed: widget.onViewDisposed,
|
||||||
)
|
)
|
||||||
: entry != null
|
: entry != null
|
||||||
? SingleEntryScroller(
|
? SingleEntryScroller(
|
||||||
entry: entry!,
|
entry: entry!,
|
||||||
)
|
)
|
||||||
: SizedBox(),
|
: SizedBox();
|
||||||
NotificationListener<BackUpNotification>(
|
|
||||||
onNotification: (notification) {
|
final infoPage = NotificationListener<BackUpNotification>(
|
||||||
widget.onImagePageRequested();
|
onNotification: (notification) {
|
||||||
return true;
|
widget.onImagePageRequested();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
child: AnimatedBuilder(
|
||||||
|
animation: widget.verticalPager,
|
||||||
|
builder: (context, child) {
|
||||||
|
return Visibility(
|
||||||
|
visible: widget.verticalPager.page! > 1,
|
||||||
|
child: child!,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
child: InfoPage(
|
child: InfoPage(
|
||||||
collection: collection,
|
collection: collection,
|
||||||
entryNotifier: widget.entryNotifier,
|
entryNotifier: widget.entryNotifier,
|
||||||
visibleNotifier: _infoPageVisibleNotifier,
|
isScrollingNotifier: _isVerticallyScrollingNotifier,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final pages = [
|
||||||
|
transitionPage,
|
||||||
|
imagePage,
|
||||||
|
infoPage,
|
||||||
];
|
];
|
||||||
return ValueListenableBuilder<Color>(
|
return ValueListenableBuilder<Color>(
|
||||||
valueListenable: _backgroundColorNotifier,
|
valueListenable: _backgroundColorNotifier,
|
||||||
|
@ -115,10 +134,7 @@ class _ViewerVerticalPageViewState extends State<ViewerVerticalPageView> {
|
||||||
scrollDirection: Axis.vertical,
|
scrollDirection: Axis.vertical,
|
||||||
controller: widget.verticalPager,
|
controller: widget.verticalPager,
|
||||||
physics: MagnifierScrollerPhysics(parent: PageScrollPhysics()),
|
physics: MagnifierScrollerPhysics(parent: PageScrollPhysics()),
|
||||||
onPageChanged: (page) {
|
onPageChanged: widget.onVerticalPageChanged,
|
||||||
widget.onVerticalPageChanged(page);
|
|
||||||
_infoPageVisibleNotifier.value = page == pages.length - 1;
|
|
||||||
},
|
|
||||||
children: pages,
|
children: pages,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
@ -127,6 +143,16 @@ class _ViewerVerticalPageViewState extends State<ViewerVerticalPageView> {
|
||||||
void _onVerticalPageControllerChanged() {
|
void _onVerticalPageControllerChanged() {
|
||||||
final opacity = min(1.0, widget.verticalPager.page!);
|
final opacity = min(1.0, widget.verticalPager.page!);
|
||||||
_backgroundColorNotifier.value = _backgroundColorNotifier.value.withOpacity(opacity * opacity);
|
_backgroundColorNotifier.value = _backgroundColorNotifier.value.withOpacity(opacity * opacity);
|
||||||
|
|
||||||
|
_isVerticallyScrollingNotifier.value = true;
|
||||||
|
_stopScrollMonitoringTimer();
|
||||||
|
_verticalScrollMonitoringTimer = Timer(Durations.infoScrollMonitoringTimerDelay, () {
|
||||||
|
_isVerticallyScrollingNotifier.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _stopScrollMonitoringTimer() {
|
||||||
|
_verticalScrollMonitoringTimer?.cancel();
|
||||||
}
|
}
|
||||||
|
|
||||||
// when the entry changed (e.g. by scrolling through the PageView, or if the entry got deleted)
|
// when the entry changed (e.g. by scrolling through the PageView, or if the entry got deleted)
|
||||||
|
|
|
@ -216,12 +216,11 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildTopOverlay() {
|
Widget _buildTopOverlay() {
|
||||||
final child = ValueListenableBuilder<AvesEntry?>(
|
Widget child = ValueListenableBuilder<AvesEntry?>(
|
||||||
valueListenable: _entryNotifier,
|
valueListenable: _entryNotifier,
|
||||||
builder: (context, mainEntry, child) {
|
builder: (context, mainEntry, child) {
|
||||||
if (mainEntry == null) return SizedBox.shrink();
|
if (mainEntry == null) return SizedBox.shrink();
|
||||||
|
|
||||||
final viewStateNotifier = _viewStateNotifiers.firstWhereOrNull((kv) => kv.item1 == mainEntry.uri)?.item2;
|
|
||||||
return ViewerTopOverlay(
|
return ViewerTopOverlay(
|
||||||
mainEntry: mainEntry,
|
mainEntry: mainEntry,
|
||||||
scale: _topOverlayScale,
|
scale: _topOverlayScale,
|
||||||
|
@ -242,11 +241,12 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
|
||||||
}
|
}
|
||||||
_actionDelegate.onActionSelected(context, targetEntry, action);
|
_actionDelegate.onActionSelected(context, targetEntry, action);
|
||||||
},
|
},
|
||||||
viewStateNotifier: viewStateNotifier,
|
viewStateNotifier: _viewStateNotifiers.firstWhereOrNull((kv) => kv.item1 == mainEntry.uri)?.item2,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return ValueListenableBuilder<int>(
|
|
||||||
|
child = ValueListenableBuilder<int>(
|
||||||
valueListenable: _currentVerticalPage,
|
valueListenable: _currentVerticalPage,
|
||||||
builder: (context, page, child) {
|
builder: (context, page, child) {
|
||||||
return Visibility(
|
return Visibility(
|
||||||
|
@ -256,13 +256,26 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
|
||||||
},
|
},
|
||||||
child: child,
|
child: child,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
child = ValueListenableBuilder<double>(
|
||||||
|
valueListenable: _overlayAnimationController,
|
||||||
|
builder: (context, animation, child) {
|
||||||
|
return Visibility(
|
||||||
|
visible: !_overlayAnimationController.isDismissed,
|
||||||
|
child: child!,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
|
||||||
|
return child;
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildBottomOverlay() {
|
Widget _buildBottomOverlay() {
|
||||||
Widget bottomOverlay = ValueListenableBuilder<AvesEntry?>(
|
Widget child = ValueListenableBuilder<AvesEntry?>(
|
||||||
valueListenable: _entryNotifier,
|
valueListenable: _entryNotifier,
|
||||||
builder: (context, entry, child) {
|
builder: (context, mainEntry, child) {
|
||||||
if (entry == null) return SizedBox.shrink();
|
if (mainEntry == null) return SizedBox.shrink();
|
||||||
|
|
||||||
Widget? _buildExtraBottomOverlay(AvesEntry pageEntry) {
|
Widget? _buildExtraBottomOverlay(AvesEntry pageEntry) {
|
||||||
// a 360 video is both a video and a panorama but only the video controls are displayed
|
// a 360 video is both a video and a panorama but only the video controls are displayed
|
||||||
|
@ -284,7 +297,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
final multiPageController = entry.isMultiPage ? context.read<MultiPageConductor>().getController(entry) : null;
|
final multiPageController = mainEntry.isMultiPage ? context.read<MultiPageConductor>().getController(mainEntry) : null;
|
||||||
final extraBottomOverlay = multiPageController != null
|
final extraBottomOverlay = multiPageController != null
|
||||||
? StreamBuilder<MultiPageInfo?>(
|
? StreamBuilder<MultiPageInfo?>(
|
||||||
stream: multiPageController.infoStream,
|
stream: multiPageController.infoStream,
|
||||||
|
@ -299,9 +312,9 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
: _buildExtraBottomOverlay(entry);
|
: _buildExtraBottomOverlay(mainEntry);
|
||||||
|
|
||||||
final child = Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
if (extraBottomOverlay != null)
|
if (extraBottomOverlay != null)
|
||||||
ExtraBottomOverlay(
|
ExtraBottomOverlay(
|
||||||
|
@ -322,20 +335,10 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
return ValueListenableBuilder<double>(
|
|
||||||
valueListenable: _overlayAnimationController,
|
|
||||||
builder: (context, animation, child) {
|
|
||||||
return Visibility(
|
|
||||||
visible: _overlayAnimationController.status != AnimationStatus.dismissed,
|
|
||||||
child: child!,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: child,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
bottomOverlay = Selector<MediaQueryData, double>(
|
child = Selector<MediaQueryData, double>(
|
||||||
selector: (c, mq) => mq.size.height,
|
selector: (c, mq) => mq.size.height,
|
||||||
builder: (c, mqHeight, child) {
|
builder: (c, mqHeight, child) {
|
||||||
// when orientation change, the `PageController` offset is not updated right away
|
// when orientation change, the `PageController` offset is not updated right away
|
||||||
|
@ -350,9 +353,19 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
|
||||||
child: child,
|
child: child,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: bottomOverlay,
|
child: child,
|
||||||
|
);
|
||||||
|
|
||||||
|
return ValueListenableBuilder<double>(
|
||||||
|
valueListenable: _overlayAnimationController,
|
||||||
|
builder: (context, animation, child) {
|
||||||
|
return Visibility(
|
||||||
|
visible: !_overlayAnimationController.isDismissed,
|
||||||
|
child: child!,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: child,
|
||||||
);
|
);
|
||||||
return bottomOverlay;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onVerticalPageControllerChange() {
|
void _onVerticalPageControllerChange() {
|
||||||
|
@ -383,10 +396,11 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _goToVerticalPage(int page) {
|
Future<void> _goToVerticalPage(int page) {
|
||||||
|
// duration & curve should feel similar to changing page by vertical fling
|
||||||
return _verticalPager.animateToPage(
|
return _verticalPager.animateToPage(
|
||||||
page,
|
page,
|
||||||
duration: Durations.viewerVerticalPageScrollAnimation,
|
duration: Durations.viewerVerticalPageScrollAnimation,
|
||||||
curve: Curves.easeInOut,
|
curve: Curves.easeOutQuart,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -14,20 +14,19 @@ import 'package:aves/widgets/common/extensions/build_context.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/viewer/info/common.dart';
|
import 'package:aves/widgets/viewer/info/common.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
class BasicSection extends StatelessWidget {
|
class BasicSection extends StatelessWidget {
|
||||||
final AvesEntry entry;
|
final AvesEntry entry;
|
||||||
final CollectionLens? collection;
|
final CollectionLens? collection;
|
||||||
final ValueNotifier<bool> visibleNotifier;
|
|
||||||
final FilterCallback onFilter;
|
final FilterCallback onFilter;
|
||||||
|
|
||||||
const BasicSection({
|
const BasicSection({
|
||||||
Key? key,
|
Key? key,
|
||||||
required this.entry,
|
required this.entry,
|
||||||
this.collection,
|
this.collection,
|
||||||
required this.visibleNotifier,
|
|
||||||
required this.onFilter,
|
required this.onFilter,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@ -65,7 +64,6 @@ class BasicSection extends StatelessWidget {
|
||||||
}),
|
}),
|
||||||
OwnerProp(
|
OwnerProp(
|
||||||
entry: entry,
|
entry: entry,
|
||||||
visibleNotifier: visibleNotifier,
|
|
||||||
),
|
),
|
||||||
_buildChips(context),
|
_buildChips(context),
|
||||||
],
|
],
|
||||||
|
@ -120,11 +118,9 @@ class BasicSection extends StatelessWidget {
|
||||||
|
|
||||||
class OwnerProp extends StatefulWidget {
|
class OwnerProp extends StatefulWidget {
|
||||||
final AvesEntry entry;
|
final AvesEntry entry;
|
||||||
final ValueNotifier<bool> visibleNotifier;
|
|
||||||
|
|
||||||
const OwnerProp({
|
const OwnerProp({
|
||||||
required this.entry,
|
required this.entry,
|
||||||
required this.visibleNotifier,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -132,53 +128,33 @@ class OwnerProp extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _OwnerPropState extends State<OwnerProp> {
|
class _OwnerPropState extends State<OwnerProp> {
|
||||||
final ValueNotifier<String?> _loadedUri = ValueNotifier(null);
|
late Future<String?> _ownerPackageFuture;
|
||||||
String? _ownerPackage;
|
|
||||||
|
|
||||||
AvesEntry get entry => widget.entry;
|
AvesEntry get entry => widget.entry;
|
||||||
|
|
||||||
bool get isVisible => widget.visibleNotifier.value;
|
|
||||||
|
|
||||||
static const iconSize = 20.0;
|
static const iconSize = 20.0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_registerWidget(widget);
|
final isMediaContent = entry.uri.startsWith('content://media/external/');
|
||||||
_getOwner();
|
if (isMediaContent) {
|
||||||
}
|
_ownerPackageFuture = metadataService.getContentResolverProp(entry, 'owner_package_name');
|
||||||
|
} else {
|
||||||
@override
|
_ownerPackageFuture = SynchronousFuture(null);
|
||||||
void didUpdateWidget(covariant OwnerProp oldWidget) {
|
}
|
||||||
super.didUpdateWidget(oldWidget);
|
|
||||||
_unregisterWidget(oldWidget);
|
|
||||||
_registerWidget(widget);
|
|
||||||
_getOwner();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_unregisterWidget(widget);
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _registerWidget(OwnerProp widget) {
|
|
||||||
widget.visibleNotifier.addListener(_getOwner);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _unregisterWidget(OwnerProp widget) {
|
|
||||||
widget.visibleNotifier.removeListener(_getOwner);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ValueListenableBuilder<String?>(
|
return FutureBuilder<String?>(
|
||||||
valueListenable: _loadedUri,
|
future: _ownerPackageFuture,
|
||||||
builder: (context, uri, child) {
|
builder: (context, snapshot) {
|
||||||
if (_ownerPackage == null) return SizedBox();
|
final ownerPackage = snapshot.data;
|
||||||
final appName = androidFileUtils.getCurrentAppName(_ownerPackage!) ?? _ownerPackage;
|
if (ownerPackage == null) return SizedBox();
|
||||||
|
final appName = androidFileUtils.getCurrentAppName(ownerPackage) ?? ownerPackage;
|
||||||
// as of Flutter v1.22.6, `SelectableText` cannot contain `WidgetSpan`
|
// as of Flutter v1.22.6, `SelectableText` cannot contain `WidgetSpan`
|
||||||
// so be use a basic `Text` instead
|
// so we use a basic `Text` instead
|
||||||
return Text.rich(
|
return Text.rich(
|
||||||
TextSpan(
|
TextSpan(
|
||||||
children: [
|
children: [
|
||||||
|
@ -188,14 +164,14 @@ class _OwnerPropState extends State<OwnerProp> {
|
||||||
),
|
),
|
||||||
// `com.android.shell` is the package reported
|
// `com.android.shell` is the package reported
|
||||||
// for images copied to the device by ADB for Test Driver
|
// for images copied to the device by ADB for Test Driver
|
||||||
if (_ownerPackage != 'com.android.shell')
|
if (ownerPackage != 'com.android.shell')
|
||||||
WidgetSpan(
|
WidgetSpan(
|
||||||
alignment: PlaceholderAlignment.middle,
|
alignment: PlaceholderAlignment.middle,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 4),
|
padding: EdgeInsets.symmetric(horizontal: 4),
|
||||||
child: Image(
|
child: Image(
|
||||||
image: AppIconImage(
|
image: AppIconImage(
|
||||||
packageName: _ownerPackage!,
|
packageName: ownerPackage,
|
||||||
size: iconSize,
|
size: iconSize,
|
||||||
),
|
),
|
||||||
width: iconSize,
|
width: iconSize,
|
||||||
|
@ -213,16 +189,4 @@ class _OwnerPropState extends State<OwnerProp> {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _getOwner() async {
|
|
||||||
if (_loadedUri.value == entry.uri) return;
|
|
||||||
final isMediaContent = entry.uri.startsWith('content://media/external/');
|
|
||||||
if (isVisible && isMediaContent) {
|
|
||||||
_ownerPackage = await metadataService.getContentResolverProp(entry, 'owner_package_name');
|
|
||||||
_loadedUri.value = entry.uri;
|
|
||||||
} else {
|
|
||||||
_ownerPackage = null;
|
|
||||||
_loadedUri.value = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,13 +17,13 @@ import 'package:provider/provider.dart';
|
||||||
class InfoPage extends StatefulWidget {
|
class InfoPage extends StatefulWidget {
|
||||||
final CollectionLens? collection;
|
final CollectionLens? collection;
|
||||||
final ValueNotifier<AvesEntry?> entryNotifier;
|
final ValueNotifier<AvesEntry?> entryNotifier;
|
||||||
final ValueNotifier<bool> visibleNotifier;
|
final ValueNotifier<bool> isScrollingNotifier;
|
||||||
|
|
||||||
const InfoPage({
|
const InfoPage({
|
||||||
Key? key,
|
Key? key,
|
||||||
required this.collection,
|
required this.collection,
|
||||||
required this.entryNotifier,
|
required this.entryNotifier,
|
||||||
required this.visibleNotifier,
|
required this.isScrollingNotifier,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -62,7 +62,7 @@ class _InfoPageState extends State<InfoPage> {
|
||||||
? _InfoPageContent(
|
? _InfoPageContent(
|
||||||
collection: collection,
|
collection: collection,
|
||||||
entry: entry,
|
entry: entry,
|
||||||
visibleNotifier: widget.visibleNotifier,
|
isScrollingNotifier: widget.isScrollingNotifier,
|
||||||
scrollController: _scrollController,
|
scrollController: _scrollController,
|
||||||
split: mqWidth > 600,
|
split: mqWidth > 600,
|
||||||
goToViewer: _goToViewer,
|
goToViewer: _goToViewer,
|
||||||
|
@ -126,7 +126,7 @@ class _InfoPageState extends State<InfoPage> {
|
||||||
class _InfoPageContent extends StatefulWidget {
|
class _InfoPageContent extends StatefulWidget {
|
||||||
final CollectionLens? collection;
|
final CollectionLens? collection;
|
||||||
final AvesEntry entry;
|
final AvesEntry entry;
|
||||||
final ValueNotifier<bool> visibleNotifier;
|
final ValueNotifier<bool> isScrollingNotifier;
|
||||||
final ScrollController scrollController;
|
final ScrollController scrollController;
|
||||||
final bool split;
|
final bool split;
|
||||||
final VoidCallback goToViewer;
|
final VoidCallback goToViewer;
|
||||||
|
@ -135,7 +135,7 @@ class _InfoPageContent extends StatefulWidget {
|
||||||
Key? key,
|
Key? key,
|
||||||
required this.collection,
|
required this.collection,
|
||||||
required this.entry,
|
required this.entry,
|
||||||
required this.visibleNotifier,
|
required this.isScrollingNotifier,
|
||||||
required this.scrollController,
|
required this.scrollController,
|
||||||
required this.split,
|
required this.split,
|
||||||
required this.goToViewer,
|
required this.goToViewer,
|
||||||
|
@ -154,14 +154,11 @@ class _InfoPageContentState extends State<_InfoPageContent> {
|
||||||
|
|
||||||
AvesEntry get entry => widget.entry;
|
AvesEntry get entry => widget.entry;
|
||||||
|
|
||||||
ValueNotifier<bool> get visibleNotifier => widget.visibleNotifier;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final basicSection = BasicSection(
|
final basicSection = BasicSection(
|
||||||
entry: entry,
|
entry: entry,
|
||||||
collection: collection,
|
collection: collection,
|
||||||
visibleNotifier: visibleNotifier,
|
|
||||||
onFilter: _goToCollection,
|
onFilter: _goToCollection,
|
||||||
);
|
);
|
||||||
final locationAtTop = widget.split && entry.hasGps;
|
final locationAtTop = widget.split && entry.hasGps;
|
||||||
|
@ -169,7 +166,7 @@ class _InfoPageContentState extends State<_InfoPageContent> {
|
||||||
collection: collection,
|
collection: collection,
|
||||||
entry: entry,
|
entry: entry,
|
||||||
showTitle: !locationAtTop,
|
showTitle: !locationAtTop,
|
||||||
visibleNotifier: visibleNotifier,
|
isScrollingNotifier: widget.isScrollingNotifier,
|
||||||
onFilter: _goToCollection,
|
onFilter: _goToCollection,
|
||||||
);
|
);
|
||||||
final basicAndLocationSliver = locationAtTop
|
final basicAndLocationSliver = locationAtTop
|
||||||
|
@ -194,7 +191,6 @@ class _InfoPageContentState extends State<_InfoPageContent> {
|
||||||
final metadataSliver = MetadataSectionSliver(
|
final metadataSliver = MetadataSectionSliver(
|
||||||
entry: entry,
|
entry: entry,
|
||||||
metadataNotifier: _metadataNotifier,
|
metadataNotifier: _metadataNotifier,
|
||||||
visibleNotifier: visibleNotifier,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return CustomScrollView(
|
return CustomScrollView(
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/filters/location.dart';
|
import 'package:aves/model/filters/location.dart';
|
||||||
import 'package:aves/model/settings/coordinate_format.dart';
|
import 'package:aves/model/settings/coordinate_format.dart';
|
||||||
|
import 'package:aves/model/settings/enums.dart';
|
||||||
import 'package:aves/model/settings/map_style.dart';
|
import 'package:aves/model/settings/map_style.dart';
|
||||||
import 'package:aves/model/settings/settings.dart';
|
import 'package:aves/model/settings/settings.dart';
|
||||||
import 'package:aves/model/source/collection_lens.dart';
|
import 'package:aves/model/source/collection_lens.dart';
|
||||||
|
@ -15,13 +16,14 @@ import 'package:aves/widgets/viewer/info/maps/google_map.dart';
|
||||||
import 'package:aves/widgets/viewer/info/maps/leaflet_map.dart';
|
import 'package:aves/widgets/viewer/info/maps/leaflet_map.dart';
|
||||||
import 'package:aves/widgets/viewer/info/maps/marker.dart';
|
import 'package:aves/widgets/viewer/info/maps/marker.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
import 'package:tuple/tuple.dart';
|
import 'package:tuple/tuple.dart';
|
||||||
|
|
||||||
class LocationSection extends StatefulWidget {
|
class LocationSection extends StatefulWidget {
|
||||||
final CollectionLens? collection;
|
final CollectionLens? collection;
|
||||||
final AvesEntry entry;
|
final AvesEntry entry;
|
||||||
final bool showTitle;
|
final bool showTitle;
|
||||||
final ValueNotifier<bool> visibleNotifier;
|
final ValueNotifier<bool> isScrollingNotifier;
|
||||||
final FilterCallback onFilter;
|
final FilterCallback onFilter;
|
||||||
|
|
||||||
const LocationSection({
|
const LocationSection({
|
||||||
|
@ -29,7 +31,7 @@ class LocationSection extends StatefulWidget {
|
||||||
required this.collection,
|
required this.collection,
|
||||||
required this.entry,
|
required this.entry,
|
||||||
required this.showTitle,
|
required this.showTitle,
|
||||||
required this.visibleNotifier,
|
required this.isScrollingNotifier,
|
||||||
required this.onFilter,
|
required this.onFilter,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@ -38,7 +40,11 @@ class LocationSection extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _LocationSectionState extends State<LocationSection> with TickerProviderStateMixin {
|
class _LocationSectionState extends State<LocationSection> with TickerProviderStateMixin {
|
||||||
String? _loadedUri;
|
// as of google_maps_flutter v2.0.6, Google Maps initialization is blocking
|
||||||
|
// cf https://github.com/flutter/flutter/issues/28493
|
||||||
|
// it is especially severe the first time, but still significant afterwards
|
||||||
|
// so we prevent loading it while scrolling or animating
|
||||||
|
bool _googleMapsLoaded = false;
|
||||||
|
|
||||||
static const extent = 48.0;
|
static const extent = 48.0;
|
||||||
static const pointerSize = Size(8.0, 6.0);
|
static const pointerSize = Size(8.0, 6.0);
|
||||||
|
@ -69,98 +75,114 @@ class _LocationSectionState extends State<LocationSection> with TickerProviderSt
|
||||||
void _registerWidget(LocationSection widget) {
|
void _registerWidget(LocationSection widget) {
|
||||||
widget.entry.metadataChangeNotifier.addListener(_handleChange);
|
widget.entry.metadataChangeNotifier.addListener(_handleChange);
|
||||||
widget.entry.addressChangeNotifier.addListener(_handleChange);
|
widget.entry.addressChangeNotifier.addListener(_handleChange);
|
||||||
widget.visibleNotifier.addListener(_handleChange);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _unregisterWidget(LocationSection widget) {
|
void _unregisterWidget(LocationSection widget) {
|
||||||
widget.entry.metadataChangeNotifier.removeListener(_handleChange);
|
widget.entry.metadataChangeNotifier.removeListener(_handleChange);
|
||||||
widget.entry.addressChangeNotifier.removeListener(_handleChange);
|
widget.entry.addressChangeNotifier.removeListener(_handleChange);
|
||||||
widget.visibleNotifier.removeListener(_handleChange);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final showMap = (_loadedUri == entry.uri) || (entry.hasGps && widget.visibleNotifier.value);
|
if (!entry.hasGps) return SizedBox();
|
||||||
if (showMap) {
|
final latLng = entry.latLng!;
|
||||||
_loadedUri = entry.uri;
|
final geoUri = entry.geoUri!;
|
||||||
final filters = <LocationFilter>[];
|
|
||||||
if (entry.hasAddress) {
|
|
||||||
final address = entry.addressDetails!;
|
|
||||||
final country = address.countryName;
|
|
||||||
if (country != null && country.isNotEmpty) filters.add(LocationFilter(LocationLevel.country, '$country${LocationFilter.locationSeparator}${address.countryCode}'));
|
|
||||||
final place = address.place;
|
|
||||||
if (place != null && place.isNotEmpty) filters.add(LocationFilter(LocationLevel.place, place));
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget buildMarker(BuildContext context) {
|
final filters = <LocationFilter>[];
|
||||||
return ImageMarker(
|
if (entry.hasAddress) {
|
||||||
|
final address = entry.addressDetails!;
|
||||||
|
final country = address.countryName;
|
||||||
|
if (country != null && country.isNotEmpty) filters.add(LocationFilter(LocationLevel.country, '$country${LocationFilter.locationSeparator}${address.countryCode}'));
|
||||||
|
final place = address.place;
|
||||||
|
if (place != null && place.isNotEmpty) filters.add(LocationFilter(LocationLevel.place, place));
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget buildMarker(BuildContext context) => ImageMarker(
|
||||||
entry: entry,
|
entry: entry,
|
||||||
extent: extent,
|
extent: extent,
|
||||||
pointerSize: pointerSize,
|
pointerSize: pointerSize,
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
if (widget.showTitle) SectionRow(AIcons.location),
|
if (widget.showTitle) SectionRow(AIcons.location),
|
||||||
FutureBuilder<bool>(
|
FutureBuilder<bool>(
|
||||||
future: availability.isConnected,
|
future: availability.isConnected,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (snapshot.data != true) return SizedBox();
|
if (snapshot.data != true) return SizedBox();
|
||||||
final latLng = entry.latLng!;
|
return Selector<Settings, EntryMapStyle>(
|
||||||
return NotificationListener<MapStyleChangedNotification>(
|
selector: (context, s) => s.infoMapStyle,
|
||||||
onNotification: (notification) {
|
builder: (context, mapStyle, child) {
|
||||||
setState(() {});
|
final isGoogleMaps = mapStyle.isGoogleMaps;
|
||||||
return true;
|
return AnimatedSize(
|
||||||
},
|
|
||||||
child: AnimatedSize(
|
|
||||||
alignment: Alignment.topCenter,
|
alignment: Alignment.topCenter,
|
||||||
curve: Curves.easeInOutCubic,
|
curve: Curves.easeInOutCubic,
|
||||||
duration: Durations.mapStyleSwitchAnimation,
|
duration: Durations.mapStyleSwitchAnimation,
|
||||||
vsync: this,
|
vsync: this,
|
||||||
child: settings.infoMapStyle.isGoogleMaps
|
child: ValueListenableBuilder<bool>(
|
||||||
? EntryGoogleMap(
|
valueListenable: widget.isScrollingNotifier,
|
||||||
// `LatLng` used by `google_maps_flutter` is not the one from `latlong` package
|
builder: (context, scrolling, child) {
|
||||||
latLng: Tuple2<double, double>(latLng.latitude, latLng.longitude),
|
if (!scrolling && isGoogleMaps) {
|
||||||
geoUri: entry.geoUri!,
|
_googleMapsLoaded = true;
|
||||||
initialZoom: settings.infoMapZoom,
|
}
|
||||||
markerId: entry.uri,
|
return Visibility(
|
||||||
markerBuilder: buildMarker,
|
visible: !isGoogleMaps || _googleMapsLoaded,
|
||||||
)
|
replacement: Stack(
|
||||||
: EntryLeafletMap(
|
children: [
|
||||||
latLng: latLng,
|
MapDecorator(),
|
||||||
geoUri: entry.geoUri!,
|
MapButtonPanel(
|
||||||
initialZoom: settings.infoMapZoom,
|
geoUri: geoUri,
|
||||||
style: settings.infoMapStyle,
|
zoomBy: (_) {},
|
||||||
markerSize: Size(extent, extent + pointerSize.height),
|
),
|
||||||
markerBuilder: buildMarker,
|
],
|
||||||
),
|
),
|
||||||
),
|
child: child!,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
child: isGoogleMaps
|
||||||
if (entry.hasGps) _AddressInfoGroup(entry: entry),
|
? EntryGoogleMap(
|
||||||
if (filters.isNotEmpty)
|
// `LatLng` used by `google_maps_flutter` is not the one from `latlong` package
|
||||||
Padding(
|
latLng: Tuple2<double, double>(latLng.latitude, latLng.longitude),
|
||||||
padding: EdgeInsets.symmetric(horizontal: AvesFilterChip.outlineWidth / 2) + EdgeInsets.only(top: 8),
|
geoUri: geoUri,
|
||||||
child: Wrap(
|
initialZoom: settings.infoMapZoom,
|
||||||
spacing: 8,
|
markerId: entry.uri,
|
||||||
runSpacing: 8,
|
markerBuilder: buildMarker,
|
||||||
children: filters
|
)
|
||||||
.map((filter) => AvesFilterChip(
|
: EntryLeafletMap(
|
||||||
filter: filter,
|
latLng: latLng,
|
||||||
onTap: widget.onFilter,
|
geoUri: geoUri,
|
||||||
))
|
initialZoom: settings.infoMapZoom,
|
||||||
.toList(),
|
style: settings.infoMapStyle,
|
||||||
),
|
markerSize: Size(
|
||||||
|
extent + ImageMarker.outerBorderWidth * 2,
|
||||||
|
extent + ImageMarker.outerBorderWidth * 2 + pointerSize.height,
|
||||||
|
),
|
||||||
|
markerBuilder: buildMarker,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_AddressInfoGroup(entry: entry),
|
||||||
|
if (filters.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: AvesFilterChip.outlineWidth / 2) + EdgeInsets.only(top: 8),
|
||||||
|
child: Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: filters
|
||||||
|
.map((filter) => AvesFilterChip(
|
||||||
|
filter: filter,
|
||||||
|
onTap: widget.onFilter,
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
);
|
],
|
||||||
} else {
|
);
|
||||||
_loadedUri = null;
|
|
||||||
return SizedBox.shrink();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleChange() => setState(() {});
|
void _handleChange() => setState(() {});
|
||||||
|
|
|
@ -15,11 +15,13 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/scheduler.dart';
|
import 'package:flutter/scheduler.dart';
|
||||||
|
|
||||||
class MapDecorator extends StatelessWidget {
|
class MapDecorator extends StatelessWidget {
|
||||||
final Widget child;
|
final Widget? child;
|
||||||
|
|
||||||
static final BorderRadius mapBorderRadius = BorderRadius.circular(24); // to match button circles
|
static const mapBorderRadius = BorderRadius.all(Radius.circular(24)); // to match button circles
|
||||||
|
static const mapBackground = Color(0xFFDBD5D3);
|
||||||
|
static const mapLoadingGrid = Color(0xFFC4BEBB);
|
||||||
|
|
||||||
const MapDecorator({required this.child});
|
const MapDecorator({this.child});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
@ -31,9 +33,22 @@ class MapDecorator extends StatelessWidget {
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: mapBorderRadius,
|
borderRadius: mapBorderRadius,
|
||||||
child: Container(
|
child: Container(
|
||||||
color: Colors.white70,
|
color: mapBackground,
|
||||||
height: 200,
|
height: 200,
|
||||||
child: child,
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
GridPaper(
|
||||||
|
color: mapLoadingGrid,
|
||||||
|
interval: 10,
|
||||||
|
divisions: 1,
|
||||||
|
subdivisions: 1,
|
||||||
|
child: CustomPaint(
|
||||||
|
size: Size.infinite,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (child != null) child!,
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
@ -94,7 +109,6 @@ class MapButtonPanel extends StatelessWidget {
|
||||||
await Future.delayed(Durations.dialogTransitionAnimation * timeDilation);
|
await Future.delayed(Durations.dialogTransitionAnimation * timeDilation);
|
||||||
if (style != null && style != settings.infoMapStyle) {
|
if (style != null && style != settings.infoMapStyle) {
|
||||||
settings.infoMapStyle = style;
|
settings.infoMapStyle = style;
|
||||||
MapStyleChangedNotification().dispatch(context);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
tooltip: context.l10n.viewerInfoMapStyleTooltip,
|
tooltip: context.l10n.viewerInfoMapStyleTooltip,
|
||||||
|
@ -139,7 +153,7 @@ class MapOverlayButton extends StatelessWidget {
|
||||||
color: kOverlayBackgroundColor,
|
color: kOverlayBackgroundColor,
|
||||||
child: Ink(
|
child: Ink(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: AvesCircleBorder.build(context),
|
border: AvesBorder.border,
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
|
@ -154,5 +168,3 @@ class MapOverlayButton extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class MapStyleChangedNotification extends Notification {}
|
|
||||||
|
|
|
@ -30,7 +30,7 @@ class EntryGoogleMap extends StatefulWidget {
|
||||||
State<StatefulWidget> createState() => _EntryGoogleMapState();
|
State<StatefulWidget> createState() => _EntryGoogleMapState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _EntryGoogleMapState extends State<EntryGoogleMap> with AutomaticKeepAliveClientMixin {
|
class _EntryGoogleMapState extends State<EntryGoogleMap> {
|
||||||
GoogleMapController? _controller;
|
GoogleMapController? _controller;
|
||||||
late Completer<Uint8List> _markerLoaderCompleter;
|
late Completer<Uint8List> _markerLoaderCompleter;
|
||||||
|
|
||||||
|
@ -59,7 +59,6 @@ class _EntryGoogleMapState extends State<EntryGoogleMap> with AutomaticKeepAlive
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
super.build(context);
|
|
||||||
return Stack(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
MarkerGeneratorWidget(
|
MarkerGeneratorWidget(
|
||||||
|
@ -132,7 +131,4 @@ class _EntryGoogleMapState extends State<EntryGoogleMap> with AutomaticKeepAlive
|
||||||
return MapType.none;
|
return MapType.none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
bool get wantKeepAlive => true;
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -33,7 +33,7 @@ class EntryLeafletMap extends StatefulWidget {
|
||||||
State<StatefulWidget> createState() => _EntryLeafletMapState();
|
State<StatefulWidget> createState() => _EntryLeafletMapState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _EntryLeafletMapState extends State<EntryLeafletMap> with AutomaticKeepAliveClientMixin, TickerProviderStateMixin {
|
class _EntryLeafletMapState extends State<EntryLeafletMap> with TickerProviderStateMixin {
|
||||||
final MapController _mapController = MapController();
|
final MapController _mapController = MapController();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -46,7 +46,6 @@ class _EntryLeafletMapState extends State<EntryLeafletMap> with AutomaticKeepAli
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
super.build(context);
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
@ -157,9 +156,6 @@ class _EntryLeafletMapState extends State<EntryLeafletMap> with AutomaticKeepAli
|
||||||
});
|
});
|
||||||
controller.forward();
|
controller.forward();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
bool get wantKeepAlive => true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class OSMHotLayer extends StatelessWidget {
|
class OSMHotLayer extends StatelessWidget {
|
||||||
|
|
|
@ -16,8 +16,10 @@ class ImageMarker extends StatelessWidget {
|
||||||
static const double outerBorderRadiusDim = 8;
|
static const double outerBorderRadiusDim = 8;
|
||||||
static const double outerBorderWidth = 1.5;
|
static const double outerBorderWidth = 1.5;
|
||||||
static const double innerBorderWidth = 2;
|
static const double innerBorderWidth = 2;
|
||||||
static const Color outerBorderColor = Colors.white30;
|
static const outerBorderColor = Colors.white30;
|
||||||
static final Color innerBorderColor = Colors.grey[900]!;
|
static const innerBorderColor = Color(0xFF212121);
|
||||||
|
static const outerBorderRadius = BorderRadius.all(Radius.circular(outerBorderRadiusDim));
|
||||||
|
static const innerBorderRadius = BorderRadius.all(Radius.circular(outerBorderRadiusDim - outerBorderWidth));
|
||||||
|
|
||||||
const ImageMarker({
|
const ImageMarker({
|
||||||
required this.entry,
|
required this.entry,
|
||||||
|
@ -37,8 +39,21 @@ class ImageMarker extends StatelessWidget {
|
||||||
extent: extent,
|
extent: extent,
|
||||||
);
|
);
|
||||||
|
|
||||||
final outerBorderRadius = BorderRadius.circular(outerBorderRadiusDim);
|
const outerDecoration = BoxDecoration(
|
||||||
final innerBorderRadius = BorderRadius.circular(outerBorderRadiusDim - outerBorderWidth);
|
border: Border.fromBorderSide(BorderSide(
|
||||||
|
color: outerBorderColor,
|
||||||
|
width: outerBorderWidth,
|
||||||
|
)),
|
||||||
|
borderRadius: outerBorderRadius,
|
||||||
|
);
|
||||||
|
|
||||||
|
const innerDecoration = BoxDecoration(
|
||||||
|
border: Border.fromBorderSide(BorderSide(
|
||||||
|
color: innerBorderColor,
|
||||||
|
width: innerBorderWidth,
|
||||||
|
)),
|
||||||
|
borderRadius: innerBorderRadius,
|
||||||
|
);
|
||||||
|
|
||||||
return CustomPaint(
|
return CustomPaint(
|
||||||
foregroundPainter: MarkerPointerPainter(
|
foregroundPainter: MarkerPointerPainter(
|
||||||
|
@ -50,21 +65,9 @@ class ImageMarker extends StatelessWidget {
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.only(bottom: pointerSize.height),
|
padding: EdgeInsets.only(bottom: pointerSize.height),
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: outerDecoration,
|
||||||
border: Border.all(
|
|
||||||
color: outerBorderColor,
|
|
||||||
width: outerBorderWidth,
|
|
||||||
),
|
|
||||||
borderRadius: outerBorderRadius,
|
|
||||||
),
|
|
||||||
child: DecoratedBox(
|
child: DecoratedBox(
|
||||||
decoration: BoxDecoration(
|
decoration: innerDecoration,
|
||||||
border: Border.all(
|
|
||||||
color: innerBorderColor,
|
|
||||||
width: innerBorderWidth,
|
|
||||||
),
|
|
||||||
borderRadius: innerBorderRadius,
|
|
||||||
),
|
|
||||||
position: DecorationPosition.foreground,
|
position: DecorationPosition.foreground,
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: innerBorderRadius,
|
borderRadius: innerBorderRadius,
|
||||||
|
|
|
@ -19,12 +19,10 @@ import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
|
||||||
|
|
||||||
class MetadataSectionSliver extends StatefulWidget {
|
class MetadataSectionSliver extends StatefulWidget {
|
||||||
final AvesEntry entry;
|
final AvesEntry entry;
|
||||||
final ValueNotifier<bool> visibleNotifier;
|
|
||||||
final ValueNotifier<Map<String, MetadataDirectory>> metadataNotifier;
|
final ValueNotifier<Map<String, MetadataDirectory>> metadataNotifier;
|
||||||
|
|
||||||
const MetadataSectionSliver({
|
const MetadataSectionSliver({
|
||||||
required this.entry,
|
required this.entry,
|
||||||
required this.visibleNotifier,
|
|
||||||
required this.metadataNotifier,
|
required this.metadataNotifier,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -32,18 +30,13 @@ class MetadataSectionSliver extends StatefulWidget {
|
||||||
State<StatefulWidget> createState() => _MetadataSectionSliverState();
|
State<StatefulWidget> createState() => _MetadataSectionSliverState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MetadataSectionSliverState extends State<MetadataSectionSliver> with AutomaticKeepAliveClientMixin {
|
class _MetadataSectionSliverState extends State<MetadataSectionSliver> {
|
||||||
final ValueNotifier<String?> _loadedMetadataUri = ValueNotifier(null);
|
|
||||||
final ValueNotifier<String?> _expandedDirectoryNotifier = ValueNotifier(null);
|
final ValueNotifier<String?> _expandedDirectoryNotifier = ValueNotifier(null);
|
||||||
|
|
||||||
AvesEntry get entry => widget.entry;
|
AvesEntry get entry => widget.entry;
|
||||||
|
|
||||||
bool get isVisible => widget.visibleNotifier.value;
|
|
||||||
|
|
||||||
ValueNotifier<Map<String, MetadataDirectory>> get metadataNotifier => widget.metadataNotifier;
|
ValueNotifier<Map<String, MetadataDirectory>> get metadataNotifier => widget.metadataNotifier;
|
||||||
|
|
||||||
Map<String, MetadataDirectory> get metadata => metadataNotifier.value;
|
|
||||||
|
|
||||||
// directory names may contain the name of their parent directory
|
// directory names may contain the name of their parent directory
|
||||||
// if so, they are separated by this character
|
// if so, they are separated by this character
|
||||||
static const parentChildSeparator = '/';
|
static const parentChildSeparator = '/';
|
||||||
|
@ -71,18 +64,15 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
|
||||||
}
|
}
|
||||||
|
|
||||||
void _registerWidget(MetadataSectionSliver widget) {
|
void _registerWidget(MetadataSectionSliver widget) {
|
||||||
widget.visibleNotifier.addListener(_getMetadata);
|
|
||||||
widget.entry.metadataChangeNotifier.addListener(_onMetadataChanged);
|
widget.entry.metadataChangeNotifier.addListener(_onMetadataChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _unregisterWidget(MetadataSectionSliver widget) {
|
void _unregisterWidget(MetadataSectionSliver widget) {
|
||||||
widget.visibleNotifier.removeListener(_getMetadata);
|
|
||||||
widget.entry.metadataChangeNotifier.removeListener(_onMetadataChanged);
|
widget.entry.metadataChangeNotifier.removeListener(_onMetadataChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
super.build(context);
|
|
||||||
// use a `Column` inside a `SliverToBoxAdapter`, instead of a `SliverList`,
|
// use a `Column` inside a `SliverToBoxAdapter`, instead of a `SliverList`,
|
||||||
// so that we can have the metadata-dependent `AnimationLimiter` inside the metadata section
|
// so that we can have the metadata-dependent `AnimationLimiter` inside the metadata section
|
||||||
// warning: placing the `AnimationLimiter` as a parent to the `ScrollView`
|
// warning: placing the `AnimationLimiter` as a parent to the `ScrollView`
|
||||||
|
@ -92,89 +82,81 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
|
||||||
// cancel notification bubbling so that the info page
|
// cancel notification bubbling so that the info page
|
||||||
// does not misinterpret content scrolling for page scrolling
|
// does not misinterpret content scrolling for page scrolling
|
||||||
onNotification: (notification) => true,
|
onNotification: (notification) => true,
|
||||||
child: ValueListenableBuilder<String?>(
|
child: ValueListenableBuilder<Map<String, MetadataDirectory>>(
|
||||||
valueListenable: _loadedMetadataUri,
|
valueListenable: metadataNotifier,
|
||||||
builder: (context, uri, child) {
|
builder: (context, metadata, child) {
|
||||||
Widget content;
|
Widget content;
|
||||||
if (metadata.isEmpty) {
|
if (metadata.isEmpty) {
|
||||||
content = SizedBox.shrink();
|
content = SizedBox.shrink();
|
||||||
} else {
|
} else {
|
||||||
content = Column(
|
content = Column(
|
||||||
children: AnimationConfiguration.toStaggeredList(
|
children: AnimationConfiguration.toStaggeredList(
|
||||||
duration: Durations.staggeredAnimation,
|
duration: Durations.staggeredAnimation,
|
||||||
delay: Durations.staggeredAnimationDelay,
|
delay: Durations.staggeredAnimationDelay,
|
||||||
childAnimationBuilder: (child) => SlideAnimation(
|
childAnimationBuilder: (child) => SlideAnimation(
|
||||||
verticalOffset: 50.0,
|
verticalOffset: 50.0,
|
||||||
child: FadeInAnimation(
|
child: FadeInAnimation(
|
||||||
child: child,
|
child: child,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
children: [
|
||||||
|
SectionRow(AIcons.info),
|
||||||
|
...metadata.entries.map((kv) => MetadataDirTile(
|
||||||
|
entry: entry,
|
||||||
|
title: kv.key,
|
||||||
|
dir: kv.value,
|
||||||
|
expandedDirectoryNotifier: _expandedDirectoryNotifier,
|
||||||
|
)),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
children: [
|
);
|
||||||
SectionRow(AIcons.info),
|
}
|
||||||
...metadata.entries.map((kv) => MetadataDirTile(
|
|
||||||
entry: entry,
|
return AnimationLimiter(
|
||||||
title: kv.key,
|
// we update the limiter key after fetching the metadata of a new entry,
|
||||||
dir: kv.value,
|
// in order to restart the staggered animation of the metadata section
|
||||||
expandedDirectoryNotifier: _expandedDirectoryNotifier,
|
key: ValueKey(metadata.length),
|
||||||
)),
|
child: content,
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}),
|
||||||
return AnimationLimiter(
|
|
||||||
// we update the limiter key after fetching the metadata of a new entry,
|
|
||||||
// in order to restart the staggered animation of the metadata section
|
|
||||||
key: Key(uri ?? ''),
|
|
||||||
child: content,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onMetadataChanged() {
|
void _onMetadataChanged() {
|
||||||
_loadedMetadataUri.value = null;
|
|
||||||
metadataNotifier.value = {};
|
metadataNotifier.value = {};
|
||||||
_getMetadata();
|
_getMetadata();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _getMetadata() async {
|
Future<void> _getMetadata() async {
|
||||||
if (_loadedMetadataUri.value == entry.uri) return;
|
final rawMetadata = await (entry.isSvg ? SvgMetadataService.getAllMetadata(entry) : metadataService.getAllMetadata(entry));
|
||||||
if (isVisible) {
|
final directories = rawMetadata.entries.map((dirKV) {
|
||||||
final rawMetadata = await (entry.isSvg ? SvgMetadataService.getAllMetadata(entry) : metadataService.getAllMetadata(entry));
|
var directoryName = dirKV.key as String;
|
||||||
final directories = rawMetadata.entries.map((dirKV) {
|
|
||||||
var directoryName = dirKV.key as String;
|
|
||||||
|
|
||||||
String? parent;
|
String? parent;
|
||||||
final parts = directoryName.split(parentChildSeparator);
|
final parts = directoryName.split(parentChildSeparator);
|
||||||
if (parts.length > 1) {
|
if (parts.length > 1) {
|
||||||
parent = parts[0];
|
parent = parts[0];
|
||||||
directoryName = parts[1];
|
directoryName = parts[1];
|
||||||
}
|
|
||||||
|
|
||||||
final rawTags = dirKV.value as Map;
|
|
||||||
return MetadataDirectory(directoryName, parent, _toSortedTags(rawTags));
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
if (entry.isVideo || (entry.mimeType == MimeTypes.heif && entry.isMultiPage)) {
|
|
||||||
directories.addAll(await _getStreamDirectories());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final titledDirectories = directories.map((dir) {
|
final rawTags = dirKV.value as Map;
|
||||||
var title = dir.name;
|
return MetadataDirectory(directoryName, parent, _toSortedTags(rawTags));
|
||||||
if (directories.where((dir) => dir.name == title).length > 1 && dir.parent?.isNotEmpty == true) {
|
}).toList();
|
||||||
title = '${dir.parent}/$title';
|
|
||||||
}
|
if (entry.isVideo || (entry.mimeType == MimeTypes.heif && entry.isMultiPage)) {
|
||||||
return MapEntry(title, dir);
|
directories.addAll(await _getStreamDirectories());
|
||||||
}).toList()
|
|
||||||
..sort((a, b) => compareAsciiUpperCase(a.key, b.key));
|
|
||||||
metadataNotifier.value = Map.fromEntries(titledDirectories);
|
|
||||||
_loadedMetadataUri.value = entry.uri;
|
|
||||||
} else {
|
|
||||||
metadataNotifier.value = {};
|
|
||||||
_loadedMetadataUri.value = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final titledDirectories = directories.map((dir) {
|
||||||
|
var title = dir.name;
|
||||||
|
if (directories.where((dir) => dir.name == title).length > 1 && dir.parent?.isNotEmpty == true) {
|
||||||
|
title = '${dir.parent}/$title';
|
||||||
|
}
|
||||||
|
return MapEntry(title, dir);
|
||||||
|
}).toList()
|
||||||
|
..sort((a, b) => compareAsciiUpperCase(a.key, b.key));
|
||||||
|
metadataNotifier.value = Map.fromEntries(titledDirectories);
|
||||||
_expandedDirectoryNotifier.value = null;
|
_expandedDirectoryNotifier.value = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -265,9 +247,6 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
|
||||||
.cast<MapEntry<String, String>>()));
|
.cast<MapEntry<String, String>>()));
|
||||||
return tags;
|
return tags;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
bool get wantKeepAlive => true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class MetadataDirectory {
|
class MetadataDirectory {
|
||||||
|
|
|
@ -19,8 +19,11 @@ class XmpStructArrayCard extends StatefulWidget {
|
||||||
required Map<int, Map<String, String>> structByIndex,
|
required Map<int, Map<String, String>> structByIndex,
|
||||||
this.linkifier,
|
this.linkifier,
|
||||||
}) {
|
}) {
|
||||||
structs.length = structByIndex.keys.fold(0, max);
|
final length = structByIndex.keys.fold(0, max);
|
||||||
structByIndex.keys.forEach((index) => structs[index - 1] = structByIndex[index]!);
|
structs.length = length;
|
||||||
|
for (var i = 0; i < length; i++) {
|
||||||
|
structs[i] = structByIndex[i + 1] ?? {};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
|
@ -10,6 +10,7 @@ class MultiPageController {
|
||||||
final AvesEntry entry;
|
final AvesEntry entry;
|
||||||
final ValueNotifier<int?> pageNotifier = ValueNotifier(null);
|
final ValueNotifier<int?> pageNotifier = ValueNotifier(null);
|
||||||
|
|
||||||
|
bool _disposed = false;
|
||||||
MultiPageInfo? _info;
|
MultiPageInfo? _info;
|
||||||
|
|
||||||
final StreamController<MultiPageInfo?> _infoStreamController = StreamController.broadcast();
|
final StreamController<MultiPageInfo?> _infoStreamController = StreamController.broadcast();
|
||||||
|
@ -24,7 +25,7 @@ class MultiPageController {
|
||||||
|
|
||||||
MultiPageController(this.entry) {
|
MultiPageController(this.entry) {
|
||||||
metadataService.getMultiPageInfo(entry).then((value) {
|
metadataService.getMultiPageInfo(entry).then((value) {
|
||||||
if (value == null) return;
|
if (value == null || _disposed) return;
|
||||||
pageNotifier.value = value.defaultPage!.index;
|
pageNotifier.value = value.defaultPage!.index;
|
||||||
_info = value;
|
_info = value;
|
||||||
_infoStreamController.add(_info);
|
_infoStreamController.add(_info);
|
||||||
|
@ -32,6 +33,7 @@ class MultiPageController {
|
||||||
}
|
}
|
||||||
|
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
pageNotifier.dispose();
|
pageNotifier.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -119,7 +119,7 @@ class _MultiPageOverlayState extends State<MultiPageOverlay> {
|
||||||
onTap: () => _goToPage(page),
|
onTap: () => _goToPage(page),
|
||||||
child: DecoratedThumbnail(
|
child: DecoratedThumbnail(
|
||||||
entry: pageEntry,
|
entry: pageEntry,
|
||||||
extent: extent,
|
tileExtent: extent,
|
||||||
// the retrieval task queue can pile up for thumbnails of heavy pages
|
// the retrieval task queue can pile up for thumbnails of heavy pages
|
||||||
// (e.g. thumbnails of 15MP HEIF images inside 100MB+ HEIC containers)
|
// (e.g. thumbnails of 15MP HEIF images inside 100MB+ HEIC containers)
|
||||||
// so we cancel these requests when possible
|
// so we cancel these requests when possible
|
||||||
|
|
|
@ -155,7 +155,7 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTi
|
||||||
padding: EdgeInsets.symmetric(vertical: 4, horizontal: 16) + EdgeInsets.only(bottom: 16),
|
padding: EdgeInsets.symmetric(vertical: 4, horizontal: 16) + EdgeInsets.only(bottom: 16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: kOverlayBackgroundColor,
|
color: kOverlayBackgroundColor,
|
||||||
border: AvesCircleBorder.build(context),
|
border: AvesBorder.border,
|
||||||
borderRadius: BorderRadius.circular(progressBarBorderRadius),
|
borderRadius: BorderRadius.circular(progressBarBorderRadius),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
@ -184,7 +184,7 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> with SingleTi
|
||||||
if (!progress.isFinite) progress = 0.0;
|
if (!progress.isFinite) progress = 0.0;
|
||||||
return LinearProgressIndicator(
|
return LinearProgressIndicator(
|
||||||
value: progress,
|
value: progress,
|
||||||
backgroundColor: Colors.grey[700],
|
backgroundColor: Colors.grey.shade700,
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|
|
@ -24,7 +24,7 @@ class OverlayButton extends StatelessWidget {
|
||||||
color: kOverlayBackgroundColor,
|
color: kOverlayBackgroundColor,
|
||||||
child: Ink(
|
child: Ink(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: AvesCircleBorder.build(context),
|
border: AvesBorder.border,
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: child,
|
child: child,
|
||||||
|
@ -66,7 +66,7 @@ class OverlayTextButton extends StatelessWidget {
|
||||||
foregroundColor: MaterialStateProperty.all<Color>(Colors.white),
|
foregroundColor: MaterialStateProperty.all<Color>(Colors.white),
|
||||||
overlayColor: MaterialStateProperty.all<Color>(Colors.white.withOpacity(0.12)),
|
overlayColor: MaterialStateProperty.all<Color>(Colors.white.withOpacity(0.12)),
|
||||||
minimumSize: _minSize,
|
minimumSize: _minSize,
|
||||||
side: MaterialStateProperty.all<BorderSide>(AvesCircleBorder.buildSide(context)),
|
side: MaterialStateProperty.all<BorderSide>(AvesBorder.side),
|
||||||
shape: MaterialStateProperty.all<OutlinedBorder>(RoundedRectangleBorder(
|
shape: MaterialStateProperty.all<OutlinedBorder>(RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(_borderRadius),
|
borderRadius: BorderRadius.circular(_borderRadius),
|
||||||
)),
|
)),
|
||||||
|
|
|
@ -178,7 +178,7 @@ class _TopOverlayRow extends StatelessWidget {
|
||||||
if (pageEntry.canRotateAndFlip) _buildRotateAndFlipMenuItems(context),
|
if (pageEntry.canRotateAndFlip) _buildRotateAndFlipMenuItems(context),
|
||||||
PopupMenuDivider(),
|
PopupMenuDivider(),
|
||||||
...externalAppActions.map((action) => _buildPopupMenuItem(context, action)),
|
...externalAppActions.map((action) => _buildPopupMenuItem(context, action)),
|
||||||
if (kDebugMode) ...[
|
if (!kReleaseMode) ...[
|
||||||
PopupMenuDivider(),
|
PopupMenuDivider(),
|
||||||
_buildPopupMenuItem(context, EntryAction.debug),
|
_buildPopupMenuItem(context, EntryAction.debug),
|
||||||
]
|
]
|
||||||
|
|
Loading…
Reference in a new issue