diff --git a/lib/image_providers/thumbnail_provider.dart b/lib/image_providers/thumbnail_provider.dart index e318db79c..309f696cc 100644 --- a/lib/image_providers/thumbnail_provider.dart +++ b/lib/image_providers/thumbnail_provider.dart @@ -22,6 +22,7 @@ class ThumbnailProvider extends ImageProvider { return MultiFrameImageStreamCompleter( codec: _loadAsync(key, decode), scale: 1.0, + debugLabel: kReleaseMode ? null : [key.uri, key.extent].join('-'), informationCollector: () sync* { yield ErrorDescription('uri=${key.uri}, pageId=${key.pageId}, mimeType=${key.mimeType}, extent=${key.extent}'); }, diff --git a/lib/main.dart b/lib/main.dart index 650b10710..470a5c629 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -9,6 +9,18 @@ void main() { // HttpClient.enableTimelineLogging = true; // enable network traffic logging // 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 { final List errorAndStacktrace = pair; await FirebaseCrashlytics.instance.recordError( diff --git a/lib/model/entry_cache.dart b/lib/model/entry_cache.dart index 1e3de138f..25f121154 100644 --- a/lib/model/entry_cache.dart +++ b/lib/model/entry_cache.dart @@ -1,10 +1,11 @@ import 'dart:async'; -import 'dart:math'; import 'package:aves/image_providers/thumbnail_provider.dart'; import 'package:aves/image_providers/uri_image_provider.dart'; class EntryCache { + static final requestExtents = {}; + static Future evict( String uri, String mimeType, @@ -34,10 +35,8 @@ class EntryCache { isFlipped: oldIsFlipped, )).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( - extents, + requestExtents, (extent) => ThumbnailProvider(ThumbnailProviderKey( uri: uri, mimeType: mimeType, diff --git a/lib/model/entry_images.dart b/lib/model/entry_images.dart index 464e906c1..7103c69a6 100644 --- a/lib/model/entry_images.dart +++ b/lib/model/entry_images.dart @@ -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/uri_image_provider.dart'; import 'package:aves/model/entry.dart'; +import 'package:aves/model/entry_cache.dart'; import 'package:flutter/widgets.dart'; extension ExtraAvesEntry on AvesEntry { @@ -13,11 +14,7 @@ extension ExtraAvesEntry on AvesEntry { } ThumbnailProviderKey _getThumbnailProviderKey(double extent) { - // we standardize the thumbnail loading dimension by taking the nearest larger power of 2 - // 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(); - + EntryCache.requestExtents.add(extent); return ThumbnailProviderKey( uri: uri, mimeType: mimeType, @@ -25,7 +22,7 @@ extension ExtraAvesEntry on AvesEntry { rotationDegrees: rotationDegrees, isFlipped: isFlipped, dateModifiedSecs: dateModifiedSecs ?? -1, - extent: requestExtent, + extent: extent, ); } diff --git a/lib/theme/durations.dart b/lib/theme/durations.dart index 03fde8263..2b9f07fe6 100644 --- a/lib/theme/durations.dart +++ b/lib/theme/durations.dart @@ -32,14 +32,13 @@ class Durations { static const collectionOpOverlayAnimation = Duration(milliseconds: 300); static const collectionScalingBackgroundAnimation = Duration(milliseconds: 200); static const sectionHeaderAnimation = Duration(milliseconds: 200); - static const thumbnailTransition = Duration(milliseconds: 200); static const thumbnailOverlayAnimation = Duration(milliseconds: 200); // search animations static const filterRowExpandAnimation = Duration(milliseconds: 300); // viewer animations - static const viewerVerticalPageScrollAnimation = Duration(milliseconds: 300); + static const viewerVerticalPageScrollAnimation = Duration(milliseconds: 500); static const viewerOverlayAnimation = Duration(milliseconds: 200); static const viewerOverlayChangeAnimation = Duration(milliseconds: 150); static const viewerOverlayPageScrollAnimation = Duration(milliseconds: 200); @@ -56,6 +55,7 @@ class Durations { // delays & refresh intervals static const opToastDisplay = Duration(seconds: 3); + static const infoScrollMonitoringTimerDelay = Duration(milliseconds: 100); static const collectionScrollMonitoringTimerDelay = Duration(milliseconds: 100); static const collectionScalingCompleteNotificationDelay = Duration(milliseconds: 300); static const highlightScrollInitDelay = Duration(milliseconds: 800); diff --git a/lib/theme/themes.dart b/lib/theme/themes.dart index 9cf6f14bd..a572217fd 100644 --- a/lib/theme/themes.dart +++ b/lib/theme/themes.dart @@ -9,7 +9,7 @@ class Themes { static final darkTheme = ThemeData( brightness: Brightness.dark, accentColor: _accentColor, - scaffoldBackgroundColor: Colors.grey[900], + scaffoldBackgroundColor: Colors.grey.shade900, dialogBackgroundColor: Colors.grey[850], toggleableActiveColor: _accentColor, tooltipTheme: TooltipThemeData( @@ -31,7 +31,7 @@ class Themes { onSecondary: Colors.white, ), snackBarTheme: SnackBarThemeData( - backgroundColor: Colors.grey[800], + backgroundColor: Colors.grey.shade800, contentTextStyle: TextStyle( color: Colors.white, ), diff --git a/lib/utils/constants.dart b/lib/utils/constants.dart index 167370a45..db979853e 100644 --- a/lib/utils/constants.dart +++ b/lib/utils/constants.dart @@ -16,7 +16,7 @@ class Constants { ); static const embossShadow = Shadow( - color: Colors.black87, + color: Colors.black, offset: Offset(0.5, 1.0), ); diff --git a/lib/widgets/aves_app.dart b/lib/widgets/aves_app.dart index c997ed0d8..67024bdac 100644 --- a/lib/widgets/aves_app.dart +++ b/lib/widgets/aves_app.dart @@ -92,6 +92,8 @@ class _AvesAppState extends State { ...AppLocalizations.localizationsDelegates, ], supportedLocales: AppLocalizations.supportedLocales, + // checkerboardRasterCacheImages: true, + // checkerboardOffscreenLayers: true, ); }); }, diff --git a/lib/widgets/collection/collection_grid.dart b/lib/widgets/collection/collection_grid.dart index c32b22cfd..23417532b 100644 --- a/lib/widgets/collection/collection_grid.dart +++ b/lib/widgets/collection/collection_grid.dart @@ -75,19 +75,21 @@ class _CollectionGridContent extends StatelessWidget { builder: (context, tileExtent, child) { return ThumbnailTheme( extent: tileExtent, - child: Selector>( - selector: (context, c) => Tuple2(c.viewportSize.width, c.columnCount), + child: Selector>( + selector: (context, c) => Tuple3(c.viewportSize.width, c.columnCount, c.spacing), builder: (context, c, child) { final scrollableWidth = c.item1; final columnCount = c.item2; + final tileSpacing = c.item3; // do not listen for animation delay change final controller = Provider.of(context, listen: false); final tileAnimationDelay = controller.getTileAnimationDelay(Durations.staggeredAnimationPageTarget); return SectionedEntryListLayoutProvider( collection: collection, scrollableWidth: scrollableWidth, - tileExtent: tileExtent, columnCount: columnCount, + spacing: tileSpacing, + tileExtent: tileExtent, tileBuilder: (entry) => InteractiveThumbnail( key: ValueKey(entry.contentId), collection: collection, @@ -204,7 +206,7 @@ class _CollectionScaler extends StatelessWidget { extent: extent, child: DecoratedThumbnail( entry: entry, - extent: extent, + tileExtent: extent, selectable: false, highlightable: false, ), @@ -317,7 +319,7 @@ class _CollectionScrollViewState extends State<_CollectionScrollView> { // workaround to prevent scrolling the app bar away // when there is no content and we use `SliverFillRemaining` physics: collection.isEmpty ? NeverScrollableScrollPhysics() : SloppyScrollPhysics(parent: AlwaysScrollableScrollPhysics()), - cacheExtent: context.select((controller) => controller.effectiveExtentMax * 2), + cacheExtent: context.select((controller) => controller.effectiveExtentMax), slivers: [ appBar, collection.isEmpty diff --git a/lib/widgets/collection/grid/headers/album.dart b/lib/widgets/collection/grid/headers/album.dart index a47e2bff3..8b0f1a6f6 100644 --- a/lib/widgets/collection/grid/headers/album.dart +++ b/lib/widgets/collection/grid/headers/album.dart @@ -22,12 +22,14 @@ class AlbumSectionHeader extends StatelessWidget { if (directory != null) { albumIcon = IconUtils.getAlbumIcon(context: context, albumPath: directory!); if (albumIcon != null) { - albumIcon = Material( - type: MaterialType.circle, - elevation: 3, - color: Colors.transparent, - shadowColor: Colors.black, - child: albumIcon, + albumIcon = RepaintBoundary( + child: Material( + type: MaterialType.circle, + elevation: 3, + color: Colors.transparent, + shadowColor: Colors.black, + child: albumIcon, + ), ); } } diff --git a/lib/widgets/collection/grid/section_layout.dart b/lib/widgets/collection/grid/section_layout.dart index 0e9f4b948..785ea1cf4 100644 --- a/lib/widgets/collection/grid/section_layout.dart +++ b/lib/widgets/collection/grid/section_layout.dart @@ -12,6 +12,7 @@ class SectionedEntryListLayoutProvider extends SectionedListLayoutProvider? cancellableNotifier; final bool selectable, highlightable; static final Color borderColor = Colors.grey.shade700; - static const double borderWidth = .5; + static final double borderWidth = AvesBorder.borderWidth; const DecoratedThumbnail({ Key? key, required this.entry, - required this.extent, + required this.tileExtent, this.collection, this.cancellableNotifier, this.selectable = true, @@ -27,6 +28,8 @@ class DecoratedThumbnail extends StatelessWidget { @override Widget build(BuildContext context) { + final imageExtent = tileExtent - borderWidth * 2; + // 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) // 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 ? VectorImageThumbnail( entry: entry, - extent: extent, + extent: imageExtent, heroTag: heroTag, ) : RasterImageThumbnail( entry: entry, - extent: extent, + extent: imageExtent, cancellableNotifier: cancellableNotifier, heroTag: heroTag, ); @@ -49,33 +52,21 @@ class DecoratedThumbnail extends StatelessWidget { alignment: isSvg ? Alignment.center : AlignmentDirectional.bottomStart, children: [ child, - if (!isSvg) - ThumbnailEntryOverlay( - entry: entry, - extent: extent, - ), - if (selectable) - ThumbnailSelectionOverlay( - entry: entry, - extent: extent, - ), - if (highlightable) - ThumbnailHighlightOverlay( - entry: entry, - extent: extent, - ), + if (!isSvg) ThumbnailEntryOverlay(entry: entry), + if (selectable) ThumbnailSelectionOverlay(entry: entry), + if (highlightable) ThumbnailHighlightOverlay(entry: entry), ], ); return Container( - foregroundDecoration: BoxDecoration( + decoration: BoxDecoration( border: Border.all( color: borderColor, width: borderWidth, ), ), - width: extent, - height: extent, + width: tileExtent, + height: tileExtent, child: child, ); } diff --git a/lib/widgets/collection/thumbnail/error.dart b/lib/widgets/collection/thumbnail/error.dart index 701deb93f..d4a594ce5 100644 --- a/lib/widgets/collection/thumbnail/error.dart +++ b/lib/widgets/collection/thumbnail/error.dart @@ -41,12 +41,12 @@ class _ErrorThumbnailState extends State { return FutureBuilder( future: _exists, builder: (context, snapshot) { - if (snapshot.connectionState != ConnectionState.done) return SizedBox(); - final exists = snapshot.data!; - return Container( - alignment: Alignment.center, - color: Colors.black, - child: Tooltip( + Widget child; + if (snapshot.connectionState != ConnectionState.done) { + child = SizedBox(); + } else { + final exists = snapshot.data!; + child = Tooltip( message: exists ? widget.tooltip : context.l10n.viewerErrorDoesNotExist, preferBelow: false, child: exists @@ -63,7 +63,14 @@ class _ErrorThumbnailState extends State { size: extent / 2, color: color, ), - ), + ); + } + return Container( + alignment: Alignment.center, + color: Colors.black, + width: extent, + height: extent, + child: child, ); }); } diff --git a/lib/widgets/collection/thumbnail/overlay.dart b/lib/widgets/collection/thumbnail/overlay.dart index 167df28da..fc2d789ce 100644 --- a/lib/widgets/collection/thumbnail/overlay.dart +++ b/lib/widgets/collection/thumbnail/overlay.dart @@ -14,12 +14,10 @@ import 'package:provider/provider.dart'; class ThumbnailEntryOverlay extends StatelessWidget { final AvesEntry entry; - final double extent; const ThumbnailEntryOverlay({ Key? key, required this.entry, - required this.extent, }) : super(key: key); @override @@ -51,14 +49,12 @@ class ThumbnailEntryOverlay extends StatelessWidget { class ThumbnailSelectionOverlay extends StatelessWidget { final AvesEntry entry; - final double extent; static const duration = Durations.thumbnailOverlayAnimation; const ThumbnailSelectionOverlay({ Key? key, required this.entry, - required this.extent, }) : super(key: key); @override @@ -110,12 +106,10 @@ class ThumbnailSelectionOverlay extends StatelessWidget { class ThumbnailHighlightOverlay extends StatefulWidget { final AvesEntry entry; - final double extent; const ThumbnailHighlightOverlay({ Key? key, required this.entry, - required this.extent, }) : super(key: key); @override @@ -138,7 +132,7 @@ class _ThumbnailHighlightOverlayState extends State { decoration: BoxDecoration( border: Border.all( color: Theme.of(context).accentColor, - width: widget.extent * .1, + width: context.select((t) => t.highlightBorderWidth), ), ), ), diff --git a/lib/widgets/collection/thumbnail/raster.dart b/lib/widgets/collection/thumbnail/raster.dart index 85fb8e8b7..a089a0a8e 100644 --- a/lib/widgets/collection/thumbnail/raster.dart +++ b/lib/widgets/collection/thumbnail/raster.dart @@ -1,7 +1,10 @@ +import 'dart:math'; +import 'dart:ui'; + import 'package:aves/image_providers/thumbnail_provider.dart'; import 'package:aves/model/entry.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/common/extensions/build_context.dart'; import 'package:aves/widgets/common/fx/transition_image.dart'; @@ -26,7 +29,12 @@ class RasterImageThumbnail extends StatefulWidget { } class _RasterImageThumbnailState extends State { - ThumbnailProvider? _fastThumbnailProvider, _sizedThumbnailProvider; + final _providers = <_ConditionalImageProvider>[]; + _ProviderStream? _currentProviderStream; + ImageInfo? _lastImageInfo; + Object? _lastException; + late final ImageStreamListener _streamListener; + late DisposableBuildContext> _scrollAwareContext; AvesEntry get entry => widget.entry; @@ -35,6 +43,8 @@ class _RasterImageThumbnailState extends State { @override void initState() { super.initState(); + _streamListener = ImageStreamListener(_onImageLoad, onError: _onError); + _scrollAwareContext = DisposableBuildContext>(this); _registerWidget(widget); } @@ -50,6 +60,7 @@ class _RasterImageThumbnailState extends State { @override void dispose() { _unregisterWidget(widget); + _scrollAwareContext.dispose(); super.dispose(); } @@ -61,66 +72,119 @@ class _RasterImageThumbnailState extends State { void _unregisterWidget(RasterImageThumbnail widget) { widget.entry.imageChangeNotifier.removeListener(_onImageChanged); _pauseProvider(); + _currentProviderStream?.stopListening(); + _currentProviderStream = null; + _replaceImage(null); } void _initProvider() { if (!entry.canDecode) return; - _fastThumbnailProvider = entry.getThumbnail(); - _sizedThumbnailProvider = entry.getThumbnail(extent: extent); + _lastException = null; + _providers.clear(); + _providers.addAll([ + _ConditionalImageProvider( + ScrollAwareImageProvider( + context: _scrollAwareContext, + imageProvider: entry.getThumbnail(), + ), + ), + _ConditionalImageProvider( + ScrollAwareImageProvider( + context: _scrollAwareContext, + imageProvider: entry.getThumbnail(extent: extent), + ), + _needSizedProvider, + ), + ]); + _loadNextProvider(); } - void _pauseProvider() { - if (widget.cancellableNotifier?.value ?? false) { - _fastThumbnailProvider?.pause(); - _sizedThumbnailProvider?.pause(); + void _loadNextProvider([ImageInfo? imageInfo]) { + final nextIndex = _currentProviderStream == null ? 0 : (_providers.indexOf(_currentProviderStream!.provider) + 1); + if (nextIndex < _providers.length) { + 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 Widget build(BuildContext context) { if (!entry.canDecode) { return _buildError(context, context.l10n.errorUnsupportedMimeType(entry.mimeType), null); + } else if (_lastException != null) { + return _buildError(context, _lastException.toString(), null); } - final fastImage = Image( - key: ValueKey('LQ'), - image: _fastThumbnailProvider!, - errorBuilder: _buildError, - width: 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; - }, - child: frame == null ? fastImage : child, - ); - }, - errorBuilder: _buildError, + // use `RawImage` instead of `Image`, using `ImageInfo` to check dimensions + // and have more control when chaining image providers + + final imageInfo = _lastImageInfo; + final image = imageInfo == null + ? Container( + color: backgroundColor, width: extent, height: extent, + ) + : RawImage( + image: imageInfo.image, + debugImageLabel: imageInfo.debugLabel, + width: extent, + height: extent, + scale: imageInfo.scale, fit: BoxFit.cover, ); + return widget.heroTag != null ? Hero( tag: widget.heroTag!, @@ -150,3 +214,22 @@ class _RasterImageThumbnailState extends State { 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); +} diff --git a/lib/widgets/collection/thumbnail/theme.dart b/lib/widgets/collection/thumbnail/theme.dart index 66e774af8..12e92f90d 100644 --- a/lib/widgets/collection/thumbnail/theme.dart +++ b/lib/widgets/collection/thumbnail/theme.dart @@ -21,9 +21,11 @@ class ThumbnailTheme extends StatelessWidget { update: (_, settings, __) { final iconSize = min(28.0, (extent / 4)).roundToDouble(); final fontSize = (iconSize / 2).floorToDouble(); + final highlightBorderWidth = extent * .1; return ThumbnailThemeData( iconSize: iconSize, fontSize: fontSize, + highlightBorderWidth: highlightBorderWidth, showLocation: showLocation ?? settings.showThumbnailLocation, showRaw: settings.showThumbnailRaw, showVideoDuration: settings.showThumbnailVideoDuration, @@ -35,12 +37,13 @@ class ThumbnailTheme extends StatelessWidget { } class ThumbnailThemeData { - final double iconSize, fontSize; + final double iconSize, fontSize, highlightBorderWidth; final bool showLocation, showRaw, showVideoDuration; const ThumbnailThemeData({ required this.iconSize, required this.fontSize, + required this.highlightBorderWidth, required this.showLocation, required this.showRaw, required this.showVideoDuration, diff --git a/lib/widgets/common/fx/borders.dart b/lib/widgets/common/fx/borders.dart index 70ba4b054..830cdd4b5 100644 --- a/lib/widgets/common/fx/borders.dart +++ b/lib/widgets/common/fx/borders.dart @@ -1,19 +1,17 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; +import 'dart:ui'; -class AvesCircleBorder { +import 'package:flutter/material.dart'; + +class AvesBorder { static const borderColor = Colors.white30; - static double _borderWidth(BuildContext context) => context.read().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) { - return Border.fromBorderSide(buildSide(context)); - } + static BorderSide get side => BorderSide( + color: borderColor, + width: borderWidth, + ); - static BorderSide buildSide(BuildContext context) { - return BorderSide( - color: borderColor, - width: _borderWidth(context), - ); - } + static Border get border => Border.fromBorderSide(side); } diff --git a/lib/widgets/common/grid/section_layout.dart b/lib/widgets/common/grid/section_layout.dart index bef08b5ec..3c7c7d290 100644 --- a/lib/widgets/common/grid/section_layout.dart +++ b/lib/widgets/common/grid/section_layout.dart @@ -19,7 +19,7 @@ abstract class SectionedListLayoutProvider extends StatelessWidget { const SectionedListLayoutProvider({ required this.scrollableWidth, required this.columnCount, - this.spacing = 0, + required this.spacing, required this.tileExtent, required this.tileBuilder, required this.tileAnimationDelay, @@ -118,12 +118,13 @@ abstract class SectionedListLayoutProvider extends StatelessWidget { final children = []; for (var i = minItemIndex; i < maxItemIndex; i++) { final itemGridIndex = sectionGridIndex + i - minItemIndex; - final item = tileBuilder(section[i]); - if (i != minItemIndex) children.add(SizedBox(width: spacing)); + final item = RepaintBoundary( + child: tileBuilder(section[i]), + ); children.add(animate ? _buildAnimation(itemGridIndex, item) : item); } - return Row( - mainAxisSize: MainAxisSize.min, + return Wrap( + spacing: spacing, children: children, ); } diff --git a/lib/widgets/common/grid/sliver.dart b/lib/widgets/common/grid/sliver.dart index 9481f8279..e5996c1d5 100644 --- a/lib/widgets/common/grid/sliver.dart +++ b/lib/widgets/common/grid/sliver.dart @@ -30,6 +30,7 @@ class SectionedListSliver extends StatelessWidget { }, childCount: childCount, addAutomaticKeepAlives: false, + addRepaintBoundaries: false, ), ); } diff --git a/lib/widgets/common/identity/aves_expansion_tile.dart b/lib/widgets/common/identity/aves_expansion_tile.dart index b27f810ab..4845a0349 100644 --- a/lib/widgets/common/identity/aves_expansion_tile.dart +++ b/lib/widgets/common/identity/aves_expansion_tile.dart @@ -53,7 +53,7 @@ class AvesExpansionTile extends StatelessWidget { expandable: enabled, initiallyExpanded: initiallyExpanded, finalPadding: EdgeInsets.symmetric(vertical: 6.0), - baseColor: Colors.grey[900], + baseColor: Colors.grey.shade900, expandedColor: Colors.grey[850], shadowColor: Theme.of(context).shadowColor, child: Column( diff --git a/lib/widgets/common/identity/aves_icons.dart b/lib/widgets/common/identity/aves_icons.dart index f82fe2708..de3c38e82 100644 --- a/lib/widgets/common/identity/aves_icons.dart +++ b/lib/widgets/common/identity/aves_icons.dart @@ -31,7 +31,7 @@ class VideoIcon extends StatelessWidget { if (showDuration) { child = DefaultTextStyle( style: TextStyle( - color: Colors.grey[200], + color: Colors.grey.shade200, fontSize: thumbnailTheme.fontSize, ), child: child, diff --git a/lib/widgets/debug/overlay.dart b/lib/widgets/debug/overlay.dart index 5da36f76d..0dd0b5eaa 100644 --- a/lib/widgets/debug/overlay.dart +++ b/lib/widgets/debug/overlay.dart @@ -11,7 +11,7 @@ class DebugTaskQueueOverlay extends StatelessWidget { alignment: AlignmentDirectional.bottomStart, child: SafeArea( child: Container( - color: Colors.indigo[900]!.withAlpha(0xCC), + color: Colors.indigo.shade900.withAlpha(0xCC), padding: EdgeInsets.all(8), child: StreamBuilder( stream: servicePolicy.queueStream, diff --git a/lib/widgets/drawer/app_drawer.dart b/lib/widgets/drawer/app_drawer.dart index e2141091c..283606d32 100644 --- a/lib/widgets/drawer/app_drawer.dart +++ b/lib/widgets/drawer/app_drawer.dart @@ -58,7 +58,7 @@ class _AppDrawerState extends State { albumListTile, countryListTile, tagListTile, - if (kDebugMode) ...[ + if (!kReleaseMode) ...[ Divider(), debugTile, ], diff --git a/lib/widgets/filter_grids/common/filter_grid_page.dart b/lib/widgets/filter_grids/common/filter_grid_page.dart index 6bb6c0da5..f0eb9a3ba 100644 --- a/lib/widgets/filter_grids/common/filter_grid_page.dart +++ b/lib/widgets/filter_grids/common/filter_grid_page.dart @@ -222,9 +222,9 @@ class _FilterGridContent extends StatelessWidget { sections: visibleFilterSections, showHeaders: showHeaders, scrollableWidth: scrollableWidth, - tileExtent: tileExtent, columnCount: columnCount, spacing: tileSpacing, + tileExtent: tileExtent, tileBuilder: (gridItem) { final filter = gridItem.filter; final entry = gridItem.entry; diff --git a/lib/widgets/filter_grids/common/section_layout.dart b/lib/widgets/filter_grids/common/section_layout.dart index aab18021b..ab9f1e091 100644 --- a/lib/widgets/filter_grids/common/section_layout.dart +++ b/lib/widgets/filter_grids/common/section_layout.dart @@ -11,7 +11,7 @@ class SectionedFilterListLayoutProvider extends Sect required this.showHeaders, required double scrollableWidth, required int columnCount, - double spacing = 0, + required double spacing, required double tileExtent, required Widget Function(FilterGridItem gridItem) tileBuilder, required Duration tileAnimationDelay, diff --git a/lib/widgets/settings/entry_background.dart b/lib/widgets/settings/entry_background.dart index 6db16242e..5f3c58782 100644 --- a/lib/widgets/settings/entry_background.dart +++ b/lib/widgets/settings/entry_background.dart @@ -70,7 +70,7 @@ class _EntryBackgroundSelectorState extends State { width: radius * 2, decoration: BoxDecoration( color: selected.isColor ? selected.color : null, - border: AvesCircleBorder.build(context), + border: AvesBorder.border, shape: BoxShape.circle, ), child: child, diff --git a/lib/widgets/viewer/entry_vertical_pager.dart b/lib/widgets/viewer/entry_vertical_pager.dart index 0681c265d..8b34d55e0 100644 --- a/lib/widgets/viewer/entry_vertical_pager.dart +++ b/lib/widgets/viewer/entry_vertical_pager.dart @@ -1,7 +1,9 @@ +import 'dart:async'; import 'dart:math'; import 'package:aves/model/entry.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/viewer/entry_horizontal_pager.dart'; import 'package:aves/widgets/viewer/info/info_page.dart'; @@ -35,7 +37,8 @@ class ViewerVerticalPageView extends StatefulWidget { class _ViewerVerticalPageViewState extends State { final ValueNotifier _backgroundColorNotifier = ValueNotifier(Colors.black); - final ValueNotifier _infoPageVisibleNotifier = ValueNotifier(false); + final ValueNotifier _isVerticallyScrollingNotifier = ValueNotifier(false); + Timer? _verticalScrollMonitoringTimer; AvesEntry? _oldEntry; CollectionLens? get collection => widget.collection; @@ -60,6 +63,7 @@ class _ViewerVerticalPageViewState extends State { @override void dispose() { _unregisterWidget(widget); + _stopScrollMonitoringTimer(); super.dispose(); } @@ -77,32 +81,47 @@ class _ViewerVerticalPageViewState extends State { @override Widget build(BuildContext context) { - final pages = [ - // fake page for opacity transition between collection and viewer - SizedBox(), - hasCollection - ? MultiEntryScroller( - collection: collection!, - pageController: widget.horizontalPager, - onPageChanged: widget.onHorizontalPageChanged, - onViewDisposed: widget.onViewDisposed, - ) - : entry != null - ? SingleEntryScroller( - entry: entry!, - ) - : SizedBox(), - NotificationListener( - onNotification: (notification) { - widget.onImagePageRequested(); - return true; + // fake page for opacity transition between collection and viewer + final transitionPage = SizedBox(); + + final imagePage = hasCollection + ? MultiEntryScroller( + collection: collection!, + pageController: widget.horizontalPager, + onPageChanged: widget.onHorizontalPageChanged, + onViewDisposed: widget.onViewDisposed, + ) + : entry != null + ? SingleEntryScroller( + entry: entry!, + ) + : SizedBox(); + + final infoPage = NotificationListener( + onNotification: (notification) { + widget.onImagePageRequested(); + return true; + }, + child: AnimatedBuilder( + animation: widget.verticalPager, + builder: (context, child) { + return Visibility( + visible: widget.verticalPager.page! > 1, + child: child!, + ); }, child: InfoPage( collection: collection, entryNotifier: widget.entryNotifier, - visibleNotifier: _infoPageVisibleNotifier, + isScrollingNotifier: _isVerticallyScrollingNotifier, ), ), + ); + + final pages = [ + transitionPage, + imagePage, + infoPage, ]; return ValueListenableBuilder( valueListenable: _backgroundColorNotifier, @@ -115,10 +134,7 @@ class _ViewerVerticalPageViewState extends State { scrollDirection: Axis.vertical, controller: widget.verticalPager, physics: MagnifierScrollerPhysics(parent: PageScrollPhysics()), - onPageChanged: (page) { - widget.onVerticalPageChanged(page); - _infoPageVisibleNotifier.value = page == pages.length - 1; - }, + onPageChanged: widget.onVerticalPageChanged, children: pages, ), ); @@ -127,6 +143,16 @@ class _ViewerVerticalPageViewState extends State { void _onVerticalPageControllerChanged() { final opacity = min(1.0, widget.verticalPager.page!); _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) diff --git a/lib/widgets/viewer/entry_viewer_stack.dart b/lib/widgets/viewer/entry_viewer_stack.dart index 01cc2ab74..f9e75a0d1 100644 --- a/lib/widgets/viewer/entry_viewer_stack.dart +++ b/lib/widgets/viewer/entry_viewer_stack.dart @@ -216,12 +216,11 @@ class _EntryViewerStackState extends State with SingleTickerPr } Widget _buildTopOverlay() { - final child = ValueListenableBuilder( + Widget child = ValueListenableBuilder( valueListenable: _entryNotifier, builder: (context, mainEntry, child) { if (mainEntry == null) return SizedBox.shrink(); - final viewStateNotifier = _viewStateNotifiers.firstWhereOrNull((kv) => kv.item1 == mainEntry.uri)?.item2; return ViewerTopOverlay( mainEntry: mainEntry, scale: _topOverlayScale, @@ -242,11 +241,12 @@ class _EntryViewerStackState extends State with SingleTickerPr } _actionDelegate.onActionSelected(context, targetEntry, action); }, - viewStateNotifier: viewStateNotifier, + viewStateNotifier: _viewStateNotifiers.firstWhereOrNull((kv) => kv.item1 == mainEntry.uri)?.item2, ); }, ); - return ValueListenableBuilder( + + child = ValueListenableBuilder( valueListenable: _currentVerticalPage, builder: (context, page, child) { return Visibility( @@ -256,13 +256,26 @@ class _EntryViewerStackState extends State with SingleTickerPr }, child: child, ); + + child = ValueListenableBuilder( + valueListenable: _overlayAnimationController, + builder: (context, animation, child) { + return Visibility( + visible: !_overlayAnimationController.isDismissed, + child: child!, + ); + }, + child: child, + ); + + return child; } Widget _buildBottomOverlay() { - Widget bottomOverlay = ValueListenableBuilder( + Widget child = ValueListenableBuilder( valueListenable: _entryNotifier, - builder: (context, entry, child) { - if (entry == null) return SizedBox.shrink(); + builder: (context, mainEntry, child) { + if (mainEntry == null) return SizedBox.shrink(); Widget? _buildExtraBottomOverlay(AvesEntry pageEntry) { // 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 with SingleTickerPr return null; } - final multiPageController = entry.isMultiPage ? context.read().getController(entry) : null; + final multiPageController = mainEntry.isMultiPage ? context.read().getController(mainEntry) : null; final extraBottomOverlay = multiPageController != null ? StreamBuilder( stream: multiPageController.infoStream, @@ -299,9 +312,9 @@ class _EntryViewerStackState extends State with SingleTickerPr }, ); }) - : _buildExtraBottomOverlay(entry); + : _buildExtraBottomOverlay(mainEntry); - final child = Column( + return Column( children: [ if (extraBottomOverlay != null) ExtraBottomOverlay( @@ -322,20 +335,10 @@ class _EntryViewerStackState extends State with SingleTickerPr ), ], ); - return ValueListenableBuilder( - valueListenable: _overlayAnimationController, - builder: (context, animation, child) { - return Visibility( - visible: _overlayAnimationController.status != AnimationStatus.dismissed, - child: child!, - ); - }, - child: child, - ); }, ); - bottomOverlay = Selector( + child = Selector( selector: (c, mq) => mq.size.height, builder: (c, mqHeight, child) { // when orientation change, the `PageController` offset is not updated right away @@ -350,9 +353,19 @@ class _EntryViewerStackState extends State with SingleTickerPr child: child, ); }, - child: bottomOverlay, + child: child, + ); + + return ValueListenableBuilder( + valueListenable: _overlayAnimationController, + builder: (context, animation, child) { + return Visibility( + visible: !_overlayAnimationController.isDismissed, + child: child!, + ); + }, + child: child, ); - return bottomOverlay; } void _onVerticalPageControllerChange() { @@ -383,10 +396,11 @@ class _EntryViewerStackState extends State with SingleTickerPr } Future _goToVerticalPage(int page) { + // duration & curve should feel similar to changing page by vertical fling return _verticalPager.animateToPage( page, duration: Durations.viewerVerticalPageScrollAnimation, - curve: Curves.easeInOut, + curve: Curves.easeOutQuart, ); } diff --git a/lib/widgets/viewer/info/basic_section.dart b/lib/widgets/viewer/info/basic_section.dart index 1e03dc029..e898ee593 100644 --- a/lib/widgets/viewer/info/basic_section.dart +++ b/lib/widgets/viewer/info/basic_section.dart @@ -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/viewer/info/common.dart'; import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; class BasicSection extends StatelessWidget { final AvesEntry entry; final CollectionLens? collection; - final ValueNotifier visibleNotifier; final FilterCallback onFilter; const BasicSection({ Key? key, required this.entry, this.collection, - required this.visibleNotifier, required this.onFilter, }) : super(key: key); @@ -65,7 +64,6 @@ class BasicSection extends StatelessWidget { }), OwnerProp( entry: entry, - visibleNotifier: visibleNotifier, ), _buildChips(context), ], @@ -120,11 +118,9 @@ class BasicSection extends StatelessWidget { class OwnerProp extends StatefulWidget { final AvesEntry entry; - final ValueNotifier visibleNotifier; const OwnerProp({ required this.entry, - required this.visibleNotifier, }); @override @@ -132,53 +128,33 @@ class OwnerProp extends StatefulWidget { } class _OwnerPropState extends State { - final ValueNotifier _loadedUri = ValueNotifier(null); - String? _ownerPackage; + late Future _ownerPackageFuture; AvesEntry get entry => widget.entry; - bool get isVisible => widget.visibleNotifier.value; - static const iconSize = 20.0; @override void initState() { super.initState(); - _registerWidget(widget); - _getOwner(); - } - - @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); + final isMediaContent = entry.uri.startsWith('content://media/external/'); + if (isMediaContent) { + _ownerPackageFuture = metadataService.getContentResolverProp(entry, 'owner_package_name'); + } else { + _ownerPackageFuture = SynchronousFuture(null); + } } @override Widget build(BuildContext context) { - return ValueListenableBuilder( - valueListenable: _loadedUri, - builder: (context, uri, child) { - if (_ownerPackage == null) return SizedBox(); - final appName = androidFileUtils.getCurrentAppName(_ownerPackage!) ?? _ownerPackage; + return FutureBuilder( + future: _ownerPackageFuture, + builder: (context, snapshot) { + final ownerPackage = snapshot.data; + if (ownerPackage == null) return SizedBox(); + final appName = androidFileUtils.getCurrentAppName(ownerPackage) ?? ownerPackage; // 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( TextSpan( children: [ @@ -188,14 +164,14 @@ class _OwnerPropState extends State { ), // `com.android.shell` is the package reported // for images copied to the device by ADB for Test Driver - if (_ownerPackage != 'com.android.shell') + if (ownerPackage != 'com.android.shell') WidgetSpan( alignment: PlaceholderAlignment.middle, child: Padding( padding: EdgeInsets.symmetric(horizontal: 4), child: Image( image: AppIconImage( - packageName: _ownerPackage!, + packageName: ownerPackage, size: iconSize, ), width: iconSize, @@ -213,16 +189,4 @@ class _OwnerPropState extends State { }, ); } - - Future _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; - } - } } diff --git a/lib/widgets/viewer/info/info_page.dart b/lib/widgets/viewer/info/info_page.dart index 4eea6c0f3..69db8b66d 100644 --- a/lib/widgets/viewer/info/info_page.dart +++ b/lib/widgets/viewer/info/info_page.dart @@ -17,13 +17,13 @@ import 'package:provider/provider.dart'; class InfoPage extends StatefulWidget { final CollectionLens? collection; final ValueNotifier entryNotifier; - final ValueNotifier visibleNotifier; + final ValueNotifier isScrollingNotifier; const InfoPage({ Key? key, required this.collection, required this.entryNotifier, - required this.visibleNotifier, + required this.isScrollingNotifier, }) : super(key: key); @override @@ -62,7 +62,7 @@ class _InfoPageState extends State { ? _InfoPageContent( collection: collection, entry: entry, - visibleNotifier: widget.visibleNotifier, + isScrollingNotifier: widget.isScrollingNotifier, scrollController: _scrollController, split: mqWidth > 600, goToViewer: _goToViewer, @@ -126,7 +126,7 @@ class _InfoPageState extends State { class _InfoPageContent extends StatefulWidget { final CollectionLens? collection; final AvesEntry entry; - final ValueNotifier visibleNotifier; + final ValueNotifier isScrollingNotifier; final ScrollController scrollController; final bool split; final VoidCallback goToViewer; @@ -135,7 +135,7 @@ class _InfoPageContent extends StatefulWidget { Key? key, required this.collection, required this.entry, - required this.visibleNotifier, + required this.isScrollingNotifier, required this.scrollController, required this.split, required this.goToViewer, @@ -154,14 +154,11 @@ class _InfoPageContentState extends State<_InfoPageContent> { AvesEntry get entry => widget.entry; - ValueNotifier get visibleNotifier => widget.visibleNotifier; - @override Widget build(BuildContext context) { final basicSection = BasicSection( entry: entry, collection: collection, - visibleNotifier: visibleNotifier, onFilter: _goToCollection, ); final locationAtTop = widget.split && entry.hasGps; @@ -169,7 +166,7 @@ class _InfoPageContentState extends State<_InfoPageContent> { collection: collection, entry: entry, showTitle: !locationAtTop, - visibleNotifier: visibleNotifier, + isScrollingNotifier: widget.isScrollingNotifier, onFilter: _goToCollection, ); final basicAndLocationSliver = locationAtTop @@ -194,7 +191,6 @@ class _InfoPageContentState extends State<_InfoPageContent> { final metadataSliver = MetadataSectionSliver( entry: entry, metadataNotifier: _metadataNotifier, - visibleNotifier: visibleNotifier, ); return CustomScrollView( diff --git a/lib/widgets/viewer/info/location_section.dart b/lib/widgets/viewer/info/location_section.dart index 463c7360d..68915e718 100644 --- a/lib/widgets/viewer/info/location_section.dart +++ b/lib/widgets/viewer/info/location_section.dart @@ -1,6 +1,7 @@ import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/location.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/settings.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/marker.dart'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; import 'package:tuple/tuple.dart'; class LocationSection extends StatefulWidget { final CollectionLens? collection; final AvesEntry entry; final bool showTitle; - final ValueNotifier visibleNotifier; + final ValueNotifier isScrollingNotifier; final FilterCallback onFilter; const LocationSection({ @@ -29,7 +31,7 @@ class LocationSection extends StatefulWidget { required this.collection, required this.entry, required this.showTitle, - required this.visibleNotifier, + required this.isScrollingNotifier, required this.onFilter, }) : super(key: key); @@ -38,7 +40,11 @@ class LocationSection extends StatefulWidget { } class _LocationSectionState extends State 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 pointerSize = Size(8.0, 6.0); @@ -69,98 +75,114 @@ class _LocationSectionState extends State with TickerProviderSt void _registerWidget(LocationSection widget) { widget.entry.metadataChangeNotifier.addListener(_handleChange); widget.entry.addressChangeNotifier.addListener(_handleChange); - widget.visibleNotifier.addListener(_handleChange); } void _unregisterWidget(LocationSection widget) { widget.entry.metadataChangeNotifier.removeListener(_handleChange); widget.entry.addressChangeNotifier.removeListener(_handleChange); - widget.visibleNotifier.removeListener(_handleChange); } @override Widget build(BuildContext context) { - final showMap = (_loadedUri == entry.uri) || (entry.hasGps && widget.visibleNotifier.value); - if (showMap) { - _loadedUri = entry.uri; - final filters = []; - 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)); - } + if (!entry.hasGps) return SizedBox(); + final latLng = entry.latLng!; + final geoUri = entry.geoUri!; - Widget buildMarker(BuildContext context) { - return ImageMarker( + final filters = []; + 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, extent: extent, pointerSize: pointerSize, ); - } - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (widget.showTitle) SectionRow(AIcons.location), - FutureBuilder( - future: availability.isConnected, - builder: (context, snapshot) { - if (snapshot.data != true) return SizedBox(); - final latLng = entry.latLng!; - return NotificationListener( - onNotification: (notification) { - setState(() {}); - return true; - }, - child: AnimatedSize( + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.showTitle) SectionRow(AIcons.location), + FutureBuilder( + future: availability.isConnected, + builder: (context, snapshot) { + if (snapshot.data != true) return SizedBox(); + return Selector( + selector: (context, s) => s.infoMapStyle, + builder: (context, mapStyle, child) { + final isGoogleMaps = mapStyle.isGoogleMaps; + return AnimatedSize( alignment: Alignment.topCenter, curve: Curves.easeInOutCubic, duration: Durations.mapStyleSwitchAnimation, vsync: this, - child: settings.infoMapStyle.isGoogleMaps - ? EntryGoogleMap( - // `LatLng` used by `google_maps_flutter` is not the one from `latlong` package - latLng: Tuple2(latLng.latitude, latLng.longitude), - geoUri: entry.geoUri!, - initialZoom: settings.infoMapZoom, - markerId: entry.uri, - markerBuilder: buildMarker, - ) - : EntryLeafletMap( - latLng: latLng, - geoUri: entry.geoUri!, - initialZoom: settings.infoMapZoom, - style: settings.infoMapStyle, - markerSize: Size(extent, extent + pointerSize.height), - markerBuilder: buildMarker, + child: ValueListenableBuilder( + 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: (_) {}, + ), + ], ), - ), - ); - }, - ), - if (entry.hasGps) _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(), - ), + child: child!, + ); + }, + child: isGoogleMaps + ? EntryGoogleMap( + // `LatLng` used by `google_maps_flutter` is not the one from `latlong` package + latLng: Tuple2(latLng.latitude, latLng.longitude), + geoUri: geoUri, + initialZoom: settings.infoMapZoom, + markerId: entry.uri, + markerBuilder: buildMarker, + ) + : EntryLeafletMap( + latLng: latLng, + geoUri: geoUri, + initialZoom: settings.infoMapZoom, + 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(() {}); diff --git a/lib/widgets/viewer/info/maps/common.dart b/lib/widgets/viewer/info/maps/common.dart index 21b9aa08b..8041ec0a2 100644 --- a/lib/widgets/viewer/info/maps/common.dart +++ b/lib/widgets/viewer/info/maps/common.dart @@ -15,11 +15,13 @@ import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; 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 Widget build(BuildContext context) { @@ -31,9 +33,22 @@ class MapDecorator extends StatelessWidget { child: ClipRRect( borderRadius: mapBorderRadius, child: Container( - color: Colors.white70, + color: mapBackground, 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); if (style != null && style != settings.infoMapStyle) { settings.infoMapStyle = style; - MapStyleChangedNotification().dispatch(context); } }, tooltip: context.l10n.viewerInfoMapStyleTooltip, @@ -139,7 +153,7 @@ class MapOverlayButton extends StatelessWidget { color: kOverlayBackgroundColor, child: Ink( decoration: BoxDecoration( - border: AvesCircleBorder.build(context), + border: AvesBorder.border, shape: BoxShape.circle, ), child: IconButton( @@ -154,5 +168,3 @@ class MapOverlayButton extends StatelessWidget { ); } } - -class MapStyleChangedNotification extends Notification {} diff --git a/lib/widgets/viewer/info/maps/google_map.dart b/lib/widgets/viewer/info/maps/google_map.dart index 63db30514..267f949ef 100644 --- a/lib/widgets/viewer/info/maps/google_map.dart +++ b/lib/widgets/viewer/info/maps/google_map.dart @@ -30,7 +30,7 @@ class EntryGoogleMap extends StatefulWidget { State createState() => _EntryGoogleMapState(); } -class _EntryGoogleMapState extends State with AutomaticKeepAliveClientMixin { +class _EntryGoogleMapState extends State { GoogleMapController? _controller; late Completer _markerLoaderCompleter; @@ -59,7 +59,6 @@ class _EntryGoogleMapState extends State with AutomaticKeepAlive @override Widget build(BuildContext context) { - super.build(context); return Stack( children: [ MarkerGeneratorWidget( @@ -132,7 +131,4 @@ class _EntryGoogleMapState extends State with AutomaticKeepAlive return MapType.none; } } - - @override - bool get wantKeepAlive => true; } diff --git a/lib/widgets/viewer/info/maps/leaflet_map.dart b/lib/widgets/viewer/info/maps/leaflet_map.dart index c31f0cdc7..12a19023b 100644 --- a/lib/widgets/viewer/info/maps/leaflet_map.dart +++ b/lib/widgets/viewer/info/maps/leaflet_map.dart @@ -33,7 +33,7 @@ class EntryLeafletMap extends StatefulWidget { State createState() => _EntryLeafletMapState(); } -class _EntryLeafletMapState extends State with AutomaticKeepAliveClientMixin, TickerProviderStateMixin { +class _EntryLeafletMapState extends State with TickerProviderStateMixin { final MapController _mapController = MapController(); @override @@ -46,7 +46,6 @@ class _EntryLeafletMapState extends State with AutomaticKeepAli @override Widget build(BuildContext context) { - super.build(context); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -157,9 +156,6 @@ class _EntryLeafletMapState extends State with AutomaticKeepAli }); controller.forward(); } - - @override - bool get wantKeepAlive => true; } class OSMHotLayer extends StatelessWidget { diff --git a/lib/widgets/viewer/info/maps/marker.dart b/lib/widgets/viewer/info/maps/marker.dart index 69eb140d8..f055b4d15 100644 --- a/lib/widgets/viewer/info/maps/marker.dart +++ b/lib/widgets/viewer/info/maps/marker.dart @@ -16,8 +16,10 @@ class ImageMarker extends StatelessWidget { static const double outerBorderRadiusDim = 8; static const double outerBorderWidth = 1.5; static const double innerBorderWidth = 2; - static const Color outerBorderColor = Colors.white30; - static final Color innerBorderColor = Colors.grey[900]!; + static const outerBorderColor = Colors.white30; + static const innerBorderColor = Color(0xFF212121); + static const outerBorderRadius = BorderRadius.all(Radius.circular(outerBorderRadiusDim)); + static const innerBorderRadius = BorderRadius.all(Radius.circular(outerBorderRadiusDim - outerBorderWidth)); const ImageMarker({ required this.entry, @@ -37,8 +39,21 @@ class ImageMarker extends StatelessWidget { extent: extent, ); - final outerBorderRadius = BorderRadius.circular(outerBorderRadiusDim); - final innerBorderRadius = BorderRadius.circular(outerBorderRadiusDim - outerBorderWidth); + const outerDecoration = BoxDecoration( + 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( foregroundPainter: MarkerPointerPainter( @@ -50,21 +65,9 @@ class ImageMarker extends StatelessWidget { child: Padding( padding: EdgeInsets.only(bottom: pointerSize.height), child: Container( - decoration: BoxDecoration( - border: Border.all( - color: outerBorderColor, - width: outerBorderWidth, - ), - borderRadius: outerBorderRadius, - ), + decoration: outerDecoration, child: DecoratedBox( - decoration: BoxDecoration( - border: Border.all( - color: innerBorderColor, - width: innerBorderWidth, - ), - borderRadius: innerBorderRadius, - ), + decoration: innerDecoration, position: DecorationPosition.foreground, child: ClipRRect( borderRadius: innerBorderRadius, diff --git a/lib/widgets/viewer/info/metadata/metadata_section.dart b/lib/widgets/viewer/info/metadata/metadata_section.dart index 0353243cf..c4c393cbf 100644 --- a/lib/widgets/viewer/info/metadata/metadata_section.dart +++ b/lib/widgets/viewer/info/metadata/metadata_section.dart @@ -19,12 +19,10 @@ import 'package:flutter_staggered_animations/flutter_staggered_animations.dart'; class MetadataSectionSliver extends StatefulWidget { final AvesEntry entry; - final ValueNotifier visibleNotifier; final ValueNotifier> metadataNotifier; const MetadataSectionSliver({ required this.entry, - required this.visibleNotifier, required this.metadataNotifier, }); @@ -32,18 +30,13 @@ class MetadataSectionSliver extends StatefulWidget { State createState() => _MetadataSectionSliverState(); } -class _MetadataSectionSliverState extends State with AutomaticKeepAliveClientMixin { - final ValueNotifier _loadedMetadataUri = ValueNotifier(null); +class _MetadataSectionSliverState extends State { final ValueNotifier _expandedDirectoryNotifier = ValueNotifier(null); AvesEntry get entry => widget.entry; - bool get isVisible => widget.visibleNotifier.value; - ValueNotifier> get metadataNotifier => widget.metadataNotifier; - Map get metadata => metadataNotifier.value; - // directory names may contain the name of their parent directory // if so, they are separated by this character static const parentChildSeparator = '/'; @@ -71,18 +64,15 @@ class _MetadataSectionSliverState extends State with Auto } void _registerWidget(MetadataSectionSliver widget) { - widget.visibleNotifier.addListener(_getMetadata); widget.entry.metadataChangeNotifier.addListener(_onMetadataChanged); } void _unregisterWidget(MetadataSectionSliver widget) { - widget.visibleNotifier.removeListener(_getMetadata); widget.entry.metadataChangeNotifier.removeListener(_onMetadataChanged); } @override Widget build(BuildContext context) { - super.build(context); // use a `Column` inside a `SliverToBoxAdapter`, instead of a `SliverList`, // so that we can have the metadata-dependent `AnimationLimiter` inside the metadata section // warning: placing the `AnimationLimiter` as a parent to the `ScrollView` @@ -92,89 +82,81 @@ class _MetadataSectionSliverState extends State with Auto // cancel notification bubbling so that the info page // does not misinterpret content scrolling for page scrolling onNotification: (notification) => true, - child: ValueListenableBuilder( - valueListenable: _loadedMetadataUri, - builder: (context, uri, child) { - Widget content; - if (metadata.isEmpty) { - content = SizedBox.shrink(); - } else { - content = Column( - children: AnimationConfiguration.toStaggeredList( - duration: Durations.staggeredAnimation, - delay: Durations.staggeredAnimationDelay, - childAnimationBuilder: (child) => SlideAnimation( - verticalOffset: 50.0, - child: FadeInAnimation( - child: child, + child: ValueListenableBuilder>( + valueListenable: metadataNotifier, + builder: (context, metadata, child) { + Widget content; + if (metadata.isEmpty) { + content = SizedBox.shrink(); + } else { + content = Column( + children: AnimationConfiguration.toStaggeredList( + duration: Durations.staggeredAnimation, + delay: Durations.staggeredAnimationDelay, + childAnimationBuilder: (child) => SlideAnimation( + verticalOffset: 50.0, + child: FadeInAnimation( + 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, - title: kv.key, - dir: kv.value, - expandedDirectoryNotifier: _expandedDirectoryNotifier, - )), - ], - ), + ); + } + + 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: 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() { - _loadedMetadataUri.value = null; metadataNotifier.value = {}; _getMetadata(); } Future _getMetadata() async { - if (_loadedMetadataUri.value == entry.uri) return; - if (isVisible) { - final rawMetadata = await (entry.isSvg ? SvgMetadataService.getAllMetadata(entry) : metadataService.getAllMetadata(entry)); - final directories = rawMetadata.entries.map((dirKV) { - var directoryName = dirKV.key as String; + final rawMetadata = await (entry.isSvg ? SvgMetadataService.getAllMetadata(entry) : metadataService.getAllMetadata(entry)); + final directories = rawMetadata.entries.map((dirKV) { + var directoryName = dirKV.key as String; - String? parent; - final parts = directoryName.split(parentChildSeparator); - if (parts.length > 1) { - parent = parts[0]; - 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()); + String? parent; + final parts = directoryName.split(parentChildSeparator); + if (parts.length > 1) { + parent = parts[0]; + directoryName = parts[1]; } - 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); - _loadedMetadataUri.value = entry.uri; - } else { - metadataNotifier.value = {}; - _loadedMetadataUri.value = null; + 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) { + 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; } @@ -265,9 +247,6 @@ class _MetadataSectionSliverState extends State with Auto .cast>())); return tags; } - - @override - bool get wantKeepAlive => true; } class MetadataDirectory { diff --git a/lib/widgets/viewer/info/metadata/xmp_structs.dart b/lib/widgets/viewer/info/metadata/xmp_structs.dart index b94eb4d81..8f3cf6537 100644 --- a/lib/widgets/viewer/info/metadata/xmp_structs.dart +++ b/lib/widgets/viewer/info/metadata/xmp_structs.dart @@ -19,8 +19,11 @@ class XmpStructArrayCard extends StatefulWidget { required Map> structByIndex, this.linkifier, }) { - structs.length = structByIndex.keys.fold(0, max); - structByIndex.keys.forEach((index) => structs[index - 1] = structByIndex[index]!); + final length = structByIndex.keys.fold(0, max); + structs.length = length; + for (var i = 0; i < length; i++) { + structs[i] = structByIndex[i + 1] ?? {}; + } } @override diff --git a/lib/widgets/viewer/multipage/controller.dart b/lib/widgets/viewer/multipage/controller.dart index d45029b69..66c8705fb 100644 --- a/lib/widgets/viewer/multipage/controller.dart +++ b/lib/widgets/viewer/multipage/controller.dart @@ -10,6 +10,7 @@ class MultiPageController { final AvesEntry entry; final ValueNotifier pageNotifier = ValueNotifier(null); + bool _disposed = false; MultiPageInfo? _info; final StreamController _infoStreamController = StreamController.broadcast(); @@ -24,7 +25,7 @@ class MultiPageController { MultiPageController(this.entry) { metadataService.getMultiPageInfo(entry).then((value) { - if (value == null) return; + if (value == null || _disposed) return; pageNotifier.value = value.defaultPage!.index; _info = value; _infoStreamController.add(_info); @@ -32,6 +33,7 @@ class MultiPageController { } void dispose() { + _disposed = true; pageNotifier.dispose(); } diff --git a/lib/widgets/viewer/overlay/bottom/multipage.dart b/lib/widgets/viewer/overlay/bottom/multipage.dart index fc3a63589..f626246b3 100644 --- a/lib/widgets/viewer/overlay/bottom/multipage.dart +++ b/lib/widgets/viewer/overlay/bottom/multipage.dart @@ -119,7 +119,7 @@ class _MultiPageOverlayState extends State { onTap: () => _goToPage(page), child: DecoratedThumbnail( entry: pageEntry, - extent: extent, + tileExtent: extent, // the retrieval task queue can pile up for thumbnails of heavy pages // (e.g. thumbnails of 15MP HEIF images inside 100MB+ HEIC containers) // so we cancel these requests when possible diff --git a/lib/widgets/viewer/overlay/bottom/video.dart b/lib/widgets/viewer/overlay/bottom/video.dart index 80d865bf1..fa3a80804 100644 --- a/lib/widgets/viewer/overlay/bottom/video.dart +++ b/lib/widgets/viewer/overlay/bottom/video.dart @@ -155,7 +155,7 @@ class _VideoControlOverlayState extends State with SingleTi padding: EdgeInsets.symmetric(vertical: 4, horizontal: 16) + EdgeInsets.only(bottom: 16), decoration: BoxDecoration( color: kOverlayBackgroundColor, - border: AvesCircleBorder.build(context), + border: AvesBorder.border, borderRadius: BorderRadius.circular(progressBarBorderRadius), ), child: Column( @@ -184,7 +184,7 @@ class _VideoControlOverlayState extends State with SingleTi if (!progress.isFinite) progress = 0.0; return LinearProgressIndicator( value: progress, - backgroundColor: Colors.grey[700], + backgroundColor: Colors.grey.shade700, ); }), ), diff --git a/lib/widgets/viewer/overlay/common.dart b/lib/widgets/viewer/overlay/common.dart index 49e131a8c..40a51ebfa 100644 --- a/lib/widgets/viewer/overlay/common.dart +++ b/lib/widgets/viewer/overlay/common.dart @@ -24,7 +24,7 @@ class OverlayButton extends StatelessWidget { color: kOverlayBackgroundColor, child: Ink( decoration: BoxDecoration( - border: AvesCircleBorder.build(context), + border: AvesBorder.border, shape: BoxShape.circle, ), child: child, @@ -66,7 +66,7 @@ class OverlayTextButton extends StatelessWidget { foregroundColor: MaterialStateProperty.all(Colors.white), overlayColor: MaterialStateProperty.all(Colors.white.withOpacity(0.12)), minimumSize: _minSize, - side: MaterialStateProperty.all(AvesCircleBorder.buildSide(context)), + side: MaterialStateProperty.all(AvesBorder.side), shape: MaterialStateProperty.all(RoundedRectangleBorder( borderRadius: BorderRadius.circular(_borderRadius), )), diff --git a/lib/widgets/viewer/overlay/top.dart b/lib/widgets/viewer/overlay/top.dart index a30e70a89..83bf15e3e 100644 --- a/lib/widgets/viewer/overlay/top.dart +++ b/lib/widgets/viewer/overlay/top.dart @@ -178,7 +178,7 @@ class _TopOverlayRow extends StatelessWidget { if (pageEntry.canRotateAndFlip) _buildRotateAndFlipMenuItems(context), PopupMenuDivider(), ...externalAppActions.map((action) => _buildPopupMenuItem(context, action)), - if (kDebugMode) ...[ + if (!kReleaseMode) ...[ PopupMenuDivider(), _buildPopupMenuItem(context, EntryAction.debug), ]