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
+1 -1
View File
@@ -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(
+64 -3
View File
@@ -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<List<ReportCardModel>> recommended();
Future<List<ReportCardModel>> reports();
Future<List<Institution>> institutions();
@@ -12,9 +14,66 @@ abstract class ReportDataSource {
Future<List<AudioItem>> listen();
Future<ReportDetail> reportDetail(String reportId);
Future<ModuleDetail> moduleDetail(String reportId, String moduleId);
@override
Future<List<ReportCardModel>> 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<List<ReportCardModel>> 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<ReportDetail> getReportDetail(String reportId) =>
reportDetail(reportId);
@override
Future<List<Institution>> getInstitutions() => institutions();
@override
Future<Institution> getInstitutionDetail(String institutionId) =>
institutionDetail(institutionId);
@override
Future<List<AudioItem>> getListenItems() => listen();
@override
Future<ModuleDetail> 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> moduleDetail(String reportId, String moduleId) async {
return ModuleDetail.fromJson(await _get('/reports/$reportId/modules/$moduleId'));
return ModuleDetail.fromJson(
await _get('/reports/$reportId/modules/$moduleId'),
);
}
}
+52 -10
View File
@@ -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<List<ReportCardModel>>((ref) async {
final dataSource = ref.watch(reportDataSourceProvider);
return dataSource.recommended();
});
final dataSource = ref.watch(reportDataSourceProvider);
return dataSource.recommended();
});
final reportsProvider =
final recommendedByTopicProvider =
FutureProvider.autoDispose<List<ReportCardModel>>((ref) async {
final repository = ref.watch(reportRepositoryProvider);
final topic = ref.watch(recommendTopicProvider);
return repository.getRecommended(topic: topic);
});
final reportsProvider = FutureProvider.autoDispose<List<ReportCardModel>>((
ref,
) async {
final dataSource = ref.watch(reportDataSourceProvider);
return dataSource.reports();
});
final institutionsProvider =
FutureProvider.autoDispose<List<Institution>>((ref) async {
final dataSource = ref.watch(reportDataSourceProvider);
return dataSource.institutions();
final filteredReportsProvider =
FutureProvider.autoDispose<List<ReportCardModel>>((ref) async {
final repository = ref.watch(reportRepositoryProvider);
final query = ref.watch(reportFilterProvider);
return repository.getReports(query);
});
final institutionsProvider = FutureProvider.autoDispose<List<Institution>>((
ref,
) async {
final repository = ref.watch(reportRepositoryProvider);
return repository.getInstitutions();
});
final listenProvider = FutureProvider.autoDispose<List<AudioItem>>((ref) async {
final dataSource = ref.watch(reportDataSourceProvider);
return dataSource.listen();
final repository = ref.watch(reportRepositoryProvider);
return repository.getListenItems();
});
final profileHistoryReportsProvider =
FutureProvider.autoDispose<List<ReportCardModel>>((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<List<ReportCardModel>>((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<List<ReportCardModel>>((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);
});
+51
View File
@@ -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<ReportDataSource>((ref) {
@@ -17,3 +23,48 @@ final audioPlayerControllerProvider =
StateNotifierProvider<AudioPlayerController, PlayerStateModel>((ref) {
return AudioPlayerController();
});
final reportRepositoryProvider = Provider<ReportRepository>((ref) {
return ref.watch(reportDataSourceProvider);
});
final userStateRepositoryProvider = Provider<UserStateRepository>((ref) {
return MemoryUserStateRepository();
});
final outboundRepositoryProvider = Provider<OutboundRepository>((ref) {
return MemoryOutboundRepository();
});
final recommendTopicProvider =
StateNotifierProvider<RecommendTopicController, String>((ref) {
return RecommendTopicController();
});
final reportFilterProvider =
StateNotifierProvider<ReportFilterController, ReportQuery>((ref) {
return ReportFilterController();
});
final authControllerProvider = StateNotifierProvider<AuthController, AuthState>(
(ref) {
return AuthController(ref.watch(userStateRepositoryProvider));
},
);
final profileControllerProvider =
StateNotifierProvider<ProfileController, ProfileState>((ref) {
return ProfileController(ref.watch(userStateRepositoryProvider));
});
final detailNavigationProvider =
StateNotifierProvider<DetailNavigationController, DetailNavigationState>((
ref,
) {
return DetailNavigationController();
});
final sheetControllerProvider =
StateNotifierProvider<SheetController, SheetState>((ref) {
return SheetController();
});
@@ -0,0 +1,16 @@
import '../state/app_interaction_state.dart';
abstract class OutboundRepository {
Future<void> recordOutbound(OutboundEvent event);
}
class MemoryOutboundRepository implements OutboundRepository {
final List<OutboundEvent> _events = [];
List<OutboundEvent> get events => List.unmodifiable(_events);
@override
Future<void> recordOutbound(OutboundEvent event) async {
_events.add(event);
}
}
@@ -0,0 +1,12 @@
import '../models/models.dart';
import '../state/report_query.dart';
abstract class ReportRepository {
Future<List<ReportCardModel>> getRecommended({String? topic});
Future<List<ReportCardModel>> getReports(ReportQuery query);
Future<ReportDetail> getReportDetail(String reportId);
Future<List<Institution>> getInstitutions();
Future<Institution> getInstitutionDetail(String institutionId);
Future<List<AudioItem>> getListenItems();
Future<ModuleDetail> getModuleDetail(String reportId, String moduleId);
}
@@ -0,0 +1,80 @@
import '../state/app_interaction_state.dart';
abstract class UserStateRepository {
Future<bool> isLoggedIn();
Future<void> login(LoginMethod method);
Future<void> logout();
Future<Set<String>> getFavorites();
Future<void> toggleFavorite(String reportId);
Future<Set<String>> getSavedListens();
Future<void> toggleSavedListen(String reportId);
Future<List<String>> getHistory();
Future<void> addHistory(String reportId);
Future<Map<String, double>> getAudioProgress();
Future<void> saveAudioProgress(String audioId, double seconds);
}
class MemoryUserStateRepository implements UserStateRepository {
bool _loggedIn = false;
final Set<String> _favorites = {};
final Set<String> _savedListens = {};
final List<String> _history = [];
final Map<String, double> _audioProgress = {};
@override
Future<bool> isLoggedIn() async => _loggedIn;
@override
Future<void> login(LoginMethod method) async {
_loggedIn = true;
}
@override
Future<void> logout() async {
_loggedIn = false;
}
@override
Future<Set<String>> getFavorites() async => {..._favorites};
@override
Future<void> toggleFavorite(String reportId) async {
if (!_favorites.add(reportId)) {
_favorites.remove(reportId);
}
}
@override
Future<Set<String>> getSavedListens() async => {..._savedListens};
@override
Future<void> toggleSavedListen(String reportId) async {
if (!_savedListens.add(reportId)) {
_savedListens.remove(reportId);
}
}
@override
Future<List<String>> getHistory() async => [..._history];
@override
Future<void> addHistory(String reportId) async {
_history.remove(reportId);
_history.insert(0, reportId);
if (_history.length > 40) {
_history.removeRange(40, _history.length);
}
}
@override
Future<Map<String, double>> getAudioProgress() async => {..._audioProgress};
@override
Future<void> saveAudioProgress(String audioId, double seconds) async {
_audioProgress[audioId] = seconds < 0 ? 0 : seconds;
}
}
+160
View File
@@ -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<String> favorites;
final Set<String> savedListens;
final List<String> history;
ProfileState copyWith({
Set<String>? favorites,
Set<String>? savedListens,
List<String>? 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<DetailStackEntry> stack;
final Map<AppTab, double> tabScroll;
DetailNavigationState copyWith({
AppTab? originTab,
List<DetailStackEntry>? stack,
Map<AppTab, double>? 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<ReportCardModel> 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();
+160
View File
@@ -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<String> {
RecommendTopicController() : super('全部');
void select(String topic) {
state = topic;
}
}
class ReportFilterController extends StateNotifier<ReportQuery> {
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<AuthState> {
AuthController(this._repository) : super(const AuthState()) {
_load();
}
final UserStateRepository _repository;
Future<void> _load() async {
state = state.copyWith(loggedIn: await _repository.isLoggedIn());
}
void requireLogin(PendingLoginAction action) {
if (state.loggedIn) return;
state = state.copyWith(pendingAction: action);
}
Future<PendingLoginAction?> login(LoginMethod method) async {
final pending = state.pendingAction;
await _repository.login(method);
state = const AuthState(loggedIn: true);
return pending;
}
Future<void> logout() async {
await _repository.logout();
state = const AuthState();
}
void clearPending() {
state = state.copyWith(pendingAction: null);
}
}
class ProfileController extends StateNotifier<ProfileState> {
ProfileController(this._repository) : super(const ProfileState()) {
refresh();
}
final UserStateRepository _repository;
Future<void> refresh() async {
state = ProfileState(
favorites: await _repository.getFavorites(),
savedListens: await _repository.getSavedListens(),
history: await _repository.getHistory(),
);
}
Future<void> toggleFavorite(String reportId) async {
await _repository.toggleFavorite(reportId);
await refresh();
}
Future<void> toggleSavedListen(String reportId) async {
await _repository.toggleSavedListen(reportId);
await refresh();
}
Future<void> addHistory(String reportId) async {
await _repository.addHistory(reportId);
await refresh();
}
}
class DetailNavigationController extends StateNotifier<DetailNavigationState> {
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<SheetState> {
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<ReportCardModel> reports;
List<ReportCardModel> byIds(Iterable<String> ids) {
final byId = {for (final report in reports) report.id: report};
return ids.map((id) => byId[id]).whereType<ReportCardModel>().toList();
}
}
+44
View File
@@ -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();
+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,
),
),
],
),
+59 -22
View File
@@ -122,47 +122,84 @@ final routerProvider = Provider<GoRouter>((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<void> _slidePage({
required GoRouterState state,
required Widget child,
}) {
return CustomTransitionPage<void>(
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<Offset>(
begin: const Offset(0.08, 0),
end: Offset.zero,
).animate(curved),
child: child,
),
);
},
);
}
class _TabSurface extends StatelessWidget {
const _TabSurface({required this.child});
+12 -3
View File
@@ -7,6 +7,8 @@ import 'states.dart';
Future<void> showLoginSheet(
BuildContext context, {
String reason = '登录后保存当前动作',
VoidCallback? onPhoneLogin,
VoidCallback? onSecondaryLogin,
}) {
return showShadSheet<void>(
context: context,
@@ -23,7 +25,8 @@ Future<void> showLoginSheet(
expand: true,
onPressed: () {
Navigator.pop(context);
showAppToast(context, '登录接口待接入,已保留当前页面');
onPhoneLogin?.call();
showAppToast(context, '已使用本地登录态继续');
},
),
const SizedBox(height: 8),
@@ -34,7 +37,8 @@ Future<void> showLoginSheet(
expand: true,
onPressed: () {
Navigator.pop(context);
showAppToast(context, '真实 auth 待后端接入');
onSecondaryLogin?.call();
showAppToast(context, '已使用本地登录态继续');
},
),
],
@@ -43,7 +47,11 @@ Future<void> showLoginSheet(
);
}
Future<void> showOutboundSheet(BuildContext context, {required String title}) {
Future<void> showOutboundSheet(
BuildContext context, {
required String title,
VoidCallback? onConfirm,
}) {
return showShadSheet<void>(
context: context,
side: ShadSheetSide.bottom,
@@ -57,6 +65,7 @@ Future<void> showOutboundSheet(BuildContext context, {required String title}) {
expand: true,
onPressed: () {
Navigator.pop(context);
onConfirm?.call();
showAppToast(context, '外跳事件接口待接入');
},
),