fix:对比原型增加功能交互
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import '../models/models.dart';
|
import '../models/models.dart';
|
||||||
import 'report_data_source.dart';
|
import 'report_data_source.dart';
|
||||||
|
|
||||||
class MockReportDataSource implements ReportDataSource {
|
class MockReportDataSource extends ReportDataSource {
|
||||||
MockReportDataSource();
|
MockReportDataSource();
|
||||||
|
|
||||||
static final Institution _wgcSummary = _institutionSummary(
|
static final Institution _wgcSummary = _institutionSummary(
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ import 'dart:convert';
|
|||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
import '../models/models.dart';
|
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>> recommended();
|
||||||
Future<List<ReportCardModel>> reports();
|
Future<List<ReportCardModel>> reports();
|
||||||
Future<List<Institution>> institutions();
|
Future<List<Institution>> institutions();
|
||||||
@@ -12,9 +14,66 @@ abstract class ReportDataSource {
|
|||||||
Future<List<AudioItem>> listen();
|
Future<List<AudioItem>> listen();
|
||||||
Future<ReportDetail> reportDetail(String reportId);
|
Future<ReportDetail> reportDetail(String reportId);
|
||||||
Future<ModuleDetail> moduleDetail(String reportId, String moduleId);
|
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({
|
RnbApiDataSource({
|
||||||
http.Client? client,
|
http.Client? client,
|
||||||
this.baseUrl = const String.fromEnvironment('RNB_API_BASE'),
|
this.baseUrl = const String.fromEnvironment('RNB_API_BASE'),
|
||||||
@@ -72,6 +131,8 @@ class RnbApiDataSource implements ReportDataSource {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<ModuleDetail> moduleDetail(String reportId, String moduleId) async {
|
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 'models/models.dart';
|
||||||
import 'providers.dart';
|
import 'providers.dart';
|
||||||
|
import 'state/app_state_controllers.dart';
|
||||||
|
import 'state/report_query.dart';
|
||||||
|
|
||||||
final recommendedReportsProvider =
|
final recommendedReportsProvider =
|
||||||
FutureProvider.autoDispose<List<ReportCardModel>>((ref) async {
|
FutureProvider.autoDispose<List<ReportCardModel>>((ref) async {
|
||||||
final dataSource = ref.watch(reportDataSourceProvider);
|
final dataSource = ref.watch(reportDataSourceProvider);
|
||||||
return dataSource.recommended();
|
return dataSource.recommended();
|
||||||
});
|
});
|
||||||
|
|
||||||
final reportsProvider =
|
final recommendedByTopicProvider =
|
||||||
FutureProvider.autoDispose<List<ReportCardModel>>((ref) async {
|
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);
|
final dataSource = ref.watch(reportDataSourceProvider);
|
||||||
return dataSource.reports();
|
return dataSource.reports();
|
||||||
});
|
});
|
||||||
|
|
||||||
final institutionsProvider =
|
final filteredReportsProvider =
|
||||||
FutureProvider.autoDispose<List<Institution>>((ref) async {
|
FutureProvider.autoDispose<List<ReportCardModel>>((ref) async {
|
||||||
final dataSource = ref.watch(reportDataSourceProvider);
|
final repository = ref.watch(reportRepositoryProvider);
|
||||||
return dataSource.institutions();
|
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 listenProvider = FutureProvider.autoDispose<List<AudioItem>>((ref) async {
|
||||||
final dataSource = ref.watch(reportDataSourceProvider);
|
final repository = ref.watch(reportRepositoryProvider);
|
||||||
return dataSource.listen();
|
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/mock_report_data_source.dart';
|
||||||
import 'api/report_data_source.dart';
|
import 'api/report_data_source.dart';
|
||||||
import 'audio_player_controller.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';
|
import '../widgets/mini_player.dart';
|
||||||
|
|
||||||
final reportDataSourceProvider = Provider<ReportDataSource>((ref) {
|
final reportDataSourceProvider = Provider<ReportDataSource>((ref) {
|
||||||
@@ -17,3 +23,48 @@ final audioPlayerControllerProvider =
|
|||||||
StateNotifierProvider<AudioPlayerController, PlayerStateModel>((ref) {
|
StateNotifierProvider<AudioPlayerController, PlayerStateModel>((ref) {
|
||||||
return AudioPlayerController();
|
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/api/report_data_source.dart';
|
||||||
import '../../data/models/models.dart';
|
import '../../data/models/models.dart';
|
||||||
|
import '../../data/providers.dart';
|
||||||
|
import '../../data/state/app_interaction_state.dart';
|
||||||
import '../../theme/app_icons.dart';
|
import '../../theme/app_icons.dart';
|
||||||
import '../../theme/yanting_text.dart';
|
import '../../theme/yanting_text.dart';
|
||||||
import '../../theme/yanting_tokens.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});
|
const _ActionBar({required this.detail});
|
||||||
|
|
||||||
final ReportDetail detail;
|
final ReportDetail detail;
|
||||||
|
|
||||||
@override
|
@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(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: AppButton(
|
child: AppButton(
|
||||||
label: '收藏',
|
label: isFavorite ? '已收藏' : '收藏',
|
||||||
icon: AppIcons.heart,
|
icon: isFavorite ? AppIcons.heartFill : AppIcons.heart,
|
||||||
kind: AppButtonKind.ghost,
|
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),
|
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(
|
Expanded(
|
||||||
child: AppButton(
|
child: AppButton(
|
||||||
label: '原文',
|
label: '原文',
|
||||||
icon: AppIcons.externalLink,
|
icon: AppIcons.externalLink,
|
||||||
kind: AppButtonKind.ghost,
|
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 {
|
class _Toc extends StatelessWidget {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
import '../../data/api/report_data_source.dart';
|
import '../../data/api/report_data_source.dart';
|
||||||
import '../../data/content_providers.dart';
|
import '../../data/content_providers.dart';
|
||||||
import '../../data/models/models.dart';
|
import '../../data/models/models.dart';
|
||||||
|
import '../../data/providers.dart';
|
||||||
import '../../routing/app_routes.dart';
|
import '../../routing/app_routes.dart';
|
||||||
import '../../theme/yanting_tokens.dart';
|
import '../../theme/yanting_tokens.dart';
|
||||||
import '../../widgets/badges.dart';
|
import '../../widgets/badges.dart';
|
||||||
@@ -41,28 +41,25 @@ class FeedPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final topic = useState('全部');
|
final currentTopic = ref.watch(recommendTopicProvider);
|
||||||
final snapshot = ref.watch(recommendedReportsProvider);
|
final snapshot = ref.watch(recommendedByTopicProvider);
|
||||||
|
const topics = ['全部', '宏观', '贵金属', '大宗', '能源', '跨资产', '央行'];
|
||||||
|
|
||||||
return snapshot.when(
|
return snapshot.when(
|
||||||
loading: () => const LoadingState(),
|
loading: () => const LoadingState(),
|
||||||
error: (error, _) => ErrorState(
|
error: (error, _) => ErrorState(
|
||||||
message: error.toString(),
|
message: error.toString(),
|
||||||
onRetry: () => ref.invalidate(recommendedReportsProvider),
|
onRetry: () => ref.invalidate(recommendedByTopicProvider),
|
||||||
),
|
),
|
||||||
data: (items) {
|
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) {
|
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(
|
return ListView(
|
||||||
padding: const EdgeInsets.fromLTRB(
|
padding: const EdgeInsets.fromLTRB(
|
||||||
@@ -83,41 +80,44 @@ class FeedPage extends HookConsumerWidget {
|
|||||||
child: AppChip(
|
child: AppChip(
|
||||||
label: t,
|
label: t,
|
||||||
selected: t == currentTopic,
|
selected: t == currentTopic,
|
||||||
onTap: () => topic.value = t,
|
onTap: () =>
|
||||||
|
ref.read(recommendTopicProvider.notifier).select(t),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: YantingSpacing.cardGap),
|
const SizedBox(height: YantingSpacing.cardGap),
|
||||||
if (visible.isEmpty)
|
ReportCardWidget(
|
||||||
const EmptyState(
|
report: items.first,
|
||||||
title: '暂无可推荐的研报解读',
|
hero: true,
|
||||||
message: '换个主题,或去研报页看看全部内容',
|
onTap: () {
|
||||||
icon: Icons.filter_alt_off,
|
ref
|
||||||
)
|
.read(profileControllerProvider.notifier)
|
||||||
else ...[
|
.addHistory(items.first.id);
|
||||||
ReportCardWidget(
|
openReportDetail(
|
||||||
report: visible.first,
|
|
||||||
hero: true,
|
|
||||||
onTap: () => openReportDetail(
|
|
||||||
context,
|
context,
|
||||||
dataSource,
|
dataSource,
|
||||||
visible.first,
|
items.first,
|
||||||
player: player,
|
player: player,
|
||||||
onStartAudio: onStartModuleAudio,
|
onStartAudio: onStartModuleAudio,
|
||||||
onToggleAudio: onToggleAudio,
|
onToggleAudio: onToggleAudio,
|
||||||
onSeekAudio: onSeekAudio,
|
onSeekAudio: onSeekAudio,
|
||||||
onSpeed: onSpeed,
|
onSpeed: onSpeed,
|
||||||
),
|
);
|
||||||
onPlayTap: () => _playFromReport(onPlay, visible.first),
|
},
|
||||||
),
|
onPlayTap: () => _playFromReport(onPlay, items.first),
|
||||||
const SizedBox(height: YantingSpacing.sectionGap),
|
),
|
||||||
const SectionTitle(title: '最新解读', icon: Icons.chevron_right),
|
const SizedBox(height: YantingSpacing.sectionGap),
|
||||||
for (final report in visible.skip(1)) ...[
|
const SectionTitle(title: '最新解读', icon: Icons.chevron_right),
|
||||||
ReportCardWidget(
|
for (final report in items.skip(1)) ...[
|
||||||
report: report,
|
ReportCardWidget(
|
||||||
onTap: () => openReportDetail(
|
report: report,
|
||||||
|
onTap: () {
|
||||||
|
ref
|
||||||
|
.read(profileControllerProvider.notifier)
|
||||||
|
.addHistory(report.id);
|
||||||
|
openReportDetail(
|
||||||
context,
|
context,
|
||||||
dataSource,
|
dataSource,
|
||||||
report,
|
report,
|
||||||
@@ -126,11 +126,11 @@ class FeedPage extends HookConsumerWidget {
|
|||||||
onToggleAudio: onToggleAudio,
|
onToggleAudio: onToggleAudio,
|
||||||
onSeekAudio: onSeekAudio,
|
onSeekAudio: onSeekAudio,
|
||||||
onSpeed: onSpeed,
|
onSpeed: onSpeed,
|
||||||
),
|
);
|
||||||
onPlayTap: () => _playFromReport(onPlay, report),
|
},
|
||||||
),
|
onPlayTap: () => _playFromReport(onPlay, report),
|
||||||
const SizedBox(height: YantingSpacing.x3),
|
),
|
||||||
],
|
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/api/report_data_source.dart';
|
||||||
import '../../data/models/models.dart';
|
import '../../data/models/models.dart';
|
||||||
|
import '../../data/providers.dart';
|
||||||
|
import '../../data/state/app_interaction_state.dart';
|
||||||
import '../../routing/app_routes.dart';
|
import '../../routing/app_routes.dart';
|
||||||
import '../../theme/app_icons.dart';
|
import '../../theme/app_icons.dart';
|
||||||
import '../../theme/yanting_text.dart';
|
import '../../theme/yanting_text.dart';
|
||||||
@@ -67,7 +69,7 @@ class InstitutionDetailPage extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _InstitutionDetailContent extends StatelessWidget {
|
class _InstitutionDetailContent extends ConsumerWidget {
|
||||||
const _InstitutionDetailContent({
|
const _InstitutionDetailContent({
|
||||||
required this.item,
|
required this.item,
|
||||||
required this.dataSource,
|
required this.dataSource,
|
||||||
@@ -77,7 +79,7 @@ class _InstitutionDetailContent extends StatelessWidget {
|
|||||||
final ReportDataSource dataSource;
|
final ReportDataSource dataSource;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
return ListView(
|
return ListView(
|
||||||
padding: const EdgeInsets.fromLTRB(
|
padding: const EdgeInsets.fromLTRB(
|
||||||
YantingSpacing.x4,
|
YantingSpacing.x4,
|
||||||
@@ -165,7 +167,12 @@ class _InstitutionDetailContent extends StatelessWidget {
|
|||||||
for (final report in item.recentReports) ...[
|
for (final report in item.recentReports) ...[
|
||||||
ReportCardWidget(
|
ReportCardWidget(
|
||||||
report: report,
|
report: report,
|
||||||
onTap: () => openReportDetail(context, dataSource, report),
|
onTap: () {
|
||||||
|
ref
|
||||||
|
.read(profileControllerProvider.notifier)
|
||||||
|
.addHistory(report.id);
|
||||||
|
openReportDetail(context, dataSource, report);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: YantingSpacing.x3),
|
const SizedBox(height: YantingSpacing.x3),
|
||||||
],
|
],
|
||||||
@@ -174,7 +181,19 @@ class _InstitutionDetailContent extends StatelessWidget {
|
|||||||
icon: AppIcons.externalLink,
|
icon: AppIcons.externalLink,
|
||||||
kind: AppButtonKind.ghost,
|
kind: AppButtonKind.ghost,
|
||||||
expand: true,
|
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/api/report_data_source.dart';
|
||||||
import '../../data/content_providers.dart';
|
import '../../data/content_providers.dart';
|
||||||
import '../../data/models/models.dart';
|
import '../../data/models/models.dart';
|
||||||
|
import '../../data/providers.dart';
|
||||||
import '../../theme/app_icons.dart';
|
import '../../theme/app_icons.dart';
|
||||||
import '../../theme/yanting_text.dart';
|
import '../../theme/yanting_text.dart';
|
||||||
import '../../theme/yanting_tokens.dart';
|
import '../../theme/yanting_tokens.dart';
|
||||||
@@ -49,12 +50,15 @@ class ListenPage extends HookConsumerWidget {
|
|||||||
const SectionTitle(title: '继续收听'),
|
const SectionTitle(title: '继续收听'),
|
||||||
_ContinueListeningCard(
|
_ContinueListeningCard(
|
||||||
item: current,
|
item: current,
|
||||||
onPlay: () => onPlay(current),
|
onPlay: () => _playAndRemember(ref, current),
|
||||||
),
|
),
|
||||||
const SectionTitle(title: '全部音频', icon: AppIcons.arrowRight),
|
const SectionTitle(title: '全部音频', icon: AppIcons.arrowRight),
|
||||||
const SizedBox(height: YantingSpacing.cardGap),
|
const SizedBox(height: YantingSpacing.cardGap),
|
||||||
for (final item in items.skip(1)) ...[
|
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),
|
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 {
|
class _ContinueListeningCard extends StatelessWidget {
|
||||||
@@ -98,13 +107,13 @@ class _ContinueListeningCard extends StatelessWidget {
|
|||||||
crossAxisAlignment: WrapCrossAlignment.center,
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
item.institution.nameCn,
|
item.institution.nameCn,
|
||||||
style: YantingText.meta.copyWith(
|
style: YantingText.meta.copyWith(
|
||||||
color: colors.foreground,
|
color: colors.foreground,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
Text('·', style: YantingText.meta),
|
Text('·', style: YantingText.meta),
|
||||||
Text(
|
Text(
|
||||||
'全长 ${formatDuration(item.durationSec)}',
|
'全长 ${formatDuration(item.durationSec)}',
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||||
|
|
||||||
import '../../data/api/report_data_source.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 '../../routing/app_routes.dart';
|
||||||
|
import '../../theme/app_icons.dart';
|
||||||
import '../../theme/yanting_text.dart';
|
import '../../theme/yanting_text.dart';
|
||||||
import '../../theme/yanting_tokens.dart';
|
import '../../theme/yanting_tokens.dart';
|
||||||
import '../../widgets/app_buttons.dart';
|
import '../../widgets/app_buttons.dart';
|
||||||
@@ -12,15 +17,34 @@ import '../../widgets/app_card.dart';
|
|||||||
import '../../widgets/page_header.dart';
|
import '../../widgets/page_header.dart';
|
||||||
import '../../widgets/sheets.dart';
|
import '../../widgets/sheets.dart';
|
||||||
import '../../widgets/states.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});
|
const ProfilePage({required this.dataSource, super.key});
|
||||||
|
|
||||||
final ReportDataSource dataSource;
|
final ReportDataSource dataSource;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final colors = ShadTheme.of(context).colorScheme;
|
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(
|
return ListView(
|
||||||
padding: const EdgeInsets.fromLTRB(
|
padding: const EdgeInsets.fromLTRB(
|
||||||
YantingSpacing.screenX,
|
YantingSpacing.screenX,
|
||||||
@@ -46,7 +70,7 @@ class ProfilePage extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'未登录',
|
auth.loggedIn ? '已登录' : '未登录',
|
||||||
style: YantingText.cardTitle.copyWith(
|
style: YantingText.cardTitle.copyWith(
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
color: colors.foreground,
|
color: colors.foreground,
|
||||||
@@ -54,7 +78,7 @@ class ProfilePage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 5),
|
const SizedBox(height: 5),
|
||||||
Text(
|
Text(
|
||||||
'登录后同步收藏、历史和听单',
|
auth.loggedIn ? '收藏、历史和听单已在本地同步' : '登录后同步收藏、历史和听单',
|
||||||
style: YantingText.meta.copyWith(
|
style: YantingText.meta.copyWith(
|
||||||
height: 1.5,
|
height: 1.5,
|
||||||
color: colors.mutedForeground,
|
color: colors.mutedForeground,
|
||||||
@@ -68,9 +92,16 @@ class ProfilePage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: YantingSpacing.x3),
|
const SizedBox(height: YantingSpacing.x3),
|
||||||
AppButton(
|
AppButton(
|
||||||
label: '登录 / 注册',
|
label: auth.loggedIn ? '退出登录' : '登录 / 注册',
|
||||||
expand: true,
|
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),
|
const SizedBox(height: 18),
|
||||||
_MenuGroup(
|
_MenuGroup(
|
||||||
@@ -78,8 +109,43 @@ class ProfilePage extends StatelessWidget {
|
|||||||
_MenuRow(
|
_MenuRow(
|
||||||
icon: AppIcons.history,
|
icon: AppIcons.history,
|
||||||
title: '本地浏览记录',
|
title: '本地浏览记录',
|
||||||
trailing: '0 条 · 本地临时',
|
trailing: '$historyCount 条 · 本地临时',
|
||||||
onTap: () => showAppToast(context, '历史同步接口待接入'),
|
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(
|
_MenuRow(
|
||||||
icon: AppIcons.fileList,
|
icon: AppIcons.fileList,
|
||||||
title: '用户协议',
|
title: '用户协议',
|
||||||
onTap: () => showOutboundSheet(context, title: '用户协议'),
|
onTap: () =>
|
||||||
|
_showOutbound(context, ref, 'user_agreement', '用户协议'),
|
||||||
),
|
),
|
||||||
_MenuRow(
|
_MenuRow(
|
||||||
icon: AppIcons.shield,
|
icon: AppIcons.shield,
|
||||||
title: '隐私政策',
|
title: '隐私政策',
|
||||||
onTap: () => showOutboundSheet(context, title: '隐私政策'),
|
onTap: () =>
|
||||||
|
_showOutbound(context, ref, 'privacy_policy', '隐私政策'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: YantingSpacing.x3),
|
const SizedBox(height: YantingSpacing.x3),
|
||||||
AppCard(
|
AppCard(
|
||||||
color: colors.secondary,
|
color: colors.secondary,
|
||||||
onTap: () => showOutboundSheet(context, title: '相关服务'),
|
onTap: () => _showOutbound(context, ref, 'profile_services', '相关服务'),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
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 {
|
class _MenuGroup extends StatelessWidget {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import 'package:shadcn_ui/shadcn_ui.dart';
|
|||||||
import '../../data/api/report_data_source.dart';
|
import '../../data/api/report_data_source.dart';
|
||||||
import '../../data/content_providers.dart';
|
import '../../data/content_providers.dart';
|
||||||
import '../../data/models/models.dart';
|
import '../../data/models/models.dart';
|
||||||
|
import '../../data/providers.dart';
|
||||||
|
import '../../data/state/report_query.dart';
|
||||||
import '../../routing/app_routes.dart';
|
import '../../routing/app_routes.dart';
|
||||||
import '../../theme/yanting_text.dart';
|
import '../../theme/yanting_text.dart';
|
||||||
import '../../theme/yanting_tokens.dart';
|
import '../../theme/yanting_tokens.dart';
|
||||||
@@ -44,28 +46,34 @@ class ReportsPage extends HookConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final theme = ShadTheme.of(context);
|
final theme = ShadTheme.of(context);
|
||||||
final searchController = useTextEditingController();
|
final searchController = useTextEditingController();
|
||||||
final query = useState('');
|
final query = ref.watch(reportFilterProvider);
|
||||||
final topic = useState('');
|
final snapshot = ref.watch(filteredReportsProvider);
|
||||||
final hasAudio = useState(false);
|
final allReportsSnapshot = ref.watch(reportsProvider);
|
||||||
final snapshot = 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(
|
return snapshot.when(
|
||||||
loading: () => const LoadingState(label: '正在搜索研报'),
|
loading: () => const LoadingState(label: '正在搜索研报'),
|
||||||
error: (error, _) => ErrorState(
|
error: (error, _) => ErrorState(
|
||||||
message: error.toString(),
|
message: error.toString(),
|
||||||
onRetry: () => ref.invalidate(reportsProvider),
|
onRetry: () => ref.invalidate(filteredReportsProvider),
|
||||||
),
|
),
|
||||||
data: (items) {
|
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(
|
return SingleChildScrollView(
|
||||||
padding: const EdgeInsets.fromLTRB(
|
padding: const EdgeInsets.fromLTRB(
|
||||||
YantingSpacing.screenX,
|
YantingSpacing.screenX,
|
||||||
@@ -84,7 +92,7 @@ class ReportsPage extends HookConsumerWidget {
|
|||||||
padding: EdgeInsets.only(right: 8),
|
padding: EdgeInsets.only(right: 8),
|
||||||
child: Icon(LucideIcons.search, size: 16),
|
child: Icon(LucideIcons.search, size: 16),
|
||||||
),
|
),
|
||||||
trailing: currentQuery.isEmpty
|
trailing: query.search.isEmpty
|
||||||
? null
|
? null
|
||||||
: Padding(
|
: Padding(
|
||||||
padding: const EdgeInsets.only(left: 8),
|
padding: const EdgeInsets.only(left: 8),
|
||||||
@@ -92,12 +100,12 @@ class ReportsPage extends HookConsumerWidget {
|
|||||||
size: ShadButtonSize.sm,
|
size: ShadButtonSize.sm,
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
searchController.clear();
|
searchController.clear();
|
||||||
query.value = '';
|
controller.setSearch('');
|
||||||
},
|
},
|
||||||
child: const Icon(LucideIcons.x, size: 16),
|
child: const Icon(LucideIcons.x, size: 16),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
onChanged: (value) => query.value = value.trim(),
|
onChanged: (value) => controller.setSearch(value.trim()),
|
||||||
),
|
),
|
||||||
const SizedBox(height: YantingSpacing.cardGap),
|
const SizedBox(height: YantingSpacing.cardGap),
|
||||||
Row(
|
Row(
|
||||||
@@ -109,36 +117,42 @@ class ReportsPage extends HookConsumerWidget {
|
|||||||
runSpacing: YantingSpacing.x2,
|
runSpacing: YantingSpacing.x2,
|
||||||
children: [
|
children: [
|
||||||
ShadButton.outline(
|
ShadButton.outline(
|
||||||
onPressed: items.isEmpty
|
onPressed: allReports.isEmpty
|
||||||
? null
|
? null
|
||||||
: () => _openFilterSheet(
|
: () => _openFilterSheet(
|
||||||
context,
|
context,
|
||||||
items: items,
|
items: allReports,
|
||||||
topic: topic,
|
institutions: institutions,
|
||||||
),
|
),
|
||||||
leading: const Icon(
|
leading: const Icon(
|
||||||
LucideIcons.slidersHorizontal,
|
LucideIcons.slidersHorizontal,
|
||||||
size: 16,
|
size: 16,
|
||||||
),
|
),
|
||||||
child: const Text('筛选'),
|
child: Text(query.hasActiveFilter ? '筛选中' : '筛选'),
|
||||||
),
|
),
|
||||||
ShadButton.outline(
|
ShadButton.outline(
|
||||||
onPressed: () {},
|
onPressed: () => controller.setSort(
|
||||||
|
query.sort == ReportSort.latest
|
||||||
|
? ReportSort.oldest
|
||||||
|
: ReportSort.latest,
|
||||||
|
),
|
||||||
leading: const Icon(
|
leading: const Icon(
|
||||||
LucideIcons.arrowUpDown,
|
LucideIcons.arrowUpDown,
|
||||||
size: 16,
|
size: 16,
|
||||||
),
|
),
|
||||||
child: const Text('最新'),
|
child: Text(
|
||||||
|
query.sort == ReportSort.latest ? '最新' : '最早',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
ShadBadge.secondary(
|
ShadBadge.secondary(
|
||||||
onPressed: () => hasAudio.value = !currentHasAudio,
|
onPressed: controller.toggleAudio,
|
||||||
backgroundColor: currentHasAudio
|
backgroundColor: query.hasAudio
|
||||||
? theme.colorScheme.foreground
|
? theme.colorScheme.foreground
|
||||||
: theme.colorScheme.secondary,
|
: theme.colorScheme.secondary,
|
||||||
foregroundColor: currentHasAudio
|
foregroundColor: query.hasAudio
|
||||||
? theme.colorScheme.background
|
? theme.colorScheme.background
|
||||||
: theme.colorScheme.secondaryForeground,
|
: theme.colorScheme.secondaryForeground,
|
||||||
hoverBackgroundColor: currentHasAudio
|
hoverBackgroundColor: query.hasAudio
|
||||||
? theme.colorScheme.foreground.withValues(
|
? theme.colorScheme.foreground.withValues(
|
||||||
alpha: 0.9,
|
alpha: 0.9,
|
||||||
)
|
)
|
||||||
@@ -151,40 +165,40 @@ class ReportsPage extends HookConsumerWidget {
|
|||||||
const SizedBox(width: YantingSpacing.x2),
|
const SizedBox(width: YantingSpacing.x2),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 10),
|
padding: const EdgeInsets.only(top: 10),
|
||||||
child: Text(
|
child: Text('共 ${items.length} 篇', style: YantingText.meta),
|
||||||
'共 ${filtered.length} 篇',
|
|
||||||
style: YantingText.meta,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: YantingSpacing.cardGap),
|
const SizedBox(height: YantingSpacing.cardGap),
|
||||||
if (filtered.isEmpty)
|
if (items.isEmpty)
|
||||||
EmptyState(
|
EmptyState(
|
||||||
title: currentQuery.isNotEmpty ? '未找到相关研报' : '当前筛选下暂无研报',
|
title: query.search.isNotEmpty ? '未找到相关研报' : '当前筛选下暂无研报',
|
||||||
message: currentQuery.isNotEmpty ? '换个关键词试试' : '调整筛选条件后再试',
|
message: query.search.isNotEmpty ? '换个关键词试试' : '调整筛选条件后再试',
|
||||||
actionLabel: '清除筛选',
|
actionLabel: '清除筛选',
|
||||||
onAction: () {
|
onAction: () {
|
||||||
searchController.clear();
|
searchController.clear();
|
||||||
query.value = '';
|
controller.reset();
|
||||||
topic.value = '';
|
|
||||||
hasAudio.value = false;
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
for (final report in filtered) ...[
|
for (final report in items) ...[
|
||||||
ReportCardWidget(
|
ReportCardWidget(
|
||||||
report: report,
|
report: report,
|
||||||
onTap: () => openReportDetail(
|
onTap: () {
|
||||||
context,
|
ref
|
||||||
dataSource,
|
.read(profileControllerProvider.notifier)
|
||||||
report,
|
.addHistory(report.id);
|
||||||
player: player,
|
openReportDetail(
|
||||||
onStartAudio: onStartModuleAudio,
|
context,
|
||||||
onToggleAudio: onToggleAudio,
|
dataSource,
|
||||||
onSeekAudio: onSeekAudio,
|
report,
|
||||||
onSpeed: onSpeed,
|
player: player,
|
||||||
),
|
onStartAudio: onStartModuleAudio,
|
||||||
|
onToggleAudio: onToggleAudio,
|
||||||
|
onSeekAudio: onSeekAudio,
|
||||||
|
onSpeed: onSpeed,
|
||||||
|
);
|
||||||
|
},
|
||||||
onPlayTap: () => onPlay(
|
onPlayTap: () => onPlay(
|
||||||
AudioItem(
|
AudioItem(
|
||||||
audioId: 'local_${report.id}',
|
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(
|
void _openFilterSheet(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required List<ReportCardModel> items,
|
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>(
|
showShadSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
side: ShadSheetSide.bottom,
|
side: ShadSheetSide.bottom,
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
final theme = ShadTheme.of(context);
|
return Consumer(
|
||||||
final selectedBackground = theme.colorScheme.foreground;
|
builder: (context, ref, _) {
|
||||||
final selectedForeground = theme.colorScheme.background;
|
final theme = ShadTheme.of(context);
|
||||||
final unselectedBackground = theme.colorScheme.secondary;
|
final query = ref.watch(reportFilterProvider);
|
||||||
final unselectedForeground = theme.colorScheme.secondaryForeground;
|
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(
|
ShadBadge option({
|
||||||
title: const Text('筛选研报'),
|
required String label,
|
||||||
description: const Text('按主题快速收窄列表。'),
|
required bool selected,
|
||||||
child: Column(
|
required VoidCallback onPressed,
|
||||||
mainAxisSize: MainAxisSize.min,
|
}) {
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
return ShadBadge.secondary(
|
||||||
children: [
|
onPressed: onPressed,
|
||||||
Wrap(
|
backgroundColor: selected
|
||||||
spacing: YantingSpacing.x2,
|
? selectedBackground
|
||||||
runSpacing: YantingSpacing.x2,
|
: 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: [
|
children: [
|
||||||
ShadBadge.secondary(
|
const _FilterGroupTitle('主题'),
|
||||||
onPressed: () => topic.value = '',
|
Wrap(
|
||||||
backgroundColor: topic.value.isEmpty
|
spacing: YantingSpacing.x2,
|
||||||
? selectedBackground
|
runSpacing: YantingSpacing.x2,
|
||||||
: unselectedBackground,
|
children: [
|
||||||
foregroundColor: topic.value.isEmpty
|
option(
|
||||||
? selectedForeground
|
label: '全部主题',
|
||||||
: unselectedForeground,
|
selected: query.topic == null,
|
||||||
hoverBackgroundColor: topic.value.isEmpty
|
onPressed: () => controller.setTopic(null),
|
||||||
? selectedBackground.withValues(alpha: 0.9)
|
),
|
||||||
: theme.colorScheme.border,
|
for (final topic in topics)
|
||||||
child: const Text('全部主题'),
|
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:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:shadcn_ui/shadcn_ui.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 '../../routing/app_routes.dart';
|
||||||
import '../../theme/app_icons.dart';
|
import '../../theme/app_icons.dart';
|
||||||
import '../../theme/theme_controller.dart';
|
import '../../theme/theme_controller.dart';
|
||||||
@@ -88,13 +90,23 @@ class SettingsPage extends ConsumerWidget {
|
|||||||
_LinkTile(
|
_LinkTile(
|
||||||
icon: Icons.description_outlined,
|
icon: Icons.description_outlined,
|
||||||
title: '用户协议',
|
title: '用户协议',
|
||||||
onTap: () => showOutboundSheet(context, title: '用户协议'),
|
onTap: () => _showOutbound(
|
||||||
|
context,
|
||||||
|
ref,
|
||||||
|
'settings_user_agreement',
|
||||||
|
'用户协议',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const Divider(height: 1, thickness: 1),
|
const Divider(height: 1, thickness: 1),
|
||||||
_LinkTile(
|
_LinkTile(
|
||||||
icon: Icons.privacy_tip_outlined,
|
icon: Icons.privacy_tip_outlined,
|
||||||
title: '隐私政策',
|
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 {
|
class _ThemeModeTile extends StatelessWidget {
|
||||||
|
|||||||
@@ -69,7 +69,9 @@ class _ShellPageState extends ConsumerState<ShellPage> {
|
|||||||
Text(
|
Text(
|
||||||
header.title,
|
header.title,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: YantingText.listTitle,
|
style: YantingText.listTitle.copyWith(
|
||||||
|
color: theme.colorScheme.foreground,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
if (header.subtitle.isNotEmpty)
|
if (header.subtitle.isNotEmpty)
|
||||||
Text(
|
Text(
|
||||||
@@ -77,7 +79,10 @@ class _ShellPageState extends ConsumerState<ShellPage> {
|
|||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
textAlign: TextAlign.center,
|
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(
|
GoRoute(
|
||||||
path: AppRoutes.reportDetail,
|
path: AppRoutes.reportDetail,
|
||||||
builder: (context, state) {
|
pageBuilder: (context, state) {
|
||||||
final id = state.pathParameters['id'] ?? '';
|
final id = state.pathParameters['id'] ?? '';
|
||||||
final args = state.extra as ReportDetailRouteArgs?;
|
final args = state.extra as ReportDetailRouteArgs?;
|
||||||
return Consumer(
|
return _slidePage(
|
||||||
builder: (context, ref, _) {
|
state: state,
|
||||||
final player = ref.watch(audioPlayerControllerProvider);
|
child: Consumer(
|
||||||
final controller = ref.read(
|
builder: (context, ref, _) {
|
||||||
audioPlayerControllerProvider.notifier,
|
final player = ref.watch(audioPlayerControllerProvider);
|
||||||
);
|
final controller = ref.read(
|
||||||
return ReportDetailPage(
|
audioPlayerControllerProvider.notifier,
|
||||||
reportId: id,
|
);
|
||||||
dataSource: args?.dataSource ?? dataSource,
|
return ReportDetailPage(
|
||||||
player: player,
|
reportId: id,
|
||||||
onStartAudio: args?.onStartAudio ?? controller.startModuleAudio,
|
dataSource: args?.dataSource ?? dataSource,
|
||||||
onToggleAudio: args?.onToggleAudio ?? controller.toggleAudio,
|
player: player,
|
||||||
onSeekAudio: args?.onSeekAudio ?? controller.seekAudio,
|
onStartAudio:
|
||||||
onSpeed: args?.onSpeed ?? controller.cycleSpeed,
|
args?.onStartAudio ?? controller.startModuleAudio,
|
||||||
);
|
onToggleAudio: args?.onToggleAudio ?? controller.toggleAudio,
|
||||||
},
|
onSeekAudio: args?.onSeekAudio ?? controller.seekAudio,
|
||||||
|
onSpeed: args?.onSpeed ?? controller.cycleSpeed,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: AppRoutes.institutionDetail,
|
path: AppRoutes.institutionDetail,
|
||||||
builder: (context, state) {
|
pageBuilder: (context, state) {
|
||||||
final id = state.pathParameters['id'] ?? '';
|
final id = state.pathParameters['id'] ?? '';
|
||||||
final args = state.extra as InstitutionDetailRouteArgs?;
|
final args = state.extra as InstitutionDetailRouteArgs?;
|
||||||
return InstitutionDetailPage(
|
return _slidePage(
|
||||||
institutionId: id,
|
state: state,
|
||||||
dataSource: args?.dataSource ?? dataSource,
|
child: InstitutionDetailPage(
|
||||||
|
institutionId: id,
|
||||||
|
dataSource: args?.dataSource ?? dataSource,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: AppRoutes.settings,
|
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 {
|
class _TabSurface extends StatelessWidget {
|
||||||
const _TabSurface({required this.child});
|
const _TabSurface({required this.child});
|
||||||
|
|
||||||
|
|||||||
+12
-3
@@ -7,6 +7,8 @@ import 'states.dart';
|
|||||||
Future<void> showLoginSheet(
|
Future<void> showLoginSheet(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
String reason = '登录后保存当前动作',
|
String reason = '登录后保存当前动作',
|
||||||
|
VoidCallback? onPhoneLogin,
|
||||||
|
VoidCallback? onSecondaryLogin,
|
||||||
}) {
|
}) {
|
||||||
return showShadSheet<void>(
|
return showShadSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -23,7 +25,8 @@ Future<void> showLoginSheet(
|
|||||||
expand: true,
|
expand: true,
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
showAppToast(context, '登录接口待接入,已保留当前页面');
|
onPhoneLogin?.call();
|
||||||
|
showAppToast(context, '已使用本地登录态继续');
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
@@ -34,7 +37,8 @@ Future<void> showLoginSheet(
|
|||||||
expand: true,
|
expand: true,
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Navigator.pop(context);
|
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>(
|
return showShadSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
side: ShadSheetSide.bottom,
|
side: ShadSheetSide.bottom,
|
||||||
@@ -57,6 +65,7 @@ Future<void> showOutboundSheet(BuildContext context, {required String title}) {
|
|||||||
expand: true,
|
expand: true,
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
|
onConfirm?.call();
|
||||||
showAppToast(context, '外跳事件接口待接入');
|
showAppToast(context, '外跳事件接口待接入');
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ void main() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
class FakeDataSource implements ReportDataSource {
|
class FakeDataSource extends ReportDataSource {
|
||||||
final institution = const Institution(
|
final institution = const Institution(
|
||||||
id: 'inst_ssga',
|
id: 'inst_ssga',
|
||||||
nameCn: '道富环球投资管理',
|
nameCn: '道富环球投资管理',
|
||||||
|
|||||||
Reference in New Issue
Block a user