add
This commit is contained in:
@@ -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(),
|
||||
);
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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),
|
||||
);
|
||||
@@ -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),
|
||||
);
|
||||
@@ -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),
|
||||
);
|
||||
@@ -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),
|
||||
);
|
||||
@@ -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(),
|
||||
);
|
||||
@@ -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());
|
||||
@@ -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),
|
||||
);
|
||||
@@ -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(),
|
||||
);
|
||||
@@ -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(),
|
||||
);
|
||||
Reference in New Issue
Block a user