diff --git a/CHANGELOG.md b/CHANGELOG.md index 1183df518..025140960 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to this project will be documented in this file. - Bottom navigation bar - Collection: thumbnail overlay tag icon +- Collection: fast-scrolling shows breadcrumbs from groups - Settings: search - `huawei` app flavor (Petal Maps, no Crashlytics) diff --git a/lib/widgets/collection/collection_grid.dart b/lib/widgets/collection/collection_grid.dart index a7b4f59c3..9665ea7ef 100644 --- a/lib/widgets/collection/collection_grid.dart +++ b/lib/widgets/collection/collection_grid.dart @@ -8,6 +8,7 @@ import 'package:aves/model/filters/mime.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/enums.dart'; +import 'package:aves/model/source/section_keys.dart'; import 'package:aves/ref/mime_types.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; @@ -21,8 +22,10 @@ import 'package:aves/widgets/common/basic/insets.dart'; import 'package:aves/widgets/common/behaviour/sloppy_scroll_physics.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/media_query.dart'; +import 'package:aves/widgets/common/grid/draggable_thumb_label.dart'; import 'package:aves/widgets/common/grid/item_tracker.dart'; import 'package:aves/widgets/common/grid/scaling.dart'; +import 'package:aves/widgets/common/grid/section_layout.dart'; import 'package:aves/widgets/common/grid/selector.dart'; import 'package:aves/widgets/common/grid/sliver.dart'; import 'package:aves/widgets/common/grid/theme.dart'; @@ -34,6 +37,7 @@ import 'package:aves/widgets/common/tile_extent_controller.dart'; import 'package:aves/widgets/navigation/nav_bar/nav_bar.dart'; import 'package:flutter/material.dart'; import 'package:flutter_staggered_animations/flutter_staggered_animations.dart'; +import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; import 'package:tuple/tuple.dart'; @@ -347,26 +351,34 @@ class _CollectionScrollViewState extends State<_CollectionScrollView> { valueListenable: widget.appBarHeightNotifier, builder: (context, appBarHeight, child) => Selector( selector: (context, mq) => mq.effectiveBottomPadding, - builder: (context, mqPaddingBottom, child) => DraggableScrollbar( - backgroundColor: Colors.white, - scrollThumbHeight: avesScrollThumbHeight, - scrollThumbBuilder: avesScrollThumbBuilder( - height: avesScrollThumbHeight, - backgroundColor: Colors.white, - ), - controller: widget.scrollController, - padding: EdgeInsets.only( - // padding to keep scroll thumb between app bar above and nav bar below - top: appBarHeight, - bottom: mqPaddingBottom, - ), - labelTextBuilder: (offsetY) => CollectionDraggableThumbLabel( - collection: collection, - offsetY: offsetY, - ), - child: scrollView, - ), - child: child, + builder: (context, mqPaddingBottom, child) { + return Selector, List>( + selector: (context, layout) => layout.sectionLayouts, + builder: (context, sectionLayouts, child) { + return DraggableScrollbar( + backgroundColor: Colors.white, + scrollThumbSize: Size(avesScrollThumbWidth, avesScrollThumbHeight), + scrollThumbBuilder: avesScrollThumbBuilder( + height: avesScrollThumbHeight, + backgroundColor: Colors.white, + ), + controller: widget.scrollController, + crumbsBuilder: () => _getCrumbs(sectionLayouts), + padding: EdgeInsets.only( + // padding to keep scroll thumb between app bar above and nav bar below + top: appBarHeight, + bottom: mqPaddingBottom, + ), + labelTextBuilder: (offsetY) => CollectionDraggableThumbLabel( + collection: collection, + offsetY: offsetY, + ), + crumbTextBuilder: (label) => DraggableCrumbLabel(label: label), + child: scrollView, + ); + }, + ); + }, ), ); } @@ -433,4 +445,64 @@ class _CollectionScrollViewState extends State<_CollectionScrollView> { void _stopScrollMonitoringTimer() { _scrollMonitoringTimer?.cancel(); } + + Map _getCrumbs(List sectionLayouts) { + final crumbs = {}; + if (sectionLayouts.length <= 1) return crumbs; + + void addAlbums(CollectionLens collection, List sectionLayouts, Map crumbs) { + final source = collection.source; + sectionLayouts.forEach((section) { + final directory = (section.sectionKey as EntryAlbumSectionKey).directory; + if (directory != null) { + final label = source.getAlbumDisplayName(context, directory); + crumbs[section.minOffset] = label; + } + }); + } + + final collection = widget.collection; + switch (collection.sortFactor) { + case EntrySortFactor.date: + switch (collection.sectionFactor) { + case EntryGroupFactor.album: + addAlbums(collection, sectionLayouts, crumbs); + break; + case EntryGroupFactor.month: + case EntryGroupFactor.day: + final firstKey = sectionLayouts.first.sectionKey; + final lastKey = sectionLayouts.last.sectionKey; + if (firstKey is EntryDateSectionKey && lastKey is EntryDateSectionKey) { + final newest = firstKey.date; + final oldest = lastKey.date; + if (newest != null && oldest != null) { + final localeName = context.l10n.localeName; + final dateFormat = newest.difference(oldest).inDays > 365 ? DateFormat.y(localeName) : DateFormat.MMM(localeName); + String? lastLabel; + sectionLayouts.forEach((section) { + final date = (section.sectionKey as EntryDateSectionKey).date; + if (date != null) { + final label = dateFormat.format(date); + if (label != lastLabel) { + crumbs[section.minOffset] = label; + lastLabel = label; + } + } + }); + } + } + break; + case EntryGroupFactor.none: + break; + } + break; + case EntrySortFactor.name: + addAlbums(collection, sectionLayouts, crumbs); + break; + case EntrySortFactor.rating: + case EntrySortFactor.size: + break; + } + return crumbs; + } } diff --git a/lib/widgets/common/basic/draggable_scrollbar.dart b/lib/widgets/common/basic/draggable_scrollbar.dart index b3b0ea687..23817bafb 100644 --- a/lib/widgets/common/basic/draggable_scrollbar.dart +++ b/lib/widgets/common/basic/draggable_scrollbar.dart @@ -24,7 +24,8 @@ typedef ScrollThumbBuilder = Widget Function( }); /// Build a Text widget using the current scroll offset -typedef LabelTextBuilder = Widget Function(double offsetY); +typedef OffsetLabelBuilder = Widget Function(double offsetY); +typedef TextLabelBuilder = Widget Function(String label); /// A widget that will display a BoxScrollView with a ScrollThumb that can be dragged /// for quick navigation of the BoxScrollView. @@ -32,14 +33,15 @@ class DraggableScrollbar extends StatefulWidget { /// The background color of the label and thumb final Color backgroundColor; - /// The height of the scroll thumb - final double scrollThumbHeight; + final Map Function()? crumbsBuilder; + + final Size scrollThumbSize; /// A function that builds a thumb using the current configuration final ScrollThumbBuilder scrollThumbBuilder; /// The amount of padding that should surround the thumb - final EdgeInsets? padding; + final EdgeInsets padding; /// Determines how quickly the scrollbar will animate in and out final Duration scrollbarAnimationDuration; @@ -48,7 +50,9 @@ class DraggableScrollbar extends StatefulWidget { final Duration scrollbarTimeToFade; /// Build a Text widget from the current offset in the BoxScrollView - final LabelTextBuilder? labelTextBuilder; + final OffsetLabelBuilder labelTextBuilder; + + final TextLabelBuilder crumbTextBuilder; /// The ScrollController for the BoxScrollView final ScrollController controller; @@ -59,13 +63,15 @@ class DraggableScrollbar extends StatefulWidget { DraggableScrollbar({ Key? key, required this.backgroundColor, - required this.scrollThumbHeight, + required this.scrollThumbSize, required this.scrollThumbBuilder, required this.controller, - this.padding, + this.crumbsBuilder, + this.padding = EdgeInsets.zero, this.scrollbarAnimationDuration = const Duration(milliseconds: 300), this.scrollbarTimeToFade = const Duration(milliseconds: 1000), - this.labelTextBuilder, + required this.labelTextBuilder, + required this.crumbTextBuilder, required this.child, }) : assert(child.scrollDirection == Axis.vertical), super(key: key); @@ -73,6 +79,8 @@ class DraggableScrollbar extends StatefulWidget { @override State createState() => _DraggableScrollbarState(); + static const double labelThumbPadding = 16; + static Widget buildScrollThumbAndLabel({ required Widget scrollThumb, required Color backgroundColor, @@ -91,7 +99,7 @@ class DraggableScrollbar extends StatefulWidget { backgroundColor: backgroundColor, child: labelText, ), - const SizedBox(width: 24), + const SizedBox(width: labelThumbPadding), scrollThumb, ], ); @@ -141,6 +149,11 @@ class _DraggableScrollbarState extends State with TickerProv late AnimationController _labelAnimationController; late Animation _labelAnimation; Timer? _fadeoutTimer; + Map? _modelCrumbs; + final List<_Crumb> _viewportCrumbs = []; + + static const crumbPadding = 30.0; + static const crumbOffsetRatioThreshold = 10; @override void initState() { @@ -167,6 +180,15 @@ class _DraggableScrollbarState extends State with TickerProv ); } + @override + void didUpdateWidget(covariant DraggableScrollbar oldWidget) { + super.didUpdateWidget(oldWidget); + + if (oldWidget.crumbsBuilder != widget.crumbsBuilder) { + _modelCrumbs = null; + } + } + @override void dispose() { _thumbAnimationController.dispose(); @@ -177,7 +199,9 @@ class _DraggableScrollbarState extends State with TickerProv ScrollController get controller => widget.controller; - double get thumbMaxScrollExtent => context.size!.height - widget.scrollThumbHeight - (widget.padding?.vertical ?? 0.0); + double get scrollBarHeight => context.size!.height - widget.padding.vertical; + + double get thumbMaxScrollExtent => scrollBarHeight - widget.scrollThumbSize.height; double get thumbMinScrollExtent => 0.0; @@ -193,6 +217,27 @@ class _DraggableScrollbarState extends State with TickerProv RepaintBoundary( child: widget.child, ), + if (_isDragInProcess) + ..._viewportCrumbs.map((crumb) { + return Positioned.directional( + textDirection: Directionality.of(context), + top: crumb.labelOffset, + end: DraggableScrollbar.labelThumbPadding + widget.scrollThumbSize.width, + child: Padding( + padding: widget.padding, + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: widget.scrollThumbSize.height), + child: Center( + child: ScrollLabel( + animation: kAlwaysCompleteAnimation, + backgroundColor: widget.backgroundColor, + child: widget.crumbTextBuilder(crumb.label), + ), + ), + ), + ), + ); + }), RepaintBoundary( child: GestureDetector( onLongPressStart: (details) { @@ -212,16 +257,16 @@ class _DraggableScrollbarState extends State with TickerProv valueListenable: _thumbOffsetNotifier, builder: (context, thumbOffset, child) => Container( alignment: AlignmentDirectional.topEnd, - padding: EdgeInsets.only(top: thumbOffset) + (widget.padding ?? EdgeInsets.zero), + padding: EdgeInsets.only(top: thumbOffset) + widget.padding, child: widget.scrollThumbBuilder( widget.backgroundColor, _thumbAnimation, _labelAnimation, - widget.scrollThumbHeight, - labelText: (widget.labelTextBuilder != null && _isDragInProcess) + widget.scrollThumbSize.height, + labelText: _isDragInProcess ? ValueListenableBuilder( valueListenable: _viewOffsetNotifier, - builder: (context, viewOffset, child) => widget.labelTextBuilder!.call(viewOffset + thumbOffset), + builder: (context, viewOffset, child) => widget.labelTextBuilder(viewOffset + thumbOffset), ) : null, ), @@ -261,6 +306,7 @@ class _DraggableScrollbarState extends State with TickerProv _labelAnimationController.forward(); _fadeoutTimer?.cancel(); _showThumb(); + _updateViewportCrumbs(); setState(() => _isDragInProcess = true); } @@ -296,6 +342,50 @@ class _DraggableScrollbarState extends State with TickerProv _fadeoutTimer = null; }); } + + void _updateViewportCrumbs() { + _viewportCrumbs.clear(); + final crumbsBuilder = widget.crumbsBuilder; + if (crumbsBuilder != null) { + final position = controller.position; + final contentHeight = position.maxScrollExtent + thumbMaxScrollExtent + position.viewportDimension; + final ratio = contentHeight / scrollBarHeight; + if (ratio > crumbOffsetRatioThreshold) { + final maxLabelOffset = scrollBarHeight - widget.scrollThumbSize.height; + double lastLabelOffset = -crumbPadding; + _modelCrumbs ??= crumbsBuilder(); + _modelCrumbs!.entries.forEach((kv) { + final viewOffset = kv.key; + final label = kv.value; + final labelOffset = (viewOffset / ratio).roundToDouble(); + if (labelOffset >= lastLabelOffset + crumbPadding && labelOffset < maxLabelOffset) { + lastLabelOffset = labelOffset; + _viewportCrumbs.add(_Crumb( + viewOffset: viewOffset, + labelOffset: labelOffset, + label: label, + )); + } + }); + // hide lonesome crumb, whether it is because of a single section, + // or because multiple sections collapsed to a single crumb + if (_viewportCrumbs.length == 1) { + _viewportCrumbs.clear(); + } + } + } + } +} + +class _Crumb { + final double viewOffset, labelOffset; + final String label; + + const _Crumb({ + required this.viewOffset, + required this.labelOffset, + required this.label, + }); } ///This cut 2 lines in arrow shape diff --git a/lib/widgets/common/grid/draggable_thumb_label.dart b/lib/widgets/common/grid/draggable_thumb_label.dart index 65ae604e1..375b1c71f 100644 --- a/lib/widgets/common/grid/draggable_thumb_label.dart +++ b/lib/widgets/common/grid/draggable_thumb_label.dart @@ -5,6 +5,26 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; +class DraggableCrumbLabel extends StatelessWidget { + final String label; + + const DraggableCrumbLabel({ + Key? key, + required this.label, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: _crumbLabelMaxWidth), + child: Padding( + padding: _padding, + child: _buildText(label, isCrumb: true), + ), + ); + } +} + class DraggableThumbLabel extends StatelessWidget { final double offsetY; final List Function(BuildContext context, T item) lineBuilder; @@ -30,30 +50,20 @@ class DraggableThumbLabel extends StatelessWidget { if (lines.isEmpty) return const SizedBox(); return ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 140), + constraints: const BoxConstraints(maxWidth: _thumbLabelMaxWidth), child: Padding( - padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), + padding: _padding, child: lines.length > 1 ? Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, - children: lines.map(_buildText).toList(), + children: lines.map((v) => _buildText(v, isCrumb: false)).toList(), ) - : _buildText(lines.first), + : _buildText(lines.first, isCrumb: false), ), ); } - Widget _buildText(String text) => Text( - text, - style: const TextStyle( - color: Colors.black, - ), - softWrap: false, - overflow: TextOverflow.fade, - maxLines: 1, - ); - static String formatMonthThumbLabel(BuildContext context, DateTime? date) { final l10n = context.l10n; if (date == null) return l10n.sectionUnknown; @@ -66,3 +76,18 @@ class DraggableThumbLabel extends StatelessWidget { return formatDay(date, l10n.localeName); } } + +const double _crumbLabelMaxWidth = 96; +const double _thumbLabelMaxWidth = 144; +const EdgeInsets _padding = EdgeInsets.symmetric(vertical: 4, horizontal: 8); + +Widget _buildText(String text, {required bool isCrumb}) => Text( + text, + style: TextStyle( + color: Colors.black, + fontSize: isCrumb ? 10 : 14, + ), + softWrap: false, + overflow: TextOverflow.fade, + maxLines: 1, + ); diff --git a/lib/widgets/common/identity/scroll_thumb.dart b/lib/widgets/common/identity/scroll_thumb.dart index 3b6ccf73f..4fdf08c40 100644 --- a/lib/widgets/common/identity/scroll_thumb.dart +++ b/lib/widgets/common/identity/scroll_thumb.dart @@ -15,12 +15,12 @@ ScrollThumbBuilder avesScrollThumbBuilder({ borderRadius: BorderRadius.all(Radius.circular(12)), ), height: height, - margin: const EdgeInsetsDirectional.only(end: 1), - padding: const EdgeInsets.all(2), + margin: _margin, + padding: _padding, child: ClipPath( clipper: ArrowClipper(), child: Container( - width: 20.0, + width: _width, decoration: BoxDecoration( color: backgroundColor, borderRadius: const BorderRadius.all(Radius.circular(12)), @@ -38,3 +38,9 @@ ScrollThumbBuilder avesScrollThumbBuilder({ ); }; } + +const _margin = EdgeInsetsDirectional.only(end: 1); +const _padding = EdgeInsets.all(2); +const _width = 20.0; + +double get avesScrollThumbWidth => _width + _padding.horizontal + _margin.horizontal; diff --git a/lib/widgets/common/magnifier/core/core.dart b/lib/widgets/common/magnifier/core/core.dart index 495a857f6..bbaf1e69a 100644 --- a/lib/widgets/common/magnifier/core/core.dart +++ b/lib/widgets/common/magnifier/core/core.dart @@ -197,11 +197,12 @@ class _MagnifierCoreState extends State with TickerProviderStateM } void onTap(TapUpDetails details) { - if (widget.onTap == null) return; + final onTap = widget.onTap; + if (onTap == null) return; final viewportTapPosition = details.localPosition; final childTapPosition = scaleBoundaries.viewportToChildPosition(controller, viewportTapPosition); - widget.onTap!.call(context, details, controller.currentState, childTapPosition); + onTap(context, details, controller.currentState, childTapPosition); } void onDoubleTap(TapDownDetails details) { diff --git a/lib/widgets/filter_grids/common/filter_grid_page.dart b/lib/widgets/filter_grids/common/filter_grid_page.dart index 4bd83cd7a..06ff239ff 100644 --- a/lib/widgets/filter_grids/common/filter_grid_page.dart +++ b/lib/widgets/filter_grids/common/filter_grid_page.dart @@ -503,7 +503,7 @@ class _FilterScrollView extends StatelessWidget { selector: (context, mq) => mq.effectiveBottomPadding, builder: (context, mqPaddingBottom, child) => DraggableScrollbar( backgroundColor: Colors.white, - scrollThumbHeight: avesScrollThumbHeight, + scrollThumbSize: Size(avesScrollThumbWidth, avesScrollThumbHeight), scrollThumbBuilder: avesScrollThumbBuilder( height: avesScrollThumbHeight, backgroundColor: Colors.white, @@ -518,6 +518,7 @@ class _FilterScrollView extends StatelessWidget { sortFactor: sortFactor, offsetY: offsetY, ), + crumbTextBuilder: (offsetY) => const SizedBox(), child: scrollView, ), );