fix:对比原型增加功能交互
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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)}',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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, '外跳事件接口待接入');
|
||||
},
|
||||
),
|
||||
|
||||
@@ -29,7 +29,7 @@ void main() {
|
||||
});
|
||||
}
|
||||
|
||||
class FakeDataSource implements ReportDataSource {
|
||||
class FakeDataSource extends ReportDataSource {
|
||||
final institution = const Institution(
|
||||
id: 'inst_ssga',
|
||||
nameCn: '道富环球投资管理',
|
||||
|
||||
Reference in New Issue
Block a user