From ac794ae58a15a44ccf8445a975a9ea73ddb80d7a Mon Sep 17 00:00:00 2001 From: jingyun <> Date: Sun, 7 Jun 2026 10:58:05 +0800 Subject: [PATCH] =?UTF-8?q?fix=EF=BC=9A=E5=AF=B9=E6=AF=94=E5=8E=9F?= =?UTF-8?q?=E5=9E=8B=E5=A2=9E=E5=8A=A0=E5=8A=9F=E8=83=BD=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/data/api/mock_report_data_source.dart | 2 +- lib/data/api/report_data_source.dart | 67 +++- lib/data/content_providers.dart | 62 +++- lib/data/providers.dart | 51 +++ .../repositories/outbound_repository.dart | 16 + lib/data/repositories/report_repository.dart | 12 + .../repositories/user_state_repository.dart | 80 ++++ lib/data/state/app_interaction_state.dart | 160 ++++++++ lib/data/state/app_state_controllers.dart | 160 ++++++++ lib/data/state/report_query.dart | 44 +++ lib/features/detail/report_detail_page.dart | 111 +++++- lib/features/feed/feed_page.dart | 84 ++--- .../institutions/institution_detail_page.dart | 27 +- lib/features/listen/listen_page.dart | 25 +- lib/features/profile/profile_page.dart | 195 +++++++++- lib/features/reports/reports_page.dart | 341 ++++++++++++------ lib/features/settings/settings_page.dart | 31 +- lib/features/shell_page.dart | 9 +- lib/routing/app_router.dart | 81 +++-- lib/widgets/sheets.dart | 15 +- test/widget_test.dart | 2 +- 21 files changed, 1342 insertions(+), 233 deletions(-) create mode 100644 lib/data/repositories/outbound_repository.dart create mode 100644 lib/data/repositories/report_repository.dart create mode 100644 lib/data/repositories/user_state_repository.dart create mode 100644 lib/data/state/app_interaction_state.dart create mode 100644 lib/data/state/app_state_controllers.dart create mode 100644 lib/data/state/report_query.dart diff --git a/lib/data/api/mock_report_data_source.dart b/lib/data/api/mock_report_data_source.dart index c26b235..975e28e 100644 --- a/lib/data/api/mock_report_data_source.dart +++ b/lib/data/api/mock_report_data_source.dart @@ -1,7 +1,7 @@ import '../models/models.dart'; import 'report_data_source.dart'; -class MockReportDataSource implements ReportDataSource { +class MockReportDataSource extends ReportDataSource { MockReportDataSource(); static final Institution _wgcSummary = _institutionSummary( diff --git a/lib/data/api/report_data_source.dart b/lib/data/api/report_data_source.dart index 6953024..f1d9ae1 100644 --- a/lib/data/api/report_data_source.dart +++ b/lib/data/api/report_data_source.dart @@ -3,8 +3,10 @@ import 'dart:convert'; import 'package:http/http.dart' as http; import '../models/models.dart'; +import '../repositories/report_repository.dart'; +import '../state/report_query.dart'; -abstract class ReportDataSource { +abstract class ReportDataSource extends ReportRepository { Future> recommended(); Future> reports(); Future> institutions(); @@ -12,9 +14,66 @@ abstract class ReportDataSource { Future> listen(); Future reportDetail(String reportId); Future moduleDetail(String reportId, String moduleId); + + @override + Future> getRecommended({String? topic}) async { + final items = await recommended(); + if (topic == null || topic == '全部') return items; + return items.where((item) => item.topics.contains(topic)).toList(); + } + + @override + Future> getReports(ReportQuery query) async { + final currentSearch = query.search.trim().toLowerCase(); + final items = await reports(); + final filtered = items.where((item) { + final haystack = + '${item.titleCn} ${item.subtitleCn} ${item.oneLiner} ' + '${item.institution.nameCn} ${item.institution.nameEn} ' + '${item.topics.join(' ')}' + .toLowerCase(); + if (currentSearch.isNotEmpty && !haystack.contains(currentSearch)) { + 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; + } + + @override + Future getReportDetail(String reportId) => + reportDetail(reportId); + + @override + Future> getInstitutions() => institutions(); + + @override + Future getInstitutionDetail(String institutionId) => + institutionDetail(institutionId); + + @override + Future> getListenItems() => listen(); + + @override + Future getModuleDetail(String reportId, String moduleId) => + moduleDetail(reportId, moduleId); } -class RnbApiDataSource implements ReportDataSource { +class RnbApiDataSource extends ReportDataSource { RnbApiDataSource({ http.Client? client, this.baseUrl = const String.fromEnvironment('RNB_API_BASE'), @@ -72,6 +131,8 @@ class RnbApiDataSource implements ReportDataSource { @override Future moduleDetail(String reportId, String moduleId) async { - return ModuleDetail.fromJson(await _get('/reports/$reportId/modules/$moduleId')); + return ModuleDetail.fromJson( + await _get('/reports/$reportId/modules/$moduleId'), + ); } } diff --git a/lib/data/content_providers.dart b/lib/data/content_providers.dart index 7229315..6441985 100644 --- a/lib/data/content_providers.dart +++ b/lib/data/content_providers.dart @@ -2,26 +2,68 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'models/models.dart'; import 'providers.dart'; +import 'state/app_state_controllers.dart'; +import 'state/report_query.dart'; final recommendedReportsProvider = FutureProvider.autoDispose>((ref) async { - final dataSource = ref.watch(reportDataSourceProvider); - return dataSource.recommended(); -}); + final dataSource = ref.watch(reportDataSourceProvider); + return dataSource.recommended(); + }); -final reportsProvider = +final recommendedByTopicProvider = FutureProvider.autoDispose>((ref) async { + final repository = ref.watch(reportRepositoryProvider); + final topic = ref.watch(recommendTopicProvider); + return repository.getRecommended(topic: topic); + }); + +final reportsProvider = FutureProvider.autoDispose>(( + ref, +) async { final dataSource = ref.watch(reportDataSourceProvider); return dataSource.reports(); }); -final institutionsProvider = - FutureProvider.autoDispose>((ref) async { - final dataSource = ref.watch(reportDataSourceProvider); - return dataSource.institutions(); +final filteredReportsProvider = + FutureProvider.autoDispose>((ref) async { + final repository = ref.watch(reportRepositoryProvider); + final query = ref.watch(reportFilterProvider); + return repository.getReports(query); + }); + +final institutionsProvider = FutureProvider.autoDispose>(( + ref, +) async { + final repository = ref.watch(reportRepositoryProvider); + return repository.getInstitutions(); }); final listenProvider = FutureProvider.autoDispose>((ref) async { - final dataSource = ref.watch(reportDataSourceProvider); - return dataSource.listen(); + final repository = ref.watch(reportRepositoryProvider); + return repository.getListenItems(); }); + +final profileHistoryReportsProvider = + FutureProvider.autoDispose>((ref) async { + final profile = ref.watch(profileControllerProvider); + final repository = ref.watch(reportRepositoryProvider); + final reports = await repository.getReports(const ReportQuery()); + return ProfileListBuilder(reports).byIds(profile.history); + }); + +final profileFavoriteReportsProvider = + FutureProvider.autoDispose>((ref) async { + final profile = ref.watch(profileControllerProvider); + final repository = ref.watch(reportRepositoryProvider); + final reports = await repository.getReports(const ReportQuery()); + return ProfileListBuilder(reports).byIds(profile.favorites); + }); + +final profileSavedListenReportsProvider = + FutureProvider.autoDispose>((ref) async { + final profile = ref.watch(profileControllerProvider); + final repository = ref.watch(reportRepositoryProvider); + final reports = await repository.getReports(const ReportQuery()); + return ProfileListBuilder(reports).byIds(profile.savedListens); + }); diff --git a/lib/data/providers.dart b/lib/data/providers.dart index fffb266..cda443a 100644 --- a/lib/data/providers.dart +++ b/lib/data/providers.dart @@ -3,6 +3,12 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'api/mock_report_data_source.dart'; import 'api/report_data_source.dart'; import 'audio_player_controller.dart'; +import 'repositories/outbound_repository.dart'; +import 'repositories/report_repository.dart'; +import 'repositories/user_state_repository.dart'; +import 'state/app_interaction_state.dart'; +import 'state/app_state_controllers.dart'; +import 'state/report_query.dart'; import '../widgets/mini_player.dart'; final reportDataSourceProvider = Provider((ref) { @@ -17,3 +23,48 @@ final audioPlayerControllerProvider = StateNotifierProvider((ref) { return AudioPlayerController(); }); + +final reportRepositoryProvider = Provider((ref) { + return ref.watch(reportDataSourceProvider); +}); + +final userStateRepositoryProvider = Provider((ref) { + return MemoryUserStateRepository(); +}); + +final outboundRepositoryProvider = Provider((ref) { + return MemoryOutboundRepository(); +}); + +final recommendTopicProvider = + StateNotifierProvider((ref) { + return RecommendTopicController(); + }); + +final reportFilterProvider = + StateNotifierProvider((ref) { + return ReportFilterController(); + }); + +final authControllerProvider = StateNotifierProvider( + (ref) { + return AuthController(ref.watch(userStateRepositoryProvider)); + }, +); + +final profileControllerProvider = + StateNotifierProvider((ref) { + return ProfileController(ref.watch(userStateRepositoryProvider)); + }); + +final detailNavigationProvider = + StateNotifierProvider(( + ref, + ) { + return DetailNavigationController(); + }); + +final sheetControllerProvider = + StateNotifierProvider((ref) { + return SheetController(); + }); diff --git a/lib/data/repositories/outbound_repository.dart b/lib/data/repositories/outbound_repository.dart new file mode 100644 index 0000000..87f4984 --- /dev/null +++ b/lib/data/repositories/outbound_repository.dart @@ -0,0 +1,16 @@ +import '../state/app_interaction_state.dart'; + +abstract class OutboundRepository { + Future recordOutbound(OutboundEvent event); +} + +class MemoryOutboundRepository implements OutboundRepository { + final List _events = []; + + List get events => List.unmodifiable(_events); + + @override + Future recordOutbound(OutboundEvent event) async { + _events.add(event); + } +} diff --git a/lib/data/repositories/report_repository.dart b/lib/data/repositories/report_repository.dart new file mode 100644 index 0000000..96aa6fc --- /dev/null +++ b/lib/data/repositories/report_repository.dart @@ -0,0 +1,12 @@ +import '../models/models.dart'; +import '../state/report_query.dart'; + +abstract class ReportRepository { + Future> getRecommended({String? topic}); + Future> getReports(ReportQuery query); + Future getReportDetail(String reportId); + Future> getInstitutions(); + Future getInstitutionDetail(String institutionId); + Future> getListenItems(); + Future getModuleDetail(String reportId, String moduleId); +} diff --git a/lib/data/repositories/user_state_repository.dart b/lib/data/repositories/user_state_repository.dart new file mode 100644 index 0000000..b3fcaaa --- /dev/null +++ b/lib/data/repositories/user_state_repository.dart @@ -0,0 +1,80 @@ +import '../state/app_interaction_state.dart'; + +abstract class UserStateRepository { + Future isLoggedIn(); + Future login(LoginMethod method); + Future logout(); + + Future> getFavorites(); + Future toggleFavorite(String reportId); + + Future> getSavedListens(); + Future toggleSavedListen(String reportId); + + Future> getHistory(); + Future addHistory(String reportId); + + Future> getAudioProgress(); + Future saveAudioProgress(String audioId, double seconds); +} + +class MemoryUserStateRepository implements UserStateRepository { + bool _loggedIn = false; + final Set _favorites = {}; + final Set _savedListens = {}; + final List _history = []; + final Map _audioProgress = {}; + + @override + Future isLoggedIn() async => _loggedIn; + + @override + Future login(LoginMethod method) async { + _loggedIn = true; + } + + @override + Future logout() async { + _loggedIn = false; + } + + @override + Future> getFavorites() async => {..._favorites}; + + @override + Future toggleFavorite(String reportId) async { + if (!_favorites.add(reportId)) { + _favorites.remove(reportId); + } + } + + @override + Future> getSavedListens() async => {..._savedListens}; + + @override + Future toggleSavedListen(String reportId) async { + if (!_savedListens.add(reportId)) { + _savedListens.remove(reportId); + } + } + + @override + Future> getHistory() async => [..._history]; + + @override + Future addHistory(String reportId) async { + _history.remove(reportId); + _history.insert(0, reportId); + if (_history.length > 40) { + _history.removeRange(40, _history.length); + } + } + + @override + Future> getAudioProgress() async => {..._audioProgress}; + + @override + Future saveAudioProgress(String audioId, double seconds) async { + _audioProgress[audioId] = seconds < 0 ? 0 : seconds; + } +} diff --git a/lib/data/state/app_interaction_state.dart b/lib/data/state/app_interaction_state.dart new file mode 100644 index 0000000..aabded4 --- /dev/null +++ b/lib/data/state/app_interaction_state.dart @@ -0,0 +1,160 @@ +import '../models/models.dart'; + +class AuthState { + const AuthState({this.loggedIn = false, this.pendingAction}); + + final bool loggedIn; + final PendingLoginAction? pendingAction; + + AuthState copyWith({bool? loggedIn, Object? pendingAction = _sentinel}) { + return AuthState( + loggedIn: loggedIn ?? this.loggedIn, + pendingAction: identical(pendingAction, _sentinel) + ? this.pendingAction + : pendingAction as PendingLoginAction?, + ); + } +} + +class PendingLoginAction { + const PendingLoginAction({ + required this.action, + required this.reportId, + required this.contextText, + }); + + final LoginRequiredAction action; + final String reportId; + final String contextText; +} + +enum LoginRequiredAction { favorite, saveListen } + +enum LoginMethod { phone, wechat, apple } + +class ProfileState { + const ProfileState({ + this.favorites = const {}, + this.savedListens = const {}, + this.history = const [], + }); + + final Set favorites; + final Set savedListens; + final List history; + + ProfileState copyWith({ + Set? favorites, + Set? savedListens, + List? history, + }) { + return ProfileState( + favorites: favorites ?? this.favorites, + savedListens: savedListens ?? this.savedListens, + history: history ?? this.history, + ); + } +} + +class DetailNavigationState { + const DetailNavigationState({ + this.originTab = AppTab.recommend, + this.stack = const [], + this.tabScroll = const {}, + }); + + final AppTab originTab; + final List stack; + final Map tabScroll; + + DetailNavigationState copyWith({ + AppTab? originTab, + List? stack, + Map? tabScroll, + }) { + return DetailNavigationState( + originTab: originTab ?? this.originTab, + stack: stack ?? this.stack, + tabScroll: tabScroll ?? this.tabScroll, + ); + } +} + +class DetailStackEntry { + const DetailStackEntry({ + required this.type, + required this.id, + this.scrollTop = 0, + }); + + final DetailEntryType type; + final String id; + final double scrollTop; + + DetailStackEntry copyWith({double? scrollTop}) { + return DetailStackEntry( + type: type, + id: id, + scrollTop: scrollTop ?? this.scrollTop, + ); + } +} + +enum DetailEntryType { report, institution } + +enum AppTab { recommend, reports, institutions, listen, profile } + +class SheetState { + const SheetState.hidden() : intent = null; + const SheetState.visible(this.intent); + + final SheetIntent? intent; + + bool get isVisible => intent != null; +} + +sealed class SheetIntent { + const SheetIntent(); +} + +class LoginSheetIntent extends SheetIntent { + const LoginSheetIntent({required this.contextText}); + + final String contextText; +} + +class FilterSheetIntent extends SheetIntent { + const FilterSheetIntent(); +} + +class OutboundSheetIntent extends SheetIntent { + const OutboundSheetIntent({required this.scene, this.refId, this.targetUrl}); + + final String scene; + final String? refId; + final String? targetUrl; +} + +class ProfileListSheetIntent extends SheetIntent { + const ProfileListSheetIntent({ + required this.kind, + required this.title, + this.reports = const [], + }); + + final ProfileListKind kind; + final String title; + final List reports; +} + +enum ProfileListKind { favorites, history, saved } + +class OutboundEvent { + const OutboundEvent({required this.scene, this.refId, this.targetUrl}); + + final String scene; + final String? refId; + final String? targetUrl; +} + +const Object _sentinel = Object(); diff --git a/lib/data/state/app_state_controllers.dart b/lib/data/state/app_state_controllers.dart new file mode 100644 index 0000000..9fbb04f --- /dev/null +++ b/lib/data/state/app_state_controllers.dart @@ -0,0 +1,160 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../models/models.dart'; +import '../repositories/user_state_repository.dart'; +import 'app_interaction_state.dart'; +import 'report_query.dart'; + +class RecommendTopicController extends StateNotifier { + RecommendTopicController() : super('全部'); + + void select(String topic) { + state = topic; + } +} + +class ReportFilterController extends StateNotifier { + ReportFilterController() : super(const ReportQuery()); + + void setSearch(String value) { + state = state.copyWith(search: value); + } + + void setTopic(String? topic) { + state = state.copyWith(topic: topic); + } + + void setInstitution(String? institutionId) { + state = state.copyWith(institutionId: institutionId); + } + + void toggleAudio() { + state = state.copyWith(hasAudio: !state.hasAudio); + } + + void setSort(ReportSort sort) { + state = state.copyWith(sort: sort); + } + + void reset() { + state = const ReportQuery(); + } +} + +class AuthController extends StateNotifier { + AuthController(this._repository) : super(const AuthState()) { + _load(); + } + + final UserStateRepository _repository; + + Future _load() async { + state = state.copyWith(loggedIn: await _repository.isLoggedIn()); + } + + void requireLogin(PendingLoginAction action) { + if (state.loggedIn) return; + state = state.copyWith(pendingAction: action); + } + + Future login(LoginMethod method) async { + final pending = state.pendingAction; + await _repository.login(method); + state = const AuthState(loggedIn: true); + return pending; + } + + Future logout() async { + await _repository.logout(); + state = const AuthState(); + } + + void clearPending() { + state = state.copyWith(pendingAction: null); + } +} + +class ProfileController extends StateNotifier { + ProfileController(this._repository) : super(const ProfileState()) { + refresh(); + } + + final UserStateRepository _repository; + + Future refresh() async { + state = ProfileState( + favorites: await _repository.getFavorites(), + savedListens: await _repository.getSavedListens(), + history: await _repository.getHistory(), + ); + } + + Future toggleFavorite(String reportId) async { + await _repository.toggleFavorite(reportId); + await refresh(); + } + + Future toggleSavedListen(String reportId) async { + await _repository.toggleSavedListen(reportId); + await refresh(); + } + + Future addHistory(String reportId) async { + await _repository.addHistory(reportId); + await refresh(); + } +} + +class DetailNavigationController extends StateNotifier { + DetailNavigationController() : super(const DetailNavigationState()); + + void rememberTabScroll(AppTab tab, double scrollTop) { + state = state.copyWith(tabScroll: {...state.tabScroll, tab: scrollTop}); + } + + void push(DetailStackEntry entry, {required AppTab originTab}) { + final stack = [...state.stack, entry]; + state = state.copyWith(originTab: originTab, stack: stack); + } + + void updateCurrentScroll(double scrollTop) { + if (state.stack.isEmpty) return; + final stack = [...state.stack]; + stack[stack.length - 1] = stack.last.copyWith(scrollTop: scrollTop); + state = state.copyWith(stack: stack); + } + + DetailStackEntry? pop() { + if (state.stack.isEmpty) return null; + final stack = [...state.stack]..removeLast(); + state = state.copyWith(stack: stack); + return stack.isEmpty ? null : stack.last; + } + + void reset() { + state = const DetailNavigationState(); + } +} + +class SheetController extends StateNotifier { + SheetController() : super(const SheetState.hidden()); + + void show(SheetIntent intent) { + state = SheetState.visible(intent); + } + + void hide() { + state = const SheetState.hidden(); + } +} + +class ProfileListBuilder { + const ProfileListBuilder(this.reports); + + final List reports; + + List byIds(Iterable ids) { + final byId = {for (final report in reports) report.id: report}; + return ids.map((id) => byId[id]).whereType().toList(); + } +} diff --git a/lib/data/state/report_query.dart b/lib/data/state/report_query.dart new file mode 100644 index 0000000..c03e9c8 --- /dev/null +++ b/lib/data/state/report_query.dart @@ -0,0 +1,44 @@ +class ReportQuery { + const ReportQuery({ + this.search = '', + this.topic, + this.institutionId, + this.hasAudio = false, + this.sort = ReportSort.latest, + }); + + final String search; + final String? topic; + final String? institutionId; + final bool hasAudio; + final ReportSort sort; + + bool get hasActiveFilter => + search.trim().isNotEmpty || + topic != null || + institutionId != null || + hasAudio || + sort != ReportSort.latest; + + ReportQuery copyWith({ + String? search, + Object? topic = _sentinel, + Object? institutionId = _sentinel, + bool? hasAudio, + ReportSort? sort, + }) { + return ReportQuery( + search: search ?? this.search, + topic: identical(topic, _sentinel) ? this.topic : topic as String?, + institutionId: identical(institutionId, _sentinel) + ? this.institutionId + : institutionId as String?, + hasAudio: hasAudio ?? this.hasAudio, + sort: sort ?? this.sort, + ); + } +} + +enum ReportSort { latest, oldest } + +const Object _sentinel = Object(); diff --git a/lib/features/detail/report_detail_page.dart b/lib/features/detail/report_detail_page.dart index 650e52c..f68c8d5 100644 --- a/lib/features/detail/report_detail_page.dart +++ b/lib/features/detail/report_detail_page.dart @@ -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 { diff --git a/lib/features/feed/feed_page.dart b/lib/features/feed/feed_page.dart index aa2f95f..51bf06c 100644 --- a/lib/features/feed/feed_page.dart +++ b/lib/features/feed/feed_page.dart @@ -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), ], ], ); diff --git a/lib/features/institutions/institution_detail_page.dart b/lib/features/institutions/institution_detail_page.dart index cf66c76..dafce1d 100644 --- a/lib/features/institutions/institution_detail_page.dart +++ b/lib/features/institutions/institution_detail_page.dart @@ -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, + ), + ), + ), ), ], ); diff --git a/lib/features/listen/listen_page.dart b/lib/features/listen/listen_page.dart index 9414877..38c6232 100644 --- a/lib/features/listen/listen_page.dart +++ b/lib/features/listen/listen_page.dart @@ -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)}', diff --git a/lib/features/profile/profile_page.dart b/lib/features/profile/profile_page.dart index a401c66..392b6f3 100644 --- a/lib/features/profile/profile_page.dart +++ b/lib/features/profile/profile_page.dart @@ -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 _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> 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> snapshot, + required String emptyTitle, + required String emptyMessage, + }) { + showShadSheet( + 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 { diff --git a/lib/features/reports/reports_page.dart b/lib/features/reports/reports_page.dart index 075d06a..68b8a9c 100644 --- a/lib/features/reports/reports_page.dart +++ b/lib/features/reports/reports_page.dart @@ -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 [], + ); + 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(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 _applyFilters( - List 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 items, - required ValueNotifier topic, + required List 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( 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), + ); + } +} diff --git a/lib/features/settings/settings_page.dart b/lib/features/settings/settings_page.dart index 8bfa14e..4386799 100644 --- a/lib/features/settings/settings_page.dart +++ b/lib/features/settings/settings_page.dart @@ -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 { diff --git a/lib/features/shell_page.dart b/lib/features/shell_page.dart index 4be9a46..39fd370 100644 --- a/lib/features/shell_page.dart +++ b/lib/features/shell_page.dart @@ -69,7 +69,9 @@ class _ShellPageState extends ConsumerState { 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 { maxLines: 1, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, - style: YantingText.meta.copyWith(fontSize: 12), + style: YantingText.meta.copyWith( + color: theme.colorScheme.mutedForeground, + fontSize: 12, + ), ), ], ), diff --git a/lib/routing/app_router.dart b/lib/routing/app_router.dart index 88c36cb..7ca9812 100644 --- a/lib/routing/app_router.dart +++ b/lib/routing/app_router.dart @@ -122,47 +122,84 @@ final routerProvider = Provider((ref) { ), GoRoute( path: AppRoutes.reportDetail, - builder: (context, state) { + pageBuilder: (context, state) { final id = state.pathParameters['id'] ?? ''; final args = state.extra as ReportDetailRouteArgs?; - return Consumer( - builder: (context, ref, _) { - final player = ref.watch(audioPlayerControllerProvider); - final controller = ref.read( - audioPlayerControllerProvider.notifier, - ); - return ReportDetailPage( - reportId: id, - dataSource: args?.dataSource ?? dataSource, - player: player, - onStartAudio: args?.onStartAudio ?? controller.startModuleAudio, - onToggleAudio: args?.onToggleAudio ?? controller.toggleAudio, - onSeekAudio: args?.onSeekAudio ?? controller.seekAudio, - onSpeed: args?.onSpeed ?? controller.cycleSpeed, - ); - }, + return _slidePage( + state: state, + child: Consumer( + builder: (context, ref, _) { + final player = ref.watch(audioPlayerControllerProvider); + final controller = ref.read( + audioPlayerControllerProvider.notifier, + ); + return ReportDetailPage( + reportId: id, + dataSource: args?.dataSource ?? dataSource, + player: player, + onStartAudio: + args?.onStartAudio ?? controller.startModuleAudio, + onToggleAudio: args?.onToggleAudio ?? controller.toggleAudio, + onSeekAudio: args?.onSeekAudio ?? controller.seekAudio, + onSpeed: args?.onSpeed ?? controller.cycleSpeed, + ); + }, + ), ); }, ), GoRoute( path: AppRoutes.institutionDetail, - builder: (context, state) { + pageBuilder: (context, state) { final id = state.pathParameters['id'] ?? ''; final args = state.extra as InstitutionDetailRouteArgs?; - return InstitutionDetailPage( - institutionId: id, - dataSource: args?.dataSource ?? dataSource, + return _slidePage( + state: state, + child: InstitutionDetailPage( + institutionId: id, + dataSource: args?.dataSource ?? dataSource, + ), ); }, ), GoRoute( path: AppRoutes.settings, - builder: (context, state) => const SettingsPage(), + pageBuilder: (context, state) => + _slidePage(state: state, child: const SettingsPage()), ), ], ); }); +CustomTransitionPage _slidePage({ + required GoRouterState state, + required Widget child, +}) { + return CustomTransitionPage( + key: state.pageKey, + transitionDuration: const Duration(milliseconds: 260), + reverseTransitionDuration: const Duration(milliseconds: 220), + child: child, + transitionsBuilder: (context, animation, secondaryAnimation, child) { + final curved = CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + ); + return FadeTransition( + opacity: curved, + child: SlideTransition( + position: Tween( + begin: const Offset(0.08, 0), + end: Offset.zero, + ).animate(curved), + child: child, + ), + ); + }, + ); +} + class _TabSurface extends StatelessWidget { const _TabSurface({required this.child}); diff --git a/lib/widgets/sheets.dart b/lib/widgets/sheets.dart index 85cd844..05f7069 100644 --- a/lib/widgets/sheets.dart +++ b/lib/widgets/sheets.dart @@ -7,6 +7,8 @@ import 'states.dart'; Future showLoginSheet( BuildContext context, { String reason = '登录后保存当前动作', + VoidCallback? onPhoneLogin, + VoidCallback? onSecondaryLogin, }) { return showShadSheet( context: context, @@ -23,7 +25,8 @@ Future showLoginSheet( expand: true, onPressed: () { Navigator.pop(context); - showAppToast(context, '登录接口待接入,已保留当前页面'); + onPhoneLogin?.call(); + showAppToast(context, '已使用本地登录态继续'); }, ), const SizedBox(height: 8), @@ -34,7 +37,8 @@ Future showLoginSheet( expand: true, onPressed: () { Navigator.pop(context); - showAppToast(context, '真实 auth 待后端接入'); + onSecondaryLogin?.call(); + showAppToast(context, '已使用本地登录态继续'); }, ), ], @@ -43,7 +47,11 @@ Future showLoginSheet( ); } -Future showOutboundSheet(BuildContext context, {required String title}) { +Future showOutboundSheet( + BuildContext context, { + required String title, + VoidCallback? onConfirm, +}) { return showShadSheet( context: context, side: ShadSheetSide.bottom, @@ -57,6 +65,7 @@ Future showOutboundSheet(BuildContext context, {required String title}) { expand: true, onPressed: () { Navigator.pop(context); + onConfirm?.call(); showAppToast(context, '外跳事件接口待接入'); }, ), diff --git a/test/widget_test.dart b/test/widget_test.dart index 336fe1f..e5f23c3 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -29,7 +29,7 @@ void main() { }); } -class FakeDataSource implements ReportDataSource { +class FakeDataSource extends ReportDataSource { final institution = const Institution( id: 'inst_ssga', nameCn: '道富环球投资管理',