Files
sm-emby-share/lib/features/modules/module_browse_page.dart
T

962 lines
30 KiB
Dart
Raw Normal View History

2026-07-14 11:11:36 +08:00
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:forui/forui.dart';
import 'package:go_router/go_router.dart';
import '../../core/contracts/script_widget.dart';
import '../../core/domain/usecases/script_widget/script_widget_service.dart';
import '../../providers/di_providers.dart';
import '../../shared/constants/layout_constants.dart';
import '../../shared/theme/app_theme.dart';
import '../../shared/utils/user_error_formatter.dart';
import '../../shared/widgets/app_card.dart';
import '../../shared/widgets/app_error_state.dart';
import '../../shared/widgets/app_loading_ring.dart';
import '../../shared/widgets/app_sliver_header.dart';
import '../../shared/widgets/empty_state.dart';
import '../../shared/widgets/error_media_row.dart';
import '../../shared/widgets/hover_scrollable_row.dart';
import '../../shared/widgets/media_poster_card.dart';
import '../../shared/widgets/media_row_metrics.dart';
import '../../shared/widgets/page_background.dart';
import '../../shared/widgets/script_video_card.dart';
import '../../shared/widgets/section_header.dart';
import '../../shared/widgets/skeleton_media_row.dart';
import 'script_widget_parameter_form.dart';
class ModuleBrowsePage extends ConsumerStatefulWidget {
final String widgetId;
const ModuleBrowsePage({super.key, required this.widgetId});
@override
ConsumerState<ModuleBrowsePage> createState() => _ModuleBrowsePageState();
}
class _ModuleBrowsePageState extends ConsumerState<ModuleBrowsePage> {
final TextEditingController _searchController = TextEditingController();
final Map<String, Map<String, Object?>> _moduleParameters =
<String, Map<String, Object?>>{};
InstalledScriptWidget? _installedWidget;
List<ScriptWidgetContentSection> _searchSections =
const <ScriptWidgetContentSection>[];
bool _searchMode = false;
bool _filtersExpanded = false;
bool _loadingSearch = false;
String? _searchError;
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
List<ScriptWidgetModule> _contentModules(ScriptWidgetManifest manifest) {
return manifest.modules
.where(
(module) =>
module.type != 'danmu' &&
module.type != 'stream' &&
module.id != 'loadResource',
)
.toList(growable: false);
}
Map<String, Object?> _parametersFor(ScriptWidgetModule module) {
return _moduleParameters.putIfAbsent(
_moduleKey(module),
() => buildScriptWidgetParameterDefaults(module.params),
);
}
List<ScriptWidgetParam> _searchParameters(ScriptWidgetModule? searchModule) {
if (searchModule == null) return const <ScriptWidgetParam>[];
return searchModule.params
.where((parameter) {
final normalizedName = parameter.name.toLowerCase();
final isKeywordParameter = const <String>{
'keyword',
'query',
'search',
'title',
}.contains(normalizedName);
final isInternalParameter = const <String>{
'constant',
'page',
'offset',
}.contains(parameter.type);
return !isKeywordParameter && !isInternalParameter;
})
.toList(growable: false);
}
Future<void> _search() async {
final searchModule = _installedWidget?.manifest.search;
if (searchModule == null) return;
final keyword = _searchController.text.trim();
if (keyword.isEmpty) return;
final searchParameters = <String, Object?>{
...buildScriptWidgetParameterDefaults(searchModule.params),
..._parametersFor(searchModule),
};
final keywordParameter = searchModule.params
.where(
(parameter) => const <String>{
'keyword',
'query',
'search',
'title',
}.contains(parameter.name.toLowerCase()),
)
.firstOrNull;
searchParameters[keywordParameter?.name ?? 'keyword'] = keyword;
setState(() {
_loadingSearch = true;
_searchError = null;
});
try {
final service = await ref.read(scriptWidgetServiceProvider.future);
final items = await service.search(widget.widgetId, searchParameters);
if (!mounted) return;
setState(() {
_searchSections = <ScriptWidgetContentSection>[
ScriptWidgetContentSection(title: '搜索结果', items: items),
];
});
} catch (error) {
if (!mounted) return;
setState(() {
_searchError = formatUserError(error);
_searchSections = const <ScriptWidgetContentSection>[];
});
} finally {
if (mounted) {
setState(() => _loadingSearch = false);
}
}
}
ScriptWidgetParam? get _searchPagingParameter {
final searchModule = _installedWidget?.manifest.search;
return searchModule?.params
.where(
(parameter) => parameter.type == 'page' || parameter.type == 'offset',
)
.firstOrNull;
}
Future<void> _changeSearchPage(int direction) async {
final searchModule = _installedWidget?.manifest.search;
final pagingParameter = _searchPagingParameter;
if (searchModule == null || pagingParameter == null) return;
final parameters = _parametersFor(searchModule);
final minimumValue = pagingParameter.type == 'page' ? 1 : 0;
final currentValue =
int.tryParse(parameters[pagingParameter.name]?.toString() ?? '') ??
minimumValue;
final countParameter = searchModule.params
.where((parameter) => parameter.type == 'count')
.firstOrNull;
final pageStep = pagingParameter.type == 'offset'
? int.tryParse(parameters[countParameter?.name]?.toString() ?? '') ?? 20
: 1;
parameters[pagingParameter.name] = (currentValue + direction * pageStep)
.clamp(minimumValue, 1 << 31);
await _search();
}
bool get _canGoBackSearchPage {
final searchModule = _installedWidget?.manifest.search;
final pagingParameter = _searchPagingParameter;
if (searchModule == null || pagingParameter == null) return false;
final minimumValue = pagingParameter.type == 'page' ? 1 : 0;
final currentValue =
int.tryParse(
_parametersFor(searchModule)[pagingParameter.name]?.toString() ?? '',
) ??
minimumValue;
return currentValue > minimumValue;
}
String get _currentSearchPageLabel {
final searchModule = _installedWidget?.manifest.search;
final pagingParameter = _searchPagingParameter;
if (searchModule == null || pagingParameter == null) return '第 1 页';
final parameters = _parametersFor(searchModule);
final minimumValue = pagingParameter.type == 'page' ? 1 : 0;
final currentValue =
int.tryParse(parameters[pagingParameter.name]?.toString() ?? '') ??
minimumValue;
if (pagingParameter.type == 'page') return '$currentValue';
final countParameter = searchModule.params
.where((parameter) => parameter.type == 'count')
.firstOrNull;
final pageSize =
int.tryParse(parameters[countParameter?.name]?.toString() ?? '') ?? 20;
return '${(currentValue ~/ pageSize) + 1}';
}
@override
Widget build(BuildContext context) {
final installedWidget = ref.watch(
installedScriptWidgetProvider(widget.widgetId),
);
return Scaffold(
backgroundColor: Colors.transparent,
extendBodyBehindAppBar: true,
body: Stack(
children: [
const Positioned.fill(child: PageBackground()),
installedWidget.when(
loading: () => const Center(child: AppLoadingRing()),
error: (error, _) => Center(
child: AppErrorState(
message: formatUserError(error),
onRetry: () => ref.invalidate(
installedScriptWidgetProvider(widget.widgetId),
),
),
),
data: (widgetData) {
_installedWidget = widgetData;
return _buildContent(widgetData);
},
),
],
),
);
}
Widget _buildContent(InstalledScriptWidget installedWidget) {
final manifest = installedWidget.manifest;
final contentModules = _contentModules(manifest);
final searchParameters = _searchParameters(manifest.search);
return CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(
parent: BouncingScrollPhysics(),
),
slivers: [
AppSliverHeader(
title: manifest.title,
bottom: _searchMode
? PreferredSize(
preferredSize: const Size.fromHeight(60),
child: _SearchControlBar(
searchController: _searchController,
searchHint: manifest.search?.description,
filtersExpanded: _filtersExpanded,
hasFilters: searchParameters.isNotEmpty,
onSearch: _search,
onToggleFilters: () {
setState(() => _filtersExpanded = !_filtersExpanded);
},
),
)
: null,
actions: [
if (manifest.search != null)
IconButton(
onPressed: () {
setState(() {
_searchMode = !_searchMode;
_filtersExpanded = false;
});
},
tooltip: _searchMode ? '关闭搜索' : '搜索',
icon: Icon(
_searchMode ? Icons.close_rounded : Icons.search_rounded,
),
),
],
),
if (manifest.description.trim().isNotEmpty)
SliverPadding(
padding: const EdgeInsets.fromLTRB(20, 14, 20, 6),
sliver: SliverToBoxAdapter(
child: _ModuleIdentityCard(installedWidget: installedWidget),
),
),
if (_searchMode)
SliverPadding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 56),
sliver: SliverList(
delegate: SliverChildListDelegate.fixed([
_buildSearchFilterPanel(manifest),
if (_filtersExpanded && searchParameters.isNotEmpty)
const SizedBox(height: 18),
_buildSearchResults(),
]),
),
)
else if (contentModules.isEmpty)
const SliverFillRemaining(
hasScrollBody: false,
child: EmptyState(
icon: Icons.video_library_outlined,
title: '没有内容功能',
message: '该模块没有可浏览的内容功能。',
),
)
else
SliverList(
delegate: SliverChildListDelegate.fixed([
for (final module in contentModules)
_ModulePreviewSection(
widgetId: widget.widgetId,
module: module,
),
const SizedBox(height: 56),
]),
),
],
);
}
Widget _buildSearchFilterPanel(ScriptWidgetManifest manifest) {
final searchModule = manifest.search;
final parameters = _searchParameters(searchModule);
return AnimatedSize(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
child: !_filtersExpanded || searchModule == null || parameters.isEmpty
? const SizedBox.shrink()
: AppCard(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (searchModule.description.trim().isNotEmpty) ...[
Text(
searchModule.description,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: context.appTokens.mutedForeground,
),
),
const SizedBox(height: 14),
],
ScriptWidgetParameterForm(
parameters: parameters,
initialValues: _parametersFor(searchModule),
onChanged: (values) {
_parametersFor(searchModule).addAll(values);
},
),
const SizedBox(height: 14),
Align(
alignment: Alignment.centerRight,
child: FButton(
onPress: _search,
child: const Text('应用并搜索'),
),
),
],
),
),
);
}
Widget _buildSearchResults() {
if (_loadingSearch) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 64),
child: Center(child: AppLoadingRing()),
);
}
if (_searchError case final searchError?) {
return AppErrorState(
compact: true,
message: searchError,
onRetry: _search,
);
}
if (_searchSections.every((section) => section.items.isEmpty)) {
return const EmptyState(
compact: true,
icon: Icons.search_rounded,
title: '输入关键词开始搜索',
message: '搜索结果会显示在这里。',
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final section in _searchSections) ...[
_ModuleSearchSection(section: section, widgetId: widget.widgetId),
const SizedBox(height: 28),
],
if (_searchPagingParameter != null)
_PaginationBar(
canGoBack: _canGoBackSearchPage,
pageLabel: _currentSearchPageLabel,
onPrevious: () => unawaited(_changeSearchPage(-1)),
onNext: () => unawaited(_changeSearchPage(1)),
),
],
);
}
}
String _moduleKey(ScriptWidgetModule module) {
return module.id.isEmpty ? module.functionName : module.id;
}
class _ModulePreviewSection extends ConsumerWidget {
final String widgetId;
final ScriptWidgetModule module;
const _ModulePreviewSection({required this.widgetId, required this.module});
ScriptWidgetParam? get _primaryEnumerationParam {
return module.params
.where(
(parameter) =>
parameter.type == 'enumeration' &&
parameter.enumOptions.length >= 2,
)
.firstOrNull;
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final previewKey = (widgetId: widgetId, moduleKey: _moduleKey(module));
final sections = ref.watch(scriptWidgetModulePreviewProvider(previewKey));
final enumerationParam = _primaryEnumerationParam;
final categoryRow = enumerationParam == null
? const SizedBox.shrink()
: _ModuleCategoryRow(
widgetId: widgetId,
moduleKey: _moduleKey(module),
moduleTitle: module.title,
parameter: enumerationParam,
);
return sections.when(
loading: () => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [categoryRow, SkeletonMediaRow(title: module.title)],
),
error: (error, _) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
categoryRow,
ErrorMediaRow(
title: module.title,
message: formatUserError(error),
onRetry: () =>
ref.invalidate(scriptWidgetModulePreviewProvider(previewKey)),
),
],
),
data: (loadedSections) {
final visibleSections = loadedSections
.where((section) => section.items.isNotEmpty)
.toList(growable: false);
if (visibleSections.isEmpty && enumerationParam == null) {
return const SizedBox.shrink();
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
categoryRow,
for (final section in visibleSections)
_ModulePreviewRow(
widgetId: widgetId,
moduleKey: _moduleKey(module),
section: section,
),
],
);
},
);
}
}
class _ModuleCategoryRow extends StatelessWidget {
final String widgetId;
final String moduleKey;
final String moduleTitle;
final ScriptWidgetParam parameter;
const _ModuleCategoryRow({
required this.widgetId,
required this.moduleKey,
required this.moduleTitle,
required this.parameter,
});
static const double _cardHeight = 74;
static const double _cardWidth = 168;
@override
Widget build(BuildContext context) {
final options = parameter.enumOptions;
final headerLabel = parameter.title.trim().isEmpty
? '$moduleTitle · 类型'
: '$moduleTitle · ${parameter.title.trim()}';
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 12, 24, 4),
child: SectionHeader(label: headerLabel, count: options.length),
),
SizedBox(
height: _cardHeight,
child: HoverScrollableRow(
builder: (_, scrollController) => ListView.separated(
controller: scrollController,
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 24),
itemCount: options.length,
separatorBuilder: (_, _) => const SizedBox(width: 12),
itemBuilder: (context, index) {
final option = options[index];
return _CategoryCard(
label: option.title.isEmpty ? option.value : option.title,
width: _cardWidth,
onTap: () => _openCategory(context, option),
);
},
),
),
),
],
);
}
void _openCategory(BuildContext context, ScriptWidgetOption option) {
final targetUri = Uri(
path:
'/modules/${Uri.encodeComponent(widgetId)}/module/'
'${Uri.encodeComponent(moduleKey)}',
queryParameters: <String, String>{parameter.name: option.value},
);
context.push(targetUri.toString());
}
}
class _CategoryCard extends StatefulWidget {
final String label;
final double width;
final VoidCallback onTap;
const _CategoryCard({
required this.label,
required this.width,
required this.onTap,
});
@override
State<_CategoryCard> createState() => _CategoryCardState();
}
class _CategoryCardState extends State<_CategoryCard> {
bool _isHovered = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final tokens = context.appTokens;
return SizedBox(
width: widget.width,
child: AppCard(
filled: true,
enableHover: false,
radius: tokens.radiusCard,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
onTap: widget.onTap,
onHoverChanged: (isHovered) {
if (_isHovered == isHovered) return;
setState(() => _isHovered = isHovered);
},
child: Row(
children: [
Icon(
Icons.style_outlined,
size: 20,
color: theme.colorScheme.primary.withValues(
alpha: _isHovered ? 0.9 : 0.65,
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
widget.label,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
],
),
),
);
}
}
class _ModulePreviewRow extends StatelessWidget {
final String widgetId;
final String moduleKey;
final ScriptWidgetContentSection section;
const _ModulePreviewRow({
required this.widgetId,
required this.moduleKey,
required this.section,
});
@override
Widget build(BuildContext context) {
final statusItems = section.items
.where((item) => item.type.toLowerCase() == 'text')
.toList(growable: false);
final contentItems = section.items
.where((item) => item.type.toLowerCase() != 'text')
.take(20)
.toList(growable: false);
final compact = MediaRowMetrics.isCompact(context);
final cardWidth = MediaRowMetrics.cardWidth(
MediaCardVariant.poster,
compact: compact,
);
final rowHeight = MediaRowMetrics.rowHeight(
MediaCardVariant.poster,
compact: compact,
);
final allRoute =
'/modules/${Uri.encodeComponent(widgetId)}/module/'
'${Uri.encodeComponent(moduleKey)}';
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (contentItems.isNotEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(24, 12, 24, 4),
child: SectionHeader(
label: section.title,
count: contentItems.length,
trailing: TextButton(
onPressed: () => context.push(allRoute),
child: const Text('查看全部'),
),
),
),
for (final statusItem in statusItems)
Padding(
padding: const EdgeInsets.fromLTRB(24, 4, 24, 12),
child: _ModuleStatusCard(item: statusItem),
),
if (contentItems.isNotEmpty)
SizedBox(
height: rowHeight,
child: HoverScrollableRow(
builder: (_, scrollController) => ListView.separated(
controller: scrollController,
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 24),
itemCount: contentItems.length,
separatorBuilder: (_, _) => const SizedBox(width: 14),
itemBuilder: (context, index) {
final item = contentItems[index];
return ScriptVideoCard(
item: item,
width: cardWidth,
onTap: () => context.push(
'/modules/${Uri.encodeComponent(widgetId)}/detail',
extra: item,
),
);
},
),
),
),
],
);
}
}
class _ModuleSearchSection extends StatelessWidget {
final ScriptWidgetContentSection section;
final String widgetId;
const _ModuleSearchSection({required this.section, required this.widgetId});
@override
Widget build(BuildContext context) {
final statusItems = section.items
.where((item) => item.type.toLowerCase() == 'text')
.toList(growable: false);
final contentItems = section.items
.where((item) => item.type.toLowerCase() != 'text')
.toList(growable: false);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SectionHeader(label: section.title, count: contentItems.length),
for (final statusItem in statusItems) ...[
_ModuleStatusCard(item: statusItem),
const SizedBox(height: 12),
],
if (contentItems.isNotEmpty)
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: contentItems.length,
gridDelegate: mediaGridDelegate,
itemBuilder: (context, index) {
final item = contentItems[index];
return ScriptVideoCard(
item: item,
width: double.infinity,
onTap: () => context.push(
'/modules/${Uri.encodeComponent(widgetId)}/detail',
extra: item,
),
);
},
),
],
);
}
}
class _ModuleIdentityCard extends StatelessWidget {
final InstalledScriptWidget installedWidget;
const _ModuleIdentityCard({required this.installedWidget});
@override
Widget build(BuildContext context) {
final manifest = installedWidget.manifest;
return AppCard(
padding: const EdgeInsets.all(16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(14),
),
child: Icon(
Icons.extension_rounded,
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
manifest.description,
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 6),
Text(
[
if (manifest.author.isNotEmpty) manifest.author,
'v${manifest.version}',
].join(' · '),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: context.appTokens.mutedForeground,
),
),
],
),
),
],
),
);
}
}
class _ModuleStatusCard extends StatelessWidget {
final ScriptVideoItem item;
const _ModuleStatusCard({required this.item});
@override
Widget build(BuildContext context) {
final normalizedId = item.id.toLowerCase();
final indicatesError =
normalizedId.startsWith('err') ||
item.title.contains('失败') ||
item.title.contains('错误') ||
item.title.contains('拦截');
final foregroundColor = indicatesError
? Theme.of(context).colorScheme.error
: context.appTokens.mutedForeground;
return AppCard(
padding: const EdgeInsets.all(16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
indicatesError
? Icons.error_outline_rounded
: Icons.info_outline_rounded,
color: foregroundColor,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title.isEmpty ? '模块提示' : item.title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: foregroundColor,
fontWeight: FontWeight.w700,
),
),
if (item.description.trim().isNotEmpty) ...[
const SizedBox(height: 4),
Text(
item.description,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: context.appTokens.mutedForeground,
),
),
],
],
),
),
],
),
);
}
}
class _SearchControlBar extends StatelessWidget {
final TextEditingController searchController;
final String? searchHint;
final bool filtersExpanded;
final bool hasFilters;
final VoidCallback onSearch;
final VoidCallback onToggleFilters;
const _SearchControlBar({
required this.searchController,
required this.searchHint,
required this.filtersExpanded,
required this.hasFilters,
required this.onSearch,
required this.onToggleFilters,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final tokens = context.appTokens;
return SizedBox(
height: 60,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 6, 12, 8),
child: Row(
children: [
Expanded(
child: TextField(
controller: searchController,
autofocus: true,
textInputAction: TextInputAction.search,
decoration: InputDecoration(
hintText: searchHint?.trim().isNotEmpty == true
? searchHint
: '输入关键词',
prefixIcon: const Icon(Icons.search_rounded, size: 19),
filled: true,
fillColor: theme.colorScheme.onSurface.withValues(
alpha: 0.05,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
isDense: true,
),
onSubmitted: (_) => onSearch(),
),
),
const SizedBox(width: 8),
IconButton(
onPressed: onSearch,
icon: const Icon(Icons.arrow_forward_rounded),
tooltip: '搜索',
),
if (hasFilters)
IconButton(
onPressed: onToggleFilters,
icon: Icon(
Icons.tune_rounded,
color: filtersExpanded
? theme.colorScheme.primary
: tokens.mutedForeground,
),
tooltip: filtersExpanded ? '收起筛选' : '展开筛选',
),
],
),
),
);
}
}
class _PaginationBar extends StatelessWidget {
final bool canGoBack;
final String pageLabel;
final VoidCallback onPrevious;
final VoidCallback onNext;
const _PaginationBar({
required this.canGoBack,
required this.pageLabel,
required this.onPrevious,
required this.onNext,
});
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
FButton(
variant: FButtonVariant.outline,
onPress: canGoBack ? onPrevious : null,
child: const Text('上一页'),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 14),
child: Text(
pageLabel,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: context.appTokens.mutedForeground,
),
),
),
FButton(onPress: onNext, child: const Text('下一页')),
],
);
}
}