feat(reward): 新增客户端奖励中心

This commit is contained in:
Zhou Weidong committed 2026-11-13 13:43:38 +08:00
1 parent 67670d46b1
commit bea08110c9
68 files changed
+3987 -332

No files matched your search

+242
View File
@@ -0,0 +1,242 @@
// Author: fruit.com.cn
import 'json_numbers.dart';
/// 奖励中心首屏数据。
class RewardOverview {
const RewardOverview({
required this.serverDate,
required this.timezone,
required this.coinBalance,
required this.checkin,
this.sections = const [],
this.activities = const [],
});
final String serverDate;
final String timezone;
final int coinBalance;
final RewardCheckinState checkin;
final List<RewardSection> sections;
final List<RewardActivity> activities;
factory RewardOverview.fromJson(Map<String, dynamic> json) {
return RewardOverview(
serverDate: json['serverDate'] as String? ?? '',
timezone: json['timezone'] as String? ?? '',
coinBalance: jsonToInt(json['coinBalance']),
checkin: RewardCheckinState.fromJson(_map(json['checkin'])),
sections: _maps(json['sections']).map(RewardSection.fromJson).toList(),
activities: _maps(
json['activities'],
).map(RewardActivity.fromJson).toList(),
);
}
}
/// 七日签到状态。
class RewardCheckinState {
const RewardCheckinState({
required this.taskId,
required this.checkedInToday,
required this.streak,
required this.currentDay,
this.days = const [],
});
final String taskId;
final bool checkedInToday;
final int streak;
final int currentDay;
final List<RewardCheckinDay> days;
factory RewardCheckinState.fromJson(Map<String, dynamic> json) {
return RewardCheckinState(
taskId: (json['taskId'] ?? '').toString(),
checkedInToday: json['checkedInToday'] == true,
streak: jsonToInt(json['streak']),
currentDay: jsonToInt(json['currentDay']),
days: _maps(json['days']).map(RewardCheckinDay.fromJson).toList(),
);
}
}
/// 签到周期中的一天。
class RewardCheckinDay {
const RewardCheckinDay({
required this.dayIndex,
required this.rewardCoin,
required this.checked,
required this.current,
});
final int dayIndex;
final int rewardCoin;
final bool checked;
final bool current;
factory RewardCheckinDay.fromJson(Map<String, dynamic> json) {
return RewardCheckinDay(
dayIndex: jsonToInt(json['dayIndex']),
rewardCoin: jsonToInt(json['rewardCoin']),
checked: json['checked'] == true,
current: json['current'] == true,
);
}
}
/// 一组奖励任务。
class RewardSection {
const RewardSection({
required this.code,
required this.title,
this.tasks = const [],
});
final String code;
final String title;
final List<RewardTask> tasks;
factory RewardSection.fromJson(Map<String, dynamic> json) {
return RewardSection(
code: json['code'] as String? ?? '',
title: json['title'] as String? ?? '',
tasks: _maps(json['tasks']).map(RewardTask.fromJson).toList(),
);
}
}
/// 奖励任务卡片。
class RewardTask {
const RewardTask({
required this.id,
required this.code,
required this.type,
required this.title,
required this.subtitle,
required this.ctaText,
required this.actionType,
required this.actionUrl,
required this.progressValue,
required this.targetValue,
required this.rewardCoin,
required this.status,
this.iconUrl = '',
});
final String id;
final String code;
final String type;
final String title;
final String subtitle;
final String ctaText;
final String actionType;
final String actionUrl;
final String iconUrl;
final int progressValue;
final int targetValue;
final int rewardCoin;
final String status;
bool get claimable => status == 'COMPLETED';
bool get claimed => status == 'CLAIMED';
double get progress =>
targetValue <= 0 ? 0 : (progressValue / targetValue).clamp(0.0, 1.0);
factory RewardTask.fromJson(Map<String, dynamic> json) {
return RewardTask(
id: (json['id'] ?? '').toString(),
code: json['code'] as String? ?? '',
type: json['type'] as String? ?? '',
title: json['title'] as String? ?? '',
subtitle: json['subtitle'] as String? ?? '',
ctaText: json['ctaText'] as String? ?? '',
actionType: json['actionType'] as String? ?? '',
actionUrl: json['actionUrl'] as String? ?? '',
iconUrl: json['iconUrl'] as String? ?? '',
progressValue: jsonToInt(json['progressValue']),
targetValue: jsonToInt(json['targetValue']),
rewardCoin: jsonToInt(json['rewardCoin']),
status: json['status'] as String? ?? 'IN_PROGRESS',
);
}
}
/// 奖励活动展示卡片。
class RewardActivity {
const RewardActivity({
required this.id,
required this.code,
required this.title,
required this.summary,
required this.ctaText,
required this.actionType,
required this.actionUrl,
this.coverUrl = '',
});
final String id;
final String code;
final String title;
final String summary;
final String ctaText;
final String actionType;
final String actionUrl;
final String coverUrl;
factory RewardActivity.fromJson(Map<String, dynamic> json) {
return RewardActivity(
id: (json['id'] ?? '').toString(),
code: json['code'] as String? ?? '',
title: json['title'] as String? ?? '',
summary: json['summary'] as String? ?? '',
ctaText: json['ctaText'] as String? ?? '',
actionType: json['actionType'] as String? ?? '',
actionUrl: json['actionUrl'] as String? ?? '',
coverUrl: json['coverUrl'] as String? ?? '',
);
}
}
/// 签到或任务领取后的余额变化结果。
class RewardActionResult {
const RewardActionResult({
required this.rewardCoin,
required this.coinBalance,
required this.alreadyCompleted,
});
final int rewardCoin;
final int coinBalance;
final bool alreadyCompleted;
factory RewardActionResult.fromCheckin(Map<String, dynamic> json) {
return RewardActionResult(
rewardCoin: jsonToInt(json['rewardCoin']),
coinBalance: jsonToInt(json['coinBalance']),
alreadyCompleted: json['alreadyCheckedIn'] == true,
);
}
factory RewardActionResult.fromClaim(Map<String, dynamic> json) {
return RewardActionResult(
rewardCoin: jsonToInt(json['rewardCoin']),
coinBalance: jsonToInt(json['coinBalance']),
alreadyCompleted: json['alreadyGranted'] == true,
);
}
}
Map<String, dynamic> _map(Object? value) {
if (value is Map<String, dynamic>) return value;
if (value is Map) return value.cast<String, dynamic>();
return const {};
}
List<Map<String, dynamic>> _maps(Object? value) {
if (value is! List) return const [];
return value
.whereType<Map>()
.map((item) => item.cast<String, dynamic>())
.toList();
}
+30
View File
@@ -0,0 +1,30 @@
// Author: fruit.com.cn
import '../models/reward.dart';
import 'api_client.dart';
import 'response_decoders.dart';
/// 奖励中心接口,对应契约 §1.8.1。
class RewardApi {
const RewardApi(this._client);
final ApiClient _client;
Future<RewardOverview> overview(String locale) async {
final data = await _client.get(
'/app/rewards/overview',
query: {'locale': locale},
);
return RewardOverview.fromJson(ResponseDecoders.asMap(data));
}
Future<RewardActionResult> checkIn() async {
final data = await _client.post('/app/rewards/check-in');
return RewardActionResult.fromCheckin(ResponseDecoders.asMap(data));
}
Future<RewardActionResult> claim(String taskId) async {
final data = await _client.post('/app/rewards/tasks/$taskId/claim');
return RewardActionResult.fromClaim(ResponseDecoders.asMap(data));
}
}
@@ -0,0 +1,29 @@
// Author: fruit.com.cn
import '../models/reward.dart';
import '../remote/reward_api.dart';
/// 奖励中心数据来源。
abstract interface class RewardRepository {
Future<RewardOverview> fetchOverview(String locale);
Future<RewardActionResult> checkIn();
Future<RewardActionResult> claim(String taskId);
}
/// 奖励中心 HTTP 仓库。
class HttpRewardRepository implements RewardRepository {
const HttpRewardRepository(this._api);
final RewardApi _api;
@override
Future<RewardOverview> fetchOverview(String locale) => _api.overview(locale);
@override
Future<RewardActionResult> checkIn() => _api.checkIn();
@override
Future<RewardActionResult> claim(String taskId) => _api.claim(taskId);
}
@@ -1,5 +1,6 @@
// Author: fruit.com.cn
import 'dart:async';
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart';
@@ -58,6 +59,10 @@ class GoogleAuthService {
static const String _serverClientId =
'11115008580-dbqe47fhp5560nhrocrppiq7hcnlvl1s.apps.googleusercontent.com';
/// 授权等待上限:插件在账号被撤销后可能永不返回,必须有超时兜底,
/// 否则页面 loading 状态会一直卡住、按钮再也点不动。
static const Duration _authTimeout = Duration(seconds: 60);
final GoogleSignIn _signIn;
bool _initialized = false;
@@ -68,22 +73,41 @@ class GoogleAuthService {
if (!_signIn.supportsAuthenticate()) {
return const GoogleSignInOutcome.unavailable();
}
final account = await _signIn.authenticate();
// 先清掉插件缓存的旧账号:用户可能在 Google 侧撤销过授权或切换过账号,
// 残留状态会让 authenticate 直接返回失效账号或干脆不弹授权页。
await _clearStaleAccount();
final account = await _signIn.authenticate().timeout(_authTimeout);
final idToken = account.authentication.idToken;
if (idToken == null || idToken.isEmpty) {
return const GoogleSignInOutcome.failed();
}
return GoogleSignInOutcome.success(idToken);
} on TimeoutException {
await _clearStaleAccount();
return const GoogleSignInOutcome.failed();
} on GoogleSignInException catch (error) {
if (error.code == GoogleSignInExceptionCode.canceled) {
return const GoogleSignInOutcome.canceled();
}
// 失败多半是插件缓存了已失效的账号(用户手动撤销授权、换账号),
// 清掉本地状态,下次点击才能重新拉起授权页。
await _clearStaleAccount();
return const GoogleSignInOutcome.failed();
} catch (_) {
await _clearStaleAccount();
return const GoogleSignInOutcome.failed();
}
}
/// 清掉插件缓存的 Google 账号;失败不影响登录结果,异常直接吞掉。
Future<void> _clearStaleAccount() async {
try {
await _signIn.signOut();
} catch (_) {
// 忽略:清缓存失败不应阻断页面恢复
}
}
/// 插件要求先 initialize 才能 authenticate;只做一次。
Future<void> _ensureInitialized() async {
if (_initialized) return;
@@ -0,0 +1,108 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import '../models/reward.dart';
import '../remote/api_exception.dart';
import '../repositories/reward_repository.dart';
/// 奖励中心状态与签到、领取动作。
class RewardController extends ChangeNotifier {
RewardController(this._repository);
final RewardRepository _repository;
RewardOverview? overview;
bool loading = false;
bool checkingIn = false;
String? claimingTaskId;
String? error;
String? actionError;
bool _disposed = false;
String _locale = 'id';
Future<void> load(String locale) async {
if (_disposed || loading) return;
_locale = locale;
loading = true;
error = null;
_notify();
try {
overview = await _repository.fetchOverview(locale);
} on ApiException catch (exception) {
error = exception.message;
} catch (_) {
error = 'rewardLoadFailed';
} finally {
loading = false;
_notify();
}
}
/// 签到成功后刷新总览,确保签到格和余额来自服务端事实。
Future<RewardActionResult?> checkIn() async {
if (_disposed || checkingIn) return null;
checkingIn = true;
actionError = null;
_notify();
try {
final result = await _repository.checkIn();
await _reload();
return result;
} on ApiException catch (exception) {
actionError = exception.message;
return null;
} catch (_) {
actionError = 'rewardActionFailed';
return null;
} finally {
checkingIn = false;
_notify();
}
}
/// 领取任务奖励;同一时刻只允许一个领取动作。
Future<RewardActionResult?> claim(String taskId) async {
if (_disposed || claimingTaskId != null) return null;
claimingTaskId = taskId;
actionError = null;
_notify();
try {
final result = await _repository.claim(taskId);
await _reload();
return result;
} on ApiException catch (exception) {
actionError = exception.message;
return null;
} catch (_) {
actionError = 'rewardActionFailed';
return null;
} finally {
claimingTaskId = null;
_notify();
}
}
void reset() {
overview = null;
loading = false;
checkingIn = false;
claimingTaskId = null;
error = null;
actionError = null;
_notify();
}
Future<void> _reload() async {
overview = await _repository.fetchOverview(_locale);
}
void _notify() {
if (!_disposed) notifyListeners();
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}