feat(reward): 新增客户端奖励中心
This commit is contained in:
1 parent
67670d46b1
commit
bea08110c9
68 files changed
+3987
-332
No files matched your search
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user