feat(mobile): keep search results visible (#26498)

Search results are replaced with a spinner when loading the next page,
which is quite jarring. Search results now remain visible when loading
the next page with a spinner at the bottom. The next page also loads
sooner, which makes it feel a lot smoother.

Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
Thomas
2026-03-04 17:27:11 +00:00
committed by GitHub
parent 7e9da945f6
commit 228ac63ab9
8 changed files with 180 additions and 110 deletions
@@ -3,30 +3,21 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
class SearchResult { class SearchResult {
final List<BaseAsset> assets; final List<BaseAsset> assets;
final double scrollOffset;
final int? nextPage; final int? nextPage;
const SearchResult({required this.assets, this.scrollOffset = 0.0, this.nextPage}); const SearchResult({required this.assets, this.nextPage});
SearchResult copyWith({List<BaseAsset>? assets, int? nextPage, double? scrollOffset}) {
return SearchResult(
assets: assets ?? this.assets,
nextPage: nextPage ?? this.nextPage,
scrollOffset: scrollOffset ?? this.scrollOffset,
);
}
@override @override
String toString() => 'SearchResult(assets: ${assets.length}, nextPage: $nextPage, scrollOffset: $scrollOffset)'; String toString() => 'SearchResult(assets: ${assets.length}, nextPage: $nextPage)';
@override @override
bool operator ==(covariant SearchResult other) { bool operator ==(covariant SearchResult other) {
if (identical(this, other)) return true; if (identical(this, other)) return true;
final listEquals = const DeepCollectionEquality().equals; final listEquals = const DeepCollectionEquality().equals;
return listEquals(other.assets, assets) && other.nextPage == nextPage && other.scrollOffset == scrollOffset; return listEquals(other.assets, assets) && other.nextPage == nextPage;
} }
@override @override
int get hashCode => assets.hashCode ^ nextPage.hashCode ^ scrollOffset.hashCode; int get hashCode => assets.hashCode ^ nextPage.hashCode;
} }
@@ -78,6 +78,9 @@ class TimelineFactory {
TimelineService fromAssets(List<BaseAsset> assets, TimelineOrigin type) => TimelineService fromAssets(List<BaseAsset> assets, TimelineOrigin type) =>
TimelineService(_timelineRepository.fromAssets(assets, type)); TimelineService(_timelineRepository.fromAssets(assets, type));
TimelineService fromAssetStream(List<BaseAsset> Function() getAssets, Stream<int> assetCount, TimelineOrigin type) =>
TimelineService(_timelineRepository.fromAssetStream(getAssets, assetCount, type));
TimelineService fromAssetsWithBuckets(List<BaseAsset> assets, TimelineOrigin type) => TimelineService fromAssetsWithBuckets(List<BaseAsset> assets, TimelineOrigin type) =>
TimelineService(_timelineRepository.fromAssetsWithBuckets(assets, type)); TimelineService(_timelineRepository.fromAssetsWithBuckets(assets, type));
@@ -112,7 +115,7 @@ class TimelineService {
if (totalAssets == 0) { if (totalAssets == 0) {
_bufferOffset = 0; _bufferOffset = 0;
_buffer.clear(); _buffer = [];
} else { } else {
final int offset; final int offset;
final int count; final int count;
@@ -276,6 +276,19 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
origin: origin, origin: origin,
); );
TimelineQuery fromAssetStream(List<BaseAsset> Function() getAssets, Stream<int> assetCount, TimelineOrigin origin) =>
(
bucketSource: () async* {
yield _generateBuckets(getAssets().length);
yield* assetCount.map(_generateBuckets);
},
assetSource: (offset, count) {
final assets = getAssets();
return Future.value(assets.skip(offset).take(count).toList(growable: false));
},
origin: origin,
);
TimelineQuery fromAssetsWithBuckets(List<BaseAsset> assets, TimelineOrigin origin) { TimelineQuery fromAssetsWithBuckets(List<BaseAsset> assets, TimelineOrigin origin) {
// Sort assets by date descending and group by day // Sort assets by date descending and group by day
final sorted = List<BaseAsset>.from(assets)..sort((a, b) => b.createdAt.compareTo(a.createdAt)); final sorted = List<BaseAsset>.from(assets)..sort((a, b) => b.createdAt.compareTo(a.createdAt));
@@ -80,51 +80,28 @@ class DriftSearchPage extends HookConsumerWidget {
final ratingCurrentFilterWidget = useState<Widget?>(null); final ratingCurrentFilterWidget = useState<Widget?>(null);
final displayOptionCurrentFilterWidget = useState<Widget?>(null); final displayOptionCurrentFilterWidget = useState<Widget?>(null);
final isSearching = useState(false);
final userPreferences = ref.watch(userMetadataPreferencesProvider); final userPreferences = ref.watch(userMetadataPreferencesProvider);
SnackBar searchInfoSnackBar(String message) { searchFilter(SearchFilter filter) {
return SnackBar(
content: Text(message, style: context.textTheme.labelLarge),
showCloseIcon: true,
behavior: SnackBarBehavior.fixed,
closeIconColor: context.colorScheme.onSurface,
);
}
searchFilter(SearchFilter filter) async {
if (filter.isEmpty) {
return;
}
if (preFilter == null && filter == previousFilter.value) { if (preFilter == null && filter == previousFilter.value) {
return; return;
} }
isSearching.value = true; ref.read(paginatedSearchProvider.notifier).clear();
ref.watch(paginatedSearchProvider.notifier).clear();
final hasResult = await ref.watch(paginatedSearchProvider.notifier).search(filter);
if (!hasResult) { if (filter.isEmpty) {
context.showSnackBar(searchInfoSnackBar('search_no_result'.t(context: context))); previousFilter.value = null;
return;
} }
unawaited(ref.read(paginatedSearchProvider.notifier).search(filter));
previousFilter.value = filter; previousFilter.value = filter;
isSearching.value = false;
} }
search() => searchFilter(filter.value); search() => searchFilter(filter.value);
loadMoreSearchResult() async { loadMoreSearchResults() {
isSearching.value = true; unawaited(ref.read(paginatedSearchProvider.notifier).search(filter.value));
final hasResult = await ref.watch(paginatedSearchProvider.notifier).search(filter.value);
if (!hasResult) {
context.showSnackBar(searchInfoSnackBar('search_no_more_result'.t(context: context)));
}
isSearching.value = false;
} }
searchPreFilter() { searchPreFilter() {
@@ -742,10 +719,10 @@ class DriftSearchPage extends HookConsumerWidget {
), ),
), ),
), ),
if (isSearching.value) if (filter.value.isEmpty)
const SliverFillRemaining(hasScrollBody: false, child: Center(child: CircularProgressIndicator())) const _SearchSuggestions()
else else
_SearchResultGrid(onScrollEnd: loadMoreSearchResult), _SearchResultGrid(onScrollEnd: loadMoreSearchResults),
], ],
), ),
); );
@@ -757,45 +734,85 @@ class _SearchResultGrid extends ConsumerWidget {
const _SearchResultGrid({required this.onScrollEnd}); const _SearchResultGrid({required this.onScrollEnd});
bool _onScrollUpdateNotification(ScrollNotification notification) {
final metrics = notification.metrics;
if (metrics.axis != Axis.vertical) return false;
final isBottomSheet = notification.context?.findAncestorWidgetOfExactType<DraggableScrollableSheet>() != null;
final remaining = metrics.maxScrollExtent - metrics.pixels;
if (remaining < metrics.viewportDimension && !isBottomSheet) {
onScrollEnd();
}
return false;
}
Widget? _bottomWidget(BuildContext context, WidgetRef ref) {
final isLoading = ref.watch(paginatedSearchProvider.select((s) => s.isLoading));
if (isLoading) {
return const SliverFillRemaining(
hasScrollBody: false,
child: Padding(
padding: EdgeInsets.all(32),
child: Center(child: CircularProgressIndicator()),
),
);
}
final hasMore = ref.watch(paginatedSearchProvider.select((s) => s.nextPage != null));
if (hasMore) return null;
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 32),
child: Center(
child: Text(
'search_no_more_result'.t(context: context),
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceVariant),
),
),
),
);
}
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final assets = ref.watch(paginatedSearchProvider.select((s) => s.assets)); final hasAssets = ref.watch(paginatedSearchProvider.select((s) => s.assets.isNotEmpty));
final isLoading = ref.watch(paginatedSearchProvider.select((s) => s.isLoading));
if (assets.isEmpty) { if (!hasAssets && !isLoading) {
return const _SearchEmptyContent(); return const _SearchNoResults();
} }
return NotificationListener<ScrollEndNotification>( return NotificationListener<ScrollUpdateNotification>(
onNotification: (notification) { onNotification: _onScrollUpdateNotification,
final isBottomSheetNotification =
notification.context?.findAncestorWidgetOfExactType<DraggableScrollableSheet>() != null;
final metrics = notification.metrics;
final isVerticalScroll = metrics.axis == Axis.vertical;
if (metrics.pixels >= metrics.maxScrollExtent && isVerticalScroll && !isBottomSheetNotification) {
onScrollEnd();
ref.read(paginatedSearchProvider.notifier).setScrollOffset(metrics.maxScrollExtent);
}
return true;
},
child: SliverFillRemaining( child: SliverFillRemaining(
child: ProviderScope( child: ProviderScope(
overrides: [ overrides: [
timelineServiceProvider.overrideWith((ref) { timelineServiceProvider.overrideWith((ref) {
final timelineService = ref.watch(timelineFactoryProvider).fromAssets(assets, TimelineOrigin.search); final notifier = ref.read(paginatedSearchProvider.notifier);
ref.onDispose(timelineService.dispose); final service = ref
return timelineService; .watch(timelineFactoryProvider)
.fromAssetStream(
() => ref.read(paginatedSearchProvider).assets,
notifier.assetCount,
TimelineOrigin.search,
);
ref.onDispose(service.dispose);
return service;
}), }),
], ],
child: Timeline( child: Timeline(
key: ValueKey(assets.length),
groupBy: GroupAssetsBy.none, groupBy: GroupAssetsBy.none,
appBar: null, appBar: null,
bottomSheet: const GeneralBottomSheet(minChildSize: 0.20), bottomSheet: const GeneralBottomSheet(minChildSize: 0.20),
snapToMonth: false, snapToMonth: false,
initialScrollOffset: ref.read(paginatedSearchProvider.select((s) => s.scrollOffset)), loadingWidget: const SizedBox.shrink(),
bottomSliverWidget: _bottomWidget(context, ref),
), ),
), ),
), ),
@@ -803,8 +820,35 @@ class _SearchResultGrid extends ConsumerWidget {
} }
} }
class _SearchEmptyContent extends StatelessWidget { class _SearchNoResults extends StatelessWidget {
const _SearchEmptyContent(); const _SearchNoResults();
@override
Widget build(BuildContext context) {
return SliverFillRemaining(
hasScrollBody: false,
child: Container(
alignment: Alignment.center,
padding: const EdgeInsets.all(48),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.search_off_rounded, size: 72, color: context.colorScheme.onSurfaceVariant),
const SizedBox(height: 24),
Text(
'search_no_result'.t(context: context),
textAlign: TextAlign.center,
style: context.textTheme.bodyLarge?.copyWith(color: context.colorScheme.onSurfaceVariant),
),
],
),
),
);
}
}
class _SearchSuggestions extends StatelessWidget {
const _SearchSuggestions();
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -1,5 +1,7 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/search_result.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/search.service.dart'; import 'package:immich_mobile/domain/services/search.service.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:immich_mobile/providers/infrastructure/search.provider.dart'; import 'package:immich_mobile/providers/infrastructure/search.provider.dart';
@@ -21,40 +23,52 @@ class SearchFilterProvider extends Notifier<SearchFilter?> {
} }
} }
final paginatedSearchProvider = StateNotifierProvider<PaginatedSearchNotifier, SearchResult>( class SearchState {
final List<BaseAsset> assets;
final int? nextPage;
final bool isLoading;
const SearchState({this.assets = const [], this.nextPage = 1, this.isLoading = false});
}
final paginatedSearchProvider = StateNotifierProvider<PaginatedSearchNotifier, SearchState>(
(ref) => PaginatedSearchNotifier(ref.watch(searchServiceProvider)), (ref) => PaginatedSearchNotifier(ref.watch(searchServiceProvider)),
); );
class PaginatedSearchNotifier extends StateNotifier<SearchResult> { class PaginatedSearchNotifier extends StateNotifier<SearchState> {
final SearchService _searchService; final SearchService _searchService;
final _assetCountController = StreamController<int>.broadcast();
PaginatedSearchNotifier(this._searchService) : super(const SearchResult(assets: [], nextPage: 1)); PaginatedSearchNotifier(this._searchService) : super(const SearchState());
Future<bool> search(SearchFilter filter) async { Stream<int> get assetCount => _assetCountController.stream;
if (state.nextPage == null) {
return false; Future<void> search(SearchFilter filter) async {
} if (state.nextPage == null || state.isLoading) return;
state = SearchState(assets: state.assets, nextPage: state.nextPage, isLoading: true);
final result = await _searchService.search(filter, state.nextPage!); final result = await _searchService.search(filter, state.nextPage!);
if (result == null) { if (result == null) {
return false; state = SearchState(assets: state.assets, nextPage: state.nextPage);
return;
} }
state = SearchResult( final assets = [...state.assets, ...result.assets];
assets: [...state.assets, ...result.assets], state = SearchState(assets: assets, nextPage: result.nextPage);
nextPage: result.nextPage,
scrollOffset: state.scrollOffset,
);
return true; _assetCountController.add(assets.length);
} }
void setScrollOffset(double offset) { void clear() {
state = state.copyWith(scrollOffset: offset); state = const SearchState();
_assetCountController.add(0);
} }
clear() { @override
state = const SearchResult(assets: [], nextPage: 1, scrollOffset: 0.0); void dispose() {
_assetCountController.close();
super.dispose();
} }
} }
@@ -5,6 +5,7 @@ const Size kTimelineFixedTileExtent = Size.square(256);
const double kTimelineSpacing = 2.0; const double kTimelineSpacing = 2.0;
const int kTimelineColumnCount = 3; const int kTimelineColumnCount = 3;
const double kScrubberThumbHeight = 48.0;
const Duration kTimelineScrubberFadeInDuration = Duration(milliseconds: 300); const Duration kTimelineScrubberFadeInDuration = Duration(milliseconds: 300);
const Duration kTimelineScrubberFadeOutDuration = Duration(milliseconds: 800); const Duration kTimelineScrubberFadeOutDuration = Duration(milliseconds: 800);
@@ -530,12 +530,14 @@ class _CircularThumb extends StatelessWidget {
elevation: 4.0, elevation: 4.0,
color: backgroundColor, color: backgroundColor,
borderRadius: const BorderRadius.only( borderRadius: const BorderRadius.only(
topLeft: Radius.circular(48.0), topLeft: Radius.circular(kScrubberThumbHeight),
bottomLeft: Radius.circular(48.0), bottomLeft: Radius.circular(kScrubberThumbHeight),
topRight: Radius.circular(4.0), topRight: Radius.circular(4.0),
bottomRight: Radius.circular(4.0), bottomRight: Radius.circular(4.0),
), ),
child: Container(constraints: BoxConstraints.tight(const Size(48.0 * 0.6, 48.0))), child: Container(
constraints: BoxConstraints.tight(const Size(kScrubberThumbHeight * 0.6, kScrubberThumbHeight)),
),
), ),
); );
} }
@@ -17,6 +17,7 @@ import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_status_floating_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/download_status_floating_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
import 'package:immich_mobile/presentation/widgets/timeline/scrubber.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/scrubber.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart'; import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
@@ -34,6 +35,7 @@ class Timeline extends StatelessWidget {
super.key, super.key,
this.topSliverWidget, this.topSliverWidget,
this.topSliverWidgetHeight, this.topSliverWidgetHeight,
this.bottomSliverWidget,
this.showStorageIndicator = false, this.showStorageIndicator = false,
this.withStack = false, this.withStack = false,
this.appBar = const ImmichSliverAppBar(floating: true, pinned: false, snap: false), this.appBar = const ImmichSliverAppBar(floating: true, pinned: false, snap: false),
@@ -41,13 +43,14 @@ class Timeline extends StatelessWidget {
this.groupBy, this.groupBy,
this.withScrubber = true, this.withScrubber = true,
this.snapToMonth = true, this.snapToMonth = true,
this.initialScrollOffset,
this.readOnly = false, this.readOnly = false,
this.persistentBottomBar = false, this.persistentBottomBar = false,
this.loadingWidget,
}); });
final Widget? topSliverWidget; final Widget? topSliverWidget;
final double? topSliverWidgetHeight; final double? topSliverWidgetHeight;
final Widget? bottomSliverWidget;
final bool showStorageIndicator; final bool showStorageIndicator;
final Widget? appBar; final Widget? appBar;
final Widget? bottomSheet; final Widget? bottomSheet;
@@ -55,9 +58,9 @@ class Timeline extends StatelessWidget {
final GroupAssetsBy? groupBy; final GroupAssetsBy? groupBy;
final bool withScrubber; final bool withScrubber;
final bool snapToMonth; final bool snapToMonth;
final double? initialScrollOffset;
final bool readOnly; final bool readOnly;
final bool persistentBottomBar; final bool persistentBottomBar;
final Widget? loadingWidget;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -82,13 +85,14 @@ class Timeline extends StatelessWidget {
child: _SliverTimeline( child: _SliverTimeline(
topSliverWidget: topSliverWidget, topSliverWidget: topSliverWidget,
topSliverWidgetHeight: topSliverWidgetHeight, topSliverWidgetHeight: topSliverWidgetHeight,
bottomSliverWidget: bottomSliverWidget,
appBar: appBar, appBar: appBar,
bottomSheet: bottomSheet, bottomSheet: bottomSheet,
withScrubber: withScrubber, withScrubber: withScrubber,
persistentBottomBar: persistentBottomBar, persistentBottomBar: persistentBottomBar,
snapToMonth: snapToMonth, snapToMonth: snapToMonth,
initialScrollOffset: initialScrollOffset,
maxWidth: constraints.maxWidth, maxWidth: constraints.maxWidth,
loadingWidget: loadingWidget,
), ),
), ),
), ),
@@ -111,24 +115,26 @@ class _SliverTimeline extends ConsumerStatefulWidget {
const _SliverTimeline({ const _SliverTimeline({
this.topSliverWidget, this.topSliverWidget,
this.topSliverWidgetHeight, this.topSliverWidgetHeight,
this.bottomSliverWidget,
this.appBar, this.appBar,
this.bottomSheet, this.bottomSheet,
this.withScrubber = true, this.withScrubber = true,
this.persistentBottomBar = false, this.persistentBottomBar = false,
this.snapToMonth = true, this.snapToMonth = true,
this.initialScrollOffset,
this.maxWidth, this.maxWidth,
this.loadingWidget,
}); });
final Widget? topSliverWidget; final Widget? topSliverWidget;
final double? topSliverWidgetHeight; final double? topSliverWidgetHeight;
final Widget? bottomSliverWidget;
final Widget? appBar; final Widget? appBar;
final Widget? bottomSheet; final Widget? bottomSheet;
final bool withScrubber; final bool withScrubber;
final bool persistentBottomBar; final bool persistentBottomBar;
final bool snapToMonth; final bool snapToMonth;
final double? initialScrollOffset;
final double? maxWidth; final double? maxWidth;
final Widget? loadingWidget;
@override @override
ConsumerState createState() => _SliverTimelineState(); ConsumerState createState() => _SliverTimelineState();
@@ -152,10 +158,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_scrollController = ScrollController( _scrollController = ScrollController(onAttach: _restoreAssetPosition);
initialScrollOffset: widget.initialScrollOffset ?? 0.0,
onAttach: _restoreAssetPosition,
);
_eventSubscription = EventStream.shared.listen(_onEvent); _eventSubscription = EventStream.shared.listen(_onEvent);
final currentTilesPerRow = ref.read(settingsProvider).get(Setting.tilesPerRow); final currentTilesPerRow = ref.read(settingsProvider).get(Setting.tilesPerRow);
@@ -373,6 +376,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
} }
}, },
child: asyncSegments.widgetWhen( child: asyncSegments.widgetWhen(
onLoading: widget.loadingWidget != null ? () => widget.loadingWidget! : null,
onData: (segments) { onData: (segments) {
final childCount = (segments.lastOrNull?.lastIndex ?? -1) + 1; final childCount = (segments.lastOrNull?.lastIndex ?? -1) + 1;
final double appBarExpandedHeight = widget.appBar != null && widget.appBar is MesmerizingSliverAppBar final double appBarExpandedHeight = widget.appBar != null && widget.appBar is MesmerizingSliverAppBar
@@ -380,12 +384,9 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
: 0; : 0;
final topPadding = context.padding.top + (widget.appBar == null ? 0 : kToolbarHeight) + 10; final topPadding = context.padding.top + (widget.appBar == null ? 0 : kToolbarHeight) + 10;
const scrubberBottomPadding = 100.0;
const bottomSheetOpenModifier = 120.0; const bottomSheetOpenModifier = 120.0;
final bottomPadding = final contentBottomPadding = context.padding.bottom + (isMultiSelectEnabled ? bottomSheetOpenModifier : 0);
context.padding.bottom + final scrubberBottomPadding = contentBottomPadding + kScrubberThumbHeight;
(widget.appBar == null ? 0 : scrubberBottomPadding) +
(isMultiSelectEnabled ? bottomSheetOpenModifier : 0);
final grid = CustomScrollView( final grid = CustomScrollView(
primary: true, primary: true,
@@ -408,7 +409,8 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
addRepaintBoundaries: false, addRepaintBoundaries: false,
), ),
), ),
SliverPadding(padding: EdgeInsets.only(bottom: bottomPadding)), if (widget.bottomSliverWidget != null) widget.bottomSliverWidget!,
SliverPadding(padding: EdgeInsets.only(bottom: contentBottomPadding)),
], ],
); );
@@ -419,7 +421,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
layoutSegments: segments, layoutSegments: segments,
timelineHeight: maxHeight, timelineHeight: maxHeight,
topPadding: topPadding, topPadding: topPadding,
bottomPadding: bottomPadding, bottomPadding: scrubberBottomPadding,
monthSegmentSnappingOffset: widget.topSliverWidgetHeight ?? 0 + appBarExpandedHeight, monthSegmentSnappingOffset: widget.topSliverWidgetHeight ?? 0 + appBarExpandedHeight,
hasAppBar: widget.appBar != null, hasAppBar: widget.appBar != null,
child: grid, child: grid,