fix:对比原型增加功能交互

This commit is contained in:
jingyun
2026-06-07 10:58:05 +08:00
parent af865b13fb
commit ac794ae58a
21 changed files with 1342 additions and 233 deletions
+224 -117
View File
@@ -6,6 +6,8 @@ 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';
@@ -44,28 +46,34 @@ class ReportsPage extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final theme = ShadTheme.of(context);
final searchController = useTextEditingController();
final query = useState('');
final topic = useState('');
final hasAudio = useState(false);
final snapshot = ref.watch(reportsProvider);
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 <ReportCardModel>[],
);
final institutions = institutionsSnapshot.maybeWhen(
data: (items) => items,
orElse: () => const <Institution>[],
);
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(reportsProvider),
onRetry: () => ref.invalidate(filteredReportsProvider),
),
data: (items) {
final currentQuery = query.value;
final currentTopic = topic.value;
final currentHasAudio = hasAudio.value;
final filtered = _applyFilters(
items,
query: currentQuery,
topic: currentTopic,
hasAudio: currentHasAudio,
);
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(
YantingSpacing.screenX,
@@ -84,7 +92,7 @@ class ReportsPage extends HookConsumerWidget {
padding: EdgeInsets.only(right: 8),
child: Icon(LucideIcons.search, size: 16),
),
trailing: currentQuery.isEmpty
trailing: query.search.isEmpty
? null
: Padding(
padding: const EdgeInsets.only(left: 8),
@@ -92,12 +100,12 @@ class ReportsPage extends HookConsumerWidget {
size: ShadButtonSize.sm,
onPressed: () {
searchController.clear();
query.value = '';
controller.setSearch('');
},
child: const Icon(LucideIcons.x, size: 16),
),
),
onChanged: (value) => query.value = value.trim(),
onChanged: (value) => controller.setSearch(value.trim()),
),
const SizedBox(height: YantingSpacing.cardGap),
Row(
@@ -109,36 +117,42 @@ class ReportsPage extends HookConsumerWidget {
runSpacing: YantingSpacing.x2,
children: [
ShadButton.outline(
onPressed: items.isEmpty
onPressed: allReports.isEmpty
? null
: () => _openFilterSheet(
context,
items: items,
topic: topic,
items: allReports,
institutions: institutions,
),
leading: const Icon(
LucideIcons.slidersHorizontal,
size: 16,
),
child: const Text('筛选'),
child: Text(query.hasActiveFilter ? '筛选中' : '筛选'),
),
ShadButton.outline(
onPressed: () {},
onPressed: () => controller.setSort(
query.sort == ReportSort.latest
? ReportSort.oldest
: ReportSort.latest,
),
leading: const Icon(
LucideIcons.arrowUpDown,
size: 16,
),
child: const Text('最新'),
child: Text(
query.sort == ReportSort.latest ? '最新' : '最早',
),
),
ShadBadge.secondary(
onPressed: () => hasAudio.value = !currentHasAudio,
backgroundColor: currentHasAudio
onPressed: controller.toggleAudio,
backgroundColor: query.hasAudio
? theme.colorScheme.foreground
: theme.colorScheme.secondary,
foregroundColor: currentHasAudio
foregroundColor: query.hasAudio
? theme.colorScheme.background
: theme.colorScheme.secondaryForeground,
hoverBackgroundColor: currentHasAudio
hoverBackgroundColor: query.hasAudio
? theme.colorScheme.foreground.withValues(
alpha: 0.9,
)
@@ -151,40 +165,40 @@ class ReportsPage extends HookConsumerWidget {
const SizedBox(width: YantingSpacing.x2),
Padding(
padding: const EdgeInsets.only(top: 10),
child: Text(
'${filtered.length}',
style: YantingText.meta,
),
child: Text('${items.length}', style: YantingText.meta),
),
],
),
const SizedBox(height: YantingSpacing.cardGap),
if (filtered.isEmpty)
if (items.isEmpty)
EmptyState(
title: currentQuery.isNotEmpty ? '未找到相关研报' : '当前筛选下暂无研报',
message: currentQuery.isNotEmpty ? '换个关键词试试' : '调整筛选条件后再试',
title: query.search.isNotEmpty ? '未找到相关研报' : '当前筛选下暂无研报',
message: query.search.isNotEmpty ? '换个关键词试试' : '调整筛选条件后再试',
actionLabel: '清除筛选',
onAction: () {
searchController.clear();
query.value = '';
topic.value = '';
hasAudio.value = false;
controller.reset();
},
)
else
for (final report in filtered) ...[
for (final report in items) ...[
ReportCardWidget(
report: report,
onTap: () => openReportDetail(
context,
dataSource,
report,
player: player,
onStartAudio: onStartModuleAudio,
onToggleAudio: onToggleAudio,
onSeekAudio: onSeekAudio,
onSpeed: onSpeed,
),
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}',
@@ -206,88 +220,181 @@ class ReportsPage extends HookConsumerWidget {
}
}
List<ReportCardModel> _applyFilters(
List<ReportCardModel> items, {
required String query,
required String topic,
required bool hasAudio,
}) {
return items.where((item) {
final hay =
'${item.titleCn} ${item.institution.nameCn} ${item.topics.join(' ')}'
.toLowerCase();
if (query.isNotEmpty && !hay.contains(query.toLowerCase())) return false;
if (topic.isNotEmpty && !item.topics.contains(topic)) return false;
if (hasAudio && !item.hasAudio) return false;
return true;
}).toList();
}
void _openFilterSheet(
BuildContext context, {
required List<ReportCardModel> items,
required ValueNotifier<String> topic,
required List<Institution> institutions,
}) {
final topics = {for (final item in items) ...item.topics}.toList();
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) {
final theme = ShadTheme.of(context);
final selectedBackground = theme.colorScheme.foreground;
final selectedForeground = theme.colorScheme.background;
final unselectedBackground = theme.colorScheme.secondary;
final unselectedForeground = theme.colorScheme.secondaryForeground;
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;
return ShadSheet(
title: const Text('筛选研报'),
description: const Text('按主题快速收窄列表。'),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: YantingSpacing.x2,
runSpacing: YantingSpacing.x2,
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: [
ShadBadge.secondary(
onPressed: () => topic.value = '',
backgroundColor: topic.value.isEmpty
? selectedBackground
: unselectedBackground,
foregroundColor: topic.value.isEmpty
? selectedForeground
: unselectedForeground,
hoverBackgroundColor: topic.value.isEmpty
? selectedBackground.withValues(alpha: 0.9)
: theme.colorScheme.border,
child: const Text('全部主题'),
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('查看结果'),
),
),
],
),
for (final t in topics)
ShadBadge.secondary(
onPressed: () => topic.value = t,
backgroundColor: topic.value == t
? selectedBackground
: unselectedBackground,
foregroundColor: topic.value == t
? selectedForeground
: unselectedForeground,
hoverBackgroundColor: topic.value == t
? selectedBackground.withValues(alpha: 0.9)
: theme.colorScheme.border,
child: Text(t),
),
],
),
const SizedBox(height: 12),
ShadButton(
width: double.infinity,
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),
);
}
}