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

View file

@ -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<dynamic> errorAndStacktrace = pair;
await FirebaseCrashlytics.instance.recordError(

View file

@ -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 = <double>{};
static Future<void> 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<double>(
extents,
requestExtents,
(extent) => ThumbnailProvider(ThumbnailProviderKey(
uri: uri,
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/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,
);
}

View file

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

View file

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

View file

@ -16,7 +16,7 @@ class Constants {
);
static const embossShadow = Shadow(
color: Colors.black87,
color: Colors.black,
offset: Offset(0.5, 1.0),
);

View file

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

View file

@ -75,19 +75,21 @@ class _CollectionGridContent extends StatelessWidget {
builder: (context, tileExtent, child) {
return ThumbnailTheme(
extent: tileExtent,
child: Selector<TileExtentController, Tuple2<double, int>>(
selector: (context, c) => Tuple2(c.viewportSize.width, c.columnCount),
child: Selector<TileExtentController, Tuple3<double, int, double>>(
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<TileExtentController>(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<TileExtentController, double>((controller) => controller.effectiveExtentMax * 2),
cacheExtent: context.select<TileExtentController, double>((controller) => controller.effectiveExtentMax),
slivers: [
appBar,
collection.isEmpty

View file

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

View file

@ -12,6 +12,7 @@ class SectionedEntryListLayoutProvider extends SectionedListLayoutProvider<AvesE
required this.collection,
required double scrollableWidth,
required int columnCount,
required double spacing,
required double tileExtent,
required Widget Function(AvesEntry entry) tileBuilder,
required Duration tileAnimationDelay,
@ -19,6 +20,7 @@ class SectionedEntryListLayoutProvider extends SectionedListLayoutProvider<AvesE
}) : super(
scrollableWidth: scrollableWidth,
columnCount: columnCount,
spacing: spacing,
tileExtent: tileExtent,
tileBuilder: tileBuilder,
tileAnimationDelay: tileAnimationDelay,

View file

@ -51,7 +51,7 @@ class InteractiveThumbnail extends StatelessWidget {
metaData: ScalerMetadata(entry),
child: DecoratedThumbnail(
entry: entry,
extent: tileExtent,
tileExtent: tileExtent,
collection: collection,
// when the user is scrolling faster than we can retrieve the thumbnails,
// 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/raster.dart';
import 'package:aves/widgets/collection/thumbnail/vector.dart';
import 'package:aves/widgets/common/fx/borders.dart';
import 'package:flutter/material.dart';
class DecoratedThumbnail extends StatelessWidget {
final AvesEntry entry;
final double extent;
final double tileExtent;
final CollectionLens? collection;
final ValueNotifier<bool>? 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,
);
}

View file

@ -41,12 +41,12 @@ class _ErrorThumbnailState extends State<ErrorThumbnail> {
return FutureBuilder<bool>(
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<ErrorThumbnail> {
size: extent / 2,
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 {
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<ThumbnailHighlightOverlay> {
decoration: BoxDecoration(
border: Border.all(
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/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<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;
@ -35,6 +43,8 @@ class _RasterImageThumbnailState extends State<RasterImageThumbnail> {
@override
void initState() {
super.initState();
_streamListener = ImageStreamListener(_onImageLoad, onError: _onError);
_scrollAwareContext = DisposableBuildContext<State<RasterImageThumbnail>>(this);
_registerWidget(widget);
}
@ -50,6 +60,7 @@ class _RasterImageThumbnailState extends State<RasterImageThumbnail> {
@override
void dispose() {
_unregisterWidget(widget);
_scrollAwareContext.dispose();
super.dispose();
}
@ -61,66 +72,119 @@ class _RasterImageThumbnailState extends State<RasterImageThumbnail> {
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<RasterImageThumbnail> {
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, __) {
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,

View file

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

View file

@ -19,7 +19,7 @@ abstract class SectionedListLayoutProvider<T> 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<T> extends StatelessWidget {
final children = <Widget>[];
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,
);
}

View file

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

View file

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

View file

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

View file

@ -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<QueueState>(
stream: servicePolicy.queueStream,

View file

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

View file

@ -222,9 +222,9 @@ class _FilterGridContent<T extends CollectionFilter> 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;

View file

@ -11,7 +11,7 @@ class SectionedFilterListLayoutProvider<T extends CollectionFilter> extends Sect
required this.showHeaders,
required double scrollableWidth,
required int columnCount,
double spacing = 0,
required double spacing,
required double tileExtent,
required Widget Function(FilterGridItem<T> gridItem) tileBuilder,
required Duration tileAnimationDelay,

View file

@ -70,7 +70,7 @@ class _EntryBackgroundSelectorState extends State<EntryBackgroundSelector> {
width: radius * 2,
decoration: BoxDecoration(
color: selected.isColor ? selected.color : null,
border: AvesCircleBorder.build(context),
border: AvesBorder.border,
shape: BoxShape.circle,
),
child: child,

View file

@ -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<ViewerVerticalPageView> {
final ValueNotifier<Color> _backgroundColorNotifier = ValueNotifier(Colors.black);
final ValueNotifier<bool> _infoPageVisibleNotifier = ValueNotifier(false);
final ValueNotifier<bool> _isVerticallyScrollingNotifier = ValueNotifier(false);
Timer? _verticalScrollMonitoringTimer;
AvesEntry? _oldEntry;
CollectionLens? get collection => widget.collection;
@ -60,6 +63,7 @@ class _ViewerVerticalPageViewState extends State<ViewerVerticalPageView> {
@override
void dispose() {
_unregisterWidget(widget);
_stopScrollMonitoringTimer();
super.dispose();
}
@ -77,32 +81,47 @@ class _ViewerVerticalPageViewState extends State<ViewerVerticalPageView> {
@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<BackUpNotification>(
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<BackUpNotification>(
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<Color>(
valueListenable: _backgroundColorNotifier,
@ -115,10 +134,7 @@ class _ViewerVerticalPageViewState extends State<ViewerVerticalPageView> {
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<ViewerVerticalPageView> {
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)

View file

@ -216,12 +216,11 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
}
Widget _buildTopOverlay() {
final child = ValueListenableBuilder<AvesEntry?>(
Widget child = ValueListenableBuilder<AvesEntry?>(
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<EntryViewerStack> with SingleTickerPr
}
_actionDelegate.onActionSelected(context, targetEntry, action);
},
viewStateNotifier: viewStateNotifier,
viewStateNotifier: _viewStateNotifiers.firstWhereOrNull((kv) => kv.item1 == mainEntry.uri)?.item2,
);
},
);
return ValueListenableBuilder<int>(
child = ValueListenableBuilder<int>(
valueListenable: _currentVerticalPage,
builder: (context, page, child) {
return Visibility(
@ -256,13 +256,26 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
},
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 bottomOverlay = ValueListenableBuilder<AvesEntry?>(
Widget child = ValueListenableBuilder<AvesEntry?>(
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<EntryViewerStack> with SingleTickerPr
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
? StreamBuilder<MultiPageInfo?>(
stream: multiPageController.infoStream,
@ -299,9 +312,9 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
},
);
})
: _buildExtraBottomOverlay(entry);
: _buildExtraBottomOverlay(mainEntry);
final child = Column(
return Column(
children: [
if (extraBottomOverlay != null)
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,
builder: (c, mqHeight, child) {
// 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: 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() {
@ -383,10 +396,11 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
}
Future<void> _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,
);
}

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/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<bool> 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<bool> visibleNotifier;
const OwnerProp({
required this.entry,
required this.visibleNotifier,
});
@override
@ -132,53 +128,33 @@ class OwnerProp extends StatefulWidget {
}
class _OwnerPropState extends State<OwnerProp> {
final ValueNotifier<String?> _loadedUri = ValueNotifier(null);
String? _ownerPackage;
late Future<String?> _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<String?>(
valueListenable: _loadedUri,
builder: (context, uri, child) {
if (_ownerPackage == null) return SizedBox();
final appName = androidFileUtils.getCurrentAppName(_ownerPackage!) ?? _ownerPackage;
return FutureBuilder<String?>(
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<OwnerProp> {
),
// `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<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 {
final CollectionLens? collection;
final ValueNotifier<AvesEntry?> entryNotifier;
final ValueNotifier<bool> visibleNotifier;
final ValueNotifier<bool> 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<InfoPage> {
? _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<InfoPage> {
class _InfoPageContent extends StatefulWidget {
final CollectionLens? collection;
final AvesEntry entry;
final ValueNotifier<bool> visibleNotifier;
final ValueNotifier<bool> 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<bool> 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(

View file

@ -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<bool> visibleNotifier;
final ValueNotifier<bool> 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<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 pointerSize = Size(8.0, 6.0);
@ -69,98 +75,114 @@ class _LocationSectionState extends State<LocationSection> 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 = <LocationFilter>[];
if (entry.hasAddress) {
final address = entry.addressDetails!;
final country = address.countryName;
if (country != null && country.isNotEmpty) filters.add(LocationFilter(LocationLevel.country, '$country${LocationFilter.locationSeparator}${address.countryCode}'));
final place = address.place;
if (place != null && place.isNotEmpty) filters.add(LocationFilter(LocationLevel.place, place));
}
if (!entry.hasGps) return SizedBox();
final latLng = entry.latLng!;
final geoUri = entry.geoUri!;
Widget buildMarker(BuildContext context) {
return ImageMarker(
final filters = <LocationFilter>[];
if (entry.hasAddress) {
final address = entry.addressDetails!;
final country = address.countryName;
if (country != null && country.isNotEmpty) filters.add(LocationFilter(LocationLevel.country, '$country${LocationFilter.locationSeparator}${address.countryCode}'));
final place = address.place;
if (place != null && place.isNotEmpty) filters.add(LocationFilter(LocationLevel.place, place));
}
Widget buildMarker(BuildContext context) => ImageMarker(
entry: entry,
extent: extent,
pointerSize: pointerSize,
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.showTitle) SectionRow(AIcons.location),
FutureBuilder<bool>(
future: availability.isConnected,
builder: (context, snapshot) {
if (snapshot.data != true) return SizedBox();
final latLng = entry.latLng!;
return NotificationListener<MapStyleChangedNotification>(
onNotification: (notification) {
setState(() {});
return true;
},
child: AnimatedSize(
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.showTitle) SectionRow(AIcons.location),
FutureBuilder<bool>(
future: availability.isConnected,
builder: (context, snapshot) {
if (snapshot.data != true) return SizedBox();
return Selector<Settings, EntryMapStyle>(
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<double, double>(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<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: (_) {},
),
],
),
),
);
},
),
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<double, double>(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(() {});

View file

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

View file

@ -30,7 +30,7 @@ class EntryGoogleMap extends StatefulWidget {
State<StatefulWidget> createState() => _EntryGoogleMapState();
}
class _EntryGoogleMapState extends State<EntryGoogleMap> with AutomaticKeepAliveClientMixin {
class _EntryGoogleMapState extends State<EntryGoogleMap> {
GoogleMapController? _controller;
late Completer<Uint8List> _markerLoaderCompleter;
@ -59,7 +59,6 @@ class _EntryGoogleMapState extends State<EntryGoogleMap> with AutomaticKeepAlive
@override
Widget build(BuildContext context) {
super.build(context);
return Stack(
children: [
MarkerGeneratorWidget(
@ -132,7 +131,4 @@ class _EntryGoogleMapState extends State<EntryGoogleMap> with AutomaticKeepAlive
return MapType.none;
}
}
@override
bool get wantKeepAlive => true;
}

View file

@ -33,7 +33,7 @@ class EntryLeafletMap extends StatefulWidget {
State<StatefulWidget> createState() => _EntryLeafletMapState();
}
class _EntryLeafletMapState extends State<EntryLeafletMap> with AutomaticKeepAliveClientMixin, TickerProviderStateMixin {
class _EntryLeafletMapState extends State<EntryLeafletMap> with TickerProviderStateMixin {
final MapController _mapController = MapController();
@override
@ -46,7 +46,6 @@ class _EntryLeafletMapState extends State<EntryLeafletMap> with AutomaticKeepAli
@override
Widget build(BuildContext context) {
super.build(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -157,9 +156,6 @@ class _EntryLeafletMapState extends State<EntryLeafletMap> with AutomaticKeepAli
});
controller.forward();
}
@override
bool get wantKeepAlive => true;
}
class OSMHotLayer extends StatelessWidget {

View file

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

View file

@ -19,12 +19,10 @@ import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
class MetadataSectionSliver extends StatefulWidget {
final AvesEntry entry;
final ValueNotifier<bool> visibleNotifier;
final ValueNotifier<Map<String, MetadataDirectory>> metadataNotifier;
const MetadataSectionSliver({
required this.entry,
required this.visibleNotifier,
required this.metadataNotifier,
});
@ -32,18 +30,13 @@ class MetadataSectionSliver extends StatefulWidget {
State<StatefulWidget> createState() => _MetadataSectionSliverState();
}
class _MetadataSectionSliverState extends State<MetadataSectionSliver> with AutomaticKeepAliveClientMixin {
final ValueNotifier<String?> _loadedMetadataUri = ValueNotifier(null);
class _MetadataSectionSliverState extends State<MetadataSectionSliver> {
final ValueNotifier<String?> _expandedDirectoryNotifier = ValueNotifier(null);
AvesEntry get entry => widget.entry;
bool get isVisible => widget.visibleNotifier.value;
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
// if so, they are separated by this character
static const parentChildSeparator = '/';
@ -71,18 +64,15 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> 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<MetadataSectionSliver> with Auto
// cancel notification bubbling so that the info page
// does not misinterpret content scrolling for page scrolling
onNotification: (notification) => true,
child: ValueListenableBuilder<String?>(
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<Map<String, MetadataDirectory>>(
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<void> _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<MetadataSectionSliver> with Auto
.cast<MapEntry<String, String>>()));
return tags;
}
@override
bool get wantKeepAlive => true;
}
class MetadataDirectory {

View file

@ -19,8 +19,11 @@ class XmpStructArrayCard extends StatefulWidget {
required Map<int, Map<String, String>> 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

View file

@ -10,6 +10,7 @@ class MultiPageController {
final AvesEntry entry;
final ValueNotifier<int?> pageNotifier = ValueNotifier(null);
bool _disposed = false;
MultiPageInfo? _info;
final StreamController<MultiPageInfo?> _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();
}

View file

@ -119,7 +119,7 @@ class _MultiPageOverlayState extends State<MultiPageOverlay> {
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

View file

@ -155,7 +155,7 @@ class _VideoControlOverlayState extends State<VideoControlOverlay> 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<VideoControlOverlay> with SingleTi
if (!progress.isFinite) progress = 0.0;
return LinearProgressIndicator(
value: progress,
backgroundColor: Colors.grey[700],
backgroundColor: Colors.grey.shade700,
);
}),
),

View file

@ -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<Color>(Colors.white),
overlayColor: MaterialStateProperty.all<Color>(Colors.white.withOpacity(0.12)),
minimumSize: _minSize,
side: MaterialStateProperty.all<BorderSide>(AvesCircleBorder.buildSide(context)),
side: MaterialStateProperty.all<BorderSide>(AvesBorder.side),
shape: MaterialStateProperty.all<OutlinedBorder>(RoundedRectangleBorder(
borderRadius: BorderRadius.circular(_borderRadius),
)),

View file

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