#67 mosaic layout

This commit is contained in:
Thibault Deckers 2022-09-24 18:42:03 +02:00
parent 0bb21b9787
commit 279c2b0f42
49 changed files with 1922 additions and 830 deletions

View file

@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file.
## <a id="unreleased"></a>[Unreleased]
### Added
- mosaic layout
## <a id="v1.7.0"></a>[v1.7.0] - 2022-09-19
### Added

View file

@ -419,6 +419,7 @@
"viewDialogLayoutSectionTitle": "Layout",
"viewDialogReverseSortOrder": "Reverse sort order",
"tileLayoutMosaic": "Mosaic",
"tileLayoutGrid": "Grid",
"tileLayoutList": "List",

View file

@ -289,6 +289,7 @@
"viewDialogLayoutSectionTitle": "Vue",
"viewDialogReverseSortOrder": "Inverser lordre",
"tileLayoutMosaic": "Mosaïque",
"tileLayoutGrid": "Grille",
"tileLayoutList": "Liste",

View file

@ -289,6 +289,7 @@
"viewDialogLayoutSectionTitle": "배치",
"viewDialogReverseSortOrder": "순서를 뒤바꾸기",
"tileLayoutMosaic": "모자이크",
"tileLayoutGrid": "바둑판",
"tileLayoutList": "목록",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -83,6 +83,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
];
static const _layoutOptions = [
TileLayout.mosaic,
TileLayout.grid,
TileLayout.list,
];
@ -537,9 +538,9 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
builder: (context) {
return TileViewDialog<EntrySortFactor, EntryGroupFactor, TileLayout>(
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,
);

View file

@ -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<CollectionGrid> {
TileExtentController? _tileExtentController;
String get settingsRouteKey => widget.settingsRouteKey;
@override
void dispose() {
_tileExtentController?.dispose();
@ -72,14 +78,17 @@ class _CollectionGridState extends State<CollectionGrid> {
@override
Widget build(BuildContext context) {
_tileExtentController ??= TileExtentController(
settingsRouteKey: widget.settingsRouteKey,
final spacing = context.select<Settings, double>((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: CollectionGrid.spacing,
spacing: spacing,
horizontalPadding: 2,
);
}
return TileExtentControllerProvider(
controller: _tileExtentController!,
child: _CollectionGridContent(),
@ -260,12 +269,13 @@ class _CollectionScaler extends StatelessWidget {
final metrics = context.select<TileExtentController, Tuple2<double, double>>((v) => Tuple2(v.spacing, v.horizontalPadding));
final tileSpacing = metrics.item1;
final horizontalPadding = metrics.item2;
final brightness = Theme.of(context).brightness;
return GridScaleGestureDetector<AvesEntry>(
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,
);
}

View file

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

View file

@ -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<AvesEntry> {
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<AvesE
}) : super(
tileWidth: tileExtent,
tileHeight: tileExtent,
coverRatioResolver: (item) => item.displayAspectRatio,
);
@override

View file

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

View file

@ -5,6 +5,7 @@ class TextDropdownButton<T> extends DropdownButton<T> {
super.key,
required List<T> values,
required String Function(T value) valueText,
IconData Function(T value)? valueIcon,
super.value,
super.hint,
super.disabledHint,
@ -32,21 +33,51 @@ class TextDropdownButton<T> extends DropdownButton<T> {
items: values
.map((v) => DropdownMenuItem<T>(
value: v,
child: Text(valueText(v)),
child: _buildItem(valueText(v), valueIcon?.call(v), selected: false),
))
.toList(),
selectedItemBuilder: (context) => values
.map((v) => DropdownMenuItem<T>(
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<T>(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;
}
}

View file

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

View file

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

View file

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

View file

@ -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<T> 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<T> 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<T> extends State<GridScaleGestureDetector<T
OverlayEntry? _overlayEntry;
ScalerMetadata<T>? _metadata;
TileLayout get tileLayout => widget.tileLayout;
@override
Widget build(BuildContext context) {
final gestureSettings = context.select<MediaQueryData, DeviceGestureSettings>((mq) => mq.gestureSettings);
@ -108,59 +109,66 @@ class _GridScaleGestureDetectorState<T> extends State<GridScaleGestureDetector<T
// abort if we cannot find an image to show on overlay
if (renderMetaData == null) return;
_metadata = renderMetaData.metaData;
switch (tileLayout) {
case TileLayout.mosaic:
_startSize = Size.square(tileExtentController.extentNotifier.value);
break;
case TileLayout.grid:
case TileLayout.list:
_startSize = renderMetaData.size;
break;
}
_scaledSizeNotifier = ValueNotifier(_startSize!);
// not the same as `MediaQuery` metrics, because of screen insets/padding
final scrollViewWidth = scrollableBox.size.width;
final scrollViewXMin = scrollableBox.localToGlobal(Offset.zero).dx;
final scrollViewXMax = scrollableBox.localToGlobal(Offset(scrollViewWidth, 0)).dx;
final horizontalPadding = tileExtentController.horizontalPadding;
final xMin = scrollViewXMin + horizontalPadding;
final xMax = scrollViewXMax - horizontalPadding;
final scrollViewRect = scrollableBox.localToGlobal(Offset.zero) & scrollableBox.size;
final contentRect = scrollViewRect.deflate(tileExtentController.horizontalPadding);
_extentMin = tileExtentController.effectiveExtentMin;
_extentMax = tileExtentController.effectiveExtentMax;
final halfSize = _startSize! / 2;
final tileCenter = renderMetaData.localToGlobal(Offset(halfSize.width, halfSize.height));
final tileLayout = widget.tileLayout;
_overlayEntry = OverlayEntry(
builder: (context) => _ScaleOverlay(
builder: (scaledTileSize) {
late final double themeExtent;
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:
themeExtent = scaledTileSize.width;
break;
case TileLayout.list:
themeExtent = scaledTileSize.height;
break;
}
return SizedBox.fromSize(
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: themeExtent,
child: widget.scaledBuilder(_metadata!.item, scaledTileSize),
),
);
},
tileLayout: tileLayout,
center: tileCenter,
xMin: xMin,
xMax: xMax,
gridBuilder: widget.gridBuilder,
scaledSizeNotifier: _scaledSizeNotifier!,
extent: tileLayout == TileLayout.grid ? scaledTileSize.width : scaledTileSize.height,
child: widget.scaledItemBuilder(_metadata!.item, scaledTileSize),
),
),
),
);
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<T> extends State<GridScaleGestureDetector<T
final oldExtent = tileExtentController.extentNotifier.value;
// sanitize and update grid layout if necessary
late final double preferredExtent;
switch (widget.tileLayout) {
switch (tileLayout) {
case TileLayout.mosaic:
case TileLayout.grid:
preferredExtent = _scaledSizeNotifier!.value.width;
break;
@ -226,241 +235,3 @@ class _GridScaleGestureDetectorState<T> extends State<GridScaleGestureDetector<T
return null;
}
}
class _ScaleOverlay extends StatefulWidget {
final Widget Function(Size scaledTileSize) builder;
final TileLayout tileLayout;
final Offset center;
final double xMin, xMax;
final ValueNotifier<Size> 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<Size>(
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<MediaQueryData, Size>((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;
}

View file

@ -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<T> 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<SectionedListLayout<T>>(
update: (context, _) => _updateLayouts(context),
child: child,
);
}
SectionedListLayout<T> _updateLayouts(BuildContext context) {
final _showHeaders = showHeaders;
final _sections = sections;
final sectionKeys = _sections.keys.toList();
final animate = tileAnimationDelay > Duration.zero;
final sectionLayouts = <SectionLayout>[];
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<T>(
sections: _sections,
showHeaders: _showHeaders,
columnCount: columnCount,
tileWidth: tileWidth,
tileHeight: tileHeight,
spacing: spacing,
horizontalPadding: horizontalPadding,
sectionLayouts: sectionLayouts,
);
}
Widget _buildInSection(
BuildContext context,
List<T> 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 = <Widget>[];
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<DurationsData>();
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<SectionKey, List<T>> 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<bool>('showHeaders', showHeaders));
}
}
class SectionedListLayout<T> {
final Map<SectionKey, List<T>> sections;
final bool showHeaders;
final int columnCount;
final double tileWidth, tileHeight, spacing, horizontalPadding;
final List<SectionLayout> 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<SectionKey?, List<T>>? 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<Object?> 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<Widget> 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', textDirection));
}
}
class _GridRowParentData extends ContainerBoxParentData<RenderBox> {}
class _RenderGridRow extends RenderBox with ContainerRenderObjectMixin<RenderBox, _GridRowParentData>, RenderBoxContainerDefaultsMixin<RenderBox, _GridRowParentData> {
_RenderGridRow({
List<RenderBox>? 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', textDirection));
}
}

View file

@ -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<T> extends SectionedListLayout<T> {
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<SectionKey?, List<T>>? 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];
}
}

View file

@ -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<Widget> 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', textDirection));
}
}
class _GridRowParentData extends ContainerBoxParentData<RenderBox> {}
class RenderFixedExtentGridRow extends RenderBox with ContainerRenderObjectMixin<RenderBox, _GridRowParentData>, RenderBoxContainerDefaultsMixin<RenderBox, _GridRowParentData> {
RenderFixedExtentGridRow({
List<RenderBox>? 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', textDirection));
}
}

View file

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

View file

@ -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<Size> 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<Size>(
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<MediaQueryData, Size>((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,
],
),
);
}
}

View file

@ -0,0 +1,41 @@
import 'package:aves/widgets/common/grid/sections/section_layout.dart';
class FixedExtentSectionLayout extends SectionLayout {
final double tileHeight, mainAxisStride;
@override
List<Object?> 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;
}
}

View file

@ -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<T> extends SectionLayoutBuilder<T> {
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<T> 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<T>(
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<T> 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,
),
);
},
);
}
}

View file

@ -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<T> {
final Map<SectionKey, List<T>> sections;
final bool showHeaders;
final double spacing, horizontalPadding;
final List<SectionLayout> 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);
}

View file

@ -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<T> extends SectionedListLayout<T> {
const MosaicSectionedListLayout({
required super.sections,
required super.showHeaders,
required super.spacing,
required super.horizontalPadding,
required super.sectionLayouts,
});
List<MosaicRowLayout> _rowsFor(SectionLayout sectionLayout) => (sectionLayout as MosaicSectionLayout).rows;
@override
Rect? getTileRect(T item) {
final MapEntry<SectionKey?, List<T>>? 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];
}
}

View file

@ -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<Widget> 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<MosaicRowLayout>('rowLayout', rowLayout));
properties.add(DoubleProperty('spacing', spacing));
properties.add(EnumProperty<TextDirection>('textDirection', textDirection));
}
}
class _GridRowParentData extends ContainerBoxParentData<RenderBox> {}
class RenderMosaicGridRow extends RenderBox with ContainerRenderObjectMixin<RenderBox, _GridRowParentData>, RenderBoxContainerDefaultsMixin<RenderBox, _GridRowParentData> {
RenderMosaicGridRow({
List<RenderBox>? 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<MosaicRowLayout>('rowLayout', rowLayout));
properties.add(DoubleProperty('spacing', spacing));
properties.add(EnumProperty<TextDirection>('textDirection', textDirection));
}
}

View file

@ -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 = <double>[
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 = <Widget>[];
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,
);
}
}

View file

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

View file

@ -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<MosaicRowLayout> rows;
@override
List<Object?> 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<double> itemWidths;
@override
List<Object?> 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;
}

View file

@ -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<T> extends SectionLayoutBuilder<T> {
int _currentIndex = 0;
double _currentOffset = 0;
late double Function(int itemCount) rowAvailableWidth;
late double rowHeightMax;
final CoverRatioResolver<T> 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<T> 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<T>(
sections: sections,
showHeaders: showHeaders,
spacing: spacing,
horizontalPadding: horizontalPadding,
sectionLayouts: sectionLayouts,
);
}
@override
SectionLayout buildSectionLayout({
required double headerExtent,
required SectionKey sectionKey,
required List<T> 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<MosaicRowLayout> computeMosaicRows<T>({
required List<T> section,
required double Function(int itemCount) availableWidthFor,
required double heightMax,
required double targetExtent,
required double spacing,
required double bottom,
required CoverRatioResolver<T> coverRatioResolver,
}) {
final rows = <MosaicRowLayout>[];
final items = <T>[];
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;
}
}

View file

@ -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<T> = double Function(T item);
abstract class SectionedListLayoutProvider<T> 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<T> 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<SectionedListLayout<T>>(
update: (context, _) {
switch (tileLayout) {
case TileLayout.mosaic:
return MosaicSectionLayoutBuilder<T>(
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<T>(
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<SectionKey, List<T>> 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', 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<bool>('showHeaders', showHeaders));
}
}

View file

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

View file

@ -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<T> {
final Map<SectionKey, List<T>> 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<T> updateLayouts(BuildContext context);
SectionLayout buildSectionLayout({
required double headerExtent,
required SectionKey sectionKey,
required List<T> section,
required bool animate,
});
Widget buildSectionWidget({
required BuildContext context,
required List<T> section,
required int sectionGridIndex,
required int sectionChildIndex,
required Tuple2<int, int> Function() itemIndexRange,
required SectionKey sectionKey,
required double headerExtent,
required bool animate,
required Widget Function(List<Widget> 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 = <Widget>[];
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<DurationsData>();
return AnimationConfiguration.staggeredGrid(
position: index,
columnCount: columnCount,
duration: durations.staggeredAnimation,
delay: tileAnimationDelay,
child: SlideAnimation(
verticalOffset: 50.0,
child: FadeInAnimation(
child: child,
),
),
);
}
}

View file

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

View file

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

View file

@ -9,7 +9,7 @@ class DecoratedThumbnail extends StatelessWidget {
final AvesEntry entry;
final double tileExtent;
final ValueNotifier<bool>? 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,
);
}

View file

@ -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<bool>? 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<ThumbnailImage> 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<ThumbnailImage> {
@ -52,6 +61,8 @@ class _ThumbnailImageState extends State<ThumbnailImage> {
double get extent => widget.extent;
bool get isMosaic => widget.isMosaic;
@override
void initState() {
super.initState();
@ -180,13 +191,7 @@ class _ThumbnailImageState extends State<ThumbnailImage> {
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<ThumbnailImage> {
// 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<Settings, EntryBackground>(
selector: (context, s) => s.imageBackground,
@ -240,8 +253,8 @@ class _ThumbnailImageState extends State<ThumbnailImage> {
return RawImage(
image: imageInfo.image,
debugImageLabel: imageInfo.debugLabel,
width: extent,
height: extent,
width: thumbnailWidth,
height: thumbnailHeight,
scale: imageInfo.scale,
color: backgroundColor,
colorBlendMode: BlendMode.dstOver,

View file

@ -172,7 +172,7 @@ class _RenameEntrySetPageState extends State<RenameEntrySetPage> {
);
},
separatorBuilder: (context, index) => const SizedBox(
height: CollectionGrid.spacing,
height: CollectionGrid.fixedExtentLayoutSpacing,
),
itemCount: min(entryCount, previewMax),
),

View file

@ -12,18 +12,18 @@ import 'aves_dialog.dart';
class TileViewDialog<S, G, L> extends StatefulWidget {
final Tuple4<S?, G?, L?, bool> initialValue;
final Map<S, String> sortOptions;
final Map<G, String> groupOptions;
final Map<L, String> layoutOptions;
final List<TileViewDialogOption<S>> sortOptions;
final List<TileViewDialogOption<G>> groupOptions;
final List<TileViewDialogOption<L>> 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<S, G, L> extends State<TileViewDialog<S, G, L>> with
late L? _selectedLayout;
late bool _reverseSort;
Map<S, String> get sortOptions => widget.sortOptions;
List<TileViewDialogOption<S>> get sortOptions => widget.sortOptions;
Map<G, String> get groupOptions => widget.groupOptions;
List<TileViewDialogOption<G>> get groupOptions => widget.groupOptions;
Map<L, String> get layoutOptions => widget.layoutOptions;
List<TileViewDialogOption<L>> get layoutOptions => widget.layoutOptions;
bool get canGroup => (widget.canGroup ?? (s, g, l) => true).call(_selectedSort, _selectedGroup, _selectedLayout);
@ -131,7 +131,7 @@ class _TileViewDialogState<S, G, L> extends State<TileViewDialog<S, G, L>> with
required IconData icon,
required String title,
Widget? trailing,
required Map<T, String> options,
required List<TileViewDialogOption<T>> options,
required T value,
required ValueChanged<T?> onChanged,
Widget? bottom,
@ -171,8 +171,9 @@ class _TileViewDialogState<S, G, L> extends State<TileViewDialog<S, G, L>> with
Padding(
padding: EdgeInsetsDirectional.only(start: iconSize + 16, end: 12),
child: TextDropdownButton<T>(
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<S, G, L> extends State<TileViewDialog<S, G, L>> with
);
}
}
@immutable
class TileViewDialogOption<T> {
final T value;
final String title;
final IconData icon;
const TileViewDialogOption({
required this.value,
required this.title,
required this.icon,
});
}

View file

@ -149,9 +149,9 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate<AlbumFilter> with
builder: (context) {
return TileViewDialog<ChipSortFactor, AlbumChipGroupFactor, TileLayout>(
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),
);
},

View file

@ -54,6 +54,7 @@ abstract class ChipSetActionDelegate<T extends CollectionFilter> with FeedbackMi
];
static const layoutOptions = [
TileLayout.mosaic,
TileLayout.grid,
TileLayout.list,
];
@ -222,8 +223,8 @@ abstract class ChipSetActionDelegate<T extends CollectionFilter> with FeedbackMi
builder: (context) {
return TileViewDialog<ChipSortFactor, void, TileLayout>(
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),
);
},

View file

@ -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<T extends CollectionFilter> 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<T extends CollectionFilter> 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<T extends CollectionFilter> extends StatelessWidget {
}
Widget _buildChip(BuildContext context, CollectionSource source) {
final entry = coverEntry ?? source.coverEntry(filter);
final entry = source.coverEntry(filter);
final titlePadding = min<double>(4.0, extent / 32);
Key? chipKey;
if (filter is AlbumFilter) {

View file

@ -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<T extends CollectionFilter> extends StatelessWidget {
@override
Widget build(BuildContext context) {
final source = context.read<CollectionSource>();
final settingsRouteKey = context.read<TileExtentController>().settingsRouteKey;
final tileLayout = context.select<Settings, TileLayout>((s) => s.getTileLayout(settingsRouteKey));
return Selector<Query, bool>(
@ -284,7 +289,7 @@ class _FilterGridContent<T extends CollectionFilter> 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<T extends CollectionFilter> 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<T extends CollectionFilter> extends StatelessWidget {
final metrics = context.select<TileExtentController, Tuple2<double, double>>((v) => Tuple2(v.spacing, v.horizontalPadding));
final tileSpacing = metrics.item1;
final horizontalPadding = metrics.item2;
final brightness = Theme.of(context).brightness;
return GridScaleGestureDetector<FilterGridItem<T>>(
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<T extends CollectionFilter> 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<T extends CollectionFilter> 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<AvesColorsData>().neutral,
width: AvesFilterChip.outlineWidth,
),
borderRadius: BorderRadius.all(CoveredFilterChip.radius(targetExtent)),
),
),
highlightItem: (item) => item.filter,
child: child,
);

View file

@ -139,6 +139,7 @@ class FilterTile<T extends CollectionFilter> extends StatelessWidget {
final onChipTap = onTap != null ? (filter) => onTap?.call() : null;
switch (tileLayout) {
case TileLayout.mosaic:
case TileLayout.grid:
return FilterChipGridDecorator<T, FilterGridItem<T>>(
gridItem: gridItem,

View file

@ -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<T extends CollectionFilter> extends Sect
required super.tileHeight,
required super.tileBuilder,
required super.tileAnimationDelay,
required super.coverRatioResolver,
required super.child,
});

View file

@ -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"
]
}