fix:对比原型增加功能交互

This commit is contained in:
jingyun
2026-06-07 10:58:05 +08:00
parent af865b13fb
commit ac794ae58a
21 changed files with 1342 additions and 233 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
import '../models/models.dart';
import 'report_data_source.dart';
class MockReportDataSource implements ReportDataSource {
class MockReportDataSource extends ReportDataSource {
MockReportDataSource();
static final Institution _wgcSummary = _institutionSummary(
+64 -3
View File
@@ -3,8 +3,10 @@ import 'dart:convert';
import 'package:http/http.dart' as http;
import '../models/models.dart';
import '../repositories/report_repository.dart';
import '../state/report_query.dart';
abstract class ReportDataSource {
abstract class ReportDataSource extends ReportRepository {
Future<List<ReportCardModel>> recommended();
Future<List<ReportCardModel>> reports();
Future<List<Institution>> institutions();
@@ -12,9 +14,66 @@ abstract class ReportDataSource {
Future<List<AudioItem>> listen();
Future<ReportDetail> reportDetail(String reportId);
Future<ModuleDetail> moduleDetail(String reportId, String moduleId);
@override
Future<List<ReportCardModel>> getRecommended({String? topic}) async {
final items = await recommended();
if (topic == null || topic == '全部') return items;
return items.where((item) => item.topics.contains(topic)).toList();
}
@override
Future<List<ReportCardModel>> getReports(ReportQuery query) async {
final currentSearch = query.search.trim().toLowerCase();
final items = await reports();
final filtered = items.where((item) {
final haystack =
'${item.titleCn} ${item.subtitleCn} ${item.oneLiner} '
'${item.institution.nameCn} ${item.institution.nameEn} '
'${item.topics.join(' ')}'
.toLowerCase();
if (currentSearch.isNotEmpty && !haystack.contains(currentSearch)) {
return false;
}
if (query.topic != null && !item.topics.contains(query.topic)) {
return false;
}
if (query.institutionId != null &&
item.institution.id != query.institutionId) {
return false;
}
if (query.hasAudio && !item.hasAudio) {
return false;
}
return true;
}).toList();
filtered.sort((a, b) {
final result = (b.releasedAt ?? '').compareTo(a.releasedAt ?? '');
return query.sort == ReportSort.oldest ? -result : result;
});
return filtered;
}
@override
Future<ReportDetail> getReportDetail(String reportId) =>
reportDetail(reportId);
@override
Future<List<Institution>> getInstitutions() => institutions();
@override
Future<Institution> getInstitutionDetail(String institutionId) =>
institutionDetail(institutionId);
@override
Future<List<AudioItem>> getListenItems() => listen();
@override
Future<ModuleDetail> getModuleDetail(String reportId, String moduleId) =>
moduleDetail(reportId, moduleId);
}
class RnbApiDataSource implements ReportDataSource {
class RnbApiDataSource extends ReportDataSource {
RnbApiDataSource({
http.Client? client,
this.baseUrl = const String.fromEnvironment('RNB_API_BASE'),
@@ -72,6 +131,8 @@ class RnbApiDataSource implements ReportDataSource {
@override
Future<ModuleDetail> moduleDetail(String reportId, String moduleId) async {
return ModuleDetail.fromJson(await _get('/reports/$reportId/modules/$moduleId'));
return ModuleDetail.fromJson(
await _get('/reports/$reportId/modules/$moduleId'),
);
}
}
+52 -10
View File
@@ -2,26 +2,68 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'models/models.dart';
import 'providers.dart';
import 'state/app_state_controllers.dart';
import 'state/report_query.dart';
final recommendedReportsProvider =
FutureProvider.autoDispose<List<ReportCardModel>>((ref) async {
final dataSource = ref.watch(reportDataSourceProvider);
return dataSource.recommended();
});
final dataSource = ref.watch(reportDataSourceProvider);
return dataSource.recommended();
});
final reportsProvider =
final recommendedByTopicProvider =
FutureProvider.autoDispose<List<ReportCardModel>>((ref) async {
final repository = ref.watch(reportRepositoryProvider);
final topic = ref.watch(recommendTopicProvider);
return repository.getRecommended(topic: topic);
});
final reportsProvider = FutureProvider.autoDispose<List<ReportCardModel>>((
ref,
) async {
final dataSource = ref.watch(reportDataSourceProvider);
return dataSource.reports();
});
final institutionsProvider =
FutureProvider.autoDispose<List<Institution>>((ref) async {
final dataSource = ref.watch(reportDataSourceProvider);
return dataSource.institutions();
final filteredReportsProvider =
FutureProvider.autoDispose<List<ReportCardModel>>((ref) async {
final repository = ref.watch(reportRepositoryProvider);
final query = ref.watch(reportFilterProvider);
return repository.getReports(query);
});
final institutionsProvider = FutureProvider.autoDispose<List<Institution>>((
ref,
) async {
final repository = ref.watch(reportRepositoryProvider);
return repository.getInstitutions();
});
final listenProvider = FutureProvider.autoDispose<List<AudioItem>>((ref) async {
final dataSource = ref.watch(reportDataSourceProvider);
return dataSource.listen();
final repository = ref.watch(reportRepositoryProvider);
return repository.getListenItems();
});
final profileHistoryReportsProvider =
FutureProvider.autoDispose<List<ReportCardModel>>((ref) async {
final profile = ref.watch(profileControllerProvider);
final repository = ref.watch(reportRepositoryProvider);
final reports = await repository.getReports(const ReportQuery());
return ProfileListBuilder(reports).byIds(profile.history);
});
final profileFavoriteReportsProvider =
FutureProvider.autoDispose<List<ReportCardModel>>((ref) async {
final profile = ref.watch(profileControllerProvider);
final repository = ref.watch(reportRepositoryProvider);
final reports = await repository.getReports(const ReportQuery());
return ProfileListBuilder(reports).byIds(profile.favorites);
});
final profileSavedListenReportsProvider =
FutureProvider.autoDispose<List<ReportCardModel>>((ref) async {
final profile = ref.watch(profileControllerProvider);
final repository = ref.watch(reportRepositoryProvider);
final reports = await repository.getReports(const ReportQuery());
return ProfileListBuilder(reports).byIds(profile.savedListens);
});
+51
View File
@@ -3,6 +3,12 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'api/mock_report_data_source.dart';
import 'api/report_data_source.dart';
import 'audio_player_controller.dart';
import 'repositories/outbound_repository.dart';
import 'repositories/report_repository.dart';
import 'repositories/user_state_repository.dart';
import 'state/app_interaction_state.dart';
import 'state/app_state_controllers.dart';
import 'state/report_query.dart';
import '../widgets/mini_player.dart';
final reportDataSourceProvider = Provider<ReportDataSource>((ref) {
@@ -17,3 +23,48 @@ final audioPlayerControllerProvider =
StateNotifierProvider<AudioPlayerController, PlayerStateModel>((ref) {
return AudioPlayerController();
});
final reportRepositoryProvider = Provider<ReportRepository>((ref) {
return ref.watch(reportDataSourceProvider);
});
final userStateRepositoryProvider = Provider<UserStateRepository>((ref) {
return MemoryUserStateRepository();
});
final outboundRepositoryProvider = Provider<OutboundRepository>((ref) {
return MemoryOutboundRepository();
});
final recommendTopicProvider =
StateNotifierProvider<RecommendTopicController, String>((ref) {
return RecommendTopicController();
});
final reportFilterProvider =
StateNotifierProvider<ReportFilterController, ReportQuery>((ref) {
return ReportFilterController();
});
final authControllerProvider = StateNotifierProvider<AuthController, AuthState>(
(ref) {
return AuthController(ref.watch(userStateRepositoryProvider));
},
);
final profileControllerProvider =
StateNotifierProvider<ProfileController, ProfileState>((ref) {
return ProfileController(ref.watch(userStateRepositoryProvider));
});
final detailNavigationProvider =
StateNotifierProvider<DetailNavigationController, DetailNavigationState>((
ref,
) {
return DetailNavigationController();
});
final sheetControllerProvider =
StateNotifierProvider<SheetController, SheetState>((ref) {
return SheetController();
});
@@ -0,0 +1,16 @@
import '../state/app_interaction_state.dart';
abstract class OutboundRepository {
Future<void> recordOutbound(OutboundEvent event);
}
class MemoryOutboundRepository implements OutboundRepository {
final List<OutboundEvent> _events = [];
List<OutboundEvent> get events => List.unmodifiable(_events);
@override
Future<void> recordOutbound(OutboundEvent event) async {
_events.add(event);
}
}
@@ -0,0 +1,12 @@
import '../models/models.dart';
import '../state/report_query.dart';
abstract class ReportRepository {
Future<List<ReportCardModel>> getRecommended({String? topic});
Future<List<ReportCardModel>> getReports(ReportQuery query);
Future<ReportDetail> getReportDetail(String reportId);
Future<List<Institution>> getInstitutions();
Future<Institution> getInstitutionDetail(String institutionId);
Future<List<AudioItem>> getListenItems();
Future<ModuleDetail> getModuleDetail(String reportId, String moduleId);
}
@@ -0,0 +1,80 @@
import '../state/app_interaction_state.dart';
abstract class UserStateRepository {
Future<bool> isLoggedIn();
Future<void> login(LoginMethod method);
Future<void> logout();
Future<Set<String>> getFavorites();
Future<void> toggleFavorite(String reportId);
Future<Set<String>> getSavedListens();
Future<void> toggleSavedListen(String reportId);
Future<List<String>> getHistory();
Future<void> addHistory(String reportId);
Future<Map<String, double>> getAudioProgress();
Future<void> saveAudioProgress(String audioId, double seconds);
}
class MemoryUserStateRepository implements UserStateRepository {
bool _loggedIn = false;
final Set<String> _favorites = {};
final Set<String> _savedListens = {};
final List<String> _history = [];
final Map<String, double> _audioProgress = {};
@override
Future<bool> isLoggedIn() async => _loggedIn;
@override
Future<void> login(LoginMethod method) async {
_loggedIn = true;
}
@override
Future<void> logout() async {
_loggedIn = false;
}
@override
Future<Set<String>> getFavorites() async => {..._favorites};
@override
Future<void> toggleFavorite(String reportId) async {
if (!_favorites.add(reportId)) {
_favorites.remove(reportId);
}
}
@override
Future<Set<String>> getSavedListens() async => {..._savedListens};
@override
Future<void> toggleSavedListen(String reportId) async {
if (!_savedListens.add(reportId)) {
_savedListens.remove(reportId);
}
}
@override
Future<List<String>> getHistory() async => [..._history];
@override
Future<void> addHistory(String reportId) async {
_history.remove(reportId);
_history.insert(0, reportId);
if (_history.length > 40) {
_history.removeRange(40, _history.length);
}
}
@override
Future<Map<String, double>> getAudioProgress() async => {..._audioProgress};
@override
Future<void> saveAudioProgress(String audioId, double seconds) async {
_audioProgress[audioId] = seconds < 0 ? 0 : seconds;
}
}
+160
View File
@@ -0,0 +1,160 @@
import '../models/models.dart';
class AuthState {
const AuthState({this.loggedIn = false, this.pendingAction});
final bool loggedIn;
final PendingLoginAction? pendingAction;
AuthState copyWith({bool? loggedIn, Object? pendingAction = _sentinel}) {
return AuthState(
loggedIn: loggedIn ?? this.loggedIn,
pendingAction: identical(pendingAction, _sentinel)
? this.pendingAction
: pendingAction as PendingLoginAction?,
);
}
}
class PendingLoginAction {
const PendingLoginAction({
required this.action,
required this.reportId,
required this.contextText,
});
final LoginRequiredAction action;
final String reportId;
final String contextText;
}
enum LoginRequiredAction { favorite, saveListen }
enum LoginMethod { phone, wechat, apple }
class ProfileState {
const ProfileState({
this.favorites = const {},
this.savedListens = const {},
this.history = const [],
});
final Set<String> favorites;
final Set<String> savedListens;
final List<String> history;
ProfileState copyWith({
Set<String>? favorites,
Set<String>? savedListens,
List<String>? history,
}) {
return ProfileState(
favorites: favorites ?? this.favorites,
savedListens: savedListens ?? this.savedListens,
history: history ?? this.history,
);
}
}
class DetailNavigationState {
const DetailNavigationState({
this.originTab = AppTab.recommend,
this.stack = const [],
this.tabScroll = const {},
});
final AppTab originTab;
final List<DetailStackEntry> stack;
final Map<AppTab, double> tabScroll;
DetailNavigationState copyWith({
AppTab? originTab,
List<DetailStackEntry>? stack,
Map<AppTab, double>? tabScroll,
}) {
return DetailNavigationState(
originTab: originTab ?? this.originTab,
stack: stack ?? this.stack,
tabScroll: tabScroll ?? this.tabScroll,
);
}
}
class DetailStackEntry {
const DetailStackEntry({
required this.type,
required this.id,
this.scrollTop = 0,
});
final DetailEntryType type;
final String id;
final double scrollTop;
DetailStackEntry copyWith({double? scrollTop}) {
return DetailStackEntry(
type: type,
id: id,
scrollTop: scrollTop ?? this.scrollTop,
);
}
}
enum DetailEntryType { report, institution }
enum AppTab { recommend, reports, institutions, listen, profile }
class SheetState {
const SheetState.hidden() : intent = null;
const SheetState.visible(this.intent);
final SheetIntent? intent;
bool get isVisible => intent != null;
}
sealed class SheetIntent {
const SheetIntent();
}
class LoginSheetIntent extends SheetIntent {
const LoginSheetIntent({required this.contextText});
final String contextText;
}
class FilterSheetIntent extends SheetIntent {
const FilterSheetIntent();
}
class OutboundSheetIntent extends SheetIntent {
const OutboundSheetIntent({required this.scene, this.refId, this.targetUrl});
final String scene;
final String? refId;
final String? targetUrl;
}
class ProfileListSheetIntent extends SheetIntent {
const ProfileListSheetIntent({
required this.kind,
required this.title,
this.reports = const [],
});
final ProfileListKind kind;
final String title;
final List<ReportCardModel> reports;
}
enum ProfileListKind { favorites, history, saved }
class OutboundEvent {
const OutboundEvent({required this.scene, this.refId, this.targetUrl});
final String scene;
final String? refId;
final String? targetUrl;
}
const Object _sentinel = Object();
+160
View File
@@ -0,0 +1,160 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../models/models.dart';
import '../repositories/user_state_repository.dart';
import 'app_interaction_state.dart';
import 'report_query.dart';
class RecommendTopicController extends StateNotifier<String> {
RecommendTopicController() : super('全部');
void select(String topic) {
state = topic;
}
}
class ReportFilterController extends StateNotifier<ReportQuery> {
ReportFilterController() : super(const ReportQuery());
void setSearch(String value) {
state = state.copyWith(search: value);
}
void setTopic(String? topic) {
state = state.copyWith(topic: topic);
}
void setInstitution(String? institutionId) {
state = state.copyWith(institutionId: institutionId);
}
void toggleAudio() {
state = state.copyWith(hasAudio: !state.hasAudio);
}
void setSort(ReportSort sort) {
state = state.copyWith(sort: sort);
}
void reset() {
state = const ReportQuery();
}
}
class AuthController extends StateNotifier<AuthState> {
AuthController(this._repository) : super(const AuthState()) {
_load();
}
final UserStateRepository _repository;
Future<void> _load() async {
state = state.copyWith(loggedIn: await _repository.isLoggedIn());
}
void requireLogin(PendingLoginAction action) {
if (state.loggedIn) return;
state = state.copyWith(pendingAction: action);
}
Future<PendingLoginAction?> login(LoginMethod method) async {
final pending = state.pendingAction;
await _repository.login(method);
state = const AuthState(loggedIn: true);
return pending;
}
Future<void> logout() async {
await _repository.logout();
state = const AuthState();
}
void clearPending() {
state = state.copyWith(pendingAction: null);
}
}
class ProfileController extends StateNotifier<ProfileState> {
ProfileController(this._repository) : super(const ProfileState()) {
refresh();
}
final UserStateRepository _repository;
Future<void> refresh() async {
state = ProfileState(
favorites: await _repository.getFavorites(),
savedListens: await _repository.getSavedListens(),
history: await _repository.getHistory(),
);
}
Future<void> toggleFavorite(String reportId) async {
await _repository.toggleFavorite(reportId);
await refresh();
}
Future<void> toggleSavedListen(String reportId) async {
await _repository.toggleSavedListen(reportId);
await refresh();
}
Future<void> addHistory(String reportId) async {
await _repository.addHistory(reportId);
await refresh();
}
}
class DetailNavigationController extends StateNotifier<DetailNavigationState> {
DetailNavigationController() : super(const DetailNavigationState());
void rememberTabScroll(AppTab tab, double scrollTop) {
state = state.copyWith(tabScroll: {...state.tabScroll, tab: scrollTop});
}
void push(DetailStackEntry entry, {required AppTab originTab}) {
final stack = [...state.stack, entry];
state = state.copyWith(originTab: originTab, stack: stack);
}
void updateCurrentScroll(double scrollTop) {
if (state.stack.isEmpty) return;
final stack = [...state.stack];
stack[stack.length - 1] = stack.last.copyWith(scrollTop: scrollTop);
state = state.copyWith(stack: stack);
}
DetailStackEntry? pop() {
if (state.stack.isEmpty) return null;
final stack = [...state.stack]..removeLast();
state = state.copyWith(stack: stack);
return stack.isEmpty ? null : stack.last;
}
void reset() {
state = const DetailNavigationState();
}
}
class SheetController extends StateNotifier<SheetState> {
SheetController() : super(const SheetState.hidden());
void show(SheetIntent intent) {
state = SheetState.visible(intent);
}
void hide() {
state = const SheetState.hidden();
}
}
class ProfileListBuilder {
const ProfileListBuilder(this.reports);
final List<ReportCardModel> reports;
List<ReportCardModel> byIds(Iterable<String> ids) {
final byId = {for (final report in reports) report.id: report};
return ids.map((id) => byId[id]).whereType<ReportCardModel>().toList();
}
}
+44
View File
@@ -0,0 +1,44 @@
class ReportQuery {
const ReportQuery({
this.search = '',
this.topic,
this.institutionId,
this.hasAudio = false,
this.sort = ReportSort.latest,
});
final String search;
final String? topic;
final String? institutionId;
final bool hasAudio;
final ReportSort sort;
bool get hasActiveFilter =>
search.trim().isNotEmpty ||
topic != null ||
institutionId != null ||
hasAudio ||
sort != ReportSort.latest;
ReportQuery copyWith({
String? search,
Object? topic = _sentinel,
Object? institutionId = _sentinel,
bool? hasAudio,
ReportSort? sort,
}) {
return ReportQuery(
search: search ?? this.search,
topic: identical(topic, _sentinel) ? this.topic : topic as String?,
institutionId: identical(institutionId, _sentinel)
? this.institutionId
: institutionId as String?,
hasAudio: hasAudio ?? this.hasAudio,
sort: sort ?? this.sort,
);
}
}
enum ReportSort { latest, oldest }
const Object _sentinel = Object();