423 lines
16 KiB
Dart
423 lines
16 KiB
Dart
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(reportsProvider);
|
|
final institutionsSnapshot = ref.watch(institutionsProvider);
|
|
final controller = ref.read(reportFilterProvider.notifier);
|
|
final institutions = institutionsSnapshot.maybeWhen(
|
|
data: (items) => items,
|
|
orElse: () => const <Institution>[],
|
|
);
|
|
|
|
return snapshot.when(
|
|
loading: () => const LoadingState(label: '正在加载研报'),
|
|
error: (error, _) => ErrorState(
|
|
message: error.toString(),
|
|
onRetry: () => ref.invalidate(reportsProvider),
|
|
),
|
|
data: (allReports) {
|
|
final items = _applyReportQuery(allReports, query);
|
|
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),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
List<ReportCardModel> _applyReportQuery(
|
|
List<ReportCardModel> items,
|
|
ReportQuery query,
|
|
) {
|
|
final search = query.search.trim().toLowerCase();
|
|
final filtered = items.where((item) {
|
|
final haystack =
|
|
'${item.titleCn} ${item.subtitleCn} ${item.oneLiner} '
|
|
'${item.institution.nameCn} ${item.institution.nameEn} '
|
|
'${item.topics.join(' ')}'
|
|
.toLowerCase();
|
|
if (search.isNotEmpty && !haystack.contains(search)) {
|
|
return false;
|
|
}
|
|
if (query.topic != null && !item.topics.contains(query.topic)) {
|
|
return false;
|
|
}
|
|
if (query.institutionId != null &&
|
|
item.institution.id != query.institutionId) {
|
|
return false;
|
|
}
|
|
if (query.hasAudio && !item.hasAudio) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}).toList();
|
|
filtered.sort((a, b) {
|
|
final result = (b.releasedAt ?? '').compareTo(a.releasedAt ?? '');
|
|
return query.sort == ReportSort.oldest ? -result : result;
|
|
});
|
|
return filtered;
|
|
}
|
|
|
|
void _openFilterSheet(
|
|
BuildContext context, {
|
|
required List<ReportCardModel> items,
|
|
required List<Institution> 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<void>(
|
|
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),
|
|
);
|
|
}
|
|
}
|