album grid prep

This commit is contained in:
Thibault Deckers 2021-01-13 14:18:30 +09:00
parent 229b2e7b2b
commit f952deff15
18 changed files with 339 additions and 226 deletions

View file

@ -5,6 +5,7 @@ import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/section_keys.dart';
import 'package:aves/model/source/tag.dart'; import 'package:aves/model/source/tag.dart';
import 'package:aves/utils/change_notifier.dart'; import 'package:aves/utils/change_notifier.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
@ -22,7 +23,7 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
List<ImageEntry> _filteredEntries; List<ImageEntry> _filteredEntries;
List<StreamSubscription> _subscriptions = []; List<StreamSubscription> _subscriptions = [];
Map<dynamic, List<ImageEntry>> sections = Map.unmodifiable({}); Map<SectionKey, List<ImageEntry>> sections = Map.unmodifiable({});
CollectionLens({ CollectionLens({
@required this.source, @required this.source,
@ -138,13 +139,13 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
case EntrySortFactor.date: case EntrySortFactor.date:
switch (groupFactor) { switch (groupFactor) {
case EntryGroupFactor.album: case EntryGroupFactor.album:
sections = groupBy<ImageEntry, String>(_filteredEntries, (entry) => entry.directory); sections = groupBy<ImageEntry, AlbumSectionKey>(_filteredEntries, (entry) => AlbumSectionKey(entry.directory));
break; break;
case EntryGroupFactor.month: case EntryGroupFactor.month:
sections = groupBy<ImageEntry, DateTime>(_filteredEntries, (entry) => entry.monthTaken); sections = groupBy<ImageEntry, DateSectionKey>(_filteredEntries, (entry) => DateSectionKey(entry.monthTaken));
break; break;
case EntryGroupFactor.day: case EntryGroupFactor.day:
sections = groupBy<ImageEntry, DateTime>(_filteredEntries, (entry) => entry.dayTaken); sections = groupBy<ImageEntry, DateSectionKey>(_filteredEntries, (entry) => DateSectionKey(entry.dayTaken));
break; break;
case EntryGroupFactor.none: case EntryGroupFactor.none:
sections = Map.fromEntries([ sections = Map.fromEntries([
@ -159,8 +160,8 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
]); ]);
break; break;
case EntrySortFactor.name: case EntrySortFactor.name:
final byAlbum = groupBy<ImageEntry, String>(_filteredEntries, (entry) => entry.directory); final byAlbum = groupBy<ImageEntry, AlbumSectionKey>(_filteredEntries, (entry) => AlbumSectionKey(entry.directory));
sections = SplayTreeMap<String, List<ImageEntry>>.of(byAlbum, source.compareAlbumsByName); sections = SplayTreeMap<AlbumSectionKey, List<ImageEntry>>.of(byAlbum, (a, b) => source.compareAlbumsByName(a.folderPath, b.folderPath));
break; break;
} }
sections = Map.unmodifiable(sections); sections = Map.unmodifiable(sections);

View file

@ -0,0 +1,41 @@
import 'package:flutter/foundation.dart';
class SectionKey {
const SectionKey();
}
class AlbumSectionKey extends SectionKey {
final String folderPath;
const AlbumSectionKey(this.folderPath);
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is AlbumSectionKey && other.folderPath == folderPath;
}
@override
int get hashCode => folderPath.hashCode;
@override
String toString() => '$runtimeType#${shortHash(this)}{folderPath=$folderPath}';
}
class DateSectionKey extends SectionKey {
final DateTime date;
const DateSectionKey(this.date);
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is DateSectionKey && other.date == date;
}
@override
int get hashCode => date.hashCode;
@override
String toString() => '$runtimeType#${shortHash(this)}{date=$date}';
}

View file

@ -1,17 +1,20 @@
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/widgets/collection/grid/header_generic.dart'; import 'package:aves/widgets/common/grid/header.dart';
import 'package:aves/model/source/section_keys.dart';
import 'package:aves/widgets/common/identity/aves_icons.dart'; import 'package:aves/widgets/common/identity/aves_icons.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class AlbumSectionHeader extends StatelessWidget { class AlbumSectionHeader extends StatelessWidget {
final String folderPath, albumName; final String folderPath, albumName;
const AlbumSectionHeader({ AlbumSectionHeader({
Key key, Key key,
@required CollectionSource source,
@required this.folderPath, @required this.folderPath,
@required this.albumName, }) : albumName = source.getUniqueAlbumName(folderPath),
}) : super(key: key); super(key: key);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -25,8 +28,8 @@ class AlbumSectionHeader extends StatelessWidget {
child: albumIcon, child: albumIcon,
); );
} }
return TitleSectionHeader( return SectionHeader(
sectionKey: folderPath, sectionKey: AlbumSectionKey(folderPath),
leading: albumIcon, leading: albumIcon,
title: albumName, title: albumName,
trailing: androidFileUtils.isOnRemovableStorage(folderPath) trailing: androidFileUtils.isOnRemovableStorage(folderPath)
@ -38,4 +41,15 @@ class AlbumSectionHeader extends StatelessWidget {
: null, : null,
); );
} }
static double getPreferredHeight(BuildContext context, double maxWidth, CollectionSource source, AlbumSectionKey sectionKey) {
final folderPath = sectionKey.folderPath;
return SectionHeader.getPreferredHeight(
context: context,
maxWidth: maxWidth,
title: source.getUniqueAlbumName(folderPath),
hasLeading: androidFileUtils.getAlbumType(folderPath) != AlbumType.regular,
hasTrailing: androidFileUtils.isOnRemovableStorage(folderPath),
);
}
} }

View file

@ -0,0 +1,74 @@
import 'dart:math';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/enums.dart';
import 'package:aves/model/source/section_keys.dart';
import 'package:aves/widgets/collection/grid/headers/album.dart';
import 'package:aves/widgets/collection/grid/headers/date.dart';
import 'package:aves/widgets/common/grid/header.dart';
import 'package:flutter/material.dart';
class CollectionSectionHeader extends StatelessWidget {
final CollectionLens collection;
final SectionKey sectionKey;
final double height;
const CollectionSectionHeader({
Key key,
@required this.collection,
@required this.sectionKey,
@required this.height,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final header = _buildHeader();
return header != null
? SizedBox(
height: height,
child: header,
)
: SizedBox.shrink();
}
Widget _buildHeader() {
Widget _buildAlbumHeader() => AlbumSectionHeader(
key: ValueKey(sectionKey),
source: collection.source,
folderPath: (sectionKey as AlbumSectionKey).folderPath,
);
switch (collection.sortFactor) {
case EntrySortFactor.date:
switch (collection.groupFactor) {
case EntryGroupFactor.album:
return _buildAlbumHeader();
case EntryGroupFactor.month:
return MonthSectionHeader(key: ValueKey(sectionKey), date: (sectionKey as DateSectionKey).date);
case EntryGroupFactor.day:
return DaySectionHeader(key: ValueKey(sectionKey), date: (sectionKey as DateSectionKey).date);
case EntryGroupFactor.none:
break;
}
break;
case EntrySortFactor.name:
return _buildAlbumHeader();
case EntrySortFactor.size:
break;
}
return null;
}
static double getPreferredHeight(BuildContext context, double maxWidth, CollectionSource source, SectionKey sectionKey) {
var headerExtent = 0.0;
if (sectionKey is AlbumSectionKey) {
// only compute height for album headers, as they're the only likely ones to split on multiple lines
headerExtent = AlbumSectionHeader.getPreferredHeight(context, maxWidth, source, sectionKey);
}
final textScaleFactor = MediaQuery.textScaleFactorOf(context);
headerExtent = max(headerExtent, SectionHeader.leadingDimension * textScaleFactor) + SectionHeader.padding.vertical;
return headerExtent;
}
}

View file

@ -1,5 +1,6 @@
import 'package:aves/model/source/section_keys.dart';
import 'package:aves/utils/time_utils.dart'; import 'package:aves/utils/time_utils.dart';
import 'package:aves/widgets/collection/grid/header_generic.dart'; import 'package:aves/widgets/common/grid/header.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
@ -35,8 +36,8 @@ class DaySectionHeader extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return TitleSectionHeader( return SectionHeader(
sectionKey: date, sectionKey: DateSectionKey(date),
title: text, title: text,
); );
} }
@ -64,8 +65,8 @@ class MonthSectionHeader extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return TitleSectionHeader( return SectionHeader(
sectionKey: date, sectionKey: DateSectionKey(date),
title: text, title: text,
); );
} }

View file

@ -0,0 +1,46 @@
import 'package:aves/model/image_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:flutter/foundation.dart';
import 'package:flutter/material.dart';
class SectionedEntryListLayoutProvider extends SectionedListLayoutProvider<ImageEntry> {
final CollectionLens collection;
const SectionedEntryListLayoutProvider({
@required this.collection,
@required double scrollableWidth,
@required int columnCount,
@required double tileExtent,
@required Widget Function(ImageEntry entry) tileBuilder,
@required Widget child,
}) : super(
scrollableWidth: scrollableWidth,
columnCount: columnCount,
tileExtent: tileExtent,
tileBuilder: tileBuilder,
child: child,
);
@override
bool needHeaders() => collection.showHeaders;
@override
Map<SectionKey, List<ImageEntry>> getSections() => collection.sections;
@override
double getHeaderExtent(BuildContext context, SectionKey sectionKey) {
return CollectionSectionHeader.getPreferredHeight(context, scrollableWidth, collection.source, sectionKey);
}
@override
Widget buildHeader(BuildContext context, SectionKey sectionKey, double headerExtent) {
return CollectionSectionHeader(
collection: collection,
sectionKey: sectionKey,
height: headerExtent,
);
}
}

View file

@ -4,7 +4,7 @@ import 'dart:math';
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/utils/math_utils.dart'; import 'package:aves/utils/math_utils.dart';
import 'package:aves/widgets/collection/grid/list_section_layout.dart'; import 'package:aves/widgets/common/grid/section_layout.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -135,7 +135,8 @@ class _GridSelectionGestureDetectorState extends State<GridSelectionGestureDetec
// but when it is scrolling (through controller animation), result is incomplete and children are missing, // but when it is scrolling (through controller animation), result is incomplete and children are missing,
// so we use custom layout computation instead to find the entry. // so we use custom layout computation instead to find the entry.
final offset = Offset(0, scrollController.offset - appBarHeight) + localPosition; final offset = Offset(0, scrollController.offset - appBarHeight) + localPosition;
return context.read<SectionedListLayout>().getEntryAt(offset); final sectionedListLayout = context.read<SectionedListLayout<ImageEntry>>();
return sectionedListLayout.getEntryAt(offset);
} }
void _toggleSelectionToIndex(int toIndex) { void _toggleSelectionToIndex(int toIndex) {

View file

@ -2,47 +2,19 @@ import 'package:aves/main.dart';
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/services/viewer_service.dart'; import 'package:aves/services/viewer_service.dart';
import 'package:aves/widgets/collection/grid/list_known_extent.dart';
import 'package:aves/widgets/collection/grid/list_section_layout.dart';
import 'package:aves/widgets/collection/thumbnail/decorated.dart'; import 'package:aves/widgets/collection/thumbnail/decorated.dart';
import 'package:aves/widgets/common/behaviour/routes.dart'; import 'package:aves/widgets/common/behaviour/routes.dart';
import 'package:aves/widgets/common/scaling.dart'; import 'package:aves/widgets/common/scaling.dart';
import 'package:aves/widgets/viewer/entry_viewer_page.dart'; import 'package:aves/widgets/viewer/entry_viewer_page.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
// Use a `SliverList` instead of multiple `SliverGrid` because having one `SliverGrid` per section does not scale up. class InteractiveThumbnail extends StatelessWidget {
// With the multiple `SliverGrid` solution, thumbnails at the beginning of each sections are built even though they are offscreen
// because of `RenderSliverMultiBoxAdaptor.addInitialChild` called by `RenderSliverGrid.performLayout` (line 547), as of Flutter v1.17.0.
class CollectionListSliver extends StatelessWidget {
const CollectionListSliver();
@override
Widget build(BuildContext context) {
final sectionLayouts = Provider.of<SectionedListLayout>(context).sectionLayouts;
final childCount = sectionLayouts.isEmpty ? 0 : sectionLayouts.last.lastIndex + 1;
return SliverKnownExtentList(
sectionLayouts: sectionLayouts,
delegate: SliverChildBuilderDelegate(
(context, index) {
if (index >= childCount) return null;
final sectionLayout = sectionLayouts.firstWhere((section) => section.hasChild(index), orElse: () => null);
return sectionLayout?.builder(context, index) ?? SizedBox.shrink();
},
childCount: childCount,
addAutomaticKeepAlives: false,
),
);
}
}
class GridThumbnail extends StatelessWidget {
final CollectionLens collection; final CollectionLens collection;
final ImageEntry entry; final ImageEntry entry;
final double tileExtent; final double tileExtent;
final ValueNotifier<bool> isScrollingNotifier; final ValueNotifier<bool> isScrollingNotifier;
const GridThumbnail({ const InteractiveThumbnail({
Key key, Key key,
this.collection, this.collection,
@required this.entry, @required this.entry,

View file

@ -30,12 +30,12 @@ class DecoratedThumbnail extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var child = entry.isSvg var child = entry.isSvg
? ThumbnailVectorImage( ? VectorImageThumbnail(
entry: entry, entry: entry,
extent: extent, extent: extent,
heroTag: heroTag, heroTag: heroTag,
) )
: ThumbnailRasterImage( : RasterImageThumbnail(
entry: entry, entry: entry,
extent: extent, extent: extent,
isScrollingNotifier: isScrollingNotifier, isScrollingNotifier: isScrollingNotifier,

View file

@ -8,14 +8,14 @@ import 'package:aves/widgets/collection/thumbnail/error.dart';
import 'package:aves/widgets/common/fx/transition_image.dart'; import 'package:aves/widgets/common/fx/transition_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class ThumbnailRasterImage extends StatefulWidget { class RasterImageThumbnail extends StatefulWidget {
final ImageEntry entry; final ImageEntry entry;
final double extent; final double extent;
final int page; final int page;
final ValueNotifier<bool> isScrollingNotifier; final ValueNotifier<bool> isScrollingNotifier;
final Object heroTag; final Object heroTag;
const ThumbnailRasterImage({ const RasterImageThumbnail({
Key key, Key key,
@required this.entry, @required this.entry,
@required this.extent, @required this.extent,
@ -25,10 +25,10 @@ class ThumbnailRasterImage extends StatefulWidget {
}) : super(key: key); }) : super(key: key);
@override @override
_ThumbnailRasterImageState createState() => _ThumbnailRasterImageState(); _RasterImageThumbnailState createState() => _RasterImageThumbnailState();
} }
class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> { class _RasterImageThumbnailState extends State<RasterImageThumbnail> {
ThumbnailProvider _fastThumbnailProvider, _sizedThumbnailProvider; ThumbnailProvider _fastThumbnailProvider, _sizedThumbnailProvider;
ImageEntry get entry => widget.entry; ImageEntry get entry => widget.entry;
@ -51,7 +51,7 @@ class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
} }
@override @override
void didUpdateWidget(covariant ThumbnailRasterImage oldWidget) { void didUpdateWidget(covariant RasterImageThumbnail oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (oldWidget.entry != entry) { if (oldWidget.entry != entry) {
_unregisterWidget(oldWidget); _unregisterWidget(oldWidget);
@ -65,12 +65,12 @@ class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
super.dispose(); super.dispose();
} }
void _registerWidget(ThumbnailRasterImage widget) { void _registerWidget(RasterImageThumbnail widget) {
widget.entry.imageChangeNotifier.addListener(_onImageChanged); widget.entry.imageChangeNotifier.addListener(_onImageChanged);
_initProvider(); _initProvider();
} }
void _unregisterWidget(ThumbnailRasterImage widget) { void _unregisterWidget(RasterImageThumbnail widget) {
widget.entry.imageChangeNotifier.removeListener(_onImageChanged); widget.entry.imageChangeNotifier.removeListener(_onImageChanged);
_pauseProvider(); _pauseProvider();
} }

View file

@ -7,12 +7,12 @@ import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_svg/flutter_svg.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class ThumbnailVectorImage extends StatelessWidget { class VectorImageThumbnail extends StatelessWidget {
final ImageEntry entry; final ImageEntry entry;
final double extent; final double extent;
final Object heroTag; final Object heroTag;
const ThumbnailVectorImage({ const VectorImageThumbnail({
Key key, Key key,
@required this.entry, @required this.entry,
@required this.extent, @required this.extent,

View file

@ -12,12 +12,14 @@ import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/collection/app_bar.dart'; import 'package:aves/widgets/collection/app_bar.dart';
import 'package:aves/widgets/collection/empty.dart'; import 'package:aves/widgets/collection/empty.dart';
import 'package:aves/widgets/collection/grid/list_section_layout.dart'; import 'package:aves/widgets/collection/grid/section_layout.dart';
import 'package:aves/widgets/collection/grid/list_sliver.dart';
import 'package:aves/widgets/collection/grid/selector.dart'; import 'package:aves/widgets/collection/grid/selector.dart';
import 'package:aves/widgets/collection/grid/thumbnail.dart';
import 'package:aves/widgets/collection/thumbnail/decorated.dart'; import 'package:aves/widgets/collection/thumbnail/decorated.dart';
import 'package:aves/widgets/common/behaviour/routes.dart'; import 'package:aves/widgets/common/behaviour/routes.dart';
import 'package:aves/widgets/common/behaviour/sloppy_scroll_physics.dart'; import 'package:aves/widgets/common/behaviour/sloppy_scroll_physics.dart';
import 'package:aves/widgets/common/grid/section_layout.dart';
import 'package:aves/widgets/common/grid/sliver.dart';
import 'package:aves/widgets/common/identity/scroll_thumb.dart'; import 'package:aves/widgets/common/identity/scroll_thumb.dart';
import 'package:aves/widgets/common/providers/highlight_info_provider.dart'; import 'package:aves/widgets/common/providers/highlight_info_provider.dart';
import 'package:aves/widgets/common/scaling.dart'; import 'package:aves/widgets/common/scaling.dart';
@ -98,7 +100,7 @@ class ThumbnailCollection extends StatelessWidget {
highlightable: false, highlightable: false,
), ),
getScaledItemTileRect: (context, entry) { getScaledItemTileRect: (context, entry) {
final sectionedListLayout = Provider.of<SectionedListLayout>(context, listen: false); final sectionedListLayout = context.read<SectionedListLayout<ImageEntry>>();
return sectionedListLayout.getTileRect(entry) ?? Rect.zero; return sectionedListLayout.getTileRect(entry) ?? Rect.zero;
}, },
onScaled: (entry) => Provider.of<HighlightInfo>(context, listen: false).add(entry), onScaled: (entry) => Provider.of<HighlightInfo>(context, listen: false).add(entry),
@ -115,12 +117,12 @@ class ThumbnailCollection extends StatelessWidget {
final sectionedListLayoutProvider = ValueListenableBuilder<double>( final sectionedListLayoutProvider = ValueListenableBuilder<double>(
valueListenable: _tileExtentNotifier, valueListenable: _tileExtentNotifier,
builder: (context, tileExtent, child) => SectionedListLayoutProvider( builder: (context, tileExtent, child) => SectionedEntryListLayoutProvider(
collection: collection, collection: collection,
scrollableWidth: viewportSize.width, scrollableWidth: viewportSize.width,
tileExtent: tileExtent, tileExtent: tileExtent,
columnCount: tileExtentManager.getEffectiveColumnCountForExtent(viewportSize, tileExtent), columnCount: tileExtentManager.getEffectiveColumnCountForExtent(viewportSize, tileExtent),
thumbnailBuilder: (entry) => GridThumbnail( tileBuilder: (entry) => InteractiveThumbnail(
key: ValueKey(entry.contentId), key: ValueKey(entry.contentId),
collection: collection, collection: collection,
entry: entry, entry: entry,
@ -215,7 +217,7 @@ class _CollectionScrollViewState extends State<CollectionScrollView> {
child: _buildEmptyCollectionPlaceholder(collection), child: _buildEmptyCollectionPlaceholder(collection),
hasScrollBody: false, hasScrollBody: false,
) )
: CollectionListSliver(), : SectionedListSliver<ImageEntry>(),
SliverToBoxAdapter( SliverToBoxAdapter(
child: Selector<MediaQueryData, double>( child: Selector<MediaQueryData, double>(
selector: (context, mq) => mq.viewInsets.bottom, selector: (context, mq) => mq.viewInsets.bottom,

View file

@ -1,115 +1,19 @@
import 'dart:math';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/enums.dart'; import 'package:aves/model/source/enums.dart';
import 'package:aves/model/source/section_keys.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/utils/constants.dart'; import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/collection/grid/header_album.dart';
import 'package:aves/widgets/collection/grid/header_date.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class SectionHeader extends StatelessWidget { class SectionHeader extends StatelessWidget {
final CollectionLens collection; final SectionKey sectionKey;
final dynamic sectionKey;
final double height;
const SectionHeader({
Key key,
@required this.collection,
@required this.sectionKey,
@required this.height,
}) : super(key: key);
@override
Widget build(BuildContext context) {
Widget header;
switch (collection.sortFactor) {
case EntrySortFactor.date:
switch (collection.groupFactor) {
case EntryGroupFactor.album:
header = _buildAlbumSectionHeader();
break;
case EntryGroupFactor.month:
header = MonthSectionHeader(key: ValueKey(sectionKey), date: sectionKey as DateTime);
break;
case EntryGroupFactor.day:
header = DaySectionHeader(key: ValueKey(sectionKey), date: sectionKey as DateTime);
break;
case EntryGroupFactor.none:
break;
}
break;
case EntrySortFactor.size:
break;
case EntrySortFactor.name:
header = _buildAlbumSectionHeader();
break;
}
return header != null
? SizedBox(
height: height,
child: header,
)
: SizedBox.shrink();
}
Widget _buildAlbumSectionHeader() {
final folderPath = sectionKey as String;
return AlbumSectionHeader(
key: ValueKey(folderPath),
folderPath: folderPath,
albumName: collection.source.getUniqueAlbumName(folderPath),
);
}
// TODO TLAD cache header extent computation?
static double computeHeaderHeight(BuildContext context, CollectionSource source, dynamic sectionKey, double scrollableWidth) {
var headerExtent = 0.0;
final textScaleFactor = MediaQuery.textScaleFactorOf(context);
if (sectionKey is String) {
// only compute height for album headers, as they're the only likely ones to split on multiple lines
final hasLeading = androidFileUtils.getAlbumType(sectionKey) != AlbumType.regular;
final hasTrailing = androidFileUtils.isOnRemovableStorage(sectionKey);
final text = source.getUniqueAlbumName(sectionKey);
final maxWidth = scrollableWidth - TitleSectionHeader.padding.horizontal;
final para = RenderParagraph(
TextSpan(
children: [
// as of Flutter v1.22.3, `RenderParagraph` fails to lay out `WidgetSpan` offscreen
// so we use a hair space times a magic number to match width
TextSpan(
text: '\u200A' * (hasLeading ? 23 : 1),
// force a higher first line to match leading icon/selector dimension
style: TextStyle(height: 2.3 * textScaleFactor),
), // 23 hair spaces match a width of 40.0
if (hasTrailing) TextSpan(text: '\u200A' * 17),
TextSpan(
text: text,
style: Constants.titleTextStyle,
),
],
),
textDirection: TextDirection.ltr,
textScaleFactor: textScaleFactor,
)..layout(BoxConstraints(maxWidth: maxWidth), parentUsesSize: true);
headerExtent = para.getMaxIntrinsicHeight(maxWidth);
}
headerExtent = max(headerExtent, TitleSectionHeader.leadingDimension * textScaleFactor) + TitleSectionHeader.padding.vertical;
return headerExtent;
}
}
class TitleSectionHeader extends StatelessWidget {
final dynamic sectionKey;
final Widget leading, trailing; final Widget leading, trailing;
final String title; final String title;
const TitleSectionHeader({ const SectionHeader({
Key key, Key key,
@required this.sectionKey, @required this.sectionKey,
this.leading, this.leading,
@ -136,7 +40,7 @@ class TitleSectionHeader extends StatelessWidget {
children: [ children: [
WidgetSpan( WidgetSpan(
alignment: widgetSpanAlignment, alignment: widgetSpanAlignment,
child: SectionSelectableLeading( child: _SectionSelectableLeading(
sectionKey: sectionKey, sectionKey: sectionKey,
browsingBuilder: leading != null browsingBuilder: leading != null
? (context) => Container( ? (context) => Container(
@ -178,21 +82,54 @@ class TitleSectionHeader extends StatelessWidget {
collection.addToSelection(sectionEntries); collection.addToSelection(sectionEntries);
} }
} }
// TODO TLAD cache header extent computation?
static double getPreferredHeight({
@required BuildContext context,
@required double maxWidth,
@required String title,
bool hasLeading = false,
bool hasTrailing = false,
}) {
final textScaleFactor = MediaQuery.textScaleFactorOf(context);
final maxContentWidth = maxWidth - SectionHeader.padding.horizontal;
final para = RenderParagraph(
TextSpan(
children: [
// as of Flutter v1.22.3, `RenderParagraph` fails to lay out `WidgetSpan` offscreen
// so we use a hair space times a magic number to match width
TextSpan(
text: '\u200A' * (hasLeading ? 23 : 1),
// force a higher first line to match leading icon/selector dimension
style: TextStyle(height: 2.3 * textScaleFactor),
), // 23 hair spaces match a width of 40.0
if (hasTrailing) TextSpan(text: '\u200A' * 17),
TextSpan(
text: title,
style: Constants.titleTextStyle,
),
],
),
textDirection: TextDirection.ltr,
textScaleFactor: textScaleFactor,
)..layout(BoxConstraints(maxWidth: maxContentWidth), parentUsesSize: true);
return para.getMaxIntrinsicHeight(maxContentWidth);
}
} }
class SectionSelectableLeading extends StatelessWidget { class _SectionSelectableLeading extends StatelessWidget {
final dynamic sectionKey; final SectionKey sectionKey;
final WidgetBuilder browsingBuilder; final WidgetBuilder browsingBuilder;
final VoidCallback onPressed; final VoidCallback onPressed;
const SectionSelectableLeading({ const _SectionSelectableLeading({
Key key, Key key,
@required this.sectionKey, @required this.sectionKey,
@required this.browsingBuilder, @required this.browsingBuilder,
@required this.onPressed, @required this.onPressed,
}) : super(key: key); }) : super(key: key);
static const leadingDimension = TitleSectionHeader.leadingDimension; static const leadingDimension = SectionHeader.leadingDimension;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {

View file

@ -1,49 +1,46 @@
import 'dart:math'; import 'dart:math';
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/source/section_keys.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/widgets/collection/grid/header_generic.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class SectionedListLayoutProvider extends StatelessWidget { abstract class SectionedListLayoutProvider<T> extends StatelessWidget {
final CollectionLens collection;
final int columnCount;
final double scrollableWidth; final double scrollableWidth;
final int columnCount;
final double tileExtent; final double tileExtent;
final Widget Function(ImageEntry entry) thumbnailBuilder; final Widget Function(T entry) tileBuilder;
final Widget child; final Widget child;
const SectionedListLayoutProvider({ const SectionedListLayoutProvider({
@required this.collection,
@required this.scrollableWidth, @required this.scrollableWidth,
@required this.tileExtent,
@required this.columnCount, @required this.columnCount,
@required this.thumbnailBuilder, @required this.tileExtent,
@required this.tileBuilder,
@required this.child, @required this.child,
}) : assert(scrollableWidth != 0); }) : assert(scrollableWidth != 0);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ProxyProvider0<SectionedListLayout>( return ProxyProvider0<SectionedListLayout<T>>(
update: (context, __) => _updateLayouts(context), update: (context, __) => _updateLayouts(context),
child: child, child: child,
); );
} }
SectionedListLayout _updateLayouts(BuildContext context) { SectionedListLayout<T> _updateLayouts(BuildContext context) {
final sectionLayouts = <SectionLayout>[]; final showHeaders = needHeaders();
final showHeaders = collection.showHeaders; final sections = getSections();
final source = collection.source;
final sections = collection.sections;
final sectionKeys = sections.keys.toList(); final sectionKeys = sections.keys.toList();
final sectionLayouts = <SectionLayout>[];
var currentIndex = 0, currentOffset = 0.0; var currentIndex = 0, currentOffset = 0.0;
sectionKeys.forEach((sectionKey) { sectionKeys.forEach((sectionKey) {
final sectionEntryCount = sections[sectionKey].length; final section = sections[sectionKey];
final sectionEntryCount = section.length;
final sectionChildCount = 1 + (sectionEntryCount / columnCount).ceil(); final sectionChildCount = 1 + (sectionEntryCount / columnCount).ceil();
final headerExtent = showHeaders ? SectionHeader.computeHeaderHeight(context, source, sectionKey, scrollableWidth) : 0.0; final headerExtent = showHeaders ? getHeaderExtent(context, sectionKey) : 0.0;
final sectionFirstIndex = currentIndex; final sectionFirstIndex = currentIndex;
currentIndex += sectionChildCount; currentIndex += sectionChildCount;
@ -63,29 +60,30 @@ class SectionedListLayoutProvider extends StatelessWidget {
headerExtent: headerExtent, headerExtent: headerExtent,
tileExtent: tileExtent, tileExtent: tileExtent,
builder: (context, listIndex) => _buildInSection( builder: (context, listIndex) => _buildInSection(
context,
section,
listIndex - sectionFirstIndex, listIndex - sectionFirstIndex,
collection,
sectionKey, sectionKey,
headerExtent, headerExtent,
), ),
), ),
); );
}); });
return SectionedListLayout( return SectionedListLayout<T>(
collection: collection, sections: sections,
showHeaders: showHeaders,
columnCount: columnCount, columnCount: columnCount,
tileExtent: tileExtent, tileExtent: tileExtent,
sectionLayouts: sectionLayouts, sectionLayouts: sectionLayouts,
); );
} }
Widget _buildInSection(int sectionChildIndex, CollectionLens collection, dynamic sectionKey, double headerExtent) { Widget _buildInSection(BuildContext context, List<T> section, int sectionChildIndex, SectionKey sectionKey, double headerExtent) {
if (sectionChildIndex == 0) { if (sectionChildIndex == 0) {
return headerBuilder(collection, sectionKey, headerExtent); return headerExtent > 0 ? buildHeader(context, sectionKey, headerExtent) : SizedBox.shrink();
} }
sectionChildIndex--; sectionChildIndex--;
final section = collection.sections[sectionKey];
final sectionEntryCount = section.length; final sectionEntryCount = section.length;
final minEntryIndex = sectionChildIndex * columnCount; final minEntryIndex = sectionChildIndex * columnCount;
@ -93,7 +91,7 @@ class SectionedListLayoutProvider extends StatelessWidget {
final children = <Widget>[]; final children = <Widget>[];
for (var i = minEntryIndex; i < maxEntryIndex; i++) { for (var i = minEntryIndex; i < maxEntryIndex; i++) {
final entry = section[i]; final entry = section[i];
children.add(thumbnailBuilder(entry)); children.add(tileBuilder(entry));
} }
return Row( return Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@ -101,39 +99,38 @@ class SectionedListLayoutProvider extends StatelessWidget {
); );
} }
Widget headerBuilder(CollectionLens collection, dynamic sectionKey, double headerExtent) { bool needHeaders();
return collection.showHeaders
? SectionHeader( Map<SectionKey, List<T>> getSections();
collection: collection,
sectionKey: sectionKey, double getHeaderExtent(BuildContext context, SectionKey sectionKey);
height: headerExtent,
) Widget buildHeader(BuildContext context, SectionKey sectionKey, double headerExtent);
: SizedBox.shrink();
}
} }
class SectionedListLayout { class SectionedListLayout<T> {
final CollectionLens collection; final Map<SectionKey, List<T>> sections;
final bool showHeaders;
final int columnCount; final int columnCount;
final double tileExtent; final double tileExtent;
final List<SectionLayout> sectionLayouts; final List<SectionLayout> sectionLayouts;
const SectionedListLayout({ const SectionedListLayout({
@required this.collection, @required this.sections,
@required this.showHeaders,
@required this.columnCount, @required this.columnCount,
@required this.tileExtent, @required this.tileExtent,
@required this.sectionLayouts, @required this.sectionLayouts,
}); });
Rect getTileRect(ImageEntry entry) { Rect getTileRect(T entry) {
final section = collection.sections.entries.firstWhere((kv) => kv.value.contains(entry), orElse: () => null); final section = sections.entries.firstWhere((kv) => kv.value.contains(entry), orElse: () => null);
if (section == null) return null; if (section == null) return null;
final sectionKey = section.key; final sectionKey = section.key;
final sectionLayout = sectionLayouts.firstWhere((sl) => sl.sectionKey == sectionKey, orElse: () => null); final sectionLayout = sectionLayouts.firstWhere((sl) => sl.sectionKey == sectionKey, orElse: () => null);
if (sectionLayout == null) return null; if (sectionLayout == null) return null;
final showHeaders = collection.showHeaders;
final sectionEntryIndex = section.value.indexOf(entry); final sectionEntryIndex = section.value.indexOf(entry);
final column = sectionEntryIndex % columnCount; final column = sectionEntryIndex % columnCount;
final row = (sectionEntryIndex / columnCount).floor(); final row = (sectionEntryIndex / columnCount).floor();
@ -144,12 +141,12 @@ class SectionedListLayout {
return Rect.fromLTWH(left, top, tileExtent, tileExtent); return Rect.fromLTWH(left, top, tileExtent, tileExtent);
} }
ImageEntry getEntryAt(Offset position) { T getEntryAt(Offset position) {
var dy = position.dy; var dy = position.dy;
final sectionLayout = sectionLayouts.firstWhere((sl) => dy < sl.maxOffset, orElse: () => null); final sectionLayout = sectionLayouts.firstWhere((sl) => dy < sl.maxOffset, orElse: () => null);
if (sectionLayout == null) return null; if (sectionLayout == null) return null;
final section = collection.sections[sectionLayout.sectionKey]; final section = sections[sectionLayout.sectionKey];
if (section == null) return null; if (section == null) return null;
dy -= sectionLayout.minOffset + sectionLayout.headerExtent; dy -= sectionLayout.minOffset + sectionLayout.headerExtent;
@ -165,7 +162,7 @@ class SectionedListLayout {
} }
class SectionLayout { class SectionLayout {
final dynamic sectionKey; final SectionKey sectionKey;
final int firstIndex, lastIndex; final int firstIndex, lastIndex;
final double minOffset, maxOffset; final double minOffset, maxOffset;
final double headerExtent, tileExtent; final double headerExtent, tileExtent;

View file

@ -1,32 +1,59 @@
import 'dart:math' as math; import 'dart:math' as math;
import 'package:aves/widgets/collection/grid/list_section_layout.dart'; import 'package:aves/widgets/common/grid/section_layout.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:provider/provider.dart';
class SliverKnownExtentList extends SliverMultiBoxAdaptorWidget { // Use a `SliverList` instead of multiple `SliverGrid` because having one `SliverGrid` per section does not scale up.
// With the multiple `SliverGrid` solution, thumbnails at the beginning of each sections are built even though they are offscreen
// because of `RenderSliverMultiBoxAdaptor.addInitialChild` called by `RenderSliverGrid.performLayout` (line 547), as of Flutter v1.17.0.
class SectionedListSliver<T> extends StatelessWidget {
const SectionedListSliver();
@override
Widget build(BuildContext context) {
final sectionLayouts = context.watch<SectionedListLayout<T>>().sectionLayouts;
final childCount = sectionLayouts.isEmpty ? 0 : sectionLayouts.last.lastIndex + 1;
return _SliverKnownExtentList(
sectionLayouts: sectionLayouts,
delegate: SliverChildBuilderDelegate(
(context, index) {
if (index >= childCount) return null;
final sectionLayout = sectionLayouts.firstWhere((section) => section.hasChild(index), orElse: () => null);
return sectionLayout?.builder(context, index) ?? SizedBox.shrink();
},
childCount: childCount,
addAutomaticKeepAlives: false,
),
);
}
}
class _SliverKnownExtentList extends SliverMultiBoxAdaptorWidget {
final List<SectionLayout> sectionLayouts; final List<SectionLayout> sectionLayouts;
const SliverKnownExtentList({ const _SliverKnownExtentList({
Key key, Key key,
@required SliverChildDelegate delegate, @required SliverChildDelegate delegate,
@required this.sectionLayouts, @required this.sectionLayouts,
}) : super(key: key, delegate: delegate); }) : super(key: key, delegate: delegate);
@override @override
RenderSliverKnownExtentBoxAdaptor createRenderObject(BuildContext context) { _RenderSliverKnownExtentBoxAdaptor createRenderObject(BuildContext context) {
final element = context as SliverMultiBoxAdaptorElement; final element = context as SliverMultiBoxAdaptorElement;
return RenderSliverKnownExtentBoxAdaptor(childManager: element, sectionLayouts: sectionLayouts); return _RenderSliverKnownExtentBoxAdaptor(childManager: element, sectionLayouts: sectionLayouts);
} }
@override @override
void updateRenderObject(BuildContext context, RenderSliverKnownExtentBoxAdaptor renderObject) { void updateRenderObject(BuildContext context, _RenderSliverKnownExtentBoxAdaptor renderObject) {
renderObject.sectionLayouts = sectionLayouts; renderObject.sectionLayouts = sectionLayouts;
} }
} }
class RenderSliverKnownExtentBoxAdaptor extends RenderSliverMultiBoxAdaptor { class _RenderSliverKnownExtentBoxAdaptor extends RenderSliverMultiBoxAdaptor {
List<SectionLayout> _sectionLayouts; List<SectionLayout> _sectionLayouts;
List<SectionLayout> get sectionLayouts => _sectionLayouts; List<SectionLayout> get sectionLayouts => _sectionLayouts;
@ -38,7 +65,7 @@ class RenderSliverKnownExtentBoxAdaptor extends RenderSliverMultiBoxAdaptor {
markNeedsLayout(); markNeedsLayout();
} }
RenderSliverKnownExtentBoxAdaptor({ _RenderSliverKnownExtentBoxAdaptor({
@required RenderSliverBoxChildManager childManager, @required RenderSliverBoxChildManager childManager,
@required List<SectionLayout> sectionLayouts, @required List<SectionLayout> sectionLayouts,
}) : _sectionLayouts = sectionLayouts, }) : _sectionLayouts = sectionLayouts,

View file

@ -43,11 +43,11 @@ class DecoratedFilterChip extends StatelessWidget {
final backgroundImage = entry == null final backgroundImage = entry == null
? Container(color: Colors.white) ? Container(color: Colors.white)
: entry.isSvg : entry.isSvg
? ThumbnailVectorImage( ? VectorImageThumbnail(
entry: entry, entry: entry,
extent: extent, extent: extent,
) )
: ThumbnailRasterImage( : RasterImageThumbnail(
entry: entry, entry: entry,
extent: extent, extent: extent,
); );

View file

@ -27,11 +27,11 @@ class ImageMarker extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final thumbnail = entry.isSvg final thumbnail = entry.isSvg
? ThumbnailVectorImage( ? VectorImageThumbnail(
entry: entry, entry: entry,
extent: extent, extent: extent,
) )
: ThumbnailRasterImage( : RasterImageThumbnail(
entry: entry, entry: entry,
extent: extent, extent: extent,
); );

View file

@ -120,7 +120,7 @@ class _MultiPageOverlayState extends State<MultiPageOverlay> {
child: Container( child: Container(
width: extent, width: extent,
height: extent, height: extent,
child: ThumbnailRasterImage( child: RasterImageThumbnail(
entry: entry, entry: entry,
extent: extent, extent: extent,
page: page, page: page,