import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import '../../data/api/report_data_source.dart'; import '../../data/content_providers.dart'; import '../../data/models/models.dart'; import '../../data/providers.dart'; import '../../data/state/report_query.dart'; import '../../routing/app_routes.dart'; import '../../theme/yanting_text.dart'; import '../../theme/yanting_tokens.dart'; import '../../widgets/mini_player.dart'; import '../../widgets/page_header.dart'; import '../../widgets/states.dart'; import '../shared/report_card_widget.dart'; class ReportsPage extends HookConsumerWidget { const ReportsPage({ required this.dataSource, required this.onPlay, this.player = const PlayerStateModel(), this.onStartModuleAudio, this.onToggleAudio, this.onSeekAudio, this.onSpeed, super.key, }); final ReportDataSource dataSource; final void Function(AudioItem item) onPlay; final PlayerStateModel player; final void Function( String audioId, String reportId, String title, int durationSec, )? onStartModuleAudio; final VoidCallback? onToggleAudio; final void Function(int delta)? onSeekAudio; final VoidCallback? onSpeed; @override Widget build(BuildContext context, WidgetRef ref) { final theme = ShadTheme.of(context); final searchController = useTextEditingController(); final query = ref.watch(reportFilterProvider); final snapshot = ref.watch(filteredReportsProvider); final allReportsSnapshot = ref.watch(reportsProvider); final institutionsSnapshot = ref.watch(institutionsProvider); final controller = ref.read(reportFilterProvider.notifier); final allReports = allReportsSnapshot.maybeWhen( data: (items) => items, orElse: () => const [], ); final institutions = institutionsSnapshot.maybeWhen( data: (items) => items, orElse: () => const [], ); useEffect(() { if (searchController.text != query.search) { searchController.text = query.search; } return null; }, [query.search]); return snapshot.when( loading: () => const LoadingState(label: '正在搜索研报'), error: (error, _) => ErrorState( message: error.toString(), onRetry: () => ref.invalidate(filteredReportsProvider), ), data: (items) { return SingleChildScrollView( padding: const EdgeInsets.fromLTRB( YantingSpacing.screenX, 4, YantingSpacing.screenX, 16, ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const PageHeader(title: '研报', subtitle: '全部已发布研报解读'), ShadInput( controller: searchController, placeholder: const Text('搜索标题、机构或主题'), leading: const Padding( padding: EdgeInsets.only(right: 8), child: Icon(LucideIcons.search, size: 16), ), trailing: query.search.isEmpty ? null : Padding( padding: const EdgeInsets.only(left: 8), child: ShadButton.ghost( size: ShadButtonSize.sm, onPressed: () { searchController.clear(); controller.setSearch(''); }, child: const Icon(LucideIcons.x, size: 16), ), ), onChanged: (value) => controller.setSearch(value.trim()), ), const SizedBox(height: YantingSpacing.cardGap), Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Wrap( spacing: YantingSpacing.x2, runSpacing: YantingSpacing.x2, children: [ ShadButton.outline( onPressed: allReports.isEmpty ? null : () => _openFilterSheet( context, items: allReports, institutions: institutions, ), leading: const Icon( LucideIcons.slidersHorizontal, size: 16, ), child: Text(query.hasActiveFilter ? '筛选中' : '筛选'), ), ShadButton.outline( onPressed: () => controller.setSort( query.sort == ReportSort.latest ? ReportSort.oldest : ReportSort.latest, ), leading: const Icon( LucideIcons.arrowUpDown, size: 16, ), child: Text( query.sort == ReportSort.latest ? '最新' : '最早', ), ), ShadBadge.secondary( onPressed: controller.toggleAudio, backgroundColor: query.hasAudio ? theme.colorScheme.foreground : theme.colorScheme.secondary, foregroundColor: query.hasAudio ? theme.colorScheme.background : theme.colorScheme.secondaryForeground, hoverBackgroundColor: query.hasAudio ? theme.colorScheme.foreground.withValues( alpha: 0.9, ) : theme.colorScheme.border, child: const Text('音频'), ), ], ), ), const SizedBox(width: YantingSpacing.x2), Padding( padding: const EdgeInsets.only(top: 10), child: Text('共 ${items.length} 篇', style: YantingText.meta), ), ], ), const SizedBox(height: YantingSpacing.cardGap), if (items.isEmpty) EmptyState( title: query.search.isNotEmpty ? '未找到相关研报' : '当前筛选下暂无研报', message: query.search.isNotEmpty ? '换个关键词试试' : '调整筛选条件后再试', actionLabel: '清除筛选', onAction: () { searchController.clear(); controller.reset(); }, ) else for (final report in items) ...[ ReportCardWidget( report: report, onTap: () { ref .read(profileControllerProvider.notifier) .addHistory(report.id); openReportDetail( context, dataSource, report, player: player, onStartAudio: onStartModuleAudio, onToggleAudio: onToggleAudio, onSeekAudio: onSeekAudio, onSpeed: onSpeed, ); }, onPlayTap: () => onPlay( AudioItem( audioId: 'local_${report.id}', reportId: report.id, titleCn: report.titleCn, reportTitleCn: report.titleCn, durationSec: 180, institution: report.institution, ), ), ), const SizedBox(height: YantingSpacing.x3), ], ], ), ); }, ); } } void _openFilterSheet( BuildContext context, { required List items, required List institutions, }) { const demoTopics = ['宏观', '贵金属', '大宗', '能源', '跨资产', '央行']; final dynamicTopics = {for (final item in items) ...item.topics}; final topics = [ ...demoTopics, ...dynamicTopics.where((topic) => !demoTopics.contains(topic)), ]; final orderedInstitutions = [...institutions] ..sort((a, b) => b.reportCount.compareTo(a.reportCount)); showShadSheet( context: context, side: ShadSheetSide.bottom, builder: (context) { return Consumer( builder: (context, ref, _) { final theme = ShadTheme.of(context); final query = ref.watch(reportFilterProvider); final controller = ref.read(reportFilterProvider.notifier); final selectedBackground = theme.colorScheme.foreground; final selectedForeground = theme.colorScheme.background; final unselectedBackground = theme.colorScheme.secondary; final unselectedForeground = theme.colorScheme.secondaryForeground; ShadBadge option({ required String label, required bool selected, required VoidCallback onPressed, }) { return ShadBadge.secondary( onPressed: onPressed, backgroundColor: selected ? selectedBackground : unselectedBackground, foregroundColor: selected ? selectedForeground : unselectedForeground, hoverBackgroundColor: selected ? selectedBackground.withValues(alpha: 0.9) : theme.colorScheme.border, child: Text(label), ); } return ShadSheet( title: const Text('筛选研报'), description: const Text('按主题、机构和音频状态收窄列表。'), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const _FilterGroupTitle('主题'), Wrap( spacing: YantingSpacing.x2, runSpacing: YantingSpacing.x2, children: [ option( label: '全部主题', selected: query.topic == null, onPressed: () => controller.setTopic(null), ), for (final topic in topics) option( label: topic, selected: query.topic == topic, onPressed: () => controller.setTopic( query.topic == topic ? null : topic, ), ), ], ), const SizedBox(height: YantingSpacing.x4), const _FilterGroupTitle('机构'), Wrap( spacing: YantingSpacing.x2, runSpacing: YantingSpacing.x2, children: [ option( label: '全部机构', selected: query.institutionId == null, onPressed: () => controller.setInstitution(null), ), for (final institution in orderedInstitutions.take(8)) option( label: institution.nameCn, selected: query.institutionId == institution.id, onPressed: () => controller.setInstitution( query.institutionId == institution.id ? null : institution.id, ), ), ], ), const SizedBox(height: YantingSpacing.x4), const _FilterGroupTitle('音频'), Wrap( spacing: YantingSpacing.x2, runSpacing: YantingSpacing.x2, children: [ option( label: '不限', selected: !query.hasAudio, onPressed: () { if (query.hasAudio) controller.toggleAudio(); }, ), option( label: '只看音频', selected: query.hasAudio, onPressed: () { if (!query.hasAudio) controller.toggleAudio(); }, ), ], ), const SizedBox(height: YantingSpacing.x4), const _FilterGroupTitle('排序'), Wrap( spacing: YantingSpacing.x2, runSpacing: YantingSpacing.x2, children: [ option( label: '最新发布', selected: query.sort == ReportSort.latest, onPressed: () => controller.setSort(ReportSort.latest), ), option( label: '最早发布', selected: query.sort == ReportSort.oldest, onPressed: () => controller.setSort(ReportSort.oldest), ), ], ), const SizedBox(height: YantingSpacing.x4), Row( children: [ Expanded( child: ShadButton.outline( onPressed: controller.reset, child: const Text('重置'), ), ), const SizedBox(width: YantingSpacing.x2), Expanded( child: ShadButton( onPressed: () => Navigator.pop(context), child: const Text('查看结果'), ), ), ], ), ], ), ); }, ); }, ); } class _FilterGroupTitle extends StatelessWidget { const _FilterGroupTitle(this.label); final String label; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.only(bottom: YantingSpacing.x2), child: Text(label, style: YantingText.sectionTitle), ); } }