various perf related changes
This commit is contained in:
parent
29cb704391
commit
b539597c62
43 changed files with 552 additions and 444 deletions
|
@ -22,6 +22,7 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
|
|||
return MultiFrameImageStreamCompleter(
|
||||
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}');
|
||||
},
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
|
|
|
@ -16,7 +16,7 @@ class Constants {
|
|||
);
|
||||
|
||||
static const embossShadow = Shadow(
|
||||
color: Colors.black87,
|
||||
color: Colors.black,
|
||||
offset: Offset(0.5, 1.0),
|
||||
);
|
||||
|
||||
|
|
|
@ -92,6 +92,8 @@ class _AvesAppState extends State<AvesApp> {
|
|||
...AppLocalizations.localizationsDelegates,
|
||||
],
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
// checkerboardRasterCacheImages: true,
|
||||
// checkerboardOffscreenLayers: true,
|
||||
);
|
||||
});
|
||||
},
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
@ -30,6 +30,7 @@ class SectionedListSliver<T> extends StatelessWidget {
|
|||
},
|
||||
childCount: childCount,
|
||||
addAutomaticKeepAlives: false,
|
||||
addRepaintBoundaries: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -58,7 +58,7 @@ class _AppDrawerState extends State<AppDrawer> {
|
|||
albumListTile,
|
||||
countryListTile,
|
||||
tagListTile,
|
||||
if (kDebugMode) ...[
|
||||
if (!kReleaseMode) ...[
|
||||
Divider(),
|
||||
debugTile,
|
||||
],
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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(() {});
|
||||
|
|
|
@ -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 {}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
}),
|
||||
),
|
||||
|
|
|
@ -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),
|
||||
)),
|
||||
|
|
|
@ -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),
|
||||
]
|
||||
|
|
Loading…
Reference in a new issue