275 lines
7.9 KiB
Dart
275 lines
7.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
|
|
///
|
|
/// Subclasses must implement:
|
|
/// - [loadData]: Load data from the Plex API
|
|
/// - [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 mixin that provides the common implementation for library tabs
|
|
/// This preserves AutomaticKeepAliveClientMixin functionality
|
|
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;
|
|
|
|
// 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;
|
|
|
|
@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() {
|
|
_refreshSubscription?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(W oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
// Reload if library changed
|
|
if (oldWidget.library.globalKey != widget.library.globalKey) {
|
|
// 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 items from the API
|
|
/// This is the main data loading function that subclasses must implement
|
|
Future<List<T>> loadData();
|
|
|
|
/// 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;
|
|
|
|
if (hasFocusableContent) {
|
|
_hasFocusedChromeFallback = false;
|
|
if (hasFocused) return;
|
|
hasFocused = true;
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) {
|
|
focusFirstItem();
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (!_hasFocusedChromeFallback) {
|
|
_hasFocusedChromeFallback = true;
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) {
|
|
focusEmptyState();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/// 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() {}
|
|
|
|
/// Load items with error handling and state management
|
|
Future<void> loadItems() async {
|
|
setState(() {
|
|
_isLoading = true;
|
|
_errorMessage = null;
|
|
_items = []; // Clear items to prevent showing stale data during load
|
|
});
|
|
|
|
try {
|
|
final loadedItems = await loadData();
|
|
|
|
if (!mounted) return;
|
|
|
|
setState(() {
|
|
_items = loadedItems;
|
|
_isLoading = false;
|
|
});
|
|
|
|
// Mark data as loaded and try to focus
|
|
_hasLoadedData = true;
|
|
tryFocus();
|
|
|
|
// Notify parent that data has loaded
|
|
if (widget.onDataLoaded != null) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
widget.onDataLoaded!();
|
|
});
|
|
}
|
|
} catch (e, stackTrace) {
|
|
final message = localizedLoadErrorMessage(e, stackTrace, context: errorContext);
|
|
if (!mounted) 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)),
|
|
);
|
|
}
|
|
}
|