fix(libraries): stabilize alpha and sort controls
This commit is contained in:
@@ -210,27 +210,43 @@ class _HubDetailScreenState extends State<HubDetailScreen>
|
||||
|
||||
void _showSortBottomSheet() {
|
||||
final overlayContext = _overlayChildKey.currentContext ?? context;
|
||||
OverlaySheetController.of(overlayContext).show(
|
||||
builder: (context) => SortBottomSheet(
|
||||
sortOptions: _sortOptions,
|
||||
selectedSort: _selectedSort,
|
||||
isSortDescending: _isSortDescending,
|
||||
onSortChanged: (sort, descending) {
|
||||
setState(() {
|
||||
_selectedSort = sort;
|
||||
_isSortDescending = descending;
|
||||
MediaSort? pendingSort = _selectedSort;
|
||||
bool pendingDescending = _isSortDescending;
|
||||
bool pendingCleared = false;
|
||||
OverlaySheetController.of(overlayContext)
|
||||
.show(
|
||||
builder: (context) => SortBottomSheet(
|
||||
sortOptions: _sortOptions,
|
||||
selectedSort: _selectedSort,
|
||||
isSortDescending: _isSortDescending,
|
||||
onSortChanged: (sort, descending) {
|
||||
pendingSort = sort;
|
||||
pendingDescending = descending;
|
||||
pendingCleared = false;
|
||||
},
|
||||
onClear: () {
|
||||
pendingSort = null;
|
||||
pendingDescending = false;
|
||||
pendingCleared = true;
|
||||
},
|
||||
),
|
||||
)
|
||||
.then((_) {
|
||||
if (!mounted) return;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
if (pendingCleared) {
|
||||
_selectedSort = null;
|
||||
_isSortDescending = false;
|
||||
_applySort();
|
||||
} else if (pendingSort != null &&
|
||||
(pendingSort!.key != _selectedSort?.key || pendingDescending != _isSortDescending)) {
|
||||
_selectedSort = pendingSort;
|
||||
_isSortDescending = pendingDescending;
|
||||
_applySort();
|
||||
}
|
||||
});
|
||||
_applySort();
|
||||
},
|
||||
onClear: () {
|
||||
setState(() {
|
||||
_selectedSort = null;
|
||||
_isSortDescending = false;
|
||||
});
|
||||
_applySort();
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadMoreItems() async {
|
||||
|
||||
@@ -17,6 +17,7 @@ import 'alpha_jump_helper.dart';
|
||||
class AlphaJumpBar extends StatefulWidget {
|
||||
final List<LibraryFirstCharacter> firstCharacters;
|
||||
final void Function(int targetIndex) onJump;
|
||||
final bool descending;
|
||||
|
||||
/// The letter currently visible at the top of the grid, derived from the
|
||||
/// actual item's sort title by the parent widget.
|
||||
@@ -30,6 +31,7 @@ class AlphaJumpBar extends StatefulWidget {
|
||||
required this.firstCharacters,
|
||||
required this.onJump,
|
||||
required this.currentLetter,
|
||||
this.descending = false,
|
||||
this.focusNode,
|
||||
this.onNavigateLeft,
|
||||
this.onBack,
|
||||
@@ -63,15 +65,15 @@ class _AlphaJumpBarState extends State<AlphaJumpBar> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_helper = AlphaJumpHelper(widget.firstCharacters);
|
||||
_helper = AlphaJumpHelper(widget.firstCharacters, descending: widget.descending);
|
||||
_displayed = _helper.letters;
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(AlphaJumpBar oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.firstCharacters != widget.firstCharacters) {
|
||||
_helper = AlphaJumpHelper(widget.firstCharacters);
|
||||
if (oldWidget.firstCharacters != widget.firstCharacters || oldWidget.descending != widget.descending) {
|
||||
_helper = AlphaJumpHelper(widget.firstCharacters, descending: widget.descending);
|
||||
_lastMaxLetters = -1; // force recompute in next layout
|
||||
_displayed = _helper.letters;
|
||||
_clampHighlight();
|
||||
|
||||
@@ -28,7 +28,7 @@ class AlphaJumpHelper {
|
||||
|
||||
AlphaJumpHelper._(this.letters, this.letterToIndex, this.letterSizes, this.totalItemCount);
|
||||
|
||||
factory AlphaJumpHelper(List<LibraryFirstCharacter> firstCharacters) {
|
||||
factory AlphaJumpHelper(List<LibraryFirstCharacter> firstCharacters, {bool descending = false}) {
|
||||
// Collect characters with their sizes.
|
||||
final entries = <({String letter, int size})>[];
|
||||
final letterSizes = <String, int>{};
|
||||
@@ -43,6 +43,9 @@ class AlphaJumpHelper {
|
||||
|
||||
// Re-sort by DUCET collation to match the content endpoint's ICU sort order.
|
||||
entries.sort((a, b) => ducetCompare(a.letter, b.letter));
|
||||
if (descending) {
|
||||
entries.setAll(0, entries.reversed.toList());
|
||||
}
|
||||
|
||||
// Build cumulative index map in the corrected order.
|
||||
final letters = <String>[];
|
||||
|
||||
@@ -13,6 +13,7 @@ import 'alpha_jump_helper.dart';
|
||||
class AlphaScrollHandle extends StatefulWidget {
|
||||
final List<LibraryFirstCharacter> firstCharacters;
|
||||
final void Function(int targetIndex) onJump;
|
||||
final bool descending;
|
||||
|
||||
/// The letter currently visible at the top of the grid, derived from the
|
||||
/// actual item's sort title by the parent widget.
|
||||
@@ -26,6 +27,7 @@ class AlphaScrollHandle extends StatefulWidget {
|
||||
required this.firstCharacters,
|
||||
required this.onJump,
|
||||
required this.currentLetter,
|
||||
this.descending = false,
|
||||
required this.isScrolling,
|
||||
});
|
||||
|
||||
@@ -65,7 +67,7 @@ class _AlphaScrollHandleState extends State<AlphaScrollHandle> with SingleTicker
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_helper = AlphaJumpHelper(widget.firstCharacters);
|
||||
_helper = AlphaJumpHelper(widget.firstCharacters, descending: widget.descending);
|
||||
_opacityController = AnimationController(vsync: this, duration: _showDuration, reverseDuration: _hideDuration);
|
||||
}
|
||||
|
||||
@@ -73,8 +75,8 @@ class _AlphaScrollHandleState extends State<AlphaScrollHandle> with SingleTicker
|
||||
void didUpdateWidget(AlphaScrollHandle oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
if (oldWidget.firstCharacters != widget.firstCharacters) {
|
||||
_helper = AlphaJumpHelper(widget.firstCharacters);
|
||||
if (oldWidget.firstCharacters != widget.firstCharacters || oldWidget.descending != widget.descending) {
|
||||
_helper = AlphaJumpHelper(widget.firstCharacters, descending: widget.descending);
|
||||
}
|
||||
|
||||
if (widget.isScrolling && !oldWidget.isScrolling) {
|
||||
|
||||
@@ -32,6 +32,7 @@ abstract class LibraryAlphaBarStrategy {
|
||||
Future<({List<LibraryFirstCharacter> chars, AlphaJumpHelper helper})> loadCharacters({
|
||||
required Map<String, String> filters,
|
||||
required int? typeId,
|
||||
required bool descending,
|
||||
});
|
||||
|
||||
/// Letter to highlight given the current scroll-derived index. Plex maps
|
||||
@@ -100,6 +101,7 @@ class PlexAlphaBarStrategy implements LibraryAlphaBarStrategy {
|
||||
Future<({List<LibraryFirstCharacter> chars, AlphaJumpHelper helper})> loadCharacters({
|
||||
required Map<String, String> filters,
|
||||
required int? typeId,
|
||||
required bool descending,
|
||||
}) async {
|
||||
if (isShared) {
|
||||
// Shared libraries don't support first-characters.
|
||||
@@ -109,7 +111,7 @@ class PlexAlphaBarStrategy implements LibraryAlphaBarStrategy {
|
||||
final params = Map<String, String>.from(filters);
|
||||
params['includeCollections'] = '1';
|
||||
final chars = await client.getFirstCharacters(libraryKey, type: typeId, filters: params.isNotEmpty ? params : null);
|
||||
return (chars: chars, helper: AlphaJumpHelper(chars));
|
||||
return (chars: chars, helper: AlphaJumpHelper(chars, descending: descending));
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -184,9 +186,10 @@ class JellyfinAlphaBarStrategy implements LibraryAlphaBarStrategy {
|
||||
Future<({List<LibraryFirstCharacter> chars, AlphaJumpHelper helper})> loadCharacters({
|
||||
required Map<String, String> filters,
|
||||
required int? typeId,
|
||||
required bool descending,
|
||||
}) async {
|
||||
final synthetic = [for (final l in _letters) LibraryFirstCharacter(key: l, title: l, size: 1)];
|
||||
return (chars: synthetic, helper: AlphaJumpHelper(synthetic));
|
||||
return (chars: synthetic, helper: AlphaJumpHelper(synthetic, descending: descending));
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:plezy/widgets/app_icon.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
@@ -54,16 +56,18 @@ class _SortBottomSheetState extends State<SortBottomSheet> {
|
||||
}
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
if (!InputModeTracker.isKeyboardMode(context)) return;
|
||||
final ctx = _initialFocusNode.context;
|
||||
if (ctx != null) {
|
||||
Scrollable.ensureVisible(ctx, alignment: 0.5);
|
||||
}
|
||||
// Schedule after overlay's _autoFocus second callback so we override it.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
Timer.run(() {
|
||||
if (!mounted) return;
|
||||
_initialFocusNode.requestFocus();
|
||||
if (!InputModeTracker.isKeyboardMode(context)) return;
|
||||
final ctx = _initialFocusNode.context;
|
||||
if (ctx != null) {
|
||||
Scrollable.ensureVisible(ctx, alignment: 0.5);
|
||||
}
|
||||
// Schedule after overlay's _autoFocus second callback so we override it.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
_initialFocusNode.requestFocus();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -85,18 +89,11 @@ class _SortBottomSheetState extends State<SortBottomSheet> {
|
||||
}
|
||||
|
||||
void _handleDirectionChange(MediaSort sort, bool descending) {
|
||||
setState(() {
|
||||
_currentDescending = descending;
|
||||
});
|
||||
widget.onSortChanged(sort, descending);
|
||||
OverlaySheetController.of(context).close();
|
||||
}
|
||||
|
||||
void _handleClear() {
|
||||
setState(() {
|
||||
_currentSort = null;
|
||||
_currentDescending = false;
|
||||
});
|
||||
widget.onClear?.call();
|
||||
OverlaySheetController.of(context).close();
|
||||
}
|
||||
@@ -154,25 +151,25 @@ class _SortBottomSheetState extends State<SortBottomSheet> {
|
||||
: null,
|
||||
title: Text(sort.title),
|
||||
value: sort,
|
||||
secondary: isSelected
|
||||
? SegmentedButton<bool>(
|
||||
showSelectedIcon: false,
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: false,
|
||||
icon: AppIcon(Symbols.arrow_upward_rounded, fill: 1, size: 16),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: true,
|
||||
icon: AppIcon(Symbols.arrow_downward_rounded, fill: 1, size: 16),
|
||||
),
|
||||
],
|
||||
selected: {_currentDescending},
|
||||
onSelectionChanged: (Set<bool> newSelection) {
|
||||
_handleDirectionChange(sort, newSelection.first);
|
||||
},
|
||||
)
|
||||
: null,
|
||||
secondary: Visibility(
|
||||
visible: isSelected,
|
||||
maintainAnimation: true,
|
||||
maintainSize: true,
|
||||
maintainState: true,
|
||||
child: SegmentedButton<bool>(
|
||||
showSelectedIcon: false,
|
||||
segments: const [
|
||||
ButtonSegment(value: false, icon: AppIcon(Symbols.arrow_upward_rounded, fill: 1, size: 16)),
|
||||
ButtonSegment(value: true, icon: AppIcon(Symbols.arrow_downward_rounded, fill: 1, size: 16)),
|
||||
],
|
||||
selected: {_currentDescending},
|
||||
onSelectionChanged: isSelected
|
||||
? (Set<bool> newSelection) {
|
||||
_handleDirectionChange(sort, newSelection.first);
|
||||
}
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -217,17 +217,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
// Alpha jump bar state
|
||||
List<LibraryFirstCharacter> _firstCharacters = [];
|
||||
AlphaJumpHelper _alphaHelper = AlphaJumpHelper(const []);
|
||||
late final LibraryAlphaBarStrategy _alphaStrategy = LibraryAlphaBarStrategy.forBackend(
|
||||
widget.library.backend,
|
||||
// Resolved on demand and only invoked by [PlexAlphaBarStrategy], which is
|
||||
// only constructed when the library's backend is Plex — the bang is safe.
|
||||
plexClientProvider: () {
|
||||
final manager = context.read<MultiServerProvider>().serverManager;
|
||||
return manager.getPlexClient(widget.library.serverId ?? '')!;
|
||||
},
|
||||
libraryKey: widget.library.id,
|
||||
isShared: widget.library.isShared,
|
||||
);
|
||||
late LibraryAlphaBarStrategy _alphaStrategy = _createAlphaStrategy();
|
||||
|
||||
/// On Jellyfin libraries the alpha bar acts as a filter (matches the
|
||||
/// JF web client's UX). Holds the active letter (`#`, `A`–`Z`) or null
|
||||
@@ -274,6 +264,35 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
bool _rangeLoadScheduled = false;
|
||||
bool _topScrollResetScheduled = false;
|
||||
|
||||
LibraryAlphaBarStrategy _createAlphaStrategy() {
|
||||
final library = widget.library;
|
||||
return LibraryAlphaBarStrategy.forBackend(
|
||||
library.backend,
|
||||
// Resolved on demand and only invoked by [PlexAlphaBarStrategy], which is
|
||||
// only constructed when the library's backend is Plex — the bang is safe.
|
||||
plexClientProvider: () {
|
||||
final manager = context.read<MultiServerProvider>().serverManager;
|
||||
return manager.getPlexClient(library.serverId ?? '')!;
|
||||
},
|
||||
libraryKey: library.id,
|
||||
isShared: library.isShared,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant LibraryBrowseTab oldWidget) {
|
||||
// BaseLibraryTabState reloads during super.didUpdateWidget; refresh this
|
||||
// first so first-character requests target the new backend/library.
|
||||
if (oldWidget.library.globalKey != widget.library.globalKey ||
|
||||
oldWidget.library.id != widget.library.id ||
|
||||
oldWidget.library.backend != widget.library.backend ||
|
||||
oldWidget.library.serverId != widget.library.serverId ||
|
||||
oldWidget.library.isShared != widget.library.isShared) {
|
||||
_alphaStrategy = _createAlphaStrategy();
|
||||
}
|
||||
super.didUpdateWidget(oldWidget);
|
||||
}
|
||||
|
||||
bool get _isJellyfinLibrary => widget.library.backend == MediaBackend.jellyfin;
|
||||
int get _activeFetchSize => _isJellyfinLibrary ? _jellyfinFetchSize : _fetchSize;
|
||||
|
||||
@@ -821,25 +840,28 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
)
|
||||
.then((_) {
|
||||
if (!mounted) return;
|
||||
if (pendingCleared) {
|
||||
setState(() {
|
||||
_selectedSort = null;
|
||||
_isSortDescending = false;
|
||||
});
|
||||
_loadItems();
|
||||
_loadFirstCharacters();
|
||||
} else if (pendingSort != null &&
|
||||
(pendingSort!.key != _selectedSort?.key || pendingDescending != _isSortDescending)) {
|
||||
setState(() {
|
||||
_selectedSort = pendingSort;
|
||||
_isSortDescending = pendingDescending;
|
||||
});
|
||||
StorageService.getInstance().then((storage) {
|
||||
storage.saveLibrarySort(widget.library.globalKey, pendingSort!.key, descending: pendingDescending);
|
||||
});
|
||||
_loadItems();
|
||||
_loadFirstCharacters();
|
||||
}
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
if (pendingCleared) {
|
||||
setState(() {
|
||||
_selectedSort = null;
|
||||
_isSortDescending = false;
|
||||
});
|
||||
_loadItems();
|
||||
_loadFirstCharacters();
|
||||
} else if (pendingSort != null &&
|
||||
(pendingSort!.key != _selectedSort?.key || pendingDescending != _isSortDescending)) {
|
||||
setState(() {
|
||||
_selectedSort = pendingSort;
|
||||
_isSortDescending = pendingDescending;
|
||||
});
|
||||
StorageService.getInstance().then((storage) {
|
||||
storage.saveLibrarySort(widget.library.globalKey, pendingSort!.key, descending: pendingDescending);
|
||||
});
|
||||
_loadItems();
|
||||
_loadFirstCharacters();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -951,6 +973,13 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
|
||||
/// Whether the alpha jump bar should be shown.
|
||||
/// Only shown when sorting by title (titleSort) and not in folders mode.
|
||||
bool get _isTitleSortDescending {
|
||||
if (!_isSortDescending) return false;
|
||||
final key = _selectedSort?.key.toLowerCase();
|
||||
if (key == null || key.isEmpty) return false;
|
||||
return key == 'title' || key == 'name' || key == 'sortname' || key.startsWith('titlesort');
|
||||
}
|
||||
|
||||
bool get _shouldShowAlphaJumpBar => _alphaStrategy.shouldShow(
|
||||
totalItemCount: totalSize,
|
||||
loadedCharacterCount: _firstCharacters.length,
|
||||
@@ -970,6 +999,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
final result = await _alphaStrategy.loadCharacters(
|
||||
filters: filterParams,
|
||||
typeId: typeId.isNotEmpty ? int.tryParse(typeId) : null,
|
||||
descending: _isTitleSortDescending,
|
||||
);
|
||||
if (!mounted || currentRequestId != _firstCharactersRequestId) return;
|
||||
setState(() {
|
||||
@@ -1176,6 +1206,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
firstCharacters: _firstCharacters,
|
||||
onJump: _jumpToIndex,
|
||||
currentLetter: _alphaLetterFor(visibleIndex),
|
||||
descending: _isTitleSortDescending,
|
||||
isScrolling: scrolling,
|
||||
),
|
||||
),
|
||||
@@ -1186,6 +1217,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
firstCharacters: _firstCharacters,
|
||||
onJump: _jumpToIndex,
|
||||
currentLetter: _alphaLetterFor(visibleIndex),
|
||||
descending: _isTitleSortDescending,
|
||||
focusNode: _alphaJumpBarFocusNode,
|
||||
onNavigateLeft: _navigateToGridNearScroll,
|
||||
onBack: _navigateToGridNearScroll,
|
||||
|
||||
@@ -119,7 +119,10 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
|
||||
throwIfHttpError(response);
|
||||
final data = response.data;
|
||||
final items = _itemsArray(data);
|
||||
final total = (data is Map<String, dynamic> ? data['TotalRecordCount'] as int? : null) ?? items.length;
|
||||
final rawTotal = data is Map<String, dynamic> ? data['TotalRecordCount'] : null;
|
||||
final total = rawTotal is int
|
||||
? rawTotal
|
||||
: _fallbackPageTotal(offset: query.offset, itemCount: items.length, requestedSize: query.limit);
|
||||
return LibraryPage<MediaItem>(items: _mapItems(items), totalCount: total, offset: query.offset);
|
||||
}
|
||||
|
||||
|
||||
@@ -216,6 +216,7 @@ class JellyfinLibraryQueryTranslator implements LibraryQueryTranslator {
|
||||
'Recursive': 'true',
|
||||
'StartIndex': query.offset.toString(),
|
||||
'Limit': query.limit.toString(),
|
||||
'EnableTotalRecordCount': 'true',
|
||||
'IncludeItemTypes': _includeTypesFor(query.kind),
|
||||
'Fields': fields,
|
||||
...jellyfinImageQueryParameters,
|
||||
|
||||
+20
-14
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Scroll the nearest scrollable ancestor so [context] is centered.
|
||||
@@ -7,13 +9,15 @@ import 'package:flutter/widgets.dart';
|
||||
void scrollContextToCenter(BuildContext? context) {
|
||||
if (context == null) return;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!context.mounted) return;
|
||||
Scrollable.ensureVisible(
|
||||
context,
|
||||
alignment: 0.5,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
Timer.run(() {
|
||||
if (!context.mounted) return;
|
||||
Scrollable.ensureVisible(
|
||||
context,
|
||||
alignment: 0.5,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -25,13 +29,15 @@ void scrollContextToCenter(BuildContext? context) {
|
||||
/// controller aren't ready yet.
|
||||
void scrollToCurrentItem(ScrollController controller, GlobalKey firstItemKey, int currentIndex) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!controller.hasClients) return;
|
||||
final itemHeight = (firstItemKey.currentContext?.findRenderObject() as RenderBox?)?.size.height;
|
||||
if (itemHeight == null) return;
|
||||
final maxExtent = controller.position.maxScrollExtent;
|
||||
if (!maxExtent.isFinite) return;
|
||||
final target = (currentIndex * itemHeight).clamp(0.0, maxExtent);
|
||||
controller.jumpTo(target);
|
||||
Timer.run(() {
|
||||
if (!controller.hasClients) return;
|
||||
final itemHeight = (firstItemKey.currentContext?.findRenderObject() as RenderBox?)?.size.height;
|
||||
if (itemHeight == null) return;
|
||||
final maxExtent = controller.position.maxScrollExtent;
|
||||
if (!maxExtent.isFinite) return;
|
||||
final target = (currentIndex * itemHeight).clamp(0.0, maxExtent);
|
||||
controller.jumpTo(target);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -531,7 +531,10 @@ class _OverlaySheetHostState extends State<OverlaySheetHost> with SingleTickerPr
|
||||
BoxConstraints(maxWidth: isDesktop ? 700 : double.infinity, maxHeight: isDesktop ? 400 : size.height * 0.75);
|
||||
|
||||
// Slide direction depends on alignment: bottom sheets slide up, top sheets slide down.
|
||||
final slideBegin = isTop ? const Offset(0, -1) : const Offset(0, 1);
|
||||
// Use a pixel transform instead of FractionalTranslation so mouse-tracker
|
||||
// hit testing never depends on the sheet child's just-invalidated layout.
|
||||
final slideDirection = isTop ? -1.0 : 1.0;
|
||||
final slideDistance = size.height;
|
||||
final borderRadius = isTop
|
||||
? const BorderRadius.vertical(bottom: Radius.circular(16))
|
||||
: const BorderRadius.vertical(top: Radius.circular(16));
|
||||
@@ -617,8 +620,8 @@ class _OverlaySheetHostState extends State<OverlaySheetHost> with SingleTickerPr
|
||||
child: AnimatedBuilder(
|
||||
animation: _slideCurve,
|
||||
builder: (context, child) {
|
||||
final slideOffset = Offset.lerp(slideBegin, Offset.zero, _slideCurve.value)!;
|
||||
return FractionalTranslation(translation: slideOffset, child: child);
|
||||
final dy = slideDirection * slideDistance * (1 - _slideCurve.value);
|
||||
return Transform.translate(offset: Offset(0, dy), child: child);
|
||||
},
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, _dragOffset.clamp(0, double.infinity)),
|
||||
|
||||
@@ -36,4 +36,37 @@ void main() {
|
||||
|
||||
expect(jumpedTo, 3);
|
||||
});
|
||||
|
||||
testWidgets('Enter jumps to the descending title offset', (tester) async {
|
||||
final focusNode = FocusNode(debugLabel: 'test_alpha_jump_bar_desc');
|
||||
addTearDown(focusNode.dispose);
|
||||
|
||||
int? jumpedTo;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: SizedBox(
|
||||
height: 300,
|
||||
child: AlphaJumpBar(
|
||||
firstCharacters: const [
|
||||
LibraryFirstCharacter(key: 'A', title: 'A', size: 3),
|
||||
LibraryFirstCharacter(key: 'B', title: 'B', size: 4),
|
||||
LibraryFirstCharacter(key: 'C', title: 'C', size: 2),
|
||||
],
|
||||
currentLetter: 'B',
|
||||
descending: true,
|
||||
focusNode: focusNode,
|
||||
onJump: (index) => jumpedTo = index,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
focusNode.requestFocus();
|
||||
await tester.pump();
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
|
||||
expect(jumpedTo, 2);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/media/library_first_character.dart';
|
||||
import 'package:plezy/screens/libraries/alpha_jump_helper.dart';
|
||||
|
||||
void main() {
|
||||
const characters = [
|
||||
LibraryFirstCharacter(key: 'A', title: 'A', size: 3),
|
||||
LibraryFirstCharacter(key: 'B', title: 'B', size: 4),
|
||||
LibraryFirstCharacter(key: 'C', title: 'C', size: 2),
|
||||
];
|
||||
|
||||
test('maps title-ascending letters to cumulative offsets', () {
|
||||
final helper = AlphaJumpHelper(characters);
|
||||
|
||||
expect(helper.letters, ['A', 'B', 'C']);
|
||||
expect(helper.indexForLetter('A'), 0);
|
||||
expect(helper.indexForLetter('B'), 3);
|
||||
expect(helper.indexForLetter('C'), 7);
|
||||
expect(helper.currentLetter(0), 'A');
|
||||
expect(helper.currentLetter(3), 'B');
|
||||
expect(helper.currentLetter(8), 'C');
|
||||
});
|
||||
|
||||
test('maps title-descending letters to reversed cumulative offsets', () {
|
||||
final helper = AlphaJumpHelper(characters, descending: true);
|
||||
|
||||
expect(helper.letters, ['C', 'B', 'A']);
|
||||
expect(helper.indexForLetter('C'), 0);
|
||||
expect(helper.indexForLetter('B'), 2);
|
||||
expect(helper.indexForLetter('A'), 6);
|
||||
expect(helper.currentLetter(0), 'C');
|
||||
expect(helper.currentLetter(2), 'B');
|
||||
expect(helper.currentLetter(8), 'A');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/media/media_sort.dart';
|
||||
import 'package:plezy/screens/libraries/sort_bottom_sheet.dart';
|
||||
import 'package:plezy/widgets/overlay_sheet.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('tapping sort row and direction in overlay does not throw', (tester) async {
|
||||
const sorts = [
|
||||
MediaSort(key: 'titleSort', title: 'Title', defaultDirection: 'asc'),
|
||||
MediaSort(key: 'addedAt', title: 'Date Added', defaultDirection: 'desc'),
|
||||
];
|
||||
|
||||
MediaSort? selectedSort;
|
||||
bool? selectedDescending;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: OverlaySheetHost(
|
||||
child: Builder(
|
||||
builder: (context) => ElevatedButton(
|
||||
onPressed: () {
|
||||
OverlaySheetController.of(context).show(
|
||||
builder: (_) => SortBottomSheet(
|
||||
sortOptions: sorts,
|
||||
selectedSort: null,
|
||||
isSortDescending: false,
|
||||
onSortChanged: (sort, descending) {
|
||||
selectedSort = sort;
|
||||
selectedDescending = descending;
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('Open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Open'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
await tester.tap(find.text('Date Added'));
|
||||
await tester.pump();
|
||||
expect(tester.takeException(), isNull);
|
||||
expect(selectedSort?.key, 'addedAt');
|
||||
expect(selectedDescending, isTrue);
|
||||
|
||||
final directionControl = find.byType(SegmentedButton<bool>).hitTestable();
|
||||
expect(directionControl, findsOneWidget);
|
||||
final controlRect = tester.getRect(directionControl);
|
||||
await tester.tapAt(controlRect.centerLeft + const Offset(12, 0));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
expect(selectedSort?.key, 'addedAt');
|
||||
expect(selectedDescending, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('preselected scrolled sort sheet with active mouse does not throw', (tester) async {
|
||||
const sorts = [
|
||||
MediaSort(key: 'titleSort', title: 'Title', defaultDirection: 'asc'),
|
||||
MediaSort(key: 'addedAt', title: 'Date Added', defaultDirection: 'desc'),
|
||||
MediaSort(key: 'year', title: 'Year', defaultDirection: 'desc'),
|
||||
MediaSort(key: 'rating', title: 'Rating', defaultDirection: 'desc'),
|
||||
MediaSort(key: 'runtime', title: 'Runtime', defaultDirection: 'asc'),
|
||||
MediaSort(key: 'studio', title: 'Studio', defaultDirection: 'asc'),
|
||||
MediaSort(key: 'criticRating', title: 'Critic Rating', defaultDirection: 'desc'),
|
||||
MediaSort(key: 'viewCount', title: 'Play Count', defaultDirection: 'desc'),
|
||||
MediaSort(key: 'airTime', title: 'Air Time', defaultDirection: 'asc'),
|
||||
MediaSort(key: 'officialRating', title: 'Official Rating', defaultDirection: 'asc'),
|
||||
MediaSort(key: 'startDate', title: 'Start Date', defaultDirection: 'desc'),
|
||||
];
|
||||
|
||||
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
|
||||
addTearDown(mouse.removePointer);
|
||||
await mouse.addPointer(location: const Offset(300, 300));
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: OverlaySheetHost(
|
||||
child: Builder(
|
||||
builder: (context) => ElevatedButton(
|
||||
onPressed: () {
|
||||
OverlaySheetController.of(context).show(
|
||||
builder: (_) => SortBottomSheet(
|
||||
sortOptions: sorts,
|
||||
selectedSort: sorts.last,
|
||||
isSortDescending: true,
|
||||
onSortChanged: (_, _) {},
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('Open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Open'));
|
||||
await tester.pump();
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
await mouse.moveTo(tester.getCenter(find.byType(SortBottomSheet)));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
expect(find.text('Start Date'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -789,12 +789,41 @@ void main() {
|
||||
expect(captured!.queryParameters['ParentId'], 'lib-1');
|
||||
expect(captured!.queryParameters['StartIndex'], '50');
|
||||
expect(captured!.queryParameters['Limit'], '25');
|
||||
expect(captured!.queryParameters['EnableTotalRecordCount'], 'true');
|
||||
expect(captured!.queryParameters['IncludeItemTypes'], 'Movie');
|
||||
expect(captured!.queryParameters['Fields'], isNot(contains('MediaSources')));
|
||||
expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
|
||||
expect(captured!.queryParameters['ImageTypeLimit'], '1');
|
||||
});
|
||||
|
||||
test('fetchLibraryContent uses sentinel total fallback when server omits total', () async {
|
||||
final scoped = JellyfinClient.forTesting(
|
||||
connection: _conn(),
|
||||
httpClient: MockClient((req) async {
|
||||
final start = int.parse(req.url.queryParameters['StartIndex'] ?? '0');
|
||||
final limit = int.parse(req.url.queryParameters['Limit'] ?? '25');
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'Items': [
|
||||
for (var i = start; i < start + limit; i++) {'Id': 'movie-$i', 'Type': 'Movie', 'Name': 'Movie $i'},
|
||||
],
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}),
|
||||
);
|
||||
addTearDown(scoped.close);
|
||||
|
||||
final page = await scoped.fetchLibraryContent(
|
||||
'lib-1',
|
||||
const LibraryQuery(kind: MediaKind.movie, offset: 50, limit: 25),
|
||||
);
|
||||
|
||||
expect(page.items.length, 25);
|
||||
expect(page.totalCount, 76);
|
||||
});
|
||||
|
||||
test('fetchLibraryPagedContent uses library kind only when query kind is absent', () async {
|
||||
final captured = <Uri>[];
|
||||
final scoped = JellyfinClient.forTesting(
|
||||
|
||||
@@ -79,6 +79,7 @@ void main() {
|
||||
expect(params['Recursive'], 'true');
|
||||
expect(params['Fields'], 'UserData');
|
||||
expect(params['IncludeItemTypes'], isNotEmpty);
|
||||
expect(params['EnableTotalRecordCount'], 'true');
|
||||
expect(params['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
|
||||
expect(params['ImageTypeLimit'], '1');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user