various perf related changes

This commit is contained in:
Thibault Deckers 2021-06-06 14:48:45 +09:00
parent 29cb704391
commit b539597c62
43 changed files with 552 additions and 444 deletions

View file

@ -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}');
}, },

View file

@ -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(

View file

@ -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,

View file

@ -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,
); );
} }

View file

@ -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);

View file

@ -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,
), ),

View file

@ -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),
); );

View file

@ -92,6 +92,8 @@ class _AvesAppState extends State<AvesApp> {
...AppLocalizations.localizationsDelegates, ...AppLocalizations.localizationsDelegates,
], ],
supportedLocales: AppLocalizations.supportedLocales, supportedLocales: AppLocalizations.supportedLocales,
// checkerboardRasterCacheImages: true,
// checkerboardOffscreenLayers: true,
); );
}); });
}, },

View file

@ -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

View file

@ -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(
child: Material(
type: MaterialType.circle, type: MaterialType.circle,
elevation: 3, elevation: 3,
color: Colors.transparent, color: Colors.transparent,
shadowColor: Colors.black, shadowColor: Colors.black,
child: albumIcon, child: albumIcon,
),
); );
} }
} }

View file

@ -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,

View file

@ -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

View file

@ -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,
); );
} }

View file

@ -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;
if (snapshot.connectionState != ConnectionState.done) {
child = SizedBox();
} else {
final exists = snapshot.data!; final exists = snapshot.data!;
return Container( child = Tooltip(
alignment: Alignment.center,
color: Colors.black,
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,
); );
}); });
} }

View file

@ -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),
), ),
), ),
), ),

View file

@ -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;
final image = imageInfo == null
? Container(
color: backgroundColor,
width: extent, width: extent,
height: extent, height: extent,
fit: BoxFit.cover,
);
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; : RawImage(
}, image: imageInfo.image,
child: frame == null ? fastImage : child, debugImageLabel: imageInfo.debugLabel,
);
},
errorBuilder: _buildError,
width: extent, width: extent,
height: 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);
}

View file

@ -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,

View file

@ -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));
}
static BorderSide buildSide(BuildContext context) {
return BorderSide(
color: borderColor, color: borderColor,
width: _borderWidth(context), width: borderWidth,
); );
}
static Border get border => Border.fromBorderSide(side);
} }

View file

@ -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,
); );
} }

View file

@ -30,6 +30,7 @@ class SectionedListSliver<T> extends StatelessWidget {
}, },
childCount: childCount, childCount: childCount,
addAutomaticKeepAlives: false, addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
), ),
); );
} }

View file

@ -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(

View file

@ -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,

View file

@ -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,

View file

@ -58,7 +58,7 @@ class _AppDrawerState extends State<AppDrawer> {
albumListTile, albumListTile,
countryListTile, countryListTile,
tagListTile, tagListTile,
if (kDebugMode) ...[ if (!kReleaseMode) ...[
Divider(), Divider(),
debugTile, debugTile,
], ],

View file

@ -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;

View file

@ -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,

View file

@ -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,

View file

@ -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,10 +81,10 @@ 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
SizedBox(), final transitionPage = SizedBox();
hasCollection
final imagePage = hasCollection
? MultiEntryScroller( ? MultiEntryScroller(
collection: collection!, collection: collection!,
pageController: widget.horizontalPager, pageController: widget.horizontalPager,
@ -91,18 +95,33 @@ class _ViewerVerticalPageViewState extends State<ViewerVerticalPageView> {
? SingleEntryScroller( ? SingleEntryScroller(
entry: entry!, entry: entry!,
) )
: SizedBox(), : SizedBox();
NotificationListener<BackUpNotification>(
final infoPage = NotificationListener<BackUpNotification>(
onNotification: (notification) { onNotification: (notification) {
widget.onImagePageRequested(); widget.onImagePageRequested();
return true; 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)

View file

@ -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,
); );
} }

View file

@ -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 {
_ownerPackageFuture = SynchronousFuture(null);
} }
@override
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;
}
}
} }

View file

@ -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(

View file

@ -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,20 +75,19 @@ 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>[]; final filters = <LocationFilter>[];
if (entry.hasAddress) { if (entry.hasAddress) {
final address = entry.addressDetails!; final address = entry.addressDetails!;
@ -92,13 +97,11 @@ class _LocationSectionState extends State<LocationSection> with TickerProviderSt
if (place != null && place.isNotEmpty) filters.add(LocationFilter(LocationLevel.place, place)); if (place != null && place.isNotEmpty) filters.add(LocationFilter(LocationLevel.place, place));
} }
Widget buildMarker(BuildContext context) { Widget buildMarker(BuildContext context) => ImageMarker(
return ImageMarker(
entry: entry, entry: entry,
extent: extent, extent: extent,
pointerSize: pointerSize, pointerSize: pointerSize,
); );
}
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -108,39 +111,62 @@ class _LocationSectionState extends State<LocationSection> with TickerProviderSt
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>(
valueListenable: widget.isScrollingNotifier,
builder: (context, scrolling, child) {
if (!scrolling && isGoogleMaps) {
_googleMapsLoaded = true;
}
return Visibility(
visible: !isGoogleMaps || _googleMapsLoaded,
replacement: Stack(
children: [
MapDecorator(),
MapButtonPanel(
geoUri: geoUri,
zoomBy: (_) {},
),
],
),
child: child!,
);
},
child: isGoogleMaps
? EntryGoogleMap( ? EntryGoogleMap(
// `LatLng` used by `google_maps_flutter` is not the one from `latlong` package // `LatLng` used by `google_maps_flutter` is not the one from `latlong` package
latLng: Tuple2<double, double>(latLng.latitude, latLng.longitude), latLng: Tuple2<double, double>(latLng.latitude, latLng.longitude),
geoUri: entry.geoUri!, geoUri: geoUri,
initialZoom: settings.infoMapZoom, initialZoom: settings.infoMapZoom,
markerId: entry.uri, markerId: entry.uri,
markerBuilder: buildMarker, markerBuilder: buildMarker,
) )
: EntryLeafletMap( : EntryLeafletMap(
latLng: latLng, latLng: latLng,
geoUri: entry.geoUri!, geoUri: geoUri,
initialZoom: settings.infoMapZoom, initialZoom: settings.infoMapZoom,
style: settings.infoMapStyle, style: settings.infoMapStyle,
markerSize: Size(extent, extent + pointerSize.height), markerSize: Size(
extent + ImageMarker.outerBorderWidth * 2,
extent + ImageMarker.outerBorderWidth * 2 + pointerSize.height,
),
markerBuilder: buildMarker, markerBuilder: buildMarker,
), ),
), ),
); );
}, },
);
},
), ),
if (entry.hasGps) _AddressInfoGroup(entry: entry), _AddressInfoGroup(entry: entry),
if (filters.isNotEmpty) if (filters.isNotEmpty)
Padding( Padding(
padding: EdgeInsets.symmetric(horizontal: AvesFilterChip.outlineWidth / 2) + EdgeInsets.only(top: 8), padding: EdgeInsets.symmetric(horizontal: AvesFilterChip.outlineWidth / 2) + EdgeInsets.only(top: 8),
@ -157,10 +183,6 @@ class _LocationSectionState extends State<LocationSection> with TickerProviderSt
), ),
], ],
); );
} else {
_loadedUri = null;
return SizedBox.shrink();
}
} }
void _handleChange() => setState(() {}); void _handleChange() => setState(() {});

View file

@ -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 {}

View file

@ -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;
} }

View file

@ -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 {

View file

@ -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,

View file

@ -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,9 +82,9 @@ 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();
@ -121,27 +111,24 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
), ),
); );
} }
return AnimationLimiter( return AnimationLimiter(
// we update the limiter key after fetching the metadata of a new entry, // we update the limiter key after fetching the metadata of a new entry,
// in order to restart the staggered animation of the metadata section // in order to restart the staggered animation of the metadata section
key: Key(uri ?? ''), key: ValueKey(metadata.length),
child: content, 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;
if (isVisible) {
final rawMetadata = await (entry.isSvg ? SvgMetadataService.getAllMetadata(entry) : metadataService.getAllMetadata(entry)); final rawMetadata = await (entry.isSvg ? SvgMetadataService.getAllMetadata(entry) : metadataService.getAllMetadata(entry));
final directories = rawMetadata.entries.map((dirKV) { final directories = rawMetadata.entries.map((dirKV) {
var directoryName = dirKV.key as String; var directoryName = dirKV.key as String;
@ -170,11 +157,6 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
}).toList() }).toList()
..sort((a, b) => compareAsciiUpperCase(a.key, b.key)); ..sort((a, b) => compareAsciiUpperCase(a.key, b.key));
metadataNotifier.value = Map.fromEntries(titledDirectories); metadataNotifier.value = Map.fromEntries(titledDirectories);
_loadedMetadataUri.value = entry.uri;
} else {
metadataNotifier.value = {};
_loadedMetadataUri.value = null;
}
_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 {

View file

@ -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

View file

@ -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();
} }

View file

@ -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

View file

@ -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,
); );
}), }),
), ),

View file

@ -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),
)), )),

View file

@ -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),
] ]