This commit is contained in:
macbook_max
2026-10-15 20:36:59 +08:00
commit 6bf949949c
7923 changed files with 1369582 additions and 0 deletions
+185
View File
@@ -0,0 +1,185 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../services/asset_service.dart';
/// 资产页状态:资产钱包 + 收益明细(biz_type 筛选 + 触底分页)
class AssetState {
const AssetState({
this.wallet,
this.records = const [],
this.recordsTotal = 0,
this.bizType,
this.isLoading = false,
this.loadFailed = false,
this.loadingMore = false,
});
final AssetWallet? wallet;
final List<AssetRecord> records;
final int recordsTotal;
/// null = 全部
final int? bizType;
final bool isLoading;
final bool loadFailed;
final bool loadingMore;
bool get hasMore => records.length < recordsTotal;
AssetState copyWith({
AssetWallet? wallet,
List<AssetRecord>? records,
int? recordsTotal,
int? Function()? bizType,
bool? isLoading,
bool? loadFailed,
bool? loadingMore,
}) =>
AssetState(
wallet: wallet ?? this.wallet,
records: records ?? this.records,
recordsTotal: recordsTotal ?? this.recordsTotal,
bizType: bizType != null ? bizType() : this.bizType,
isLoading: isLoading ?? this.isLoading,
loadFailed: loadFailed ?? this.loadFailed,
loadingMore: loadingMore ?? this.loadingMore,
);
}
class AssetNotifier extends StateNotifier<AssetState> {
AssetNotifier() : super(const AssetState()) {
load();
}
static const _pageSize = 20;
Future<void> load() async {
state = state.copyWith(isLoading: true, loadFailed: false);
try {
final wallet = await AssetService.fetchAssets();
final page = await AssetService.fetchRecords(
page: 1,
pageSize: _pageSize,
bizType: state.bizType,
);
state = state.copyWith(
wallet: wallet,
records: page.items,
recordsTotal: page.total,
isLoading: false,
);
} catch (_) {
state = state.copyWith(isLoading: false, loadFailed: true);
}
}
/// 切换 biz_type 筛选并重载明细
Future<void> setBizType(int? bizType) async {
if (bizType == state.bizType) return;
state = state.copyWith(bizType: () => bizType);
await load();
}
/// 触底加载下一页
Future<void> loadMore() async {
if (state.loadingMore || !state.hasMore || state.isLoading) return;
state = state.copyWith(loadingMore: true);
try {
final page = await AssetService.fetchRecords(
page: state.records.length ~/ _pageSize + 1,
pageSize: _pageSize,
bizType: state.bizType,
);
state = state.copyWith(
records: [...state.records, ...page.items],
recordsTotal: page.total,
loadingMore: false,
);
} catch (_) {
state = state.copyWith(loadingMore: false);
}
}
}
final assetProvider = StateNotifierProvider.autoDispose<AssetNotifier, AssetState>(
(_) => AssetNotifier(),
);
/// 提现记录状态(触底分页)
class WithdrawRecordsState {
const WithdrawRecordsState({
this.items = const [],
this.total = 0,
this.isLoading = false,
this.loadFailed = false,
this.loadingMore = false,
});
final List<WithdrawRecord> items;
final int total;
final bool isLoading;
final bool loadFailed;
final bool loadingMore;
bool get hasMore => items.length < total;
WithdrawRecordsState copyWith({
List<WithdrawRecord>? items,
int? total,
bool? isLoading,
bool? loadFailed,
bool? loadingMore,
}) =>
WithdrawRecordsState(
items: items ?? this.items,
total: total ?? this.total,
isLoading: isLoading ?? this.isLoading,
loadFailed: loadFailed ?? this.loadFailed,
loadingMore: loadingMore ?? this.loadingMore,
);
}
class WithdrawRecordsNotifier extends StateNotifier<WithdrawRecordsState> {
WithdrawRecordsNotifier() : super(const WithdrawRecordsState()) {
load();
}
static const _pageSize = 20;
Future<void> load() async {
state = state.copyWith(isLoading: true, loadFailed: false);
try {
final page = await AssetService.fetchWithdrawRecords(pageSize: _pageSize);
state = state.copyWith(
items: page.items,
total: page.total,
isLoading: false,
);
} catch (_) {
state = state.copyWith(isLoading: false, loadFailed: true);
}
}
Future<void> loadMore() async {
if (state.loadingMore || !state.hasMore || state.isLoading) return;
state = state.copyWith(loadingMore: true);
try {
final page = await AssetService.fetchWithdrawRecords(
page: state.items.length ~/ _pageSize + 1,
pageSize: _pageSize,
);
state = state.copyWith(
items: [...state.items, ...page.items],
total: page.total,
loadingMore: false,
);
} catch (_) {
state = state.copyWith(loadingMore: false);
}
}
}
final withdrawRecordsProvider = StateNotifierProvider.autoDispose<
WithdrawRecordsNotifier, WithdrawRecordsState>(
(_) => WithdrawRecordsNotifier(),
);
+67
View File
@@ -0,0 +1,67 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/network/api_client.dart';
import '../core/storage/token_store.dart';
import '../models/user_model.dart';
import '../services/user_service.dart';
/// 登录态:未登录 / 已登录(含用户信息)
/// Token 本体在 TokenStore(安全存储 + 内存缓存),此处只持有用户模型与状态。
class AuthState {
const AuthState({this.user, this.restored = false});
final UserModel? user;
final bool restored; // 是否已完成启动恢复(避免启动期误跳登录)
bool get isLoggedIn => user != null && TokenStore.hasToken;
AuthState copyWith({UserModel? user, bool? restored}) => AuthState(
user: user ?? this.user,
restored: restored ?? this.restored,
);
}
class AuthNotifier extends StateNotifier<AuthState> {
AuthNotifier() : super(const AuthState(restored: false)) {
// 会话过期(401 刷新失败)→ 清空登录态
ApiClient.onSessionExpired = _onSessionExpired;
}
/// 启动恢复:读取安全存储中的 Token,有效则拉取用户信息
Future<void> restore() async {
await TokenStore.restore();
if (!TokenStore.hasToken) {
state = state.copyWith(restored: true);
return;
}
try {
final user = await UserService.fetchMe();
state = AuthState(user: user, restored: true);
} catch (_) {
// Token 失效(1002 等):本地清理,回游客态
await TokenStore.clear();
state = AuthState(restored: true);
}
}
/// 登录/注册成功:Token 已由 AuthService 写入 TokenStore,此处仅刷新用户
Future<void> onLoginSuccess(UserModel user) async {
state = AuthState(user: user, restored: true);
}
/// 修改资料/头像后同步当前用户(不改变登录态与 Token)
void updateUser(UserModel user) => state = state.copyWith(user: user);
/// 退出登录:服务端注销 + 本地清理
Future<void> logout() async => _onSessionExpired();
Future<void> _onSessionExpired() async {
await TokenStore.clear();
state = AuthState(restored: true);
}
}
/// 全局登录态 Provider
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
return AuthNotifier();
});
+253
View File
@@ -0,0 +1,253 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/comment_item.dart';
import '../services/comment_service.dart';
import 'auth_provider.dart';
/// 回复目标(null 表示直接评论视频)
class ReplyTarget {
const ReplyTarget({
required this.parentId,
required this.replyToUserId,
required this.nickname,
});
/// 一级评论 ID(楼中楼统一挂一级楼下)
final int parentId;
/// 被回复人 ID(回复一级评论时等于其作者)
final int replyToUserId;
/// 被回复人昵称(输入框提示用)
final String nickname;
}
/// 评论区状态
class CommentState {
const CommentState({
this.items = const [],
this.total = 0,
this.isLoading = false,
this.isLoadingMore = false,
this.loadFailed = false,
this.page = 1,
this.replies = const {},
this.repliesLoading = const {},
this.replyTarget,
this.posting = false,
});
/// 一级评论列表
final List<CommentItem> items;
final int total;
final bool isLoading;
final bool isLoadingMore;
/// 首屏加载失败
final bool loadFailed;
final int page;
/// 已展开的一级评论的二级回复列表
final Map<int, List<CommentItem>> replies;
/// 回复加载中的一级评论 ID
final Set<int> repliesLoading;
/// 当前回复目标(null = 评论视频本身)
final ReplyTarget? replyTarget;
/// 发表中(防重复提交)
final bool posting;
static const int pageSize = 20;
bool get hasMore => items.length < total;
CommentState copyWith({
List<CommentItem>? items,
int? total,
bool? isLoading,
bool? isLoadingMore,
bool? loadFailed,
int? page,
Map<int, List<CommentItem>>? replies,
Set<int>? repliesLoading,
ReplyTarget? Function()? replyTarget,
bool? posting,
}) =>
CommentState(
items: items ?? this.items,
total: total ?? this.total,
isLoading: isLoading ?? this.isLoading,
isLoadingMore: isLoadingMore ?? this.isLoadingMore,
loadFailed: loadFailed ?? this.loadFailed,
page: page ?? this.page,
replies: replies ?? this.replies,
repliesLoading: repliesLoading ?? this.repliesLoading,
replyTarget: replyTarget != null ? replyTarget() : this.replyTarget,
posting: posting ?? this.posting,
);
}
/// 评论区 Notifier(按 videoId 家族隔离)
class CommentNotifier extends StateNotifier<CommentState> {
CommentNotifier(this._ref, this.videoId) : super(const CommentState()) {
load();
}
final Ref _ref;
final int videoId;
/// 首屏加载
Future<void> load() async {
state = state.copyWith(isLoading: true, loadFailed: false);
try {
final page = await CommentService.list(videoId);
state = state.copyWith(
items: page.items,
total: page.total,
isLoading: false,
page: 1,
);
} catch (_) {
state = state.copyWith(isLoading: false, loadFailed: true);
}
}
/// 触底加载更多一级评论
Future<void> loadMore() async {
if (!state.hasMore || state.isLoadingMore || state.isLoading) return;
state = state.copyWith(isLoadingMore: true);
try {
final page = await CommentService.list(videoId, page: state.page + 1);
state = state.copyWith(
items: [...state.items, ...page.items],
total: page.total,
isLoadingMore: false,
page: state.page + 1,
);
} catch (_) {
state = state.copyWith(isLoadingMore: false);
}
}
/// 展开/收起二级回复;首次展开时加载(一次拉 50 条,超出部分显示总数)
Future<void> toggleReplies(CommentItem comment) async {
final replies = Map<int, List<CommentItem>>.from(state.replies);
if (replies.containsKey(comment.commentId)) {
replies.remove(comment.commentId);
state = state.copyWith(replies: replies);
return;
}
state = state.copyWith(
repliesLoading: {...state.repliesLoading, comment.commentId},
);
try {
final page = await CommentService.list(
videoId,
parentId: comment.commentId,
pageSize: 50,
);
state = state.copyWith(
replies: {...state.replies, comment.commentId: page.items},
repliesLoading: {...state.repliesLoading}..remove(comment.commentId),
);
} catch (_) {
state = state.copyWith(
repliesLoading: {...state.repliesLoading}..remove(comment.commentId),
);
}
}
/// 设置/清除回复目标
void setReplyTarget(ReplyTarget? target) =>
state = state.copyWith(replyTarget: () => target);
/// 发表评论/回复。成功返回结果(含奖励进度),失败抛异常由 UI 提示
Future<CommentCreateResult> post(String content) async {
final user = _ref.read(authProvider).user;
final target = state.replyTarget;
state = state.copyWith(posting: true);
try {
final result = await CommentService.create(
videoId,
content,
parentId: target?.parentId,
replyToUserId: target?.replyToUserId,
);
final item = CommentItem(
commentId: result.commentId,
userId: user?.userId ?? 0,
nickname: user?.nickname ?? '我',
avatarUrl: user?.avatarUrl ?? '',
content: content,
createdAt: '刚刚',
);
if (target == null) {
// 一级评论:插入顶部
state = state.copyWith(
items: [item, ...state.items],
total: state.total + 1,
posting: false,
);
} else {
// 回复:插入已展开列表,父评论 replyCount+1
final replies = Map<int, List<CommentItem>>.from(state.replies);
if (replies.containsKey(target.parentId)) {
replies[target.parentId] = [item, ...replies[target.parentId]!];
}
state = state.copyWith(
replies: replies,
items: [
for (final c in state.items)
c.commentId == target.parentId
? c.copyWith(replyCount: c.replyCount + 1)
: c,
],
total: state.total + 1,
posting: false,
replyTarget: () => null,
);
}
return result;
} catch (_) {
state = state.copyWith(posting: false);
rethrow;
}
}
/// 删除评论:parentId=0 为一级评论,否则为某一级楼下的回复
Future<void> remove(CommentItem comment, {int parentId = 0}) async {
await CommentService.delete(comment.commentId);
if (parentId == 0) {
final replies = Map<int, List<CommentItem>>.from(state.replies)
..remove(comment.commentId);
state = state.copyWith(
items: state.items.where((c) => c.commentId != comment.commentId).toList(),
total: (state.total - 1).clamp(0, 1 << 31),
replies: replies,
);
} else {
final replies = Map<int, List<CommentItem>>.from(state.replies);
replies[parentId] = (replies[parentId] ?? [])
.where((c) => c.commentId != comment.commentId)
.toList();
state = state.copyWith(
replies: replies,
items: [
for (final c in state.items)
c.commentId == parentId
? c.copyWith(replyCount: (c.replyCount - 1).clamp(0, 1 << 31))
: c,
],
total: (state.total - 1).clamp(0, 1 << 31),
);
}
}
}
/// 评论区 Provider(按视频 ID 家族)
final commentProvider =
StateNotifierProvider.family<CommentNotifier, CommentState, int>(
(ref, videoId) => CommentNotifier(ref, videoId),
);
+290
View File
@@ -0,0 +1,290 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/config/test_stream.dart';
import '../core/network/api_exception.dart';
import '../core/player/feed_player_pool.dart';
import '../core/storage/watch_dedup_store.dart';
import '../models/video_item.dart';
import '../services/video_service.dart';
import 'auth_provider.dart';
/// Feed 策略(对应《01-开发文档》2.5.6)
enum FeedStrategy {
recommend('recommend', '推荐'),
latest('latest', '最新'),
hot('hot', '热度');
const FeedStrategy(this.apiValue, this.label);
final String apiValue;
final String label;
}
/// Feed 页 UI 事件(Notifier 不持有 BuildContext,通过事件流驱动弹窗/Toast)
sealed class FeedEvent {
const FeedEvent();
}
/// 扣积分提示:顶部 Toast「本次消耗 N 积分」
class FeedToastEvent extends FeedEvent {
const FeedToastEvent(this.message);
final String message;
}
/// 游客超限:弹登录引导
class FeedGuestLimitEvent extends FeedEvent {
const FeedGuestLimitEvent();
}
/// 积分不足:弹「去充值 / 开通会员」
class FeedPointsInsufficientEvent extends FeedEvent {
const FeedPointsInsufficientEvent(this.points);
final int points;
}
/// Feed 状态
class FeedState {
const FeedState({
this.items = const [],
this.nextCursor,
this.currentIndex = 0,
this.isRefreshing = false,
this.isLoadingMore = false,
this.loadFailed = false,
this.freeRemainingToday,
});
final List<VideoItem> items;
final String? nextCursor;
final int currentIndex;
final bool isRefreshing;
final bool isLoadingMore;
/// 首屏加载失败(网络异常)
final bool loadFailed;
/// 今日免费剩余次数(首次 watch 响应后落地,null 时不显示提示条)
final int? freeRemainingToday;
bool get hasMore => nextCursor != null;
FeedState copyWith({
List<VideoItem>? items,
String? Function()? nextCursor,
int? currentIndex,
bool? isRefreshing,
bool? isLoadingMore,
bool? loadFailed,
int? Function()? freeRemainingToday,
}) =>
FeedState(
items: items ?? this.items,
nextCursor: nextCursor != null ? nextCursor() : this.nextCursor,
currentIndex: currentIndex ?? this.currentIndex,
isRefreshing: isRefreshing ?? this.isRefreshing,
isLoadingMore: isLoadingMore ?? this.isLoadingMore,
loadFailed: loadFailed ?? this.loadFailed,
freeRemainingToday: freeRemainingToday != null
? freeRemainingToday()
: this.freeRemainingToday,
);
}
class FeedNotifier extends StateNotifier<FeedState> {
FeedNotifier(this._ref, this.strategy) : super(const FeedState()) {
pool = FeedPlayerPool(onWatchComplete: _onWatchComplete)..init();
refresh();
}
final Ref _ref;
final FeedStrategy strategy;
late final FeedPlayerPool pool;
final _eventController = StreamController<FeedEvent>.broadcast();
/// UI 事件流(FeedScreen 监听后弹窗/Toast)
Stream<FeedEvent> get events => _eventController.stream;
WatchDedupStore get _dedup => WatchDedupStore.I;
/// 刷新(首屏 / 下拉 / 切策略)
Future<void> refresh() async {
state = state.copyWith(isRefreshing: true, loadFailed: false);
try {
final page = await VideoService.fetchFeed(type: strategy.apiValue);
state = FeedState(
items: page.items,
nextCursor: page.nextCursor,
freeRemainingToday: state.freeRemainingToday,
);
if (page.items.isNotEmpty) {
_bindAndReport(0);
}
} catch (_) {
state = state.copyWith(isRefreshing: false, loadFailed: true);
return;
}
}
/// 加载更多(游标分页)
Future<void> loadMore() async {
if (!state.hasMore || state.isLoadingMore) return;
state = state.copyWith(isLoadingMore: true);
try {
final page = await VideoService.fetchFeed(
type: strategy.apiValue,
cursor: state.nextCursor,
);
state = state.copyWith(
items: [...state.items, ...page.items],
nextCursor: () => page.nextCursor,
isLoadingMore: false,
);
} catch (_) {
state = state.copyWith(isLoadingMore: false);
}
}
/// 翻页:滑出即暂停、滑入自动播放并上报观看计费(《01-开发文档》5.3)
void onPageChanged(int index) {
state = state.copyWith(currentIndex: index);
_bindAndReport(index);
// 接近尾部预取下一页
if (index >= state.items.length - 3) loadMore();
}
/// 绑定播放窗口 + 观看计费上报
Future<void> _bindAndReport(int index) async {
if (index < 0 || index >= state.items.length) return;
final items = state.items;
pool.bindWindow(
index,
[for (final v in items) (videoId: v.videoId, url: resolvePlayUrl(v))],
);
final item = items[index];
// 同日同视频只上报一次(先标记再请求,失败不回滚,防重复扣费)
if (_dedup.hasWatched(item.videoId)) return;
await _dedup.markWatched(item.videoId);
try {
final result = await VideoService.reportWatch(item.videoId);
if (result.isPointsCost && result.costPoints > 0) {
_eventController.add(FeedToastEvent('本次消耗 ${result.costPoints} 积分'));
}
state = state.copyWith(
freeRemainingToday: () => result.freeRemainingToday,
);
} on ApiException catch (e) {
pool.pauseCurrent();
if (e.isGuestLimit) {
_eventController.add(const FeedGuestLimitEvent());
} else if (e.isPointsInsufficient) {
final points = (e.data?['points'] as num?)?.toInt() ?? 0;
_eventController.add(FeedPointsInsufficientEvent(points));
}
// 其他错误静默,不影响播放
} catch (_) {
// 网络异常静默,播放不中断
}
}
/// 完整观看上报(pool ≥95%/播完回调):服务端同日去重,本地再省一次请求
Future<void> _onWatchComplete(int videoId, int watchDuration) async {
if (watchDuration <= 0 || _dedup.hasCompleted(videoId)) return;
await _dedup.markCompleted(videoId);
try {
await VideoService.reportComplete(videoId, watchDuration);
} catch (_) {
// credited=false 与网络异常均无需 UI 处理
}
}
/// 点赞(乐观更新,失败回滚);双击已点赞时仅播动画不调用
Future<void> toggleLike(int videoId) async {
if (!_checkLoggedIn()) return;
final index = _indexOf(videoId);
if (index < 0) return;
final old = state.items[index];
_replaceItem(
index,
old.copyWith(
isLiked: !old.isLiked,
likeCount: old.isLiked ? old.likeCount - 1 : old.likeCount + 1,
),
);
try {
await VideoService.toggleLike(videoId);
} catch (_) {
_replaceItem(index, old); // 回滚
_eventController.add(const FeedToastEvent('操作失败,请重试'));
}
}
/// 评论数增减同步(评论弹层发表/删除成功后回调,本地计数 ±1)
void adjustCommentCount(int videoId, int delta) {
final index = _indexOf(videoId);
if (index < 0) return;
final old = state.items[index];
_replaceItem(
index,
old.copyWith(commentCount: (old.commentCount + delta).clamp(0, 1 << 31)),
);
}
/// 收藏(乐观更新,失败回滚)
Future<void> toggleFavorite(int videoId) async {
if (!_checkLoggedIn()) return;
final index = _indexOf(videoId);
if (index < 0) return;
final old = state.items[index];
_replaceItem(
index,
old.copyWith(
isFavorited: !old.isFavorited,
favoriteCount:
old.isFavorited ? old.favoriteCount - 1 : old.favoriteCount + 1,
),
);
try {
await VideoService.toggleFavorite(videoId);
} catch (_) {
_replaceItem(index, old);
_eventController.add(const FeedToastEvent('操作失败,请重试'));
}
}
bool _checkLoggedIn() {
final loggedIn = _ref.read(authProvider).isLoggedIn;
if (!loggedIn) {
// 未登录:发事件由 UI 跳登录页(复用 Toast 事件表达意图太重,直接在这里不管 UI,
// 由 interaction_bar 的点击回调先判登录态。此处双保险静默即可)
return false;
}
return true;
}
int _indexOf(int videoId) =>
state.items.indexWhere((v) => v.videoId == videoId);
void _replaceItem(int index, VideoItem item) {
final items = [...state.items];
items[index] = item;
state = state.copyWith(items: items);
}
@override
void dispose() {
_eventController.close();
pool.dispose();
super.dispose();
}
}
/// Feed Provider:按策略各持一份(切 Tab 互不污染,切回保持滚动位置)
final feedProvider = StateNotifierProvider.family<FeedNotifier, FeedState, FeedStrategy>(
(ref, strategy) => FeedNotifier(ref, strategy),
);
+94
View File
@@ -0,0 +1,94 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/network/api_exception.dart';
import '../models/membership_models.dart';
import '../services/membership_service.dart';
import '../services/user_service.dart';
import 'auth_provider.dart';
/// 会员中心状态
class MemberState {
const MemberState({
this.plans = const [],
this.benefits = const [],
this.me,
this.isLoading = false,
this.loadFailed = false,
this.redeeming = false,
});
final List<MembershipPlan> plans;
final List<String> benefits;
final MyMemberState? me;
final bool isLoading;
final bool loadFailed;
final bool redeeming;
MemberState copyWith({
List<MembershipPlan>? plans,
List<String>? benefits,
MyMemberState? me,
bool? isLoading,
bool? loadFailed,
bool? redeeming,
}) =>
MemberState(
plans: plans ?? this.plans,
benefits: benefits ?? this.benefits,
me: me ?? this.me,
isLoading: isLoading ?? this.isLoading,
loadFailed: loadFailed ?? this.loadFailed,
redeeming: redeeming ?? this.redeeming,
);
}
/// 会员中心 Notifier:套餐列表 + 我的会员状态 + 积分开通
class MemberNotifier extends StateNotifier<MemberState> {
MemberNotifier(this._ref) : super(const MemberState()) {
load();
}
final Ref _ref;
Future<void> load() async {
state = state.copyWith(isLoading: true, loadFailed: false);
try {
final plans = await MembershipService.fetchPlans();
MyMemberState? me;
// 游客无会员状态接口权限,未登录时跳过
if (_ref.read(authProvider).isLoggedIn) {
me = await MembershipService.fetchMe();
}
state = state.copyWith(
plans: plans.plans,
benefits: plans.benefits,
me: me,
isLoading: false,
);
} catch (_) {
state = state.copyWith(isLoading: false, loadFailed: true);
}
}
/// 积分开通会员。
/// 成功返回结果文案;失败抛 [ApiException] 由 UI 提示(2001 积分不足 / 2004 终身卡重复开通)。
Future<RedeemResult> redeem(String planCode) async {
state = state.copyWith(redeeming: true);
try {
final result = await MembershipService.redeem(planCode);
// 刷新我的会员状态 + 全局用户信息(积分/会员标识已变化)
final me = await MembershipService.fetchMe();
final user = await UserService.fetchMe();
state = state.copyWith(me: me, redeeming: false);
await _ref.read(authProvider.notifier).onLoginSuccess(user);
return result;
} catch (_) {
state = state.copyWith(redeeming: false);
rethrow;
}
}
}
final memberProvider = StateNotifierProvider<MemberNotifier, MemberState>(
MemberNotifier.new,
);
@@ -0,0 +1,165 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../services/notification_service.dart';
/// 未读消息数(我的页角标):登录后加载,标记已读后同步
final unreadCountProvider =
StateNotifierProvider<UnreadCountNotifier, int>((_) => UnreadCountNotifier());
class UnreadCountNotifier extends StateNotifier<int> {
UnreadCountNotifier() : super(0);
Future<void> refresh() async {
try {
state = await NotificationService.fetchUnreadCount();
} catch (_) {
// 未登录/网络异常:保持原值
}
}
/// 本地 decrement(标记单条已读后角标即时 -1,后台再 refresh 校准)
void decrement([int count = 1]) {
state = state > count ? state - count : 0;
}
void clear() => state = 0;
}
/// 通知列表状态:type 筛选 + 触底分页
class NotificationsState {
const NotificationsState({
this.items = const [],
this.total = 0,
this.type,
this.isLoading = false,
this.loadFailed = false,
this.loadingMore = false,
this.markingAll = false,
});
final List<NotificationItem> items;
final int total;
/// null = 全部
final int? type;
final bool isLoading;
final bool loadFailed;
final bool loadingMore;
final bool markingAll;
bool get hasMore => items.length < total;
int get unreadCount => items.where((e) => !e.isRead).length;
NotificationsState copyWith({
List<NotificationItem>? items,
int? total,
int? Function()? type,
bool? isLoading,
bool? loadFailed,
bool? loadingMore,
bool? markingAll,
}) =>
NotificationsState(
items: items ?? this.items,
total: total ?? this.total,
type: type != null ? type() : this.type,
isLoading: isLoading ?? this.isLoading,
loadFailed: loadFailed ?? this.loadFailed,
loadingMore: loadingMore ?? this.loadingMore,
markingAll: markingAll ?? this.markingAll,
);
}
class NotificationsNotifier extends StateNotifier<NotificationsState> {
NotificationsNotifier(this._ref) : super(const NotificationsState()) {
load();
}
final Ref _ref;
static const _pageSize = 20;
Future<void> load() async {
state = state.copyWith(isLoading: true, loadFailed: false);
try {
final page = await NotificationService.fetchNotifications(
page: 1,
pageSize: _pageSize,
type: state.type,
);
state = state.copyWith(
items: page.items,
total: page.total,
isLoading: false,
);
} catch (_) {
state = state.copyWith(isLoading: false, loadFailed: true);
}
}
/// 切换类型筛选并重载
Future<void> setType(int? type) async {
if (type == state.type) return;
state = state.copyWith(type: () => type);
await load();
}
/// 触底加载下一页
Future<void> loadMore() async {
if (state.loadingMore || !state.hasMore || state.isLoading) return;
state = state.copyWith(loadingMore: true);
try {
final page = await NotificationService.fetchNotifications(
page: state.items.length ~/ _pageSize + 1,
pageSize: _pageSize,
type: state.type,
);
state = state.copyWith(
items: [...state.items, ...page.items],
total: page.total,
loadingMore: false,
);
} catch (_) {
state = state.copyWith(loadingMore: false);
}
}
/// 单条已读:乐观更新本地,失败静默(下次进入重拉)
Future<void> markOneRead(NotificationItem item) async {
if (item.isRead) return;
state = state.copyWith(
items: [
for (final e in state.items)
if (e.id == item.id) e.copyWith(isRead: true) else e,
],
);
_ref.read(unreadCountProvider.notifier).decrement();
try {
await NotificationService.markRead(ids: [item.id]);
} catch (_) {
// 静默:以服务端为准,下次进入页面会重拉
}
}
/// 全部已读
Future<void> markAllRead() async {
if (state.markingAll) return;
state = state.copyWith(markingAll: true);
try {
await NotificationService.markRead(all: true);
if (!mounted) return;
state = state.copyWith(
items: [for (final e in state.items) e.copyWith(isRead: true)],
markingAll: false,
);
_ref.read(unreadCountProvider.notifier).clear();
} catch (_) {
if (mounted) state = state.copyWith(markingAll: false);
rethrow;
}
}
}
final notificationsProvider =
StateNotifierProvider.autoDispose<NotificationsNotifier, NotificationsState>(
(ref) => NotificationsNotifier(ref),
);
+224
View File
@@ -0,0 +1,224 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../services/payment_service.dart';
/// 充值页状态:套餐 + 选中档位/自定义金额 + 选中支付方式 + 下单中
class PaymentState {
const PaymentState({
this.result,
this.selectedPackageId,
this.customYuan,
this.selectedMethod,
this.isLoading = false,
this.loadFailed = false,
this.creating = false,
});
final PackagesResult? result;
/// null = 使用自定义金额
final int? selectedPackageId;
/// 自定义金额(元);null 表示未输入
final int? customYuan;
final int? selectedMethod;
final bool isLoading;
final bool loadFailed;
final bool creating;
/// 实际应付金额(元):优先选中档位,否则自定义金额
int? get amountYuan {
final r = result;
if (r == null) return null;
final pkgId = selectedPackageId;
if (pkgId != null) {
for (final p in r.packages) {
if (p.id == pkgId) return p.amountYuan;
}
}
return customYuan;
}
PaymentState copyWith({
PackagesResult? result,
int? Function()? selectedPackageId,
int? Function()? customYuan,
int? Function()? selectedMethod,
bool? isLoading,
bool? loadFailed,
bool? creating,
}) =>
PaymentState(
result: result ?? this.result,
selectedPackageId: selectedPackageId != null
? selectedPackageId()
: this.selectedPackageId,
customYuan: customYuan != null ? customYuan() : this.customYuan,
selectedMethod: selectedMethod != null
? selectedMethod()
: this.selectedMethod,
isLoading: isLoading ?? this.isLoading,
loadFailed: loadFailed ?? this.loadFailed,
creating: creating ?? this.creating,
);
}
class PaymentNotifier extends StateNotifier<PaymentState> {
PaymentNotifier() : super(const PaymentState()) {
load();
}
Future<void> load() async {
state = state.copyWith(isLoading: true, loadFailed: false);
try {
final result = await PaymentService.fetchPackages();
state = state.copyWith(
result: result,
// 默认选中第一档 + 第一种支付方式
selectedPackageId: () =>
result.packages.isNotEmpty ? result.packages.first.id : null,
customYuan: () => null,
selectedMethod: () =>
result.payMethods.isNotEmpty ? result.payMethods.first.method : null,
isLoading: false,
);
} catch (_) {
state = state.copyWith(isLoading: false, loadFailed: true);
}
}
/// 选择档位(清空自定义金额)
void selectPackage(int id) {
state = state.copyWith(selectedPackageId: () => id, customYuan: () => null);
}
/// 输入自定义金额(取消档位选中)
void setCustomYuan(int? yuan) {
state = state.copyWith(
customYuan: () => yuan,
selectedPackageId: () => yuan == null ? state.selectedPackageId : null,
);
}
void selectMethod(int method) {
state = state.copyWith(selectedMethod: () => method);
}
/// 创建充值订单。成功返回订单;失败抛异常由 UI 提示(1001 金额不在档位)。
Future<RechargeOrderInfo> createOrder() async {
final amount = state.amountYuan;
final method = state.selectedMethod;
if (amount == null || method == null) {
throw ArgumentError('未选择金额或支付方式');
}
state = state.copyWith(creating: true);
try {
final order = await PaymentService.createRecharge(
amountYuan: amount,
payMethod: method,
);
return order;
} finally {
if (mounted) state = state.copyWith(creating: false);
}
}
}
final paymentProvider = StateNotifierProvider<PaymentNotifier, PaymentState>(
(_) => PaymentNotifier(),
);
/// 订单轮询状态
enum OrderPollStatus { polling, paid, closed, timeout, failed }
class OrderPollState {
const OrderPollState({
this.status = OrderPollStatus.polling,
required this.order,
this.walletPoints,
});
final OrderPollStatus status;
final RechargeOrderInfo order;
final int? walletPoints;
OrderPollState copyWith({
OrderPollStatus? status,
RechargeOrderInfo? order,
int? walletPoints,
}) =>
OrderPollState(
status: status ?? this.status,
order: order ?? this.order,
walletPoints: walletPoints ?? this.walletPoints,
);
}
/// 订单轮询:每 2 秒查一次,直至 status != 0 或超过 30 次(《03-接口文档》8.3)
class OrderPollNotifier extends StateNotifier<OrderPollState> {
OrderPollNotifier(RechargeOrderInfo order)
: super(OrderPollState(order: order)) {
_start();
}
Timer? _timer;
int _count = 0;
static const _maxCount = 30;
void _start() {
_timer = Timer.periodic(const Duration(seconds: 2), (_) => _poll());
}
Future<void> _poll() async {
if (!mounted) return;
_count++;
try {
final info = await PaymentService.fetchOrder(state.order.orderNo);
if (!mounted) return;
if (info.isPaid) {
_finish(OrderPollStatus.paid, walletPoints: info.walletPoints);
} else if (!info.isPending) {
_finish(OrderPollStatus.closed);
} else if (_count >= _maxCount) {
_finish(OrderPollStatus.timeout);
}
} catch (_) {
// 网络抖动不立即失败,继续下一轮;累计超限由 _count 兜底
if (_count >= _maxCount && mounted) _finish(OrderPollStatus.timeout);
}
}
void _finish(OrderPollStatus status, {int? walletPoints}) {
_timer?.cancel();
state = state.copyWith(status: status, walletPoints: walletPoints);
}
/// 取消订单(仅待支付):成功后结束轮询
Future<void> cancel() async {
_timer?.cancel();
try {
await PaymentService.cancelOrder(state.order.orderNo);
if (mounted) state = state.copyWith(status: OrderPollStatus.closed);
} catch (_) {
// 取消失败恢复轮询
if (mounted) _start();
rethrow;
}
}
/// 主动停止轮询(离开页面时)
void stop() => _timer?.cancel();
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
}
final orderPollProvider = StateNotifierProvider.autoDispose
.family<OrderPollNotifier, OrderPollState, RechargeOrderInfo>(
(ref, order) => OrderPollNotifier(order),
);
+82
View File
@@ -0,0 +1,82 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/user_profile.dart';
import '../models/video_item.dart';
import '../services/user_service.dart';
/// 他人主页状态
class ProfileState {
const ProfileState({
this.profile,
this.works = const [],
this.worksTotal = 0,
this.isLoading = false,
this.loadFailed = false,
});
final UserPublicProfile? profile;
final List<VideoItem> works;
final int worksTotal;
final bool isLoading;
final bool loadFailed;
ProfileState copyWith({
UserPublicProfile? profile,
List<VideoItem>? works,
int? worksTotal,
bool? isLoading,
bool? loadFailed,
}) =>
ProfileState(
profile: profile ?? this.profile,
works: works ?? this.works,
worksTotal: worksTotal ?? this.worksTotal,
isLoading: isLoading ?? this.isLoading,
loadFailed: loadFailed ?? this.loadFailed,
);
}
/// 他人主页 Notifier:主页信息 + 已发布作品(一期一次拉 50 条,不做分页加载)
class ProfileNotifier extends StateNotifier<ProfileState> {
ProfileNotifier(this.userId) : super(const ProfileState()) {
load();
}
final int userId;
Future<void> load() async {
state = state.copyWith(isLoading: true, loadFailed: false);
try {
final profile = await UserService.fetchPublicProfile(userId);
final works = await UserService.fetchUserVideos(userId);
state = state.copyWith(
profile: profile,
works: works.items,
worksTotal: works.total,
isLoading: false,
);
} catch (_) {
state = state.copyWith(isLoading: false, loadFailed: true);
}
}
}
final profileProvider =
StateNotifierProvider.family<ProfileNotifier, ProfileState, int>(
(ref, userId) => ProfileNotifier(userId),
);
/// 我的作品(需登录,一期一次拉 50 条)
final myVideosProvider = FutureProvider.autoDispose<PagedVideos>(
(ref) => UserService.fetchMyVideos(),
);
/// 我的收藏(需登录)
final myFavoritesProvider = FutureProvider.autoDispose<PagedVideos>(
(ref) => UserService.fetchMyFavorites(),
);
/// 观看历史(需登录)
final myHistoryProvider = FutureProvider.autoDispose<List<HistoryItem>>(
(ref) => UserService.fetchMyHistory(),
);
+155
View File
@@ -0,0 +1,155 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/storage/search_history_store.dart';
import '../models/video_item.dart';
import '../services/search_service.dart';
/// 搜索排序(《03-接口文档》5.10)
enum SearchSort {
hot('hot', '热度'),
latest('latest', '最新');
const SearchSort(this.apiValue, this.label);
final String apiValue;
final String label;
}
/// 搜索页状态
class SearchState {
const SearchState({
this.keyword = '',
this.sort = SearchSort.hot,
this.items = const [],
this.total = 0,
this.page = 1,
this.isSearching = false,
this.isLoadingMore = false,
this.searched = false,
this.hotWords = const [],
this.history = const [],
});
final String keyword;
final SearchSort sort;
final List<VideoItem> items;
final int total;
final int page;
final bool isSearching;
final bool isLoadingMore;
/// 是否已执行过搜索(区分默认页与结果页)
final bool searched;
/// 热搜榜(进入页面时加载)
final List<String> hotWords;
/// 本地搜索历史(最新在前)
final List<String> history;
bool get hasMore => items.length < total;
SearchState copyWith({
String? keyword,
SearchSort? sort,
List<VideoItem>? items,
int? total,
int? page,
bool? isSearching,
bool? isLoadingMore,
bool? searched,
List<String>? hotWords,
List<String>? history,
}) =>
SearchState(
keyword: keyword ?? this.keyword,
sort: sort ?? this.sort,
items: items ?? this.items,
total: total ?? this.total,
page: page ?? this.page,
isSearching: isSearching ?? this.isSearching,
isLoadingMore: isLoadingMore ?? this.isLoadingMore,
searched: searched ?? this.searched,
hotWords: hotWords ?? this.hotWords,
history: history ?? this.history,
);
}
class SearchNotifier extends StateNotifier<SearchState> {
SearchNotifier() : super(SearchState(history: SearchHistoryStore.I.history));
/// 加载热搜榜(静默失败,空列表兜底)
Future<void> loadHot() async {
try {
final words = await SearchService.hotWords();
state = state.copyWith(hotWords: words);
} catch (_) {}
}
/// 执行搜索(换关键词或排序时全量重来)
Future<void> search(String keyword, {SearchSort? sort}) async {
final word = keyword.trim();
if (word.isEmpty) return;
final nextSort = sort ?? state.sort;
state = state.copyWith(
keyword: word,
sort: nextSort,
isSearching: true,
searched: true,
items: const [],
total: 0,
page: 1,
);
// 写入本地历史(去重置顶,最多 10 条)
await SearchHistoryStore.I.add(word);
state = state.copyWith(history: SearchHistoryStore.I.history);
try {
final page =
await SearchService.search(word, sort: nextSort.apiValue);
state = state.copyWith(
items: page.items,
total: page.total,
isSearching: false,
page: 1,
);
} catch (_) {
state = state.copyWith(isSearching: false);
}
}
/// 触底加载更多
Future<void> loadMore() async {
if (!state.hasMore || state.isLoadingMore || state.isSearching) return;
state = state.copyWith(isLoadingMore: true);
try {
final page = await SearchService.search(
state.keyword,
sort: state.sort.apiValue,
page: state.page + 1,
);
state = state.copyWith(
items: [...state.items, ...page.items],
total: page.total,
isLoadingMore: false,
page: state.page + 1,
);
} catch (_) {
state = state.copyWith(isLoadingMore: false);
}
}
/// 删除单条历史
Future<void> removeHistory(String word) async {
await SearchHistoryStore.I.remove(word);
state = state.copyWith(history: SearchHistoryStore.I.history);
}
/// 清空历史
Future<void> clearHistory() async {
await SearchHistoryStore.I.clear();
state = state.copyWith(history: const []);
}
}
final searchProvider =
StateNotifierProvider<SearchNotifier, SearchState>((ref) => SearchNotifier());
+118
View File
@@ -0,0 +1,118 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/video_item.dart';
import '../services/video_service.dart';
import 'search_provider.dart';
/// 标签视频页状态(《03-接口文档》5.9,游客可)
class TagVideosState {
const TagVideosState({
this.sort = SearchSort.hot,
this.items = const [],
this.total = 0,
this.page = 1,
this.isLoading = false,
this.isLoadingMore = false,
this.loaded = false,
this.loadFailed = false,
});
final SearchSort sort;
final List<VideoItem> items;
final int total;
final int page;
final bool isLoading;
final bool isLoadingMore;
/// 是否已完成首次加载(区分加载中与空结果)
final bool loaded;
/// 首次加载失败(展示重试入口)
final bool loadFailed;
bool get hasMore => items.length < total;
TagVideosState copyWith({
SearchSort? sort,
List<VideoItem>? items,
int? total,
int? page,
bool? isLoading,
bool? isLoadingMore,
bool? loaded,
bool? loadFailed,
}) =>
TagVideosState(
sort: sort ?? this.sort,
items: items ?? this.items,
total: total ?? this.total,
page: page ?? this.page,
isLoading: isLoading ?? this.isLoading,
isLoadingMore: isLoadingMore ?? this.isLoadingMore,
loaded: loaded ?? this.loaded,
loadFailed: loadFailed ?? this.loadFailed,
);
}
class TagVideosNotifier extends StateNotifier<TagVideosState> {
TagVideosNotifier(this.tagName) : super(const TagVideosState()) {
load(sort: SearchSort.hot);
}
final String tagName;
/// 首次加载 / 切换排序全量重来
Future<void> load({SearchSort? sort}) async {
final nextSort = sort ?? state.sort;
state = state.copyWith(
sort: nextSort,
isLoading: true,
loadFailed: false,
items: const [],
total: 0,
page: 1,
);
try {
final page = await VideoService.fetchTagVideos(
tagName,
sort: nextSort.apiValue,
);
state = state.copyWith(
items: page.items,
total: page.total,
isLoading: false,
loaded: true,
page: 1,
);
} catch (_) {
state = state.copyWith(isLoading: false, loaded: true, loadFailed: true);
}
}
/// 触底加载更多
Future<void> loadMore() async {
if (!state.hasMore || state.isLoadingMore || state.isLoading) return;
state = state.copyWith(isLoadingMore: true);
try {
final page = await VideoService.fetchTagVideos(
tagName,
sort: state.sort.apiValue,
page: state.page + 1,
);
state = state.copyWith(
items: [...state.items, ...page.items],
total: page.total,
isLoadingMore: false,
page: state.page + 1,
);
} catch (_) {
state = state.copyWith(isLoadingMore: false);
}
}
}
/// 按标签名分族(进入页面自动加载)
final tagVideosProvider = StateNotifierProvider.autoDispose
.family<TagVideosNotifier, TagVideosState, String>(
(ref, tagName) => TagVideosNotifier(tagName),
);
+116
View File
@@ -0,0 +1,116 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import '../core/network/api_exception.dart';
import '../services/upload_service.dart';
/// 上传发布流程步骤:选视频 → 填信息 → 发布中 → 完成
enum UploadStep { pick, edit, publishing, done }
/// 上传发布状态(《03-接口文档》5.5/5.6 三步链路)
class UploadState {
const UploadState({
this.step = UploadStep.pick,
this.file,
this.fileSize = 0,
this.progress = 0,
this.result,
this.error = '',
});
final UploadStep step;
final XFile? file; // 已选择的视频
final int fileSize; // 字节
final int progress; // 上传进度 0-100
final PublishResult? result;
final String error; // 发布失败文案(停留在 edit 步骤展示)
bool get publishing => step == UploadStep.publishing;
UploadState copyWith({
UploadStep? step,
XFile? file,
int? fileSize,
int? progress,
PublishResult? result,
String? error,
}) =>
UploadState(
step: step ?? this.step,
file: file ?? this.file,
fileSize: fileSize ?? this.fileSize,
progress: progress ?? this.progress,
result: result ?? this.result,
error: error ?? this.error,
);
}
class UploadNotifier extends StateNotifier<UploadState> {
UploadNotifier() : super(const UploadState());
final _picker = ImagePicker();
/// 从相册选择视频 → 进入填信息步骤
Future<void> pickVideo() async {
final file = await _picker.pickVideo(source: ImageSource.gallery);
if (file == null) return;
final size = await file.length();
state = UploadState(step: UploadStep.edit, file: file, fileSize: size);
}
/// 重新选择(回到选视频步骤)
void reset() => state = const UploadState();
/// 发布:获取凭证 → 上传文件(进度) → 创建视频
Future<void> submit({
required String title,
String description = '',
required int categoryId,
List<String> tags = const [],
}) async {
final file = state.file;
if (file == null || state.publishing) return;
state = state.copyWith(step: UploadStep.publishing, progress: 0, error: '');
try {
final token = await UploadService.fetchUploadToken(
fileName: file.name,
fileSize: state.fileSize,
);
await UploadService.uploadFile(
objectKey: token.objectKey,
filePath: file.path,
fileName: file.name,
onProgress: (sent, total) {
if (total > 0) {
state = state.copyWith(progress: (sent * 100) ~/ total);
}
},
);
final result = await UploadService.publish(
objectKey: token.objectKey,
title: title,
description: description,
categoryId: categoryId,
tags: tags,
);
state = state.copyWith(step: UploadStep.done, result: result);
} on ApiException catch (e) {
// 失败回到表单步骤,保留已填内容,展示服务端文案
state = state.copyWith(step: UploadStep.edit, error: e.message);
} catch (_) {
state = state.copyWith(step: UploadStep.edit, error: '网络异常,请稍后重试');
}
}
}
/// 上传发布(autoDispose:离开页面即重置流程)
final uploadProvider =
StateNotifierProvider.autoDispose<UploadNotifier, UploadState>(
(ref) => UploadNotifier(),
);
/// 分类列表(发布表单用)
final uploadCategoriesProvider =
FutureProvider.autoDispose<List<VideoCategory>>(
(ref) => UploadService.fetchCategories(),
);
+106
View File
@@ -0,0 +1,106 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../services/wallet_service.dart';
/// 钱包状态:钱包信息 + 积分明细(biz_type 筛选 + 触底分页)
class WalletState {
const WalletState({
this.wallet,
this.records = const [],
this.recordsTotal = 0,
this.bizType,
this.isLoading = false,
this.loadFailed = false,
this.loadingMore = false,
});
final WalletInfo? wallet;
final List<PointsRecord> records;
final int recordsTotal;
/// null = 全部
final int? bizType;
final bool isLoading;
final bool loadFailed;
final bool loadingMore;
bool get hasMore => records.length < recordsTotal;
WalletState copyWith({
WalletInfo? wallet,
List<PointsRecord>? records,
int? recordsTotal,
int? Function()? bizType,
bool? isLoading,
bool? loadFailed,
bool? loadingMore,
}) =>
WalletState(
wallet: wallet ?? this.wallet,
records: records ?? this.records,
recordsTotal: recordsTotal ?? this.recordsTotal,
bizType: bizType != null ? bizType() : this.bizType,
isLoading: isLoading ?? this.isLoading,
loadFailed: loadFailed ?? this.loadFailed,
loadingMore: loadingMore ?? this.loadingMore,
);
}
class WalletNotifier extends StateNotifier<WalletState> {
WalletNotifier() : super(const WalletState()) {
load();
}
static const _pageSize = 20;
Future<void> load() async {
state = state.copyWith(isLoading: true, loadFailed: false);
try {
final wallet = await WalletService.fetchWallet();
final page = await WalletService.fetchPointsRecords(
page: 1,
pageSize: _pageSize,
bizType: state.bizType,
);
state = state.copyWith(
wallet: wallet,
records: page.items,
recordsTotal: page.total,
isLoading: false,
);
} catch (_) {
state = state.copyWith(isLoading: false, loadFailed: true);
}
}
/// 切换 biz_type 筛选并重载明细
Future<void> setBizType(int? bizType) async {
if (bizType == state.bizType) return;
state = state.copyWith(bizType: () => bizType);
await load();
}
/// 触底加载下一页
Future<void> loadMore() async {
if (state.loadingMore || !state.hasMore || state.isLoading) return;
state = state.copyWith(loadingMore: true);
try {
final page = await WalletService.fetchPointsRecords(
page: state.records.length ~/ _pageSize + 1,
pageSize: _pageSize,
bizType: state.bizType,
);
state = state.copyWith(
records: [...state.records, ...page.items],
recordsTotal: page.total,
loadingMore: false,
);
} catch (_) {
state = state.copyWith(loadingMore: false);
}
}
}
final walletProvider = StateNotifierProvider<WalletNotifier, WalletState>(
(_) => WalletNotifier(),
);