BaseLibraryTabState documented an abstract loadData() that every tab "must implement", but three of the four tabs override loadItems() entirely and carried never-invoked empty loadData stubs; only the recommended tab exercised the contract, so the class docs described an extension surface that did not exist. loadItems() is now the one overridable hook, and the shared load transaction (generation tracking, localized error mapping, post-frame onDataLoaded) lives once in the protected runLoadTransaction helper that the recommended tab and the focus tests route through. No tab's load behavior changes.
323 lines
9.9 KiB
Dart
323 lines
9.9 KiB
Dart
import 'dart:async';
|
|
import 'package:flutter/material.dart';
|
|
import '../../../focus/input_mode_tracker.dart';
|
|
import '../../../media/media_library.dart';
|
|
import '../../../utils/error_message_utils.dart';
|
|
import '../../../mixins/library_tab_state.dart';
|
|
import '../../../mixins/refreshable.dart';
|
|
import '../content_state_builder.dart';
|
|
|
|
/// Base class for library tab screens that provides common state management
|
|
/// and lifecycle handling for tabs that display library content.
|
|
///
|
|
/// Type parameter T: The type of items this tab displays
|
|
///
|
|
/// State subclasses must implement:
|
|
/// - [BaseLibraryTabState.loadItems]: Load the tab's items, typically by
|
|
/// passing a fetch to [BaseLibraryTabState.runLoadTransaction]
|
|
/// - [BaseLibraryTabState.buildContent]: Build the UI for displaying loaded items
|
|
///
|
|
/// Optional overrides:
|
|
/// - [emptyIcon]: Icon to show when there are no items
|
|
/// - [emptyMessage]: Message to show when there are no items
|
|
/// - [errorContext]: Context for error messages (defaults to "content")
|
|
/// - [getRefreshStream]: Stream to listen for refresh events
|
|
abstract class BaseLibraryTab<T> extends StatefulWidget {
|
|
final MediaLibrary library;
|
|
final String? viewMode;
|
|
final String? density;
|
|
|
|
/// Callback invoked when data has finished loading successfully.
|
|
/// Used by parent to trigger focus on the first item.
|
|
final VoidCallback? onDataLoaded;
|
|
|
|
/// Whether this tab is currently the active/visible tab.
|
|
/// Used for internal focus management.
|
|
final bool isActive;
|
|
|
|
/// Whether to suppress auto-focus when tab becomes active.
|
|
/// Used when navigating via tab bar to keep focus on the tab chips.
|
|
final bool suppressAutoFocus;
|
|
|
|
/// Called when the user presses BACK in the tab content.
|
|
/// Used to navigate focus back to the tab bar.
|
|
final VoidCallback? onBack;
|
|
|
|
const BaseLibraryTab({
|
|
super.key,
|
|
required this.library,
|
|
this.viewMode,
|
|
this.density,
|
|
this.onDataLoaded,
|
|
this.isActive = false,
|
|
this.suppressAutoFocus = false,
|
|
this.onBack,
|
|
});
|
|
}
|
|
|
|
/// State base class that provides the common implementation for library tabs.
|
|
/// This preserves AutomaticKeepAliveClientMixin functionality.
|
|
///
|
|
/// [loadItems] is the single load hook. Simple tabs implement it by handing
|
|
/// their fetch to [runLoadTransaction], which owns the shared load transaction
|
|
/// (generation tracking, localized error mapping, and post-frame
|
|
/// [BaseLibraryTab.onDataLoaded] notification). Paginated tabs implement
|
|
/// [loadItems] with their own pipeline and report via [markItemsLoaded].
|
|
abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State<W>
|
|
with AutomaticKeepAliveClientMixin, Refreshable, LibraryTabStateMixin {
|
|
@override
|
|
bool get wantKeepAlive => true;
|
|
|
|
@override
|
|
MediaLibrary get library => widget.library;
|
|
|
|
@override
|
|
void refresh() {
|
|
loadItems();
|
|
}
|
|
|
|
// State management
|
|
List<T> _items = [];
|
|
bool _isLoading = false;
|
|
String? _errorMessage;
|
|
StreamSubscription<void>? _refreshSubscription;
|
|
int _loadGeneration = 0;
|
|
|
|
// Focus management
|
|
bool _hasLoadedData = false;
|
|
@protected
|
|
bool hasFocused = false;
|
|
bool _hasFocusedChromeFallback = false;
|
|
|
|
// Getters for subclasses
|
|
List<T> get items => _items;
|
|
bool get isLoading => _isLoading;
|
|
String? get errorMessage => _errorMessage;
|
|
bool get hasLoadedData => _hasLoadedData;
|
|
|
|
// Setters for subclasses that override loadItems with custom logic
|
|
@protected
|
|
set items(List<T> value) => _items = value;
|
|
@protected
|
|
set isLoading(bool value) => _isLoading = value;
|
|
@protected
|
|
set errorMessage(String? value) => _errorMessage = value;
|
|
@protected
|
|
set hasLoadedData(bool value) => _hasLoadedData = value;
|
|
|
|
@protected
|
|
int get libraryLoadGeneration => _loadGeneration;
|
|
|
|
@protected
|
|
int beginLibraryLoad() => ++_loadGeneration;
|
|
|
|
@protected
|
|
void invalidateLibraryLoad() {
|
|
_loadGeneration++;
|
|
}
|
|
|
|
@protected
|
|
bool isCurrentLibraryLoad(int generation, String libraryGlobalKey) {
|
|
return mounted && generation == _loadGeneration && widget.library.globalKey == libraryGlobalKey;
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
loadItems();
|
|
|
|
// Subscribe to refresh stream if provided
|
|
final refreshStream = getRefreshStream();
|
|
if (refreshStream != null) {
|
|
_refreshSubscription = refreshStream.listen((_) {
|
|
if (mounted) {
|
|
loadItems();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
invalidateLibraryLoad();
|
|
_refreshSubscription?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(W oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
// Reload if library changed
|
|
if (oldWidget.library.globalKey != widget.library.globalKey) {
|
|
invalidateLibraryLoad();
|
|
// Reset focus state for new library
|
|
hasFocused = false;
|
|
_hasFocusedChromeFallback = false;
|
|
_hasLoadedData = false;
|
|
// Immediately clear stale data before async load
|
|
_items = [];
|
|
_isLoading = true;
|
|
_errorMessage = null;
|
|
loadItems();
|
|
}
|
|
|
|
// Check if we should focus (became active after data loaded)
|
|
if (widget.isActive && !oldWidget.isActive) {
|
|
tryFocus();
|
|
}
|
|
}
|
|
|
|
/// Load the tab's items. Typically delegates to [runLoadTransaction].
|
|
Future<void> loadItems();
|
|
|
|
/// Build the content widget given the loaded items
|
|
/// This is called by ContentStateBuilder when items are available
|
|
Widget buildContent(List<T> items);
|
|
|
|
/// Icon to display when there are no items (empty state)
|
|
IconData get emptyIcon;
|
|
|
|
/// Message to display when there are no items (empty state)
|
|
String get emptyMessage;
|
|
|
|
/// Context string for error messages (e.g., "playlists", "collections")
|
|
String get errorContext;
|
|
|
|
/// Optional refresh stream to listen for external refresh events
|
|
/// Return null if no refresh stream is needed
|
|
Stream<void>? getRefreshStream() => null;
|
|
|
|
/// Try to focus the first item if conditions are met (active + loaded + not yet focused)
|
|
@protected
|
|
void tryFocus() {
|
|
// Don't auto-focus if suppressed (e.g., when navigating via tab bar)
|
|
if (widget.suppressAutoFocus) return;
|
|
// On mobile (touch mode), skip auto-focus to prevent ensureVisible()
|
|
// from interfering with TabBarView page animations
|
|
if (!InputModeTracker.isKeyboardMode(context)) return;
|
|
|
|
if (!widget.isActive || !_hasLoadedData) return;
|
|
|
|
final loadGeneration = _loadGeneration;
|
|
final libraryGlobalKey = widget.library.globalKey;
|
|
if (hasFocusableContent) {
|
|
_hasFocusedChromeFallback = false;
|
|
if (hasFocused) return;
|
|
hasFocused = true;
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (isCurrentLibraryLoad(loadGeneration, libraryGlobalKey)) {
|
|
focusFirstItem();
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (!_hasFocusedChromeFallback) {
|
|
_hasFocusedChromeFallback = true;
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (isCurrentLibraryLoad(loadGeneration, libraryGlobalKey)) {
|
|
focusEmptyState();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Post-load bookkeeping for tabs that replace [loadItems] with their own
|
|
/// (paginated) fetch: mark the tab loaded, take focus if it's due, and let
|
|
/// the parent know once the frame carrying the items is in.
|
|
@protected
|
|
void markItemsLoaded() {
|
|
_hasLoadedData = true;
|
|
tryFocus();
|
|
if (widget.onDataLoaded != null) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) widget.onDataLoaded!();
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Whether [focusFirstItem] has a real content target to focus.
|
|
@protected
|
|
bool get hasFocusableContent => _items.isNotEmpty;
|
|
|
|
/// Focus content when available, otherwise return to the library chrome.
|
|
void focusContentOrChrome() {
|
|
if (hasFocusableContent) {
|
|
focusFirstItem();
|
|
} else {
|
|
focusEmptyState();
|
|
}
|
|
}
|
|
|
|
/// Fallback for empty/error states, where content has no focusable child.
|
|
@protected
|
|
void focusEmptyState() {
|
|
widget.onBack?.call();
|
|
}
|
|
|
|
/// Focus the first item in the tab. Subclasses should override this.
|
|
// ignore: no-empty-block - default no-op, subclasses override to focus their first item
|
|
void focusFirstItem() {}
|
|
|
|
/// Shared load transaction: generation tracking, localized error mapping,
|
|
/// and post-frame [BaseLibraryTab.onDataLoaded] notification.
|
|
@protected
|
|
Future<void> runLoadTransaction(Future<List<T>> Function() fetch) async {
|
|
if (!mounted) return;
|
|
final loadGeneration = beginLibraryLoad();
|
|
final libraryGlobalKey = widget.library.globalKey;
|
|
|
|
setState(() {
|
|
_isLoading = true;
|
|
_errorMessage = null;
|
|
_items = []; // Clear items to prevent showing stale data during load
|
|
});
|
|
|
|
try {
|
|
final loadedItems = await fetch();
|
|
if (!isCurrentLibraryLoad(loadGeneration, libraryGlobalKey)) return;
|
|
|
|
setState(() {
|
|
_items = loadedItems;
|
|
_isLoading = false;
|
|
_hasLoadedData = true;
|
|
});
|
|
|
|
tryFocus();
|
|
|
|
final onDataLoaded = widget.onDataLoaded;
|
|
if (onDataLoaded != null) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (isCurrentLibraryLoad(loadGeneration, libraryGlobalKey)) {
|
|
onDataLoaded();
|
|
}
|
|
});
|
|
}
|
|
} catch (e, stackTrace) {
|
|
if (!isCurrentLibraryLoad(loadGeneration, libraryGlobalKey)) return;
|
|
final message = localizedLoadErrorMessage(e, stackTrace, context: errorContext);
|
|
if (!isCurrentLibraryLoad(loadGeneration, libraryGlobalKey)) return;
|
|
|
|
setState(() {
|
|
_errorMessage = message;
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
|
|
|
return ContentStateBuilder<T>(
|
|
isLoading: _isLoading,
|
|
errorMessage: _errorMessage,
|
|
items: _items,
|
|
emptyIcon: emptyIcon,
|
|
emptyMessage: emptyMessage,
|
|
onRetry: loadItems,
|
|
builder: (items) => RefreshIndicator(onRefresh: loadItems, child: buildContent(items)),
|
|
);
|
|
}
|
|
}
|