diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a258bf65..6b9692100 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added + +- mosaic layout + ## [v1.7.0] - 2022-09-19 ### Added diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b563fe5f1..67854960a 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -419,6 +419,7 @@ "viewDialogLayoutSectionTitle": "Layout", "viewDialogReverseSortOrder": "Reverse sort order", + "tileLayoutMosaic": "Mosaic", "tileLayoutGrid": "Grid", "tileLayoutList": "List", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 67fff4117..fe7253960 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -289,6 +289,7 @@ "viewDialogLayoutSectionTitle": "Vue", "viewDialogReverseSortOrder": "Inverser l’ordre", + "tileLayoutMosaic": "Mosaïque", "tileLayoutGrid": "Grille", "tileLayoutList": "Liste", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index ea9379b64..d7f102915 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -289,6 +289,7 @@ "viewDialogLayoutSectionTitle": "배치", "viewDialogReverseSortOrder": "순서를 뒤바꾸기", + "tileLayoutMosaic": "모자이크", "tileLayoutGrid": "바둑판", "tileLayoutList": "목록", diff --git a/lib/model/source/enums/enums.dart b/lib/model/source/enums/enums.dart index ae59b22f3..0ad4b5b2f 100644 --- a/lib/model/source/enums/enums.dart +++ b/lib/model/source/enums/enums.dart @@ -8,4 +8,4 @@ enum EntrySortFactor { date, name, rating, size } enum EntryGroupFactor { none, album, month, day } -enum TileLayout { grid, list } +enum TileLayout { mosaic, grid, list } diff --git a/lib/model/source/enums/view.dart b/lib/model/source/enums/view.dart index a80c116c9..5ed3c8a13 100644 --- a/lib/model/source/enums/view.dart +++ b/lib/model/source/enums/view.dart @@ -1,3 +1,4 @@ +import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:flutter/widgets.dart'; @@ -18,6 +19,19 @@ extension ExtraEntrySortFactor on EntrySortFactor { } } + IconData get icon { + switch (this) { + case EntrySortFactor.date: + return AIcons.date; + case EntrySortFactor.name: + return AIcons.name; + case EntrySortFactor.rating: + return AIcons.rating; + case EntrySortFactor.size: + return AIcons.size; + } + } + String getOrderName(BuildContext context, bool reverse) { final l10n = context.l10n; switch (this) { @@ -48,6 +62,19 @@ extension ExtraChipSortFactor on ChipSortFactor { } } + IconData get icon { + switch (this) { + case ChipSortFactor.date: + return AIcons.date; + case ChipSortFactor.name: + return AIcons.name; + case ChipSortFactor.count: + return AIcons.count; + case ChipSortFactor.size: + return AIcons.size; + } + } + String getOrderName(BuildContext context, bool reverse) { final l10n = context.l10n; switch (this) { @@ -76,6 +103,19 @@ extension ExtraEntryGroupFactor on EntryGroupFactor { return l10n.collectionGroupNone; } } + + IconData get icon { + switch (this) { + case EntryGroupFactor.album: + return AIcons.album; + case EntryGroupFactor.month: + return AIcons.dateByMonth; + case EntryGroupFactor.day: + return AIcons.dateByDay; + case EntryGroupFactor.none: + return AIcons.clear; + } + } } extension ExtraAlbumChipGroupFactor on AlbumChipGroupFactor { @@ -90,16 +130,40 @@ extension ExtraAlbumChipGroupFactor on AlbumChipGroupFactor { return l10n.albumGroupNone; } } + + IconData get icon { + switch (this) { + case AlbumChipGroupFactor.importance: + return AIcons.important; + case AlbumChipGroupFactor.volume: + return AIcons.removableStorage; + case AlbumChipGroupFactor.none: + return AIcons.clear; + } + } } extension ExtraTileLayout on TileLayout { String getName(BuildContext context) { final l10n = context.l10n; switch (this) { + case TileLayout.mosaic: + return l10n.tileLayoutMosaic; case TileLayout.grid: return l10n.tileLayoutGrid; case TileLayout.list: return l10n.tileLayoutList; } } + + IconData get icon { + switch (this) { + case TileLayout.mosaic: + return AIcons.layoutMosaic; + case TileLayout.grid: + return AIcons.layoutGrid; + case TileLayout.list: + return AIcons.layoutList; + } + } } diff --git a/lib/theme/durations.dart b/lib/theme/durations.dart index 50e81aab5..34bbf9fe8 100644 --- a/lib/theme/durations.dart +++ b/lib/theme/durations.dart @@ -24,11 +24,12 @@ class Durations { static const chipDecorationAnimation = Duration(milliseconds: 200); static const highlightScrollAnimationMinMillis = 400; static const highlightScrollAnimationMaxMillis = 2000; + static const scalingGridBackgroundAnimation = Duration(milliseconds: 200); + static const scalingGridPositionAnimation = Duration(milliseconds: 150); // collection animations static const filterBarRemovalAnimation = Duration(milliseconds: 400); static const collectionOpOverlayAnimation = Duration(milliseconds: 300); - static const collectionScalingBackgroundAnimation = Duration(milliseconds: 200); static const sectionHeaderAnimation = Duration(milliseconds: 200); static const thumbnailOverlayAnimation = Duration(milliseconds: 200); diff --git a/lib/theme/icons.dart b/lib/theme/icons.dart index 6dcdc8a03..7246282ee 100644 --- a/lib/theme/icons.dart +++ b/lib/theme/icons.dart @@ -14,8 +14,11 @@ class AIcons { static const IconData bin = Icons.delete_outlined; static const IconData broken = Icons.broken_image_outlined; static const IconData checked = Icons.done_outlined; + static const IconData count = MdiIcons.counter; static const IconData counter = Icons.plus_one_outlined; static const IconData date = Icons.calendar_today_outlined; + static const IconData dateByDay = Icons.today_outlined; + static const IconData dateByMonth = Icons.calendar_month_outlined; static const IconData dateRecent = Icons.today_outlined; static const IconData dateUndated = Icons.event_busy_outlined; static const IconData description = Icons.description_outlined; @@ -43,6 +46,7 @@ class AIcons { static const IconData sensorControlEnabled = Icons.explore_outlined; static const IconData sensorControlDisabled = Icons.explore_off_outlined; static const IconData settings = Icons.settings_outlined; + static const IconData size = Icons.data_usage_outlined; static const IconData text = Icons.format_quote_outlined; static const IconData tag = Icons.local_offer_outlined; static const IconData tagUntagged = MdiIcons.tagOffOutline; @@ -50,6 +54,9 @@ class AIcons { // view static const IconData group = Icons.group_work_outlined; static const IconData layout = Icons.grid_view_outlined; + static const IconData layoutMosaic = Icons.view_compact_outlined; + static const IconData layoutGrid = Icons.view_comfy_outlined; + static const IconData layoutList = Icons.list_outlined; static const IconData sort = Icons.sort_outlined; static const IconData sortOrder = Icons.swap_vert_outlined; @@ -111,7 +118,7 @@ class AIcons { static const IconData show = Icons.visibility_outlined; static const IconData slideshow = Icons.slideshow_outlined; static const IconData speed = Icons.speed_outlined; - static const IconData stats = Icons.pie_chart_outline_outlined; + static const IconData stats = Icons.donut_small_outlined; static const IconData streams = Icons.translate_outlined; static const IconData streamVideo = Icons.movie_outlined; static const IconData streamAudio = Icons.audiotrack_outlined; diff --git a/lib/utils/constants.dart b/lib/utils/constants.dart index 89aa6b2f4..13b362449 100644 --- a/lib/utils/constants.dart +++ b/lib/utils/constants.dart @@ -5,6 +5,11 @@ import 'package:flutter/material.dart'; import 'package:latlong2/latlong.dart'; class Constants { + // `Color(0x00FFFFFF)` is different from `Color(0x00000000)` (or `Colors.transparent`) + // when used in gradients or lerping to it + static const transparentWhite = Color(0x00FFFFFF); + static const transparentBlack = Colors.transparent; + // as of Flutter v2.8.0, overflowing `Text` miscalculates height and some text (e.g. 'Å') is clipped // so we give it a `strutStyle` with a slightly larger height static const overflowStrutStyle = StrutStyle(height: 1.3); diff --git a/lib/widgets/collection/app_bar.dart b/lib/widgets/collection/app_bar.dart index 4a2d6ccdf..13ee3c64b 100644 --- a/lib/widgets/collection/app_bar.dart +++ b/lib/widgets/collection/app_bar.dart @@ -83,6 +83,7 @@ class _CollectionAppBarState extends State with SingleTickerPr ]; static const _layoutOptions = [ + TileLayout.mosaic, TileLayout.grid, TileLayout.list, ]; @@ -537,9 +538,9 @@ class _CollectionAppBarState extends State with SingleTickerPr builder: (context) { return TileViewDialog( initialValue: initialValue, - sortOptions: Map.fromEntries(_sortOptions.map((v) => MapEntry(v, v.getName(context)))), - groupOptions: Map.fromEntries(_groupOptions.map((v) => MapEntry(v, v.getName(context)))), - layoutOptions: Map.fromEntries(_layoutOptions.map((v) => MapEntry(v, v.getName(context)))), + sortOptions: _sortOptions.map((v) => TileViewDialogOption(value: v, title: v.getName(context), icon: v.icon)).toList(), + groupOptions: _groupOptions.map((v) => TileViewDialogOption(value: v, title: v.getName(context), icon: v.icon)).toList(), + layoutOptions: _layoutOptions.map((v) => TileViewDialogOption(value: v, title: v.getName(context), icon: v.icon)).toList(), sortOrder: (factor, reverse) => factor.getOrderName(context, reverse), canGroup: (s, g, l) => s == EntrySortFactor.date, ); diff --git a/lib/widgets/collection/collection_grid.dart b/lib/widgets/collection/collection_grid.dart index 39b161ac0..8a8dc9a46 100644 --- a/lib/widgets/collection/collection_grid.dart +++ b/lib/widgets/collection/collection_grid.dart @@ -25,7 +25,9 @@ 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/sections/fixed/scale_grid.dart'; +import 'package:aves/widgets/common/grid/sections/list_layout.dart'; +import 'package:aves/widgets/common/grid/sections/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 +36,7 @@ import 'package:aves/widgets/common/identity/empty.dart'; import 'package:aves/widgets/common/identity/scroll_thumb.dart'; import 'package:aves/widgets/common/providers/tile_extent_controller_provider.dart'; import 'package:aves/widgets/common/thumbnail/decorated.dart'; +import 'package:aves/widgets/common/thumbnail/image.dart'; import 'package:aves/widgets/common/tile_extent_controller.dart'; import 'package:aves/widgets/navigation/nav_bar/nav_bar.dart'; import 'package:flutter/gestures.dart'; @@ -50,7 +53,8 @@ class CollectionGrid extends StatefulWidget { static const int columnCountDefault = 4; static const double extentMin = 46; static const double extentMax = 300; - static const double spacing = 2; + static const double fixedExtentLayoutSpacing = 2; + static const double mosaicLayoutSpacing = 4; const CollectionGrid({ super.key, @@ -64,6 +68,8 @@ class CollectionGrid extends StatefulWidget { class _CollectionGridState extends State { TileExtentController? _tileExtentController; + String get settingsRouteKey => widget.settingsRouteKey; + @override void dispose() { _tileExtentController?.dispose(); @@ -72,14 +78,17 @@ class _CollectionGridState extends State { @override Widget build(BuildContext context) { - _tileExtentController ??= TileExtentController( - settingsRouteKey: widget.settingsRouteKey, - columnCountDefault: CollectionGrid.columnCountDefault, - extentMin: CollectionGrid.extentMin, - extentMax: CollectionGrid.extentMax, - spacing: CollectionGrid.spacing, - horizontalPadding: 2, - ); + final spacing = context.select((s) => s.getTileLayout(settingsRouteKey) == TileLayout.mosaic ? CollectionGrid.mosaicLayoutSpacing : CollectionGrid.fixedExtentLayoutSpacing); + if (_tileExtentController?.spacing != spacing) { + _tileExtentController = TileExtentController( + settingsRouteKey: settingsRouteKey, + columnCountDefault: CollectionGrid.columnCountDefault, + extentMin: CollectionGrid.extentMin, + extentMax: CollectionGrid.extentMax, + spacing: spacing, + horizontalPadding: 2, + ); + } return TileExtentControllerProvider( controller: _tileExtentController!, child: _CollectionGridContent(), @@ -260,12 +269,13 @@ class _CollectionScaler extends StatelessWidget { final metrics = context.select>((v) => Tuple2(v.spacing, v.horizontalPadding)); final tileSpacing = metrics.item1; final horizontalPadding = metrics.item2; + final brightness = Theme.of(context).brightness; return GridScaleGestureDetector( scrollableKey: scrollableKey, tileLayout: tileLayout, heightForWidth: (width) => width, gridBuilder: (center, tileSize, child) => CustomPaint( - painter: GridPainter( + painter: FixedExtentGridPainter( tileLayout: tileLayout, tileCenter: center, tileSize: tileSize, @@ -278,7 +288,7 @@ class _CollectionScaler extends StatelessWidget { ), child: child, ), - scaledBuilder: (entry, tileSize) => EntryListDetailsTheme( + scaledItemBuilder: (entry, tileSize) => EntryListDetailsTheme( extent: tileSize.height, child: Tile( entry: entry, @@ -286,6 +296,15 @@ class _CollectionScaler extends StatelessWidget { tileLayout: tileLayout, ), ), + mosaicItemBuilder: (index, targetExtent) => DecoratedBox( + decoration: BoxDecoration( + color: ThumbnailImage.computeLoadingBackgroundColor(index * 10, brightness).withOpacity(.9), + border: Border.all( + color: DecoratedThumbnail.borderColor, + width: DecoratedThumbnail.borderWidth, + ), + ), + ), child: child, ); } diff --git a/lib/widgets/collection/draggable_thumb_label.dart b/lib/widgets/collection/draggable_thumb_label.dart index 145a00607..d04670818 100644 --- a/lib/widgets/collection/draggable_thumb_label.dart +++ b/lib/widgets/collection/draggable_thumb_label.dart @@ -6,7 +6,7 @@ import 'package:aves/model/source/enums/enums.dart'; import 'package:aves/utils/file_utils.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/grid/draggable_thumb_label.dart'; -import 'package:aves/widgets/common/grid/section_layout.dart'; +import 'package:aves/widgets/common/grid/sections/list_layout.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; diff --git a/lib/widgets/collection/grid/section_layout.dart b/lib/widgets/collection/grid/section_layout.dart index 6ca41975c..276861e32 100644 --- a/lib/widgets/collection/grid/section_layout.dart +++ b/lib/widgets/collection/grid/section_layout.dart @@ -2,14 +2,14 @@ import 'package:aves/model/entry.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/section_keys.dart'; import 'package:aves/widgets/collection/grid/headers/any.dart'; -import 'package:aves/widgets/common/grid/section_layout.dart'; +import 'package:aves/widgets/common/grid/sections/provider.dart'; import 'package:flutter/material.dart'; class SectionedEntryListLayoutProvider extends SectionedListLayoutProvider { final CollectionLens collection; final bool selectable; - const SectionedEntryListLayoutProvider({ + SectionedEntryListLayoutProvider({ super.key, required this.collection, required this.selectable, @@ -25,6 +25,7 @@ class SectionedEntryListLayoutProvider extends SectionedListLayoutProvider item.displayAspectRatio, ); @override diff --git a/lib/widgets/collection/grid/tile.dart b/lib/widgets/collection/grid/tile.dart index 3b4ed5225..c37693b3b 100644 --- a/lib/widgets/collection/grid/tile.dart +++ b/lib/widgets/collection/grid/tile.dart @@ -122,6 +122,7 @@ class Tile extends StatelessWidget { @override Widget build(BuildContext context) { switch (tileLayout) { + case TileLayout.mosaic: case TileLayout.grid: return _buildThumbnail(); case TileLayout.list: @@ -145,6 +146,7 @@ class Tile extends StatelessWidget { Widget _buildThumbnail() => DecoratedThumbnail( entry: entry, tileExtent: thumbnailExtent, + isMosaic: tileLayout == TileLayout.mosaic, // when the user is scrolling faster than we can retrieve the thumbnails, // the retrieval task queue can pile up for thumbnails that got disposed // in this case we pause the image retrieval task to get it out of the queue diff --git a/lib/widgets/common/basic/text_dropdown_button.dart b/lib/widgets/common/basic/text_dropdown_button.dart index 4f60ef164..8efc50028 100644 --- a/lib/widgets/common/basic/text_dropdown_button.dart +++ b/lib/widgets/common/basic/text_dropdown_button.dart @@ -5,6 +5,7 @@ class TextDropdownButton extends DropdownButton { super.key, required List values, required String Function(T value) valueText, + IconData Function(T value)? valueIcon, super.value, super.hint, super.disabledHint, @@ -32,21 +33,51 @@ class TextDropdownButton extends DropdownButton { items: values .map((v) => DropdownMenuItem( value: v, - child: Text(valueText(v)), + child: _buildItem(valueText(v), valueIcon?.call(v), selected: false), )) .toList(), selectedItemBuilder: (context) => values .map((v) => DropdownMenuItem( value: v, - child: Align( - alignment: AlignmentDirectional.centerStart, - child: Text( - valueText(v), - softWrap: false, - overflow: TextOverflow.fade, - ), - ), + child: _buildItem(valueText(v), valueIcon?.call(v), selected: true), )) .toList(), ); + + static Widget _buildItem(String text, IconData? icon, {required bool selected}) { + final softWrap = selected ? false : null; + final overflow = selected ? TextOverflow.fade : null; + + Widget child = icon != null + ? Text.rich( + TextSpan( + children: [ + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Padding( + padding: const EdgeInsetsDirectional.only(end: 8, bottom: 2), + child: Icon(icon), + ), + ), + TextSpan(text: text), + ], + ), + softWrap: softWrap, + overflow: overflow, + ) + : Text( + text, + softWrap: softWrap, + overflow: overflow, + ); + + if (selected) { + child = Align( + alignment: AlignmentDirectional.centerStart, + child: child, + ); + } + + return child; + } } diff --git a/lib/widgets/common/grid/draggable_thumb_label.dart b/lib/widgets/common/grid/draggable_thumb_label.dart index af6888457..eb516f34c 100644 --- a/lib/widgets/common/grid/draggable_thumb_label.dart +++ b/lib/widgets/common/grid/draggable_thumb_label.dart @@ -1,6 +1,6 @@ import 'package:aves/theme/format.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; -import 'package:aves/widgets/common/grid/section_layout.dart'; +import 'package:aves/widgets/common/grid/sections/list_layout.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; @@ -41,10 +41,7 @@ class DraggableThumbLabel extends StatelessWidget { final sectionLayout = sll.getSectionAt(offsetY); if (sectionLayout == null) return const SizedBox(); - final section = sll.sections[sectionLayout.sectionKey]!; - final dy = offsetY - (sectionLayout.minOffset + sectionLayout.headerExtent); - final itemIndex = dy < 0 ? 0 : (dy ~/ (sll.tileHeight + sll.spacing)) * sll.columnCount; - final item = section[itemIndex]; + final item = sll.getItemAt(Offset(0, offsetY)) ?? sll.sections[sectionLayout.sectionKey]!.first; final lines = lineBuilder(context, item); if (lines.isEmpty) return const SizedBox(); diff --git a/lib/widgets/common/grid/header.dart b/lib/widgets/common/grid/header.dart index 43137f3e2..14bcf5433 100644 --- a/lib/widgets/common/grid/header.dart +++ b/lib/widgets/common/grid/header.dart @@ -4,7 +4,7 @@ import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/utils/constants.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; -import 'package:aves/widgets/common/grid/section_layout.dart'; +import 'package:aves/widgets/common/grid/sections/list_layout.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:provider/provider.dart'; diff --git a/lib/widgets/common/grid/item_tracker.dart b/lib/widgets/common/grid/item_tracker.dart index abaa6e063..33e3d7472 100644 --- a/lib/widgets/common/grid/item_tracker.dart +++ b/lib/widgets/common/grid/item_tracker.dart @@ -4,7 +4,7 @@ import 'dart:math'; import 'package:aves/model/highlight.dart'; import 'package:aves/model/source/enums/enums.dart'; import 'package:aves/theme/durations.dart'; -import 'package:aves/widgets/common/grid/section_layout.dart'; +import 'package:aves/widgets/common/grid/sections/list_layout.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; diff --git a/lib/widgets/common/grid/scaling.dart b/lib/widgets/common/grid/scaling.dart index 96988cbff..f0e783126 100644 --- a/lib/widgets/common/grid/scaling.dart +++ b/lib/widgets/common/grid/scaling.dart @@ -1,12 +1,9 @@ -import 'dart:ui' as ui; - import 'package:aves/model/highlight.dart'; import 'package:aves/model/source/enums/enums.dart'; -import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/common/behaviour/eager_scale_gesture_recognizer.dart'; -import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/grid/sections/fixed/scale_overlay.dart'; +import 'package:aves/widgets/common/grid/sections/mosaic/scale_overlay.dart'; import 'package:aves/widgets/common/grid/theme.dart'; -import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/common/tile_extent_controller.dart'; import 'package:collection/collection.dart'; import 'package:flutter/gestures.dart'; @@ -26,7 +23,8 @@ class GridScaleGestureDetector extends StatefulWidget { final TileLayout tileLayout; final double Function(double width) heightForWidth; final Widget Function(Offset center, Size tileSize, Widget child) gridBuilder; - final Widget Function(T item, Size tileSize) scaledBuilder; + final Widget Function(T item, Size tileSize) scaledItemBuilder; + final MosaicItemBuilder mosaicItemBuilder; final Object Function(T item)? highlightItem; final Widget child; @@ -36,7 +34,8 @@ class GridScaleGestureDetector extends StatefulWidget { required this.tileLayout, required this.heightForWidth, required this.gridBuilder, - required this.scaledBuilder, + required this.scaledItemBuilder, + required this.mosaicItemBuilder, this.highlightItem, required this.child, }); @@ -53,6 +52,8 @@ class _GridScaleGestureDetectorState extends State? _metadata; + TileLayout get tileLayout => widget.tileLayout; + @override Widget build(BuildContext context) { final gestureSettings = context.select((mq) => mq.gestureSettings); @@ -108,59 +109,66 @@ class _GridScaleGestureDetectorState extends State _ScaleOverlay( - builder: (scaledTileSize) { - late final double themeExtent; - switch (tileLayout) { - case TileLayout.grid: - themeExtent = scaledTileSize.width; - break; - case TileLayout.list: - themeExtent = scaledTileSize.height; - break; - } - return SizedBox.fromSize( - size: scaledTileSize, - child: GridTheme( - extent: themeExtent, - child: widget.scaledBuilder(_metadata!.item, scaledTileSize), + switch (tileLayout) { + case TileLayout.mosaic: + _overlayEntry = OverlayEntry( + builder: (context) => MosaicScaleOverlay( + contentRect: contentRect, + spacing: tileExtentController.spacing, + extentMax: _extentMax!, + scaledSizeNotifier: _scaledSizeNotifier!, + itemBuilder: widget.mosaicItemBuilder, + ), + ); + break; + case TileLayout.grid: + case TileLayout.list: + final tileCenter = renderMetaData.localToGlobal(Offset(halfSize.width, halfSize.height)); + _overlayEntry = OverlayEntry( + builder: (context) => FixedExtentScaleOverlay( + tileLayout: tileLayout, + tileCenter: tileCenter, + contentRect: contentRect, + scaledSizeNotifier: _scaledSizeNotifier!, + gridBuilder: widget.gridBuilder, + builder: (scaledTileSize) => SizedBox.fromSize( + size: scaledTileSize, + child: GridTheme( + extent: tileLayout == TileLayout.grid ? scaledTileSize.width : scaledTileSize.height, + child: widget.scaledItemBuilder(_metadata!.item, scaledTileSize), + ), ), - ); - }, - tileLayout: tileLayout, - center: tileCenter, - xMin: xMin, - xMax: xMax, - gridBuilder: widget.gridBuilder, - scaledSizeNotifier: _scaledSizeNotifier!, - ), - ); + ), + ); + break; + } Overlay.of(scrollableContext)!.insert(_overlayEntry!); } void _onScaleUpdate(ScaleUpdateDetails details) { if (_scaledSizeNotifier == null) return; final s = details.scale; - switch (widget.tileLayout) { + switch (tileLayout) { + case TileLayout.mosaic: case TileLayout.grid: final scaledWidth = (_startSize!.width * s).clamp(_extentMin!, _extentMax!); _scaledSizeNotifier!.value = Size(scaledWidth, widget.heightForWidth(scaledWidth)); @@ -184,7 +192,8 @@ class _GridScaleGestureDetectorState extends State extends State scaledSizeNotifier; - final Widget Function(Offset center, Size tileSize, Widget child) gridBuilder; - - const _ScaleOverlay({ - required this.builder, - required this.tileLayout, - required this.center, - required this.xMin, - required this.xMax, - required this.scaledSizeNotifier, - required this.gridBuilder, - }); - - @override - State<_ScaleOverlay> createState() => _ScaleOverlayState(); -} - -class _ScaleOverlayState extends State<_ScaleOverlay> { - bool _init = false; - - Offset get center => widget.center; - - double get xMin => widget.xMin; - - double get xMax => widget.xMax; - - // `Color(0x00FFFFFF)` is different from `Color(0x00000000)` (or `Colors.transparent`) - // when used in gradients or lerping to it - static const transparentWhite = Color(0x00FFFFFF); - - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) => setState(() => _init = true)); - } - - @override - Widget build(BuildContext context) { - return MediaQueryDataProvider( - child: Builder( - builder: (context) => IgnorePointer( - child: AnimatedContainer( - decoration: _buildBackgroundDecoration(context), - duration: Durations.collectionScalingBackgroundAnimation, - child: ValueListenableBuilder( - valueListenable: widget.scaledSizeNotifier, - builder: (context, scaledSize, child) { - final width = scaledSize.width; - final height = scaledSize.height; - // keep scaled thumbnail within the screen - var dx = .0; - if (center.dx - width / 2 < xMin) { - dx = xMin - (center.dx - width / 2); - } else if (center.dx + width / 2 > xMax) { - dx = xMax - (center.dx + width / 2); - } - final clampedCenter = center.translate(dx, 0); - - var child = widget.builder(scaledSize); - child = Stack( - children: [ - Positioned( - left: clampedCenter.dx - width / 2, - top: clampedCenter.dy - height / 2, - child: DefaultTextStyle( - style: const TextStyle(), - child: child, - ), - ), - ], - ); - child = widget.gridBuilder(clampedCenter, scaledSize, child); - return child; - }, - ), - ), - ), - ), - ); - } - - BoxDecoration _buildBackgroundDecoration(BuildContext context) { - late final Offset gradientCenter; - switch (widget.tileLayout) { - case TileLayout.grid: - gradientCenter = center; - break; - case TileLayout.list: - gradientCenter = Offset(context.isRtl ? xMax : xMin, center.dy); - break; - } - - final isDark = Theme.of(context).brightness == Brightness.dark; - return _init - ? BoxDecoration( - gradient: RadialGradient( - center: FractionalOffset.fromOffsetAndSize(gradientCenter, context.select((mq) => mq.size)), - radius: 1, - colors: isDark - ? const [ - Colors.black, - Colors.black54, - ] - : const [ - Colors.white, - Colors.white38, - ], - ), - ) - : BoxDecoration( - // provide dummy gradient to lerp to the other one during animation - gradient: RadialGradient( - colors: isDark - ? const [ - Colors.transparent, - Colors.transparent, - ] - : const [ - transparentWhite, - transparentWhite, - ], - ), - ); - } -} - -class GridPainter extends CustomPainter { - final TileLayout tileLayout; - final Offset tileCenter; - final Size tileSize; - final double spacing, horizontalPadding, borderWidth; - final Radius borderRadius; - final Color color; - final TextDirection textDirection; - - const GridPainter({ - required this.tileLayout, - required this.tileCenter, - required this.tileSize, - required this.spacing, - required this.horizontalPadding, - required this.borderWidth, - required this.borderRadius, - required this.color, - required this.textDirection, - }); - - @override - void paint(Canvas canvas, Size size) { - late final Offset chipCenter; - late final Size chipSize; - late final int deltaColumn; - late final Shader strokeShader; - switch (tileLayout) { - case TileLayout.grid: - chipCenter = tileCenter; - chipSize = tileSize; - deltaColumn = 2; - strokeShader = ui.Gradient.radial( - tileCenter, - chipSize.shortestSide * 2, - [ - color, - Colors.transparent, - ], - [ - .8, - 1, - ], - ); - break; - case TileLayout.list: - chipSize = Size.square(tileSize.shortestSide); - final chipCenterToEdge = chipSize.width / 2; - chipCenter = Offset(textDirection == TextDirection.rtl ? size.width - (chipCenterToEdge + horizontalPadding) : chipCenterToEdge + horizontalPadding, tileCenter.dy); - deltaColumn = 0; - strokeShader = ui.Gradient.linear( - tileCenter - Offset(0, chipSize.shortestSide * 3), - tileCenter + Offset(0, chipSize.shortestSide * 3), - [ - Colors.transparent, - color, - color, - Colors.transparent, - ], - [ - 0, - .2, - .8, - 1, - ], - ); - break; - } - final strokePaint = Paint() - ..style = PaintingStyle.stroke - ..strokeWidth = borderWidth - ..shader = strokeShader; - final fillPaint = Paint() - ..style = PaintingStyle.fill - ..color = color.withOpacity(.25); - - final chipWidth = chipSize.width; - final chipHeight = chipSize.height; - - final deltaX = tileSize.width + spacing; - final deltaY = tileSize.height + spacing; - for (var i = -deltaColumn; i <= deltaColumn; i++) { - final dx = deltaX * i; - for (var j = -2; j <= 2; j++) { - if (i == 0 && j == 0) continue; - final dy = deltaY * j; - final rect = RRect.fromRectAndRadius( - Rect.fromCenter( - center: chipCenter + Offset(dx, dy), - width: chipWidth - borderWidth, - height: chipHeight - borderWidth, - ), - borderRadius, - ); - - if ((i.abs() == 1 && j == 0) || (j.abs() == 1 && i == 0)) { - canvas.drawRRect(rect, fillPaint); - } - canvas.drawRRect(rect, strokePaint); - } - } - } - - @override - bool shouldRepaint(covariant CustomPainter oldDelegate) => true; -} diff --git a/lib/widgets/common/grid/section_layout.dart b/lib/widgets/common/grid/section_layout.dart deleted file mode 100644 index 040304d67..000000000 --- a/lib/widgets/common/grid/section_layout.dart +++ /dev/null @@ -1,450 +0,0 @@ -import 'dart:math'; - -import 'package:aves/model/source/enums/enums.dart'; -import 'package:aves/model/source/section_keys.dart'; -import 'package:aves/theme/durations.dart'; -import 'package:collection/collection.dart'; -import 'package:equatable/equatable.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; -import 'package:flutter_staggered_animations/flutter_staggered_animations.dart'; -import 'package:provider/provider.dart'; - -abstract class SectionedListLayoutProvider extends StatelessWidget { - final double scrollableWidth; - final TileLayout tileLayout; - final int columnCount; - final double spacing, horizontalPadding, tileWidth, tileHeight; - final Widget Function(T item) tileBuilder; - final Duration tileAnimationDelay; - final Widget child; - - const SectionedListLayoutProvider({ - super.key, - required this.scrollableWidth, - required this.tileLayout, - required int columnCount, - required this.spacing, - required this.horizontalPadding, - required double tileWidth, - required this.tileHeight, - required this.tileBuilder, - required this.tileAnimationDelay, - required this.child, - }) : assert(scrollableWidth != 0), - columnCount = tileLayout == TileLayout.list ? 1 : columnCount, - tileWidth = tileLayout == TileLayout.list ? scrollableWidth - (horizontalPadding * 2) : tileWidth; - - @override - Widget build(BuildContext context) { - return ProxyProvider0>( - update: (context, _) => _updateLayouts(context), - child: child, - ); - } - - SectionedListLayout _updateLayouts(BuildContext context) { - final _showHeaders = showHeaders; - final _sections = sections; - final sectionKeys = _sections.keys.toList(); - final animate = tileAnimationDelay > Duration.zero; - - final sectionLayouts = []; - var currentIndex = 0; - var currentOffset = 0.0; - sectionKeys.forEach((sectionKey) { - final section = _sections[sectionKey]!; - final sectionItemCount = section.length; - final rowCount = (sectionItemCount / columnCount).ceil(); - final sectionChildCount = 1 + rowCount; - - final headerExtent = _showHeaders ? getHeaderExtent(context, sectionKey) : 0.0; - - final sectionFirstIndex = currentIndex; - currentIndex += sectionChildCount; - final sectionLastIndex = currentIndex - 1; - - final sectionMinOffset = currentOffset; - currentOffset += headerExtent + tileHeight * rowCount + spacing * (rowCount - 1); - final sectionMaxOffset = currentOffset; - - sectionLayouts.add( - SectionLayout( - sectionKey: sectionKey, - firstIndex: sectionFirstIndex, - lastIndex: sectionLastIndex, - minOffset: sectionMinOffset, - maxOffset: sectionMaxOffset, - headerExtent: headerExtent, - tileHeight: tileHeight, - spacing: spacing, - builder: (context, listIndex) => _buildInSection( - context, - section, - listIndex * columnCount, - listIndex - sectionFirstIndex, - sectionKey, - headerExtent, - animate, - ), - ), - ); - }); - return SectionedListLayout( - sections: _sections, - showHeaders: _showHeaders, - columnCount: columnCount, - tileWidth: tileWidth, - tileHeight: tileHeight, - spacing: spacing, - horizontalPadding: horizontalPadding, - sectionLayouts: sectionLayouts, - ); - } - - Widget _buildInSection( - BuildContext context, - List section, - int sectionGridIndex, - int sectionChildIndex, - SectionKey sectionKey, - double headerExtent, - bool animate, - ) { - if (sectionChildIndex == 0) { - final header = headerExtent > 0 ? buildHeader(context, sectionKey, headerExtent) : const SizedBox.shrink(); - return animate ? _buildAnimation(context, sectionGridIndex, header) : header; - } - sectionChildIndex--; - - final sectionItemCount = section.length; - - final minItemIndex = sectionChildIndex * columnCount; - final maxItemIndex = min(sectionItemCount, minItemIndex + columnCount); - final children = []; - for (var i = minItemIndex; i < maxItemIndex; i++) { - final itemGridIndex = sectionGridIndex + i - minItemIndex; - final item = RepaintBoundary( - child: tileBuilder(section[i]), - ); - children.add(animate ? _buildAnimation(context, itemGridIndex, item) : item); - } - return Padding( - padding: EdgeInsets.symmetric(horizontal: horizontalPadding), - child: _GridRow( - width: tileWidth, - height: tileHeight, - spacing: spacing, - textDirection: Directionality.of(context), - children: children, - ), - ); - } - - Widget _buildAnimation(BuildContext context, int index, Widget child) { - final durations = context.watch(); - return AnimationConfiguration.staggeredGrid( - position: index, - columnCount: columnCount, - duration: durations.staggeredAnimation, - delay: tileAnimationDelay, - child: SlideAnimation( - verticalOffset: 50.0, - child: FadeInAnimation( - child: child, - ), - ), - ); - } - - bool get showHeaders; - - Map> get sections; - - double getHeaderExtent(BuildContext context, SectionKey sectionKey); - - Widget buildHeader(BuildContext context, SectionKey sectionKey, double headerExtent); - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties.add(DoubleProperty('scrollableWidth', scrollableWidth)); - properties.add(IntProperty('columnCount', columnCount)); - properties.add(DoubleProperty('spacing', spacing)); - properties.add(DoubleProperty('horizontalPadding', horizontalPadding)); - properties.add(DoubleProperty('tileWidth', tileWidth)); - properties.add(DoubleProperty('tileHeight', tileHeight)); - properties.add(DiagnosticsProperty('showHeaders', showHeaders)); - } -} - -class SectionedListLayout { - final Map> sections; - final bool showHeaders; - final int columnCount; - final double tileWidth, tileHeight, spacing, horizontalPadding; - final List sectionLayouts; - - const SectionedListLayout({ - required this.sections, - required this.showHeaders, - required this.columnCount, - required this.tileWidth, - required this.tileHeight, - required this.spacing, - required this.horizontalPadding, - required this.sectionLayouts, - }); - - // return tile rectangle in layout space, i.e. x=0 is start - Rect? getTileRect(T item) { - final MapEntry>? section = sections.entries.firstWhereOrNull((kv) => kv.value.contains(item)); - if (section == null) return null; - - final sectionKey = section.key; - final sectionLayout = sectionLayouts.firstWhereOrNull((sl) => sl.sectionKey == sectionKey); - if (sectionLayout == null) return null; - - final sectionItemIndex = section.value.indexOf(item); - final column = sectionItemIndex % columnCount; - final row = (sectionItemIndex / columnCount).floor(); - final listIndex = sectionLayout.firstIndex + 1 + row; - - final left = horizontalPadding + tileWidth * column + spacing * (column - 1); - final top = sectionLayout.indexToLayoutOffset(listIndex); - return Rect.fromLTWH(left, top, tileWidth, tileHeight); - } - - SectionLayout? getSectionAt(double offsetY) => sectionLayouts.firstWhereOrNull((sl) => offsetY < sl.maxOffset); - - // `position` in layout space, i.e. x=0 is start - T? getItemAt(Offset position) { - var dy = position.dy; - final sectionLayout = getSectionAt(dy); - if (sectionLayout == null) return null; - - final section = sections[sectionLayout.sectionKey]; - if (section == null) return null; - - dy -= sectionLayout.minOffset + sectionLayout.headerExtent; - if (dy < 0) return null; - - final row = dy ~/ (tileHeight + spacing); - final column = max(0, position.dx - horizontalPadding) ~/ (tileWidth + spacing); - final index = row * columnCount + column; - if (index >= section.length) return null; - - return section[index]; - } - - @override - String toString() => '$runtimeType#${shortHash(this)}{sectionCount=${sections.length} columnCount=$columnCount, tileWidth=$tileWidth, tileHeight=$tileHeight}'; -} - -@immutable -class SectionLayout extends Equatable { - final SectionKey sectionKey; - final int firstIndex, lastIndex, bodyFirstIndex; - final double minOffset, maxOffset, bodyMinOffset; - final double headerExtent, tileHeight, spacing, mainAxisStride; - final IndexedWidgetBuilder builder; - - @override - List get props => [sectionKey, firstIndex, lastIndex, minOffset, maxOffset, headerExtent, tileHeight, spacing]; - - const SectionLayout({ - required this.sectionKey, - required this.firstIndex, - required this.lastIndex, - required this.minOffset, - required this.maxOffset, - required this.headerExtent, - required this.tileHeight, - required this.spacing, - required this.builder, - }) : bodyFirstIndex = firstIndex + 1, - bodyMinOffset = minOffset + headerExtent, - mainAxisStride = tileHeight + spacing; - - bool hasChild(int index) => firstIndex <= index && index <= lastIndex; - - bool hasChildAtOffset(double scrollOffset) => minOffset <= scrollOffset && scrollOffset <= maxOffset; - - double indexToLayoutOffset(int index) { - index -= bodyFirstIndex; - if (index < 0) return minOffset; - return bodyMinOffset + index * mainAxisStride; - } - - int getMinChildIndexForScrollOffset(double scrollOffset) { - scrollOffset -= bodyMinOffset; - if (scrollOffset < 0) return firstIndex; - return bodyFirstIndex + scrollOffset ~/ mainAxisStride; - } - - int getMaxChildIndexForScrollOffset(double scrollOffset) { - scrollOffset -= bodyMinOffset; - if (scrollOffset < 0) return firstIndex; - return bodyFirstIndex + (scrollOffset / mainAxisStride).ceil() - 1; - } -} - -class _GridRow extends MultiChildRenderObjectWidget { - final double width, height, spacing; - final TextDirection textDirection; - - _GridRow({ - required this.width, - required this.height, - required this.spacing, - required this.textDirection, - required List children, - }) : super(children: children); - - @override - RenderObject createRenderObject(BuildContext context) { - return _RenderGridRow( - width: width, - height: height, - spacing: spacing, - textDirection: textDirection, - ); - } - - @override - void updateRenderObject(BuildContext context, _RenderGridRow renderObject) { - renderObject.width = width; - renderObject.height = height; - renderObject.spacing = spacing; - renderObject.textDirection = textDirection; - } - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties.add(DoubleProperty('width', width)); - properties.add(DoubleProperty('height', height)); - properties.add(DoubleProperty('spacing', spacing)); - properties.add(EnumProperty('textDirection', textDirection)); - } -} - -class _GridRowParentData extends ContainerBoxParentData {} - -class _RenderGridRow extends RenderBox with ContainerRenderObjectMixin, RenderBoxContainerDefaultsMixin { - _RenderGridRow({ - List? children, - required double width, - required double height, - required double spacing, - required TextDirection textDirection, - }) : _width = width, - _height = height, - _spacing = spacing, - _textDirection = textDirection { - addAll(children); - } - - double get width => _width; - double _width; - - set width(double value) { - if (_width == value) return; - _width = value; - markNeedsLayout(); - } - - double get height => _height; - double _height; - - set height(double value) { - if (_height == value) return; - _height = value; - markNeedsLayout(); - } - - double get spacing => _spacing; - double _spacing; - - set spacing(double value) { - if (_spacing == value) return; - _spacing = value; - markNeedsLayout(); - } - - TextDirection get textDirection => _textDirection; - TextDirection _textDirection; - - set textDirection(TextDirection value) { - if (_textDirection == value) return; - _textDirection = value; - markNeedsLayout(); - } - - @override - void setupParentData(RenderBox child) { - if (child.parentData is! _GridRowParentData) { - child.parentData = _GridRowParentData(); - } - } - - double get intrinsicWidth => width * childCount + spacing * (childCount - 1); - - @override - double computeMinIntrinsicWidth(double height) => intrinsicWidth; - - @override - double computeMaxIntrinsicWidth(double height) => intrinsicWidth; - - @override - double computeMinIntrinsicHeight(double width) => height; - - @override - double computeMaxIntrinsicHeight(double width) => height; - - @override - void performLayout() { - var child = firstChild; - if (child == null) { - size = constraints.smallest; - return; - } - size = Size(constraints.maxWidth, height); - final childConstraints = BoxConstraints.tight(Size(width, height)); - final flipMainAxis = textDirection == TextDirection.rtl; - var offset = Offset(flipMainAxis ? size.width - width : 0, 0); - final dx = (flipMainAxis ? -1 : 1) * (width + spacing); - while (child != null) { - child.layout(childConstraints, parentUsesSize: false); - final childParentData = child.parentData! as _GridRowParentData; - childParentData.offset = offset; - offset += Offset(dx, 0); - child = childParentData.nextSibling; - } - } - - @override - double? computeDistanceToActualBaseline(TextBaseline baseline) { - return defaultComputeDistanceToHighestActualBaseline(baseline); - } - - @override - bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { - return defaultHitTestChildren(result, position: position); - } - - @override - void paint(PaintingContext context, Offset offset) { - defaultPaint(context, offset); - } - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties.add(DoubleProperty('width', width)); - properties.add(DoubleProperty('height', height)); - properties.add(DoubleProperty('spacing', spacing)); - properties.add(EnumProperty('textDirection', textDirection)); - } -} diff --git a/lib/widgets/common/grid/sections/fixed/list_layout.dart b/lib/widgets/common/grid/sections/fixed/list_layout.dart new file mode 100644 index 000000000..162fcc89c --- /dev/null +++ b/lib/widgets/common/grid/sections/fixed/list_layout.dart @@ -0,0 +1,62 @@ +import 'dart:math'; + +import 'package:aves/model/source/section_keys.dart'; +import 'package:aves/widgets/common/grid/sections/list_layout.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +class FixedExtentSectionedListLayout extends SectionedListLayout { + final int columnCount; + final double tileWidth, tileHeight; + + const FixedExtentSectionedListLayout({ + required super.sections, + required super.showHeaders, + required this.columnCount, + required this.tileWidth, + required this.tileHeight, + required super.spacing, + required super.horizontalPadding, + required super.sectionLayouts, + }); + + @override + Rect? getTileRect(T item) { + final MapEntry>? section = sections.entries.firstWhereOrNull((kv) => kv.value.contains(item)); + if (section == null) return null; + + final sectionKey = section.key; + final sectionLayout = sectionLayouts.firstWhereOrNull((sl) => sl.sectionKey == sectionKey); + if (sectionLayout == null) return null; + + final sectionItemIndex = section.value.indexOf(item); + final column = sectionItemIndex % columnCount; + final row = (sectionItemIndex / columnCount).floor(); + final listIndex = sectionLayout.firstIndex + 1 + row; + + final left = horizontalPadding + tileWidth * column + spacing * (column - 1); + final top = sectionLayout.indexToLayoutOffset(listIndex); + return Rect.fromLTWH(left, top, tileWidth, tileHeight); + } + + @override + T? getItemAt(Offset position) { + var dy = position.dy; + final sectionLayout = getSectionAt(dy); + if (sectionLayout == null) return null; + + final section = sections[sectionLayout.sectionKey]; + if (section == null) return null; + + dy -= sectionLayout.minOffset + sectionLayout.headerExtent; + if (dy < 0) return null; + + final row = dy ~/ (tileHeight + spacing); + final column = max(0, position.dx - horizontalPadding) ~/ (tileWidth + spacing); + final index = row * columnCount + column; + if (index >= section.length) return null; + + return section[index]; + } +} diff --git a/lib/widgets/common/grid/sections/fixed/row.dart b/lib/widgets/common/grid/sections/fixed/row.dart new file mode 100644 index 000000000..e490246a2 --- /dev/null +++ b/lib/widgets/common/grid/sections/fixed/row.dart @@ -0,0 +1,162 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +class FixedExtentGridRow extends MultiChildRenderObjectWidget { + final double width, height, spacing; + final TextDirection textDirection; + + FixedExtentGridRow({ + super.key, + required this.width, + required this.height, + required this.spacing, + required this.textDirection, + required List children, + }) : super(children: children); + + @override + RenderObject createRenderObject(BuildContext context) { + return RenderFixedExtentGridRow( + width: width, + height: height, + spacing: spacing, + textDirection: textDirection, + ); + } + + @override + void updateRenderObject(BuildContext context, RenderFixedExtentGridRow renderObject) { + renderObject.width = width; + renderObject.height = height; + renderObject.spacing = spacing; + renderObject.textDirection = textDirection; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DoubleProperty('width', width)); + properties.add(DoubleProperty('height', height)); + properties.add(DoubleProperty('spacing', spacing)); + properties.add(EnumProperty('textDirection', textDirection)); + } +} + +class _GridRowParentData extends ContainerBoxParentData {} + +class RenderFixedExtentGridRow extends RenderBox with ContainerRenderObjectMixin, RenderBoxContainerDefaultsMixin { + RenderFixedExtentGridRow({ + List? children, + required double width, + required double height, + required double spacing, + required TextDirection textDirection, + }) : _width = width, + _height = height, + _spacing = spacing, + _textDirection = textDirection { + addAll(children); + } + + double get width => _width; + double _width; + + set width(double value) { + if (_width == value) return; + _width = value; + markNeedsLayout(); + } + + double get height => _height; + double _height; + + set height(double value) { + if (_height == value) return; + _height = value; + markNeedsLayout(); + } + + double get spacing => _spacing; + double _spacing; + + set spacing(double value) { + if (_spacing == value) return; + _spacing = value; + markNeedsLayout(); + } + + TextDirection get textDirection => _textDirection; + TextDirection _textDirection; + + set textDirection(TextDirection value) { + if (_textDirection == value) return; + _textDirection = value; + markNeedsLayout(); + } + + @override + void setupParentData(RenderBox child) { + if (child.parentData is! _GridRowParentData) { + child.parentData = _GridRowParentData(); + } + } + + double get intrinsicWidth => width * childCount + spacing * (childCount - 1); + + @override + double computeMinIntrinsicWidth(double height) => intrinsicWidth; + + @override + double computeMaxIntrinsicWidth(double height) => intrinsicWidth; + + @override + double computeMinIntrinsicHeight(double width) => height; + + @override + double computeMaxIntrinsicHeight(double width) => height; + + @override + void performLayout() { + var child = firstChild; + if (child == null) { + size = constraints.smallest; + return; + } + size = Size(constraints.maxWidth, height); + final childConstraints = BoxConstraints.tight(Size(width, height)); + final flipMainAxis = textDirection == TextDirection.rtl; + var offset = Offset(flipMainAxis ? size.width - width : 0, 0); + final dx = (flipMainAxis ? -1 : 1) * (width + spacing); + while (child != null) { + child.layout(childConstraints, parentUsesSize: false); + final childParentData = child.parentData! as _GridRowParentData; + childParentData.offset = offset; + offset += Offset(dx, 0); + child = childParentData.nextSibling; + } + } + + @override + double? computeDistanceToActualBaseline(TextBaseline baseline) { + return defaultComputeDistanceToHighestActualBaseline(baseline); + } + + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { + return defaultHitTestChildren(result, position: position); + } + + @override + void paint(PaintingContext context, Offset offset) { + defaultPaint(context, offset); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DoubleProperty('width', width)); + properties.add(DoubleProperty('height', height)); + properties.add(DoubleProperty('spacing', spacing)); + properties.add(EnumProperty('textDirection', textDirection)); + } +} diff --git a/lib/widgets/common/grid/sections/fixed/scale_grid.dart b/lib/widgets/common/grid/sections/fixed/scale_grid.dart new file mode 100644 index 000000000..9f9d869fd --- /dev/null +++ b/lib/widgets/common/grid/sections/fixed/scale_grid.dart @@ -0,0 +1,113 @@ +import 'dart:ui' as ui; + +import 'package:aves/model/source/enums/enums.dart'; +import 'package:flutter/material.dart'; + +class FixedExtentGridPainter extends CustomPainter { + final TileLayout tileLayout; + final Offset tileCenter; + final Size tileSize; + final double spacing, horizontalPadding, borderWidth; + final Radius borderRadius; + final Color color; + final TextDirection textDirection; + + const FixedExtentGridPainter({ + required this.tileLayout, + required this.tileCenter, + required this.tileSize, + required this.spacing, + required this.horizontalPadding, + required this.borderWidth, + required this.borderRadius, + required this.color, + required this.textDirection, + }); + + @override + void paint(Canvas canvas, Size size) { + late final Offset chipCenter; + late final Size chipSize; + late final int deltaColumn; + late final Shader strokeShader; + switch (tileLayout) { + case TileLayout.mosaic: + return; + case TileLayout.grid: + chipCenter = tileCenter; + chipSize = tileSize; + deltaColumn = 2; + strokeShader = ui.Gradient.radial( + tileCenter, + chipSize.shortestSide * 2, + [ + color, + Colors.transparent, + ], + [ + .8, + 1, + ], + ); + break; + case TileLayout.list: + chipSize = Size.square(tileSize.shortestSide); + final chipCenterToEdge = chipSize.width / 2; + chipCenter = Offset(textDirection == TextDirection.rtl ? size.width - (chipCenterToEdge + horizontalPadding) : chipCenterToEdge + horizontalPadding, tileCenter.dy); + deltaColumn = 0; + strokeShader = ui.Gradient.linear( + tileCenter - Offset(0, chipSize.shortestSide * 3), + tileCenter + Offset(0, chipSize.shortestSide * 3), + [ + Colors.transparent, + color, + color, + Colors.transparent, + ], + [ + 0, + .2, + .8, + 1, + ], + ); + break; + } + final strokePaint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = borderWidth + ..shader = strokeShader; + final fillPaint = Paint() + ..style = PaintingStyle.fill + ..color = color.withOpacity(.25); + + final chipWidth = chipSize.width; + final chipHeight = chipSize.height; + + final deltaX = tileSize.width + spacing; + final deltaY = tileSize.height + spacing; + for (var i = -deltaColumn; i <= deltaColumn; i++) { + final dx = deltaX * i; + for (var j = -2; j <= 2; j++) { + if (i == 0 && j == 0) continue; + final dy = deltaY * j; + final rect = RRect.fromRectAndRadius( + Rect.fromCenter( + center: chipCenter + Offset(dx, dy), + width: chipWidth - borderWidth, + height: chipHeight - borderWidth, + ), + borderRadius, + ); + + if ((i.abs() == 1 && j == 0) || (j.abs() == 1 && i == 0)) { + canvas.drawRRect(rect, fillPaint); + } + canvas.drawRRect(rect, strokePaint); + } + } + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => true; +} diff --git a/lib/widgets/common/grid/sections/fixed/scale_overlay.dart b/lib/widgets/common/grid/sections/fixed/scale_overlay.dart new file mode 100644 index 000000000..4bae5d50e --- /dev/null +++ b/lib/widgets/common/grid/sections/fixed/scale_overlay.dart @@ -0,0 +1,136 @@ +import 'package:aves/model/source/enums/enums.dart'; +import 'package:aves/theme/durations.dart'; +import 'package:aves/utils/constants.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class FixedExtentScaleOverlay extends StatelessWidget { + final TileLayout tileLayout; + final Offset tileCenter; + final double xMin, xMax; + final ValueNotifier scaledSizeNotifier; + final Widget Function(Offset center, Size tileSize, Widget child) gridBuilder; + final Widget Function(Size scaledTileSize) builder; + + FixedExtentScaleOverlay({ + super.key, + required this.tileLayout, + required this.tileCenter, + required Rect contentRect, + required this.scaledSizeNotifier, + required this.gridBuilder, + required this.builder, + }) : xMin = contentRect.left, + xMax = contentRect.right; + + @override + Widget build(BuildContext context) { + return MediaQueryDataProvider( + child: IgnorePointer( + child: _OverlayBackground( + gradientCenter: tileLayout == TileLayout.grid ? tileCenter : Offset(context.isRtl ? xMax : xMin, tileCenter.dy), + child: ValueListenableBuilder( + valueListenable: scaledSizeNotifier, + builder: (context, scaledSize, child) { + final width = scaledSize.width; + final height = scaledSize.height; + // keep scaled thumbnail within the screen + var dx = .0; + if (tileCenter.dx - width / 2 < xMin) { + dx = xMin - (tileCenter.dx - width / 2); + } else if (tileCenter.dx + width / 2 > xMax) { + dx = xMax - (tileCenter.dx + width / 2); + } + final clampedCenter = tileCenter.translate(dx, 0); + + var child = builder(scaledSize); + child = Stack( + children: [ + Positioned( + left: clampedCenter.dx - width / 2, + top: clampedCenter.dy - height / 2, + child: DefaultTextStyle( + style: const TextStyle(), + child: child, + ), + ), + ], + ); + child = gridBuilder(clampedCenter, scaledSize, child); + return child; + }, + ), + ), + ), + ); + } +} + +class _OverlayBackground extends StatefulWidget { + final Offset gradientCenter; + final Widget child; + + const _OverlayBackground({ + required this.gradientCenter, + required this.child, + }); + + @override + State<_OverlayBackground> createState() => _OverlayBackgroundState(); +} + +class _OverlayBackgroundState extends State<_OverlayBackground> { + bool _initialized = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => setState(() => _initialized = true)); + } + + @override + Widget build(BuildContext context) { + return AnimatedContainer( + decoration: _buildBackgroundDecoration(context), + duration: Durations.scalingGridBackgroundAnimation, + child: widget.child, + ); + } + + BoxDecoration _buildBackgroundDecoration(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final gradientCenter = widget.gradientCenter; + return _initialized + ? BoxDecoration( + gradient: RadialGradient( + center: FractionalOffset.fromOffsetAndSize(gradientCenter, context.select((mq) => mq.size)), + radius: 1, + colors: isDark + ? const [ + Colors.black, + Colors.black54, + ] + : const [ + Colors.white, + Colors.white38, + ], + ), + ) + : BoxDecoration( + // provide dummy gradient to lerp to the other one during animation + gradient: RadialGradient( + colors: isDark + ? const [ + Constants.transparentBlack, + Constants.transparentBlack, + ] + : const [ + Constants.transparentWhite, + Constants.transparentWhite, + ], + ), + ); + } +} diff --git a/lib/widgets/common/grid/sections/fixed/section_layout.dart b/lib/widgets/common/grid/sections/fixed/section_layout.dart new file mode 100644 index 000000000..2d29be8e0 --- /dev/null +++ b/lib/widgets/common/grid/sections/fixed/section_layout.dart @@ -0,0 +1,41 @@ +import 'package:aves/widgets/common/grid/sections/section_layout.dart'; + +class FixedExtentSectionLayout extends SectionLayout { + final double tileHeight, mainAxisStride; + + @override + List get props => [sectionKey, firstIndex, lastIndex, minOffset, maxOffset, headerExtent, tileHeight, spacing]; + + const FixedExtentSectionLayout({ + required super.sectionKey, + required super.firstIndex, + required super.lastIndex, + required super.minOffset, + required super.maxOffset, + required super.headerExtent, + required this.tileHeight, + required super.spacing, + required super.builder, + }) : mainAxisStride = tileHeight + spacing; + + @override + double indexToLayoutOffset(int index) { + index -= bodyFirstIndex; + if (index < 0) return minOffset; + return bodyMinOffset + index * mainAxisStride; + } + + @override + int getMinChildIndexForScrollOffset(double scrollOffset) { + scrollOffset -= bodyMinOffset; + if (scrollOffset < 0) return firstIndex; + return bodyFirstIndex + scrollOffset ~/ mainAxisStride; + } + + @override + int getMaxChildIndexForScrollOffset(double scrollOffset) { + scrollOffset -= bodyMinOffset; + if (scrollOffset < 0) return firstIndex; + return bodyFirstIndex + (scrollOffset / mainAxisStride).ceil() - 1; + } +} diff --git a/lib/widgets/common/grid/sections/fixed/section_layout_builder.dart b/lib/widgets/common/grid/sections/fixed/section_layout_builder.dart new file mode 100644 index 000000000..276da0d80 --- /dev/null +++ b/lib/widgets/common/grid/sections/fixed/section_layout_builder.dart @@ -0,0 +1,108 @@ +import 'package:aves/model/source/section_keys.dart'; +import 'package:aves/widgets/common/grid/sections/fixed/list_layout.dart'; +import 'package:aves/widgets/common/grid/sections/fixed/row.dart'; +import 'package:aves/widgets/common/grid/sections/fixed/section_layout.dart'; +import 'package:aves/widgets/common/grid/sections/list_layout.dart'; +import 'package:aves/widgets/common/grid/sections/section_layout.dart'; +import 'package:aves/widgets/common/grid/sections/section_layout_builder.dart'; +import 'package:flutter/material.dart'; +import 'package:tuple/tuple.dart'; + +class FixedExtentSectionLayoutBuilder extends SectionLayoutBuilder { + int _currentIndex = 0; + double _currentOffset = 0; + + FixedExtentSectionLayoutBuilder({ + required super.sections, + required super.showHeaders, + required super.getHeaderExtent, + required super.buildHeader, + required super.scrollableWidth, + required super.tileLayout, + required super.columnCount, + required super.spacing, + required super.horizontalPadding, + required super.tileWidth, + required super.tileHeight, + required super.tileBuilder, + required super.tileAnimationDelay, + }); + + @override + SectionedListLayout updateLayouts(BuildContext context) { + final sectionLayouts = sections.keys + .map((sectionKey) => buildSectionLayout( + headerExtent: showHeaders ? getHeaderExtent(context, sectionKey) : 0.0, + sectionKey: sectionKey, + section: sections[sectionKey]!, + animate: animate, + )) + .toList(); + + return FixedExtentSectionedListLayout( + sections: sections, + showHeaders: showHeaders, + columnCount: columnCount, + tileWidth: tileWidth, + tileHeight: tileHeight, + spacing: spacing, + horizontalPadding: horizontalPadding, + sectionLayouts: sectionLayouts, + ); + } + + @override + SectionLayout buildSectionLayout({ + required double headerExtent, + required SectionKey sectionKey, + required List section, + required bool animate, + }) { + final sectionItemCount = section.length; + final rowCount = (sectionItemCount / columnCount).ceil(); + final sectionChildCount = 1 + rowCount; + + final sectionFirstIndex = _currentIndex; + _currentIndex += sectionChildCount; + final sectionLastIndex = _currentIndex - 1; + + final sectionMinOffset = _currentOffset; + _currentOffset += headerExtent + tileHeight * rowCount + spacing * (rowCount - 1); + final sectionMaxOffset = _currentOffset; + + return FixedExtentSectionLayout( + sectionKey: sectionKey, + firstIndex: sectionFirstIndex, + lastIndex: sectionLastIndex, + minOffset: sectionMinOffset, + maxOffset: sectionMaxOffset, + headerExtent: headerExtent, + tileHeight: tileHeight, + spacing: spacing, + builder: (context, listIndex) { + final textDirection = Directionality.of(context); + final sectionChildIndex = listIndex - sectionFirstIndex; + return buildSectionWidget( + context: context, + section: section, + sectionGridIndex: listIndex * columnCount, + sectionChildIndex: sectionChildIndex, + itemIndexRange: () => Tuple2( + (sectionChildIndex - 1) * columnCount, + sectionChildIndex * columnCount, + ), + sectionKey: sectionKey, + headerExtent: headerExtent, + animate: animate, + buildGridRow: (children) => FixedExtentGridRow( + width: tileWidth, + height: tileHeight, + spacing: spacing, + textDirection: textDirection, + children: children, + ), + ); + }, + ); + } +} diff --git a/lib/widgets/common/grid/sections/list_layout.dart b/lib/widgets/common/grid/sections/list_layout.dart new file mode 100644 index 000000000..aac861bb8 --- /dev/null +++ b/lib/widgets/common/grid/sections/list_layout.dart @@ -0,0 +1,28 @@ +import 'package:aves/model/source/section_keys.dart'; +import 'package:aves/widgets/common/grid/sections/section_layout.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +abstract class SectionedListLayout { + final Map> sections; + final bool showHeaders; + final double spacing, horizontalPadding; + final List sectionLayouts; + + const SectionedListLayout({ + required this.sections, + required this.showHeaders, + required this.spacing, + required this.horizontalPadding, + required this.sectionLayouts, + }); + + // return tile rectangle in layout space, i.e. x=0 is start + Rect? getTileRect(T item); + + SectionLayout? getSectionAt(double offsetY) => sectionLayouts.firstWhereOrNull((sl) => offsetY < sl.maxOffset); + + // `position` in layout space, i.e. x=0 is start + T? getItemAt(Offset position); +} diff --git a/lib/widgets/common/grid/sections/mosaic/list_layout.dart b/lib/widgets/common/grid/sections/mosaic/list_layout.dart new file mode 100644 index 000000000..4590bf91b --- /dev/null +++ b/lib/widgets/common/grid/sections/mosaic/list_layout.dart @@ -0,0 +1,78 @@ +import 'package:aves/model/source/section_keys.dart'; +import 'package:aves/widgets/common/grid/sections/list_layout.dart'; +import 'package:aves/widgets/common/grid/sections/mosaic/section_layout.dart'; +import 'package:aves/widgets/common/grid/sections/section_layout.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +class MosaicSectionedListLayout extends SectionedListLayout { + const MosaicSectionedListLayout({ + required super.sections, + required super.showHeaders, + required super.spacing, + required super.horizontalPadding, + required super.sectionLayouts, + }); + + List _rowsFor(SectionLayout sectionLayout) => (sectionLayout as MosaicSectionLayout).rows; + + @override + Rect? getTileRect(T item) { + final MapEntry>? section = sections.entries.firstWhereOrNull((kv) => kv.value.contains(item)); + if (section == null) return null; + + final sectionKey = section.key; + final sectionLayout = sectionLayouts.firstWhereOrNull((sl) => sl.sectionKey == sectionKey); + if (sectionLayout == null) return null; + + final sectionItemIndex = section.value.indexOf(item); + final row = _rowsFor(sectionLayout).firstWhereOrNull((row) => sectionItemIndex <= row.lastIndex); + if (row == null) return null; + + final rowItemIndex = sectionItemIndex - row.firstIndex; + final tileWidth = row.itemWidths[rowItemIndex]; + final tileHeight = row.height - spacing; + + var left = horizontalPadding; + row.itemWidths.forEachIndexedWhile((i, width) { + if (i == rowItemIndex) return true; + + left += width + spacing; + return false; + }); + final listIndex = sectionLayout.firstIndex + 1 + _rowsFor(sectionLayout).indexOf(row); + + final top = sectionLayout.indexToLayoutOffset(listIndex); + return Rect.fromLTWH(left, top, tileWidth, tileHeight); + } + + @override + T? getItemAt(Offset position) { + var dy = position.dy; + final sectionLayout = getSectionAt(dy); + if (sectionLayout == null) return null; + + final section = sections[sectionLayout.sectionKey]; + if (section == null) return null; + + dy -= sectionLayout.minOffset + sectionLayout.headerExtent; + if (dy < 0) return null; + + final row = _rowsFor(sectionLayout).firstWhereOrNull((v) => dy < v.maxOffset); + if (row == null) return null; + + var dx = position.dx - horizontalPadding; + var index = -1; + row.itemWidths.forEachIndexedWhile((i, width) { + dx -= width + spacing; + if (dx > 0) return true; + + index = row.firstIndex + i; + return false; + }); + + if (index < 0 || index >= section.length) return null; + return section[index]; + } +} diff --git a/lib/widgets/common/grid/sections/mosaic/row.dart b/lib/widgets/common/grid/sections/mosaic/row.dart new file mode 100644 index 000000000..4d0767f96 --- /dev/null +++ b/lib/widgets/common/grid/sections/mosaic/row.dart @@ -0,0 +1,154 @@ +import 'package:aves/widgets/common/grid/sections/mosaic/section_layout.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +class MosaicGridRow extends MultiChildRenderObjectWidget { + final MosaicRowLayout rowLayout; + final double spacing; + final TextDirection textDirection; + + MosaicGridRow({ + super.key, + required this.rowLayout, + required this.spacing, + required this.textDirection, + required List children, + }) : super(children: children); + + @override + RenderObject createRenderObject(BuildContext context) { + return RenderMosaicGridRow( + rowLayout: rowLayout, + spacing: spacing, + textDirection: textDirection, + ); + } + + @override + void updateRenderObject(BuildContext context, RenderMosaicGridRow renderObject) { + renderObject.rowLayout = rowLayout; + renderObject.spacing = spacing; + renderObject.textDirection = textDirection; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('rowLayout', rowLayout)); + properties.add(DoubleProperty('spacing', spacing)); + properties.add(EnumProperty('textDirection', textDirection)); + } +} + +class _GridRowParentData extends ContainerBoxParentData {} + +class RenderMosaicGridRow extends RenderBox with ContainerRenderObjectMixin, RenderBoxContainerDefaultsMixin { + RenderMosaicGridRow({ + List? children, + required MosaicRowLayout rowLayout, + required double spacing, + required TextDirection textDirection, + }) : _rowLayout = rowLayout, + _spacing = spacing, + _textDirection = textDirection { + addAll(children); + } + + MosaicRowLayout get rowLayout => _rowLayout; + MosaicRowLayout _rowLayout; + + set rowLayout(MosaicRowLayout value) { + if (_rowLayout == value) return; + _rowLayout = value; + markNeedsLayout(); + } + + double get spacing => _spacing; + double _spacing; + + set spacing(double value) { + if (_spacing == value) return; + _spacing = value; + markNeedsLayout(); + } + + TextDirection get textDirection => _textDirection; + TextDirection _textDirection; + + set textDirection(TextDirection value) { + if (_textDirection == value) return; + _textDirection = value; + markNeedsLayout(); + } + + @override + void setupParentData(RenderBox child) { + if (child.parentData is! _GridRowParentData) { + child.parentData = _GridRowParentData(); + } + } + + double get intrinsicWidth => rowLayout.itemWidths.sum + spacing * (childCount - 1); + + @override + double computeMinIntrinsicWidth(double height) => intrinsicWidth; + + @override + double computeMaxIntrinsicWidth(double height) => intrinsicWidth; + + @override + double computeMinIntrinsicHeight(double width) => rowLayout.height; + + @override + double computeMaxIntrinsicHeight(double width) => rowLayout.height; + + @override + void performLayout() { + var child = firstChild; + if (child == null) { + size = constraints.smallest; + return; + } + final thumbnailHeight = rowLayout.height - spacing; + size = Size(constraints.maxWidth, rowLayout.height); + final flipMainAxis = textDirection == TextDirection.rtl; + final sign = (flipMainAxis ? -1.0 : 1.0); + var i = 0; + var offset = Offset(flipMainAxis ? size.width - rowLayout.itemWidths[i] : 0, 0); + while (child != null) { + final thumbnailWidth = rowLayout.itemWidths[i]; + final childConstraints = BoxConstraints.tight(Size(thumbnailWidth, thumbnailHeight)); + child.layout(childConstraints, parentUsesSize: false); + final childParentData = child.parentData! as _GridRowParentData; + childParentData.offset = offset; + final dx = sign * (thumbnailWidth + spacing); + offset += Offset(dx, 0); + child = childParentData.nextSibling; + i++; + } + } + + @override + double? computeDistanceToActualBaseline(TextBaseline baseline) { + return defaultComputeDistanceToHighestActualBaseline(baseline); + } + + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { + return defaultHitTestChildren(result, position: position); + } + + @override + void paint(PaintingContext context, Offset offset) { + defaultPaint(context, offset); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('rowLayout', rowLayout)); + properties.add(DoubleProperty('spacing', spacing)); + properties.add(EnumProperty('textDirection', textDirection)); + } +} diff --git a/lib/widgets/common/grid/sections/mosaic/scale_grid.dart b/lib/widgets/common/grid/sections/mosaic/scale_grid.dart new file mode 100644 index 000000000..f199d80ab --- /dev/null +++ b/lib/widgets/common/grid/sections/mosaic/scale_grid.dart @@ -0,0 +1,74 @@ +import 'package:aves/theme/durations.dart'; +import 'package:aves/widgets/common/grid/sections/mosaic/scale_overlay.dart'; +import 'package:aves/widgets/common/grid/sections/mosaic/section_layout_builder.dart'; +import 'package:flutter/material.dart'; + +class MosaicGrid extends StatelessWidget { + final Rect contentRect; + final Size tileSize; + final double spacing; + final MosaicItemBuilder builder; + + static const _itemRatios = [ + 3 / 4, + 16 / 9, + 9 / 16, + 3 / 4, + 4 / 3, + 4 / 3, + 3 / 4, + 4 / 3, + 4 / 3, + 4 / 3, + ]; + + const MosaicGrid({ + super.key, + required this.contentRect, + required this.tileSize, + required this.spacing, + required this.builder, + }); + + @override + Widget build(BuildContext context) { + final children = []; + + final targetExtent = tileSize.width; + final rows = MosaicSectionLayoutBuilder.computeMosaicRows( + section: List.generate(5, (i) => _itemRatios).expand((v) => v).toList(), + availableWidthFor: (itemCount) => contentRect.width - (itemCount - 1) * spacing, + heightMax: targetExtent * MosaicSectionLayoutBuilder.heightMaxFactor, + targetExtent: targetExtent, + spacing: spacing, + bottom: tileSize.height - tileSize.width, + coverRatioResolver: (item) => item, + ); + + var i = 0; + var dy = contentRect.top; + rows.forEach((row) { + var dx = contentRect.left; + final itemHeight = row.height - spacing; + row.itemWidths.forEach((itemWidth) { + children.add( + AnimatedPositioned( + left: dx, + top: dy, + width: itemWidth, + height: itemHeight, + duration: Durations.scalingGridPositionAnimation, + child: builder(i, targetExtent), + ), + ); + dx += itemWidth + spacing; + i++; + }); + dy += row.height; + }); + + return Stack( + children: children, + ); + } +} diff --git a/lib/widgets/common/grid/sections/mosaic/scale_overlay.dart b/lib/widgets/common/grid/sections/mosaic/scale_overlay.dart new file mode 100644 index 000000000..07e7ed81d --- /dev/null +++ b/lib/widgets/common/grid/sections/mosaic/scale_overlay.dart @@ -0,0 +1,116 @@ +import 'package:aves/theme/durations.dart'; +import 'package:aves/utils/constants.dart'; +import 'package:aves/widgets/common/grid/sections/mosaic/scale_grid.dart'; +import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; +import 'package:flutter/material.dart'; + +typedef MosaicItemBuilder = Widget Function(int index, double targetExtent); + +class MosaicScaleOverlay extends StatelessWidget { + final Rect contentRect; + final double spacing, extentMax; + final ValueNotifier scaledSizeNotifier; + final MosaicItemBuilder itemBuilder; + + const MosaicScaleOverlay({ + super.key, + required this.contentRect, + required this.spacing, + required this.extentMax, + required this.scaledSizeNotifier, + required this.itemBuilder, + }); + + @override + Widget build(BuildContext context) { + return MediaQueryDataProvider( + child: IgnorePointer( + child: _OverlayBackground( + child: ValueListenableBuilder( + valueListenable: scaledSizeNotifier, + builder: (context, scaledSize, child) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + Widget _buildBar(double width, Color color) => ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(4)), + child: Container( + color: color, + width: width, + height: 4, + ), + ); + return SafeArea( + left: false, + right: false, + bottom: false, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Stack( + alignment: Alignment.center, + children: [ + _buildBar(extentMax, colorScheme.onPrimary.withOpacity(.1)), + _buildBar(scaledSize.width, colorScheme.secondary), + ], + ), + ), + Expanded( + child: MosaicGrid( + contentRect: contentRect, + tileSize: scaledSize, + spacing: spacing, + builder: itemBuilder, + ), + ), + ], + ), + ); + }, + ), + ), + ), + ); + } +} + +class _OverlayBackground extends StatefulWidget { + final Widget child; + + const _OverlayBackground({ + required this.child, + }); + + @override + State<_OverlayBackground> createState() => _OverlayBackgroundState(); +} + +class _OverlayBackgroundState extends State<_OverlayBackground> { + bool _initialized = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => setState(() => _initialized = true)); + } + + @override + Widget build(BuildContext context) { + return AnimatedContainer( + decoration: _buildBackgroundDecoration(context), + duration: Durations.scalingGridBackgroundAnimation, + child: widget.child, + ); + } + + BoxDecoration _buildBackgroundDecoration(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return _initialized + ? BoxDecoration( + color: isDark ? Colors.black87 : const Color(0xDDFFFFFF), + ) + : BoxDecoration( + color: isDark ? Constants.transparentBlack : Constants.transparentWhite, + ); + } +} diff --git a/lib/widgets/common/grid/sections/mosaic/section_layout.dart b/lib/widgets/common/grid/sections/mosaic/section_layout.dart new file mode 100644 index 000000000..0cba43a3d --- /dev/null +++ b/lib/widgets/common/grid/sections/mosaic/section_layout.dart @@ -0,0 +1,61 @@ +import 'package:aves/widgets/common/grid/sections/section_layout.dart'; +import 'package:collection/collection.dart'; +import 'package:equatable/equatable.dart'; + +class MosaicSectionLayout extends SectionLayout { + final List rows; + + @override + List get props => [sectionKey, firstIndex, lastIndex, minOffset, maxOffset, headerExtent, rows, spacing]; + + const MosaicSectionLayout({ + required super.sectionKey, + required super.firstIndex, + required super.lastIndex, + required super.minOffset, + required super.maxOffset, + required super.headerExtent, + required this.rows, + required super.spacing, + required super.builder, + }); + + @override + double indexToLayoutOffset(int index) { + index -= bodyFirstIndex; + if (index < 0) return minOffset; + return bodyMinOffset + (index < rows.length ? rows[index].minOffset : rows.lastOrNull?.maxOffset ?? 0); + } + + @override + int getMinChildIndexForScrollOffset(double scrollOffset) { + scrollOffset -= bodyMinOffset; + if (scrollOffset < 0) return firstIndex; + return bodyFirstIndex + rows.indexWhere((v) => scrollOffset < v.maxOffset); + } + + @override + int getMaxChildIndexForScrollOffset(double scrollOffset) { + scrollOffset -= bodyMinOffset; + if (scrollOffset < 0) return firstIndex; + final rowIndex = rows.indexWhere((v) => scrollOffset < v.maxOffset); + return bodyFirstIndex + (rowIndex == -1 ? rows.length - 1 : rowIndex); + } +} + +class MosaicRowLayout extends Equatable { + final int firstIndex, lastIndex; + final double minOffset, maxOffset, height; + final List itemWidths; + + @override + List get props => [firstIndex, lastIndex, minOffset, maxOffset, height, itemWidths]; + + const MosaicRowLayout({ + required this.firstIndex, + required this.lastIndex, + required this.minOffset, + required this.height, + required this.itemWidths, + }) : maxOffset = minOffset + height; +} diff --git a/lib/widgets/common/grid/sections/mosaic/section_layout_builder.dart b/lib/widgets/common/grid/sections/mosaic/section_layout_builder.dart new file mode 100644 index 000000000..5383dab0b --- /dev/null +++ b/lib/widgets/common/grid/sections/mosaic/section_layout_builder.dart @@ -0,0 +1,194 @@ +import 'dart:math'; + +import 'package:aves/model/source/section_keys.dart'; +import 'package:aves/widgets/common/grid/sections/list_layout.dart'; +import 'package:aves/widgets/common/grid/sections/mosaic/list_layout.dart'; +import 'package:aves/widgets/common/grid/sections/mosaic/row.dart'; +import 'package:aves/widgets/common/grid/sections/mosaic/section_layout.dart'; +import 'package:aves/widgets/common/grid/sections/provider.dart'; +import 'package:aves/widgets/common/grid/sections/section_layout.dart'; +import 'package:aves/widgets/common/grid/sections/section_layout_builder.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:tuple/tuple.dart'; + +class MosaicSectionLayoutBuilder extends SectionLayoutBuilder { + int _currentIndex = 0; + double _currentOffset = 0; + late double Function(int itemCount) rowAvailableWidth; + late double rowHeightMax; + final CoverRatioResolver coverRatioResolver; + + static const heightMaxFactor = 2.4; + + MosaicSectionLayoutBuilder({ + required super.sections, + required super.showHeaders, + required super.getHeaderExtent, + required super.buildHeader, + required super.scrollableWidth, + required super.tileLayout, + required super.columnCount, + required super.spacing, + required super.horizontalPadding, + required super.tileWidth, + required super.tileHeight, + required super.tileBuilder, + required super.tileAnimationDelay, + required this.coverRatioResolver, + }) { + final rowWidth = scrollableWidth - horizontalPadding * 2; + rowAvailableWidth = (itemCount) => rowWidth - (itemCount - 1) * spacing; + rowHeightMax = tileWidth * heightMaxFactor; + } + + @override + SectionedListLayout updateLayouts(BuildContext context) { + final sectionLayouts = sections.keys + .map((sectionKey) => buildSectionLayout( + headerExtent: showHeaders ? getHeaderExtent(context, sectionKey) : 0.0, + sectionKey: sectionKey, + section: sections[sectionKey]!, + animate: animate, + )) + .toList(); + + return MosaicSectionedListLayout( + sections: sections, + showHeaders: showHeaders, + spacing: spacing, + horizontalPadding: horizontalPadding, + sectionLayouts: sectionLayouts, + ); + } + + @override + SectionLayout buildSectionLayout({ + required double headerExtent, + required SectionKey sectionKey, + required List section, + required bool animate, + }) { + final rows = computeMosaicRows( + section: section, + availableWidthFor: rowAvailableWidth, + heightMax: rowHeightMax, + targetExtent: tileWidth, + spacing: spacing, + bottom: bottom, + coverRatioResolver: coverRatioResolver, + ); + final rowCount = rows.length; + final sectionChildCount = 1 + rowCount; + + final sectionFirstIndex = _currentIndex; + _currentIndex += sectionChildCount; + final sectionLastIndex = _currentIndex - 1; + + final sectionMinOffset = _currentOffset; + _currentOffset += headerExtent + rows.map((v) => v.height).sum - spacing; + final sectionMaxOffset = _currentOffset; + + return MosaicSectionLayout( + sectionKey: sectionKey, + firstIndex: sectionFirstIndex, + lastIndex: sectionLastIndex, + minOffset: sectionMinOffset, + maxOffset: sectionMaxOffset, + headerExtent: headerExtent, + rows: rows, + spacing: spacing, + builder: (context, listIndex) { + final textDirection = Directionality.of(context); + final sectionChildIndex = listIndex - sectionFirstIndex; + final row = sectionChildIndex == 0 ? null : rows[sectionChildIndex - 1]; + return buildSectionWidget( + context: context, + section: section, + sectionGridIndex: listIndex * columnCount, + sectionChildIndex: sectionChildIndex, + itemIndexRange: () => row == null ? const Tuple2(0, 0) : Tuple2(row.firstIndex, row.lastIndex + 1), + sectionKey: sectionKey, + headerExtent: headerExtent, + animate: animate, + buildGridRow: (children) { + return row == null + ? const SizedBox() + : MosaicGridRow( + rowLayout: row, + spacing: spacing, + textDirection: textDirection, + children: children, + ); + }, + ); + }, + ); + } + + static List computeMosaicRows({ + required List section, + required double Function(int itemCount) availableWidthFor, + required double heightMax, + required double targetExtent, + required double spacing, + required double bottom, + required CoverRatioResolver coverRatioResolver, + }) { + final rows = []; + final items = []; + double ratioSum = 0, ratioMin = double.infinity; + int firstIndex = 0; + double minOffset = 0; + + void addRow(int i, {required bool complete}) { + if (items.isEmpty) return; + + final availableWidth = availableWidthFor(items.length); + var height = availableWidth / ratioSum + spacing; + if (height > heightMax + precisionErrorTolerance) { + if (!complete) { + ratioSum = availableWidth / (heightMax - spacing); + addRow(i, complete: complete); + } + return; + } + + height += bottom; + rows.add(MosaicRowLayout( + firstIndex: firstIndex, + lastIndex: i - 1, + minOffset: minOffset, + height: height, + itemWidths: items.map((item) => availableWidth * coverRatioResolver(item) / ratioSum).toList(), + )); + firstIndex = i; + minOffset += height; + ratioMin = double.infinity; + ratioSum = 0; + items.clear(); + } + + section.forEachIndexed((i, item) { + final ratio = coverRatioResolver(item); + final nextAvailableWidth = availableWidthFor(items.length + 1); + final nextRatioSum = ratio + ratioSum; + final nextItemMinWidth = nextAvailableWidth * min(ratio, ratioMin) / nextRatioSum; + final nextHeight = nextAvailableWidth / nextRatioSum + spacing; + if (nextItemMinWidth < targetExtent || nextHeight < targetExtent) { + // add row when appending the next item would make other items too small + addRow(i, complete: true); + } + items.add(item); + ratioMin = min(ratio, ratioMin); + ratioSum += ratio; + }); + if (items.isNotEmpty) { + // add last row, possibly incomplete + addRow(section.length, complete: false); + } + + return rows; + } +} diff --git a/lib/widgets/common/grid/sections/provider.dart b/lib/widgets/common/grid/sections/provider.dart new file mode 100644 index 000000000..47fba31c0 --- /dev/null +++ b/lib/widgets/common/grid/sections/provider.dart @@ -0,0 +1,104 @@ +import 'package:aves/model/source/enums/enums.dart'; +import 'package:aves/model/source/section_keys.dart'; +import 'package:aves/widgets/common/grid/sections/fixed/section_layout_builder.dart'; +import 'package:aves/widgets/common/grid/sections/list_layout.dart'; +import 'package:aves/widgets/common/grid/sections/mosaic/section_layout_builder.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:provider/provider.dart'; + +typedef CoverRatioResolver = double Function(T item); + +abstract class SectionedListLayoutProvider extends StatelessWidget { + final double scrollableWidth; + final TileLayout tileLayout; + final int columnCount; + final double spacing, horizontalPadding, tileWidth, tileHeight; + final Widget Function(T item) tileBuilder; + final Duration tileAnimationDelay; + final CoverRatioResolver coverRatioResolver; + final Widget child; + + const SectionedListLayoutProvider({ + super.key, + required this.scrollableWidth, + required this.tileLayout, + required int columnCount, + required this.spacing, + required this.horizontalPadding, + required double tileWidth, + required this.tileHeight, + required this.tileBuilder, + required this.tileAnimationDelay, + required this.coverRatioResolver, + required this.child, + }) : assert(scrollableWidth != 0), + columnCount = tileLayout == TileLayout.list ? 1 : columnCount, + tileWidth = tileLayout == TileLayout.list ? scrollableWidth - (horizontalPadding * 2) : tileWidth; + + @override + Widget build(BuildContext context) { + return ProxyProvider0>( + update: (context, _) { + switch (tileLayout) { + case TileLayout.mosaic: + return MosaicSectionLayoutBuilder( + sections: sections, + showHeaders: showHeaders, + getHeaderExtent: getHeaderExtent, + buildHeader: buildHeader, + scrollableWidth: scrollableWidth, + tileLayout: tileLayout, + columnCount: columnCount, + spacing: spacing, + horizontalPadding: horizontalPadding, + tileWidth: tileWidth, + tileHeight: tileHeight, + tileBuilder: tileBuilder, + tileAnimationDelay: tileAnimationDelay, + coverRatioResolver: coverRatioResolver, + ).updateLayouts(context); + case TileLayout.grid: + case TileLayout.list: + return FixedExtentSectionLayoutBuilder( + sections: sections, + showHeaders: showHeaders, + buildHeader: buildHeader, + getHeaderExtent: getHeaderExtent, + scrollableWidth: scrollableWidth, + tileLayout: tileLayout, + columnCount: columnCount, + spacing: spacing, + horizontalPadding: horizontalPadding, + tileWidth: tileWidth, + tileHeight: tileHeight, + tileBuilder: tileBuilder, + tileAnimationDelay: tileAnimationDelay, + ).updateLayouts(context); + } + }, + child: child, + ); + } + + bool get showHeaders; + + Map> get sections; + + double getHeaderExtent(BuildContext context, SectionKey sectionKey); + + Widget buildHeader(BuildContext context, SectionKey sectionKey, double headerExtent); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DoubleProperty('scrollableWidth', scrollableWidth)); + properties.add(EnumProperty('tileLayout', tileLayout)); + properties.add(IntProperty('columnCount', columnCount)); + properties.add(DoubleProperty('spacing', spacing)); + properties.add(DoubleProperty('horizontalPadding', horizontalPadding)); + properties.add(DoubleProperty('tileWidth', tileWidth)); + properties.add(DoubleProperty('tileHeight', tileHeight)); + properties.add(DiagnosticsProperty('showHeaders', showHeaders)); + } +} diff --git a/lib/widgets/common/grid/sections/section_layout.dart b/lib/widgets/common/grid/sections/section_layout.dart new file mode 100644 index 000000000..ccc7b993b --- /dev/null +++ b/lib/widgets/common/grid/sections/section_layout.dart @@ -0,0 +1,37 @@ +import 'package:aves/model/source/section_keys.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter/material.dart'; + +@immutable +abstract class SectionLayout extends Equatable { + final SectionKey sectionKey; + final int firstIndex, lastIndex, bodyFirstIndex; + final double minOffset, maxOffset, bodyMinOffset; + final double headerExtent, spacing; + final IndexedWidgetBuilder builder; + + @override + List get props => [sectionKey, firstIndex, lastIndex, minOffset, maxOffset, headerExtent, spacing]; + + const SectionLayout({ + required this.sectionKey, + required this.firstIndex, + required this.lastIndex, + required this.minOffset, + required this.maxOffset, + required this.headerExtent, + required this.spacing, + required this.builder, + }) : bodyFirstIndex = firstIndex + 1, + bodyMinOffset = minOffset + headerExtent; + + bool hasChild(int index) => firstIndex <= index && index <= lastIndex; + + bool hasChildAtOffset(double scrollOffset) => minOffset <= scrollOffset && scrollOffset <= maxOffset; + + double indexToLayoutOffset(int index); + + int getMinChildIndexForScrollOffset(double scrollOffset); + + int getMaxChildIndexForScrollOffset(double scrollOffset); +} diff --git a/lib/widgets/common/grid/sections/section_layout_builder.dart b/lib/widgets/common/grid/sections/section_layout_builder.dart new file mode 100644 index 000000000..5706b5643 --- /dev/null +++ b/lib/widgets/common/grid/sections/section_layout_builder.dart @@ -0,0 +1,99 @@ +import 'package:aves/model/source/enums/enums.dart'; +import 'package:aves/model/source/section_keys.dart'; +import 'package:aves/theme/durations.dart'; +import 'package:aves/widgets/common/grid/sections/list_layout.dart'; +import 'package:aves/widgets/common/grid/sections/section_layout.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_staggered_animations/flutter_staggered_animations.dart'; +import 'package:provider/provider.dart'; +import 'package:tuple/tuple.dart'; + +abstract class SectionLayoutBuilder { + final Map> sections; + final bool showHeaders; + final double Function(BuildContext context, SectionKey sectionKey) getHeaderExtent; + final Widget Function(BuildContext context, SectionKey sectionKey, double headerExtent) buildHeader; + final double scrollableWidth; + final TileLayout tileLayout; + final int columnCount; + final double spacing, horizontalPadding, tileWidth, tileHeight, bottom; + final Widget Function(T item) tileBuilder; + final Duration tileAnimationDelay; + final bool animate; + + const SectionLayoutBuilder({ + required this.sections, + required this.showHeaders, + required this.getHeaderExtent, + required this.buildHeader, + required this.scrollableWidth, + required this.tileLayout, + required this.columnCount, + required this.spacing, + required this.horizontalPadding, + required this.tileWidth, + required this.tileHeight, + required this.tileBuilder, + required this.tileAnimationDelay, + }) : animate = tileAnimationDelay > Duration.zero, + bottom = tileHeight - tileWidth; + + SectionedListLayout updateLayouts(BuildContext context); + + SectionLayout buildSectionLayout({ + required double headerExtent, + required SectionKey sectionKey, + required List section, + required bool animate, + }); + + Widget buildSectionWidget({ + required BuildContext context, + required List section, + required int sectionGridIndex, + required int sectionChildIndex, + required Tuple2 Function() itemIndexRange, + required SectionKey sectionKey, + required double headerExtent, + required bool animate, + required Widget Function(List children) buildGridRow, + }) { + if (sectionChildIndex == 0) { + final header = headerExtent > 0 ? buildHeader(context, sectionKey, headerExtent) : const SizedBox(); + return animate ? _buildAnimation(context, sectionGridIndex, header) : header; + } + + final sectionItemCount = section.length; + final itemMinMax = itemIndexRange(); + final minItemIndex = itemMinMax.item1.clamp(0, sectionItemCount); + final maxItemIndex = itemMinMax.item2.clamp(0, sectionItemCount); + final children = []; + for (var i = minItemIndex; i < maxItemIndex; i++) { + final itemGridIndex = sectionGridIndex + i - minItemIndex; + final item = RepaintBoundary( + child: tileBuilder(section[i]), + ); + children.add(animate ? _buildAnimation(context, itemGridIndex, item) : item); + } + return Padding( + padding: EdgeInsets.symmetric(horizontal: horizontalPadding), + child: buildGridRow(children), + ); + } + + Widget _buildAnimation(BuildContext context, int index, Widget child) { + final durations = context.watch(); + return AnimationConfiguration.staggeredGrid( + position: index, + columnCount: columnCount, + duration: durations.staggeredAnimation, + delay: tileAnimationDelay, + child: SlideAnimation( + verticalOffset: 50.0, + child: FadeInAnimation( + child: child, + ), + ), + ); + } +} diff --git a/lib/widgets/common/grid/selector.dart b/lib/widgets/common/grid/selector.dart index 4a53f5607..20c0c98f7 100644 --- a/lib/widgets/common/grid/selector.dart +++ b/lib/widgets/common/grid/selector.dart @@ -5,7 +5,7 @@ import 'package:aves/model/selection.dart'; import 'package:aves/utils/math_utils.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/section_layout.dart'; +import 'package:aves/widgets/common/grid/sections/list_layout.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; diff --git a/lib/widgets/common/grid/sliver.dart b/lib/widgets/common/grid/sliver.dart index dffeeb407..80ce09d8f 100644 --- a/lib/widgets/common/grid/sliver.dart +++ b/lib/widgets/common/grid/sliver.dart @@ -1,6 +1,7 @@ import 'dart:math' as math; -import 'package:aves/widgets/common/grid/section_layout.dart'; +import 'package:aves/widgets/common/grid/sections/list_layout.dart'; +import 'package:aves/widgets/common/grid/sections/section_layout.dart'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -26,7 +27,7 @@ class SectionedListSliver extends StatelessWidget { (context, index) { if (index >= childCount) return null; final sectionLayout = sectionLayouts.firstWhereOrNull((section) => section.hasChild(index)); - return sectionLayout?.builder(context, index) ?? const SizedBox.shrink(); + return sectionLayout?.builder(context, index) ?? const SizedBox(); }, childCount: childCount, addAutomaticKeepAlives: false, diff --git a/lib/widgets/common/thumbnail/decorated.dart b/lib/widgets/common/thumbnail/decorated.dart index 336a3f8c9..9871d3502 100644 --- a/lib/widgets/common/thumbnail/decorated.dart +++ b/lib/widgets/common/thumbnail/decorated.dart @@ -9,7 +9,7 @@ class DecoratedThumbnail extends StatelessWidget { final AvesEntry entry; final double tileExtent; final ValueNotifier? cancellableNotifier; - final bool selectable, highlightable; + final bool isMosaic, selectable, highlightable; final Object? Function()? heroTagger; static final Color borderColor = Colors.grey.shade700; @@ -20,6 +20,7 @@ class DecoratedThumbnail extends StatelessWidget { required this.entry, required this.tileExtent, this.cancellableNotifier, + this.isMosaic = false, this.selectable = true, this.highlightable = true, this.heroTagger, @@ -27,9 +28,13 @@ class DecoratedThumbnail extends StatelessWidget { @override Widget build(BuildContext context) { + final thumbnailWidth = isMosaic ? tileExtent * entry.displayAspectRatio : tileExtent; + final thumbnailHeight = tileExtent; + Widget child = ThumbnailImage( entry: entry, extent: tileExtent, + isMosaic: isMosaic, cancellableNotifier: cancellableNotifier, heroTag: heroTagger?.call(), ); @@ -57,8 +62,8 @@ class DecoratedThumbnail extends StatelessWidget { width: borderWidth, )), ), - width: tileExtent, - height: tileExtent, + width: thumbnailWidth, + height: thumbnailHeight, child: child, ); } diff --git a/lib/widgets/common/thumbnail/image.dart b/lib/widgets/common/thumbnail/image.dart index 09d29853f..abcc6b4c3 100644 --- a/lib/widgets/common/thumbnail/image.dart +++ b/lib/widgets/common/thumbnail/image.dart @@ -19,7 +19,7 @@ import 'package:provider/provider.dart'; class ThumbnailImage extends StatefulWidget { final AvesEntry entry; final double extent; - final bool progressive; + final bool isMosaic, progressive; final BoxFit? fit; final bool showLoadingBackground; final ValueNotifier? cancellableNotifier; @@ -30,6 +30,7 @@ class ThumbnailImage extends StatefulWidget { required this.entry, required this.extent, this.progressive = true, + this.isMosaic = false, this.fit, this.showLoadingBackground = true, this.cancellableNotifier, @@ -38,6 +39,14 @@ class ThumbnailImage extends StatefulWidget { @override State createState() => _ThumbnailImageState(); + + static Color computeLoadingBackgroundColor(int hashCode, Brightness brightness) { + var rgb = 0x30 + hashCode % 0x20; + if (brightness == Brightness.light) { + rgb = 0xFF - rgb; + } + return Color.fromARGB(0xFF, rgb, rgb, rgb); + } } class _ThumbnailImageState extends State { @@ -52,6 +61,8 @@ class _ThumbnailImageState extends State { double get extent => widget.extent; + bool get isMosaic => widget.isMosaic; + @override void initState() { super.initState(); @@ -180,13 +191,7 @@ class _ThumbnailImageState extends State { Color? _loadingBackgroundColor; Color loadingBackgroundColor(BuildContext context) { - if (_loadingBackgroundColor == null) { - var rgb = 0x30 + entry.uri.hashCode % 0x20; - if (Theme.of(context).brightness == Brightness.light) { - rgb = 0xFF - rgb; - } - _loadingBackgroundColor = Color.fromARGB(0xFF, rgb, rgb, rgb); - } + _loadingBackgroundColor ??= ThumbnailImage.computeLoadingBackgroundColor(entry.uri.hashCode, Theme.of(context).brightness); return _loadingBackgroundColor!; } @@ -200,13 +205,21 @@ class _ThumbnailImageState extends State { // use `RawImage` instead of `Image`, using `ImageInfo` to check dimensions // and have more control when chaining image providers - final fit = widget.fit ?? (entry.isSvg ? BoxFit.contain : BoxFit.cover); + final thumbnailWidth = isMosaic ? extent * entry.displayAspectRatio : extent; + final thumbnailHeight = extent; + + final fit = widget.fit ?? + (entry.isSvg + ? BoxFit.contain + : isMosaic + ? BoxFit.contain + : BoxFit.cover); final imageInfo = _lastImageInfo; Widget image = imageInfo == null ? Container( color: widget.showLoadingBackground ? loadingBackgroundColor(context) : Colors.transparent, - width: extent, - height: extent, + width: thumbnailWidth, + height: thumbnailHeight, ) : Selector( selector: (context, s) => s.imageBackground, @@ -240,8 +253,8 @@ class _ThumbnailImageState extends State { return RawImage( image: imageInfo.image, debugImageLabel: imageInfo.debugLabel, - width: extent, - height: extent, + width: thumbnailWidth, + height: thumbnailHeight, scale: imageInfo.scale, color: backgroundColor, colorBlendMode: BlendMode.dstOver, diff --git a/lib/widgets/dialogs/entry_editors/rename_entry_set_dialog.dart b/lib/widgets/dialogs/entry_editors/rename_entry_set_dialog.dart index 6b718583b..14f8745be 100644 --- a/lib/widgets/dialogs/entry_editors/rename_entry_set_dialog.dart +++ b/lib/widgets/dialogs/entry_editors/rename_entry_set_dialog.dart @@ -172,7 +172,7 @@ class _RenameEntrySetPageState extends State { ); }, separatorBuilder: (context, index) => const SizedBox( - height: CollectionGrid.spacing, + height: CollectionGrid.fixedExtentLayoutSpacing, ), itemCount: min(entryCount, previewMax), ), diff --git a/lib/widgets/dialogs/tile_view_dialog.dart b/lib/widgets/dialogs/tile_view_dialog.dart index a4074732e..0e72b5f2c 100644 --- a/lib/widgets/dialogs/tile_view_dialog.dart +++ b/lib/widgets/dialogs/tile_view_dialog.dart @@ -12,18 +12,18 @@ import 'aves_dialog.dart'; class TileViewDialog extends StatefulWidget { final Tuple4 initialValue; - final Map sortOptions; - final Map groupOptions; - final Map layoutOptions; + final List> sortOptions; + final List> groupOptions; + final List> layoutOptions; final String Function(S sort, bool reverse) sortOrder; final bool Function(S? sort, G? group, L? layout)? canGroup; const TileViewDialog({ super.key, required this.initialValue, - this.sortOptions = const {}, - this.groupOptions = const {}, - this.layoutOptions = const {}, + this.sortOptions = const [], + this.groupOptions = const [], + this.layoutOptions = const [], required this.sortOrder, this.canGroup, }); @@ -38,11 +38,11 @@ class _TileViewDialogState extends State> with late L? _selectedLayout; late bool _reverseSort; - Map get sortOptions => widget.sortOptions; + List> get sortOptions => widget.sortOptions; - Map get groupOptions => widget.groupOptions; + List> get groupOptions => widget.groupOptions; - Map get layoutOptions => widget.layoutOptions; + List> get layoutOptions => widget.layoutOptions; bool get canGroup => (widget.canGroup ?? (s, g, l) => true).call(_selectedSort, _selectedGroup, _selectedLayout); @@ -131,7 +131,7 @@ class _TileViewDialogState extends State> with required IconData icon, required String title, Widget? trailing, - required Map options, + required List> options, required T value, required ValueChanged onChanged, Widget? bottom, @@ -171,8 +171,9 @@ class _TileViewDialogState extends State> with Padding( padding: EdgeInsetsDirectional.only(start: iconSize + 16, end: 12), child: TextDropdownButton( - values: options.keys.toList(), - valueText: (v) => options[v] ?? v.toString(), + values: options.map((v) => v.value).toList(), + valueText: (v) => options.firstWhere((option) => option.value == v).title, + valueIcon: (v) => options.firstWhere((option) => option.value == v).icon, value: value, onChanged: (v) => setState(() => onChanged(v)), isExpanded: true, @@ -190,3 +191,16 @@ class _TileViewDialogState extends State> with ); } } + +@immutable +class TileViewDialogOption { + final T value; + final String title; + final IconData icon; + + const TileViewDialogOption({ + required this.value, + required this.title, + required this.icon, + }); +} diff --git a/lib/widgets/filter_grids/common/action_delegates/album_set.dart b/lib/widgets/filter_grids/common/action_delegates/album_set.dart index c2945a12e..427978119 100644 --- a/lib/widgets/filter_grids/common/action_delegates/album_set.dart +++ b/lib/widgets/filter_grids/common/action_delegates/album_set.dart @@ -149,9 +149,9 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate with builder: (context) { return TileViewDialog( initialValue: initialValue, - sortOptions: Map.fromEntries(ChipSetActionDelegate.sortOptions.map((v) => MapEntry(v, v.getName(context)))), - groupOptions: Map.fromEntries(_groupOptions.map((v) => MapEntry(v, v.getName(context)))), - layoutOptions: Map.fromEntries(ChipSetActionDelegate.layoutOptions.map((v) => MapEntry(v, v.getName(context)))), + sortOptions: ChipSetActionDelegate.sortOptions.map((v) => TileViewDialogOption(value: v, title: v.getName(context), icon: v.icon)).toList(), + groupOptions: _groupOptions.map((v) => TileViewDialogOption(value: v, title: v.getName(context), icon: v.icon)).toList(), + layoutOptions: ChipSetActionDelegate.layoutOptions.map((v) => TileViewDialogOption(value: v, title: v.getName(context), icon: v.icon)).toList(), sortOrder: (factor, reverse) => factor.getOrderName(context, reverse), ); }, diff --git a/lib/widgets/filter_grids/common/action_delegates/chip_set.dart b/lib/widgets/filter_grids/common/action_delegates/chip_set.dart index 5455c9a7b..b697b8fa6 100644 --- a/lib/widgets/filter_grids/common/action_delegates/chip_set.dart +++ b/lib/widgets/filter_grids/common/action_delegates/chip_set.dart @@ -54,6 +54,7 @@ abstract class ChipSetActionDelegate with FeedbackMi ]; static const layoutOptions = [ + TileLayout.mosaic, TileLayout.grid, TileLayout.list, ]; @@ -222,8 +223,8 @@ abstract class ChipSetActionDelegate with FeedbackMi builder: (context) { return TileViewDialog( initialValue: initialValue, - sortOptions: Map.fromEntries(sortOptions.map((v) => MapEntry(v, v.getName(context)))), - layoutOptions: Map.fromEntries(layoutOptions.map((v) => MapEntry(v, v.getName(context)))), + sortOptions: sortOptions.map((v) => TileViewDialogOption(value: v, title: v.getName(context), icon: v.icon)).toList(), + layoutOptions: layoutOptions.map((v) => TileViewDialogOption(value: v, title: v.getName(context), icon: v.icon)).toList(), sortOrder: (factor, reverse) => factor.getOrderName(context, reverse), ); }, diff --git a/lib/widgets/filter_grids/common/covered_filter_chip.dart b/lib/widgets/filter_grids/common/covered_filter_chip.dart index 97b576fff..a7d9d2d26 100644 --- a/lib/widgets/filter_grids/common/covered_filter_chip.dart +++ b/lib/widgets/filter_grids/common/covered_filter_chip.dart @@ -1,7 +1,6 @@ import 'dart:math'; import 'package:aves/model/covers.dart'; -import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/location.dart'; @@ -23,7 +22,6 @@ import 'package:provider/provider.dart'; class CoveredFilterChip extends StatelessWidget { final T filter; final double extent, thumbnailExtent; - final AvesEntry? coverEntry; final bool showText, pinned; final String? banner; final FilterCallback? onTap; @@ -34,7 +32,6 @@ class CoveredFilterChip extends StatelessWidget { required this.filter, required this.extent, double? thumbnailExtent, - this.coverEntry, this.showText = true, this.pinned = false, this.banner, @@ -101,7 +98,7 @@ class CoveredFilterChip extends StatelessWidget { } Widget _buildChip(BuildContext context, CollectionSource source) { - final entry = coverEntry ?? source.coverEntry(filter); + final entry = source.coverEntry(filter); final titlePadding = min(4.0, extent / 32); Key? chipKey; if (filter is AlbumFilter) { diff --git a/lib/widgets/filter_grids/common/filter_grid_page.dart b/lib/widgets/filter_grids/common/filter_grid_page.dart index b93899747..062939671 100644 --- a/lib/widgets/filter_grids/common/filter_grid_page.dart +++ b/lib/widgets/filter_grids/common/filter_grid_page.dart @@ -6,7 +6,9 @@ import 'package:aves/model/highlight.dart'; import 'package:aves/model/query.dart'; import 'package:aves/model/selection.dart'; import 'package:aves/model/settings/settings.dart'; +import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/enums/enums.dart'; +import 'package:aves/theme/colors.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/common/basic/draggable_scrollbar.dart'; import 'package:aves/widgets/common/basic/insets.dart'; @@ -15,7 +17,8 @@ import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/media_query.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/sections/fixed/scale_grid.dart'; +import 'package:aves/widgets/common/grid/sections/list_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'; @@ -24,6 +27,7 @@ import 'package:aves/widgets/common/identity/scroll_thumb.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/common/providers/query_provider.dart'; import 'package:aves/widgets/common/providers/tile_extent_controller_provider.dart'; +import 'package:aves/widgets/common/thumbnail/image.dart'; import 'package:aves/widgets/common/tile_extent_controller.dart'; import 'package:aves/widgets/filter_grids/common/covered_filter_chip.dart'; import 'package:aves/widgets/filter_grids/common/draggable_thumb_label.dart'; @@ -244,6 +248,7 @@ class _FilterGridContent extends StatelessWidget { @override Widget build(BuildContext context) { + final source = context.read(); final settingsRouteKey = context.read().settingsRouteKey; final tileLayout = context.select((s) => s.getTileLayout(settingsRouteKey)); return Selector( @@ -284,7 +289,7 @@ class _FilterGridContent extends StatelessWidget { final tileHeight = CoveredFilterChip.tileHeight( extent: thumbnailExtent, textScaleFactor: textScaleFactor, - showText: tileLayout == TileLayout.grid, + showText: tileLayout != TileLayout.list, ); return GridTheme( extent: thumbnailExtent, @@ -312,6 +317,10 @@ class _FilterGridContent extends StatelessWidget { ); }, tileAnimationDelay: tileAnimationDelay, + coverRatioResolver: (item) { + final coverEntry = source.coverEntry(item.filter) ?? item.entry; + return coverEntry?.displayAspectRatio ?? 1; + }, child: child!, ), ), @@ -469,12 +478,13 @@ class _FilterScaler extends StatelessWidget { final metrics = context.select>((v) => Tuple2(v.spacing, v.horizontalPadding)); final tileSpacing = metrics.item1; final horizontalPadding = metrics.item2; + final brightness = Theme.of(context).brightness; return GridScaleGestureDetector>( scrollableKey: scrollableKey, tileLayout: tileLayout, heightForWidth: (width) => CoveredFilterChip.tileHeight(extent: width, textScaleFactor: textScaleFactor, showText: true), gridBuilder: (center, tileSize, child) => CustomPaint( - painter: GridPainter( + painter: FixedExtentGridPainter( tileLayout: tileLayout, tileCenter: center, tileSize: tileSize, @@ -487,7 +497,7 @@ class _FilterScaler extends StatelessWidget { ), child: child, ), - scaledBuilder: (item, tileSize) => FilterListDetailsTheme( + scaledItemBuilder: (item, tileSize) => FilterListDetailsTheme( extent: tileSize.height, child: FilterTile( gridItem: item, @@ -497,6 +507,16 @@ class _FilterScaler extends StatelessWidget { banner: bannerBuilder(context, item.filter), ), ), + mosaicItemBuilder: (index, targetExtent) => DecoratedBox( + decoration: BoxDecoration( + color: ThumbnailImage.computeLoadingBackgroundColor(index * 10, brightness).withOpacity(.9), + border: Border.all( + color: context.read().neutral, + width: AvesFilterChip.outlineWidth, + ), + borderRadius: BorderRadius.all(CoveredFilterChip.radius(targetExtent)), + ), + ), highlightItem: (item) => item.filter, child: child, ); diff --git a/lib/widgets/filter_grids/common/filter_tile.dart b/lib/widgets/filter_grids/common/filter_tile.dart index 00a0685b8..26981ca5b 100644 --- a/lib/widgets/filter_grids/common/filter_tile.dart +++ b/lib/widgets/filter_grids/common/filter_tile.dart @@ -139,6 +139,7 @@ class FilterTile extends StatelessWidget { final onChipTap = onTap != null ? (filter) => onTap?.call() : null; switch (tileLayout) { + case TileLayout.mosaic: case TileLayout.grid: return FilterChipGridDecorator>( gridItem: gridItem, diff --git a/lib/widgets/filter_grids/common/section_layout.dart b/lib/widgets/filter_grids/common/section_layout.dart index a00bd9c95..c275b9edb 100644 --- a/lib/widgets/filter_grids/common/section_layout.dart +++ b/lib/widgets/filter_grids/common/section_layout.dart @@ -1,6 +1,6 @@ import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/source/section_keys.dart'; -import 'package:aves/widgets/common/grid/section_layout.dart'; +import 'package:aves/widgets/common/grid/sections/provider.dart'; import 'package:aves/widgets/filter_grids/common/section_header.dart'; import 'package:aves/widgets/filter_grids/common/section_keys.dart'; import 'package:flutter/material.dart'; @@ -22,6 +22,7 @@ class SectionedFilterListLayoutProvider extends Sect required super.tileHeight, required super.tileBuilder, required super.tileAnimationDelay, + required super.coverRatioResolver, required super.child, }); diff --git a/untranslated.json b/untranslated.json index 3675a7374..54f8892da 100644 --- a/untranslated.json +++ b/untranslated.json @@ -1,18 +1,10 @@ { "de": [ - "entryInfoActionEditTitleDescription", - "filterNoDateLabel", - "filterNoTitleLabel", - "viewDialogReverseSortOrder", - "sortOrderNewestFirst", - "sortOrderOldestFirst", - "sortOrderAtoZ", - "sortOrderZtoA", - "sortOrderHighestFirst", - "sortOrderLowestFirst", - "sortOrderLargestFirst", - "sortOrderSmallestFirst", - "searchMetadataSectionTitle" + "tileLayoutMosaic" + ], + + "el": [ + "tileLayoutMosaic" ], "es": [ @@ -21,6 +13,7 @@ "filterNoTitleLabel", "filterRecentlyAddedLabel", "viewDialogReverseSortOrder", + "tileLayoutMosaic", "sortOrderNewestFirst", "sortOrderOldestFirst", "sortOrderAtoZ", @@ -34,12 +27,21 @@ "viewerInfoLabelDescription" ], + "id": [ + "tileLayoutMosaic" + ], + + "it": [ + "tileLayoutMosaic" + ], + "ja": [ "entryInfoActionEditTitleDescription", "filterNoDateLabel", "filterNoTitleLabel", "filterRecentlyAddedLabel", "viewDialogReverseSortOrder", + "tileLayoutMosaic", "sortOrderNewestFirst", "sortOrderOldestFirst", "sortOrderAtoZ", @@ -54,11 +56,16 @@ "viewerInfoLabelDescription" ], + "nl": [ + "tileLayoutMosaic" + ], + "pt": [ "entryInfoActionEditTitleDescription", "filterNoDateLabel", "filterNoTitleLabel", "viewDialogReverseSortOrder", + "tileLayoutMosaic", "sortOrderNewestFirst", "sortOrderOldestFirst", "sortOrderAtoZ", @@ -70,6 +77,10 @@ "searchMetadataSectionTitle" ], + "ru": [ + "tileLayoutMosaic" + ], + "tr": [ "slideshowActionResume", "slideshowActionShowInCollection", @@ -90,6 +101,7 @@ "wallpaperTargetHomeLock", "menuActionSlideshow", "viewDialogReverseSortOrder", + "tileLayoutMosaic", "sortOrderNewestFirst", "sortOrderOldestFirst", "sortOrderAtoZ", @@ -116,5 +128,9 @@ "settingsWidgetShowOutline", "viewerSetWallpaperButtonLabel", "viewerInfoLabelDescription" + ], + + "zh": [ + "tileLayoutMosaic" ] }