fix:对比原型增加功能交互
This commit is contained in:
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user