#67 mosaic layout
This commit is contained in:
parent
0bb21b9787
commit
279c2b0f42
49 changed files with 1922 additions and 830 deletions
|
@ -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
|
||||
|
|
|
@ -419,6 +419,7 @@
|
|||
"viewDialogLayoutSectionTitle": "Layout",
|
||||
"viewDialogReverseSortOrder": "Reverse sort order",
|
||||
|
||||
"tileLayoutMosaic": "Mosaic",
|
||||
"tileLayoutGrid": "Grid",
|
||||
"tileLayoutList": "List",
|
||||
|
||||
|
|
|
@ -289,6 +289,7 @@
|
|||
"viewDialogLayoutSectionTitle": "Vue",
|
||||
"viewDialogReverseSortOrder": "Inverser l’ordre",
|
||||
|
||||
"tileLayoutMosaic": "Mosaïque",
|
||||
"tileLayoutGrid": "Grille",
|
||||
"tileLayoutList": "Liste",
|
||||
|
||||
|
|
|
@ -289,6 +289,7 @@
|
|||
"viewDialogLayoutSectionTitle": "배치",
|
||||
"viewDialogReverseSortOrder": "순서를 뒤바꾸기",
|
||||
|
||||
"tileLayoutMosaic": "모자이크",
|
||||
"tileLayoutGrid": "바둑판",
|
||||
"tileLayoutList": "목록",
|
||||
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
62
lib/widgets/common/grid/sections/fixed/list_layout.dart
Normal file
62
lib/widgets/common/grid/sections/fixed/list_layout.dart
Normal 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];
|
||||
}
|
||||
}
|
162
lib/widgets/common/grid/sections/fixed/row.dart
Normal file
162
lib/widgets/common/grid/sections/fixed/row.dart
Normal 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));
|
||||
}
|
||||
}
|
113
lib/widgets/common/grid/sections/fixed/scale_grid.dart
Normal file
113
lib/widgets/common/grid/sections/fixed/scale_grid.dart
Normal 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;
|
||||
}
|
136
lib/widgets/common/grid/sections/fixed/scale_overlay.dart
Normal file
136
lib/widgets/common/grid/sections/fixed/scale_overlay.dart
Normal 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,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
41
lib/widgets/common/grid/sections/fixed/section_layout.dart
Normal file
41
lib/widgets/common/grid/sections/fixed/section_layout.dart
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
28
lib/widgets/common/grid/sections/list_layout.dart
Normal file
28
lib/widgets/common/grid/sections/list_layout.dart
Normal 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);
|
||||
}
|
78
lib/widgets/common/grid/sections/mosaic/list_layout.dart
Normal file
78
lib/widgets/common/grid/sections/mosaic/list_layout.dart
Normal 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];
|
||||
}
|
||||
}
|
154
lib/widgets/common/grid/sections/mosaic/row.dart
Normal file
154
lib/widgets/common/grid/sections/mosaic/row.dart
Normal 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));
|
||||
}
|
||||
}
|
74
lib/widgets/common/grid/sections/mosaic/scale_grid.dart
Normal file
74
lib/widgets/common/grid/sections/mosaic/scale_grid.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
116
lib/widgets/common/grid/sections/mosaic/scale_overlay.dart
Normal file
116
lib/widgets/common/grid/sections/mosaic/scale_overlay.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
61
lib/widgets/common/grid/sections/mosaic/section_layout.dart
Normal file
61
lib/widgets/common/grid/sections/mosaic/section_layout.dart
Normal 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;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
104
lib/widgets/common/grid/sections/provider.dart
Normal file
104
lib/widgets/common/grid/sections/provider.dart
Normal 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));
|
||||
}
|
||||
}
|
37
lib/widgets/common/grid/sections/section_layout.dart
Normal file
37
lib/widgets/common/grid/sections/section_layout.dart
Normal 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);
|
||||
}
|
99
lib/widgets/common/grid/sections/section_layout_builder.dart
Normal file
99
lib/widgets/common/grid/sections/section_layout_builder.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -172,7 +172,7 @@ class _RenameEntrySetPageState extends State<RenameEntrySetPage> {
|
|||
);
|
||||
},
|
||||
separatorBuilder: (context, index) => const SizedBox(
|
||||
height: CollectionGrid.spacing,
|
||||
height: CollectionGrid.fixedExtentLayoutSpacing,
|
||||
),
|
||||
itemCount: min(entryCount, previewMax),
|
||||
),
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
);
|
||||
},
|
||||
|
|
|
@ -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),
|
||||
);
|
||||
},
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue