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
+105 -6
View File
@@ -5,6 +5,8 @@ import 'package:shadcn_ui/shadcn_ui.dart';
import '../../data/api/report_data_source.dart';
import '../../data/models/models.dart';
import '../../data/providers.dart';
import '../../data/state/app_interaction_state.dart';
import '../../theme/app_icons.dart';
import '../../theme/yanting_text.dart';
import '../../theme/yanting_tokens.dart';
@@ -197,35 +199,132 @@ class _ReportDetailContent extends StatelessWidget {
}
}
class _ActionBar extends StatelessWidget {
class _ActionBar extends ConsumerWidget {
const _ActionBar({required this.detail});
final ReportDetail detail;
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final auth = ref.watch(authControllerProvider);
final profile = ref.watch(profileControllerProvider);
final isFavorite = profile.favorites.contains(detail.id);
final isSavedListen = profile.savedListens.contains(detail.id);
return Row(
children: [
Expanded(
child: AppButton(
label: '收藏',
icon: AppIcons.heart,
label: isFavorite ? '已收藏' : '收藏',
icon: isFavorite ? AppIcons.heartFill : AppIcons.heart,
kind: AppButtonKind.ghost,
onPressed: () => showLoginSheet(context, reason: '登录后保存到你的收藏'),
onPressed: () => _runLoginRequiredAction(
context,
ref,
auth,
PendingLoginAction(
action: LoginRequiredAction.favorite,
reportId: detail.id,
contextText: '登录后保存到你的收藏',
),
),
),
),
const SizedBox(width: YantingSpacing.x2),
if (detail.hasAudio) ...[
Expanded(
child: AppButton(
label: isSavedListen ? '已存听单' : '听单',
icon: isSavedListen
? AppIcons.headphonesFill
: AppIcons.headphones,
kind: AppButtonKind.ghost,
onPressed: () => _runLoginRequiredAction(
context,
ref,
auth,
PendingLoginAction(
action: LoginRequiredAction.saveListen,
reportId: detail.id,
contextText: '登录后保存到你的听单',
),
),
),
),
const SizedBox(width: YantingSpacing.x2),
],
Expanded(
child: AppButton(
label: '原文',
icon: AppIcons.externalLink,
kind: AppButtonKind.ghost,
onPressed: () => showOutboundSheet(context, title: detail.titleCn),
onPressed: () => _showSourceSheet(context, ref),
),
),
],
);
}
void _runLoginRequiredAction(
BuildContext context,
WidgetRef ref,
AuthState auth,
PendingLoginAction action,
) {
if (auth.loggedIn) {
_applyPendingAction(ref, action);
return;
}
ref.read(authControllerProvider.notifier).requireLogin(action);
showLoginSheet(
context,
reason: action.contextText,
onPhoneLogin: () => _loginAndApply(ref, LoginMethod.phone),
onSecondaryLogin: () => _loginAndApply(ref, LoginMethod.wechat),
);
}
void _loginAndApply(WidgetRef ref, LoginMethod method) {
ref.read(authControllerProvider.notifier).login(method).then((pending) {
if (pending != null) {
_applyPendingAction(ref, pending);
}
});
}
void _applyPendingAction(WidgetRef ref, PendingLoginAction action) {
final controller = ref.read(profileControllerProvider.notifier);
switch (action.action) {
case LoginRequiredAction.favorite:
controller.toggleFavorite(action.reportId);
case LoginRequiredAction.saveListen:
controller.toggleSavedListen(action.reportId);
}
}
void _showSourceSheet(BuildContext context, WidgetRef ref) {
final targetUrl = asString(
detail.source['url'],
asString(
detail.source['source_url'],
asString(detail.source['original_url']),
),
);
showOutboundSheet(
context,
title: detail.titleCn,
onConfirm: () => ref
.read(outboundRepositoryProvider)
.recordOutbound(
OutboundEvent(
scene: 'report_source',
refId: detail.id,
targetUrl: targetUrl.isEmpty ? null : targetUrl,
),
),
);
}
}
class _Toc extends StatelessWidget {
+42 -42
View File
@@ -1,10 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../data/api/report_data_source.dart';
import '../../data/content_providers.dart';
import '../../data/models/models.dart';
import '../../data/providers.dart';
import '../../routing/app_routes.dart';
import '../../theme/yanting_tokens.dart';
import '../../widgets/badges.dart';
@@ -41,28 +41,25 @@ class FeedPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final topic = useState('全部');
final snapshot = ref.watch(recommendedReportsProvider);
final currentTopic = ref.watch(recommendTopicProvider);
final snapshot = ref.watch(recommendedByTopicProvider);
const topics = ['全部', '宏观', '贵金属', '大宗', '能源', '跨资产', '央行'];
return snapshot.when(
loading: () => const LoadingState(),
error: (error, _) => ErrorState(
message: error.toString(),
onRetry: () => ref.invalidate(recommendedReportsProvider),
onRetry: () => ref.invalidate(recommendedByTopicProvider),
),
data: (items) {
final currentTopic = topic.value;
final topics = [
'全部',
...{for (final item in items) ...item.topics},
];
final visible = currentTopic == '全部'
? items
: items
.where((item) => item.topics.contains(currentTopic))
.toList();
if (items.isEmpty) {
return const EmptyState(title: '暂无可推荐的研报解读', message: '稍后再来看看最新内容');
return EmptyState(
title: currentTopic == '全部' ? '暂无可推荐的研报解读' : '当前主题暂无内容',
message: currentTopic == '全部' ? '稍后再来看看最新内容' : '换个主题,或去研报页看看全部内容',
icon: currentTopic == '全部'
? Icons.inbox_outlined
: Icons.filter_alt_off,
);
}
return ListView(
padding: const EdgeInsets.fromLTRB(
@@ -83,41 +80,44 @@ class FeedPage extends HookConsumerWidget {
child: AppChip(
label: t,
selected: t == currentTopic,
onTap: () => topic.value = t,
onTap: () =>
ref.read(recommendTopicProvider.notifier).select(t),
),
),
],
),
),
const SizedBox(height: YantingSpacing.cardGap),
if (visible.isEmpty)
const EmptyState(
title: '暂无可推荐的研报解读',
message: '换个主题,或去研报页看看全部内容',
icon: Icons.filter_alt_off,
)
else ...[
ReportCardWidget(
report: visible.first,
hero: true,
onTap: () => openReportDetail(
ReportCardWidget(
report: items.first,
hero: true,
onTap: () {
ref
.read(profileControllerProvider.notifier)
.addHistory(items.first.id);
openReportDetail(
context,
dataSource,
visible.first,
items.first,
player: player,
onStartAudio: onStartModuleAudio,
onToggleAudio: onToggleAudio,
onSeekAudio: onSeekAudio,
onSpeed: onSpeed,
),
onPlayTap: () => _playFromReport(onPlay, visible.first),
),
const SizedBox(height: YantingSpacing.sectionGap),
const SectionTitle(title: '最新解读', icon: Icons.chevron_right),
for (final report in visible.skip(1)) ...[
ReportCardWidget(
report: report,
onTap: () => openReportDetail(
);
},
onPlayTap: () => _playFromReport(onPlay, items.first),
),
const SizedBox(height: YantingSpacing.sectionGap),
const SectionTitle(title: '最新解读', icon: Icons.chevron_right),
for (final report in items.skip(1)) ...[
ReportCardWidget(
report: report,
onTap: () {
ref
.read(profileControllerProvider.notifier)
.addHistory(report.id);
openReportDetail(
context,
dataSource,
report,
@@ -126,11 +126,11 @@ class FeedPage extends HookConsumerWidget {
onToggleAudio: onToggleAudio,
onSeekAudio: onSeekAudio,
onSpeed: onSpeed,
),
onPlayTap: () => _playFromReport(onPlay, report),
),
const SizedBox(height: YantingSpacing.x3),
],
);
},
onPlayTap: () => _playFromReport(onPlay, report),
),
const SizedBox(height: YantingSpacing.x3),
],
],
);
@@ -5,6 +5,8 @@ import 'package:shadcn_ui/shadcn_ui.dart';
import '../../data/api/report_data_source.dart';
import '../../data/models/models.dart';
import '../../data/providers.dart';
import '../../data/state/app_interaction_state.dart';
import '../../routing/app_routes.dart';
import '../../theme/app_icons.dart';
import '../../theme/yanting_text.dart';
@@ -67,7 +69,7 @@ class InstitutionDetailPage extends HookConsumerWidget {
}
}
class _InstitutionDetailContent extends StatelessWidget {
class _InstitutionDetailContent extends ConsumerWidget {
const _InstitutionDetailContent({
required this.item,
required this.dataSource,
@@ -77,7 +79,7 @@ class _InstitutionDetailContent extends StatelessWidget {
final ReportDataSource dataSource;
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
return ListView(
padding: const EdgeInsets.fromLTRB(
YantingSpacing.x4,
@@ -165,7 +167,12 @@ class _InstitutionDetailContent extends StatelessWidget {
for (final report in item.recentReports) ...[
ReportCardWidget(
report: report,
onTap: () => openReportDetail(context, dataSource, report),
onTap: () {
ref
.read(profileControllerProvider.notifier)
.addHistory(report.id);
openReportDetail(context, dataSource, report);
},
),
const SizedBox(height: YantingSpacing.x3),
],
@@ -174,7 +181,19 @@ class _InstitutionDetailContent extends StatelessWidget {
icon: AppIcons.externalLink,
kind: AppButtonKind.ghost,
expand: true,
onPressed: () => showOutboundSheet(context, title: item.nameCn),
onPressed: () => showOutboundSheet(
context,
title: item.nameCn,
onConfirm: () => ref
.read(outboundRepositoryProvider)
.recordOutbound(
OutboundEvent(
scene: 'institution_service',
refId: item.id,
targetUrl: item.websiteUrl.isEmpty ? null : item.websiteUrl,
),
),
),
),
],
);
+17 -8
View File
@@ -5,6 +5,7 @@ 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 '../../theme/app_icons.dart';
import '../../theme/yanting_text.dart';
import '../../theme/yanting_tokens.dart';
@@ -49,12 +50,15 @@ class ListenPage extends HookConsumerWidget {
const SectionTitle(title: '继续收听'),
_ContinueListeningCard(
item: current,
onPlay: () => onPlay(current),
onPlay: () => _playAndRemember(ref, current),
),
const SectionTitle(title: '全部音频', icon: AppIcons.arrowRight),
const SizedBox(height: YantingSpacing.cardGap),
for (final item in items.skip(1)) ...[
_AudioListCard(item: item, onPlay: () => onPlay(item)),
_AudioListCard(
item: item,
onPlay: () => _playAndRemember(ref, item),
),
const SizedBox(height: YantingSpacing.x3),
],
],
@@ -62,6 +66,11 @@ class ListenPage extends HookConsumerWidget {
},
);
}
void _playAndRemember(WidgetRef ref, AudioItem item) {
ref.read(profileControllerProvider.notifier).addHistory(item.reportId);
onPlay(item);
}
}
class _ContinueListeningCard extends StatelessWidget {
@@ -98,13 +107,13 @@ class _ContinueListeningCard extends StatelessWidget {
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 8,
children: [
Text(
item.institution.nameCn,
style: YantingText.meta.copyWith(
color: colors.foreground,
fontWeight: FontWeight.w500,
Text(
item.institution.nameCn,
style: YantingText.meta.copyWith(
color: colors.foreground,
fontWeight: FontWeight.w500,
),
),
),
Text('·', style: YantingText.meta),
Text(
'全长 ${formatDuration(item.durationSec)}',
+183 -12
View File
@@ -1,10 +1,15 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../data/api/report_data_source.dart';
import '../../theme/app_icons.dart';
import '../../data/content_providers.dart';
import '../../data/models/models.dart';
import '../../data/providers.dart';
import '../../data/state/app_interaction_state.dart';
import '../../routing/app_routes.dart';
import '../../theme/app_icons.dart';
import '../../theme/yanting_text.dart';
import '../../theme/yanting_tokens.dart';
import '../../widgets/app_buttons.dart';
@@ -12,15 +17,34 @@ import '../../widgets/app_card.dart';
import '../../widgets/page_header.dart';
import '../../widgets/sheets.dart';
import '../../widgets/states.dart';
import '../shared/report_card_widget.dart';
class ProfilePage extends StatelessWidget {
class ProfilePage extends ConsumerWidget {
const ProfilePage({required this.dataSource, super.key});
final ReportDataSource dataSource;
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final colors = ShadTheme.of(context).colorScheme;
final auth = ref.watch(authControllerProvider);
final profile = ref.watch(profileControllerProvider);
final historySnapshot = ref.watch(profileHistoryReportsProvider);
final favoriteSnapshot = ref.watch(profileFavoriteReportsProvider);
final savedListenSnapshot = ref.watch(profileSavedListenReportsProvider);
final historyCount = historySnapshot.maybeWhen(
data: (items) => items.length,
orElse: () => profile.history.length,
);
final favoriteCount = favoriteSnapshot.maybeWhen(
data: (items) => items.length,
orElse: () => profile.favorites.length,
);
final savedListenCount = savedListenSnapshot.maybeWhen(
data: (items) => items.length,
orElse: () => profile.savedListens.length,
);
return ListView(
padding: const EdgeInsets.fromLTRB(
YantingSpacing.screenX,
@@ -46,7 +70,7 @@ class ProfilePage extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'未登录',
auth.loggedIn ? '已登录' : '未登录',
style: YantingText.cardTitle.copyWith(
fontSize: 18,
color: colors.foreground,
@@ -54,7 +78,7 @@ class ProfilePage extends StatelessWidget {
),
const SizedBox(height: 5),
Text(
'登录后同步收藏、历史和听单',
auth.loggedIn ? '收藏、历史和听单已在本地同步' : '登录后同步收藏、历史和听单',
style: YantingText.meta.copyWith(
height: 1.5,
color: colors.mutedForeground,
@@ -68,9 +92,16 @@ class ProfilePage extends StatelessWidget {
),
const SizedBox(height: YantingSpacing.x3),
AppButton(
label: '登录 / 注册',
label: auth.loggedIn ? '退出登录' : '登录 / 注册',
expand: true,
onPressed: () => showLoginSheet(context),
onPressed: auth.loggedIn
? () => ref.read(authControllerProvider.notifier).logout()
: () => showLoginSheet(
context,
reason: '登录后同步收藏、历史和听单',
onPhoneLogin: () => _login(ref, LoginMethod.phone),
onSecondaryLogin: () => _login(ref, LoginMethod.wechat),
),
),
const SizedBox(height: 18),
_MenuGroup(
@@ -78,8 +109,43 @@ class ProfilePage extends StatelessWidget {
_MenuRow(
icon: AppIcons.history,
title: '本地浏览记录',
trailing: '0 条 · 本地临时',
onTap: () => showAppToast(context, '历史同步接口待接入'),
trailing: '$historyCount 条 · 本地临时',
onTap: () => _showProfileListSheet(
context,
ref,
title: '本地浏览记录',
snapshot: historySnapshot,
emptyTitle: '暂无本地浏览记录',
emptyMessage: '打开研报详情或播放音频后会出现在这里',
),
),
_MenuRow(
icon: AppIcons.heart,
title: '我的收藏',
trailing: '$favoriteCount',
onTap: () => _showLoginAwareList(
context,
ref,
auth,
title: '我的收藏',
snapshot: favoriteSnapshot,
emptyTitle: '暂无收藏',
emptyMessage: '在研报详情页点击收藏后会出现在这里',
),
),
_MenuRow(
icon: AppIcons.headphones,
title: '已存听单',
trailing: '$savedListenCount',
onTap: () => _showLoginAwareList(
context,
ref,
auth,
title: '已存听单',
snapshot: savedListenSnapshot,
emptyTitle: '暂无已存听单',
emptyMessage: '在音频研报详情页保存听单后会出现在这里',
),
),
],
),
@@ -94,19 +160,21 @@ class ProfilePage extends StatelessWidget {
_MenuRow(
icon: AppIcons.fileList,
title: '用户协议',
onTap: () => showOutboundSheet(context, title: '用户协议'),
onTap: () =>
_showOutbound(context, ref, 'user_agreement', '用户协议'),
),
_MenuRow(
icon: AppIcons.shield,
title: '隐私政策',
onTap: () => showOutboundSheet(context, title: '隐私政策'),
onTap: () =>
_showOutbound(context, ref, 'privacy_policy', '隐私政策'),
),
],
),
const SizedBox(height: YantingSpacing.x3),
AppCard(
color: colors.secondary,
onTap: () => showOutboundSheet(context, title: '相关服务'),
onTap: () => _showOutbound(context, ref, 'profile_services', '相关服务'),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -143,6 +211,109 @@ class ProfilePage extends StatelessWidget {
],
);
}
Future<void> _login(WidgetRef ref, LoginMethod method) async {
await ref.read(authControllerProvider.notifier).login(method);
await ref.read(profileControllerProvider.notifier).refresh();
}
void _showOutbound(
BuildContext context,
WidgetRef ref,
String scene,
String title,
) {
showOutboundSheet(
context,
title: title,
onConfirm: () => ref
.read(outboundRepositoryProvider)
.recordOutbound(OutboundEvent(scene: scene)),
);
}
void _showLoginAwareList(
BuildContext context,
WidgetRef ref,
AuthState auth, {
required String title,
required AsyncValue<List<ReportCardModel>> snapshot,
required String emptyTitle,
required String emptyMessage,
}) {
if (!auth.loggedIn) {
showLoginSheet(
context,
reason: '登录后查看$title',
onPhoneLogin: () => _login(ref, LoginMethod.phone),
onSecondaryLogin: () => _login(ref, LoginMethod.wechat),
);
return;
}
_showProfileListSheet(
context,
ref,
title: title,
snapshot: snapshot,
emptyTitle: emptyTitle,
emptyMessage: emptyMessage,
);
}
void _showProfileListSheet(
BuildContext context,
WidgetRef ref, {
required String title,
required AsyncValue<List<ReportCardModel>> snapshot,
required String emptyTitle,
required String emptyMessage,
}) {
showShadSheet<void>(
context: context,
side: ShadSheetSide.bottom,
builder: (sheetContext) => ShadSheet(
title: Text(title),
description: const Text('本地状态列表,真实同步接口后续接入。'),
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(sheetContext).height * 0.66,
),
child: snapshot.when(
loading: () => const LoadingState(label: '正在加载列表'),
error: (error, _) => ErrorState(message: error.toString()),
data: (items) {
if (items.isEmpty) {
return EmptyState(
title: emptyTitle,
message: emptyMessage,
icon: Icons.inbox_outlined,
);
}
return ListView.separated(
shrinkWrap: true,
itemCount: items.length,
separatorBuilder: (_, _) =>
const SizedBox(height: YantingSpacing.x3),
itemBuilder: (_, index) {
final report = items[index];
return ReportCardWidget(
report: report,
onTap: () {
Navigator.pop(sheetContext);
ref
.read(profileControllerProvider.notifier)
.addHistory(report.id);
openReportDetail(context, dataSource, report);
},
);
},
);
},
),
),
),
);
}
}
class _MenuGroup extends StatelessWidget {
+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),
);
}
}
+29 -2
View File
@@ -3,6 +3,8 @@ import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../data/providers.dart';
import '../../data/state/app_interaction_state.dart';
import '../../routing/app_routes.dart';
import '../../theme/app_icons.dart';
import '../../theme/theme_controller.dart';
@@ -88,13 +90,23 @@ class SettingsPage extends ConsumerWidget {
_LinkTile(
icon: Icons.description_outlined,
title: '用户协议',
onTap: () => showOutboundSheet(context, title: '用户协议'),
onTap: () => _showOutbound(
context,
ref,
'settings_user_agreement',
'用户协议',
),
),
const Divider(height: 1, thickness: 1),
_LinkTile(
icon: Icons.privacy_tip_outlined,
title: '隐私政策',
onTap: () => showOutboundSheet(context, title: '隐私政策'),
onTap: () => _showOutbound(
context,
ref,
'settings_privacy_policy',
'隐私政策',
),
),
],
),
@@ -130,6 +142,21 @@ class SettingsPage extends ConsumerWidget {
),
);
}
void _showOutbound(
BuildContext context,
WidgetRef ref,
String scene,
String title,
) {
showOutboundSheet(
context,
title: title,
onConfirm: () => ref
.read(outboundRepositoryProvider)
.recordOutbound(OutboundEvent(scene: scene)),
);
}
}
class _ThemeModeTile extends StatelessWidget {
+7 -2
View File
@@ -69,7 +69,9 @@ class _ShellPageState extends ConsumerState<ShellPage> {
Text(
header.title,
textAlign: TextAlign.center,
style: YantingText.listTitle,
style: YantingText.listTitle.copyWith(
color: theme.colorScheme.foreground,
),
),
if (header.subtitle.isNotEmpty)
Text(
@@ -77,7 +79,10 @@ class _ShellPageState extends ConsumerState<ShellPage> {
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: YantingText.meta.copyWith(fontSize: 12),
style: YantingText.meta.copyWith(
color: theme.colorScheme.mutedForeground,
fontSize: 12,
),
),
],
),