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,68 @@
|
||||
{
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "app-check",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/app-check.git",
|
||||
"state" : {
|
||||
"revision" : "b7c71458b70284cc0f5a87103f66f0b1d98b1873",
|
||||
"version" : "12.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "appauth-ios",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/openid/AppAuth-iOS.git",
|
||||
"state" : {
|
||||
"revision" : "a972daac82d449d58ab119e91c68153e29ddac33",
|
||||
"version" : "3.0.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "googlesignin-ios",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/GoogleSignIn-iOS.git",
|
||||
"state" : {
|
||||
"revision" : "bfaebbc04a4d13ed7a89b0fc1fb8ad66b362cc1c",
|
||||
"version" : "10.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "googleutilities",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/GoogleUtilities.git",
|
||||
"state" : {
|
||||
"revision" : "5459ee5603def3db0fe14ca522185f42c48d978e",
|
||||
"version" : "8.1.5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "gtm-session-fetcher",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/gtm-session-fetcher.git",
|
||||
"state" : {
|
||||
"revision" : "724a52eea6329b7e12d3ad8300d76ca9f3895fcc",
|
||||
"version" : "5.3.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "gtmappauth",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/GTMAppAuth.git",
|
||||
"state" : {
|
||||
"revision" : "57094cd46bc8700d3a887e2a429f42b6e8ea3f0b",
|
||||
"version" : "6.0.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "interop-ios-for-google-sdks",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/interop-ios-for-google-sdks.git",
|
||||
"state" : {
|
||||
"revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe",
|
||||
"version" : "101.0.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 2
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import 'core/data/remote/interaction_api.dart';
|
||||
import 'core/data/remote/library_api.dart';
|
||||
import 'core/data/remote/notice_api.dart';
|
||||
import 'core/data/remote/playback_api.dart';
|
||||
import 'core/data/remote/reward_api.dart';
|
||||
import 'core/data/remote/watch_api.dart';
|
||||
import 'core/data/repositories/catalog_repository.dart';
|
||||
import 'core/data/repositories/account_repository.dart';
|
||||
@@ -33,6 +34,7 @@ import 'core/data/repositories/interaction_repository.dart';
|
||||
import 'core/data/repositories/notice_repository.dart';
|
||||
import 'core/data/repositories/playback_repository.dart';
|
||||
import 'core/data/repositories/playlist_repository.dart';
|
||||
import 'core/data/repositories/reward_repository.dart';
|
||||
import 'core/data/repositories/search_repository.dart';
|
||||
import 'core/data/repositories/store_repository.dart';
|
||||
import 'core/data/repositories/watch_history_repository.dart';
|
||||
@@ -49,6 +51,7 @@ import 'core/data/services/playlist_controller.dart';
|
||||
import 'core/data/services/event_tracker.dart';
|
||||
import 'core/data/services/playback_position_cache.dart';
|
||||
import 'core/data/services/playback_session.dart';
|
||||
import 'core/data/services/reward_controller.dart';
|
||||
import 'core/data/services/unlocked_episode_registry.dart';
|
||||
import 'core/data/services/video_player_port.dart';
|
||||
import 'core/data/services/wallet_controller.dart';
|
||||
@@ -90,6 +93,7 @@ class LumaDramaApp extends StatefulWidget {
|
||||
this.storeRepository,
|
||||
this.searchRepository,
|
||||
this.playlistRepository,
|
||||
this.rewardRepository,
|
||||
this.haptics = const FlutterHaptics(),
|
||||
});
|
||||
|
||||
@@ -111,6 +115,7 @@ class LumaDramaApp extends StatefulWidget {
|
||||
final StoreRepository? storeRepository;
|
||||
final SearchRepository? searchRepository;
|
||||
final PlaylistRepository? playlistRepository;
|
||||
final RewardRepository? rewardRepository;
|
||||
final Haptics haptics;
|
||||
|
||||
@override
|
||||
@@ -179,6 +184,13 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
|
||||
_commerceRepository,
|
||||
);
|
||||
|
||||
late final RewardRepository _rewardRepository =
|
||||
widget.rewardRepository ?? HttpRewardRepository(RewardApi(_apiClient));
|
||||
|
||||
late final RewardController _rewardController = RewardController(
|
||||
_rewardRepository,
|
||||
);
|
||||
|
||||
late final SearchRepository _searchRepository =
|
||||
widget.searchRepository ?? HttpSearchRepository(CatalogApi(_apiClient));
|
||||
|
||||
@@ -249,6 +261,7 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
|
||||
unawaited(_favoriteController.load());
|
||||
_downloadController.reset();
|
||||
_walletController.reset();
|
||||
_rewardController.reset();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,6 +271,7 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
|
||||
_favoriteController.dispose();
|
||||
_downloadController.dispose();
|
||||
_walletController.dispose();
|
||||
_rewardController.dispose();
|
||||
_interactionController.dispose();
|
||||
_localeController.dispose();
|
||||
_appConfigController.dispose();
|
||||
@@ -313,6 +327,7 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
|
||||
ChangeNotifierProvider.value(value: _favoriteController),
|
||||
ChangeNotifierProvider.value(value: _downloadController),
|
||||
ChangeNotifierProvider.value(value: _walletController),
|
||||
ChangeNotifierProvider.value(value: _rewardController),
|
||||
ChangeNotifierProvider.value(value: _interactionController),
|
||||
ChangeNotifierProvider.value(value: _localeController),
|
||||
ChangeNotifierProvider.value(value: _appConfigController),
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
/// 开发机的局域网 IP(真机上的 127.0.0.1 指向手机自己)。地址不要带结尾斜杠。
|
||||
abstract final class DevConfig {
|
||||
/// 开发机后端地址(Mac 当前 Wi-Fi 地址,换网络后需要同步修改)。
|
||||
static const String baseUrl = 'http://192.168.31.243:8081';
|
||||
static const String baseUrl = 'http://192.168.31.120:8081';
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -182,6 +182,7 @@ abstract final class AppColors {
|
||||
static const navBackground = Color(0xFF000000);
|
||||
static const brandRed = Color(0xFFF04E4E);
|
||||
static const brandRedPressed = Color(0xFFD64040);
|
||||
static const navSelectedGlow = Color(0x33F04E4E);
|
||||
static const navSelected = brandRed;
|
||||
|
||||
/// 卡片阴影(深色几乎不用阴影)。
|
||||
@@ -222,6 +223,12 @@ abstract final class AppColors {
|
||||
|
||||
/// 玻璃面板描边。
|
||||
static const glassBorder = Color(0x33FFFFFF);
|
||||
|
||||
/// 玻璃控件使用的发丝级轮廓,避免边缘抢过内容。
|
||||
static const glassHairline = Color(0x12FFFFFF);
|
||||
|
||||
/// 轻量玻璃控件的白色表面。
|
||||
static const glassSurface = Color(0x24FFFFFF);
|
||||
static const glassSurfaceStart = Color(0xEB16161A);
|
||||
static const glassSurfaceEnd = Color(0xD1101010);
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ abstract final class AppDimens {
|
||||
static const double buttonRadius = 10;
|
||||
static const double inputRadius = 10;
|
||||
static const double chipRadius = 6;
|
||||
static const double coverRadius = 12;
|
||||
static const double coverRadius = 8;
|
||||
static const double sheetRadius = 12;
|
||||
|
||||
static const double caption = 12;
|
||||
@@ -92,7 +92,7 @@ abstract final class AppDimens {
|
||||
static const double homeGridGap = 6;
|
||||
static const double homeGridRowGap = 10;
|
||||
static const double homeCardRadius = 6;
|
||||
static const double cardCoverRadius = 8;
|
||||
static const double cardCoverRadius = coverRadius;
|
||||
static const double homeCardPadding = 8;
|
||||
static const double cardTitleFont = 15;
|
||||
static const double cardTitleLineHeight = 1.3;
|
||||
@@ -157,7 +157,7 @@ abstract final class AppDimens {
|
||||
/// 首页热播榜。
|
||||
static const double rankingHeroTitle = 22;
|
||||
static const double rankingHeroSubtitle = 11;
|
||||
static const double rankingCoverRadius = 6;
|
||||
static const double rankingCoverRadius = coverRadius;
|
||||
static const double rankingCardCoverWidth = 96;
|
||||
static const double rankingCardCoverHeight = 128;
|
||||
|
||||
@@ -189,7 +189,7 @@ abstract final class AppDimens {
|
||||
static const double followingProgressHeight = 2;
|
||||
static const double followingHistoryCoverWidth = 96;
|
||||
static const double followingHistoryCoverHeight = 128;
|
||||
static const double followingHistoryCoverRadius = 6;
|
||||
static const double followingHistoryCoverRadius = coverRadius;
|
||||
static const double followingHistoryContentGap = 12;
|
||||
static const double followingHistoryRowGap = 16;
|
||||
static const double followingHistoryProgressHeight = 3;
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme/app_colors.dart';
|
||||
import '../theme/app_dimens.dart';
|
||||
import 'app_press_scale.dart';
|
||||
|
||||
const double _maxNavigationTextScale = 1.3;
|
||||
|
||||
@@ -32,7 +33,7 @@ class AppBottomNavItem {
|
||||
final bool selected;
|
||||
}
|
||||
|
||||
/// 四槽底部导航:纯黑背景,白色选中态,灰色未选中态。
|
||||
/// 均分底部导航:纯黑背景,品牌红选中态,白色未选中态。
|
||||
class AppBottomNav extends StatelessWidget {
|
||||
const AppBottomNav({super.key, required this.items, this.barKey});
|
||||
|
||||
@@ -91,35 +92,46 @@ class _NavDestination extends StatelessWidget {
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
AnimatedScale(
|
||||
duration: const Duration(milliseconds: 160),
|
||||
curve: Curves.easeOut,
|
||||
scale: item.selected ? 1 : 0.96,
|
||||
child:
|
||||
item.customIcon ??
|
||||
(assetPath == null
|
||||
? Icon(
|
||||
fallbackIcon,
|
||||
size: AppDimens.navIcon,
|
||||
color: color,
|
||||
)
|
||||
: ColorFiltered(
|
||||
colorFilter: ColorFilter.mode(
|
||||
color,
|
||||
BlendMode.srcIn,
|
||||
AppPressScale(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(AppDimens.navIcon),
|
||||
boxShadow: item.selected
|
||||
? const [
|
||||
BoxShadow(
|
||||
color: AppColors.navSelectedGlow,
|
||||
blurRadius: 12,
|
||||
spreadRadius: 1,
|
||||
),
|
||||
child: Image.asset(
|
||||
assetPath,
|
||||
width: AppDimens.navIcon,
|
||||
height: AppDimens.navIcon,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, _, _) => Icon(
|
||||
fallbackIcon,
|
||||
size: AppDimens.navIcon,
|
||||
color: color,
|
||||
]
|
||||
: const [],
|
||||
),
|
||||
child:
|
||||
item.customIcon ??
|
||||
(assetPath == null
|
||||
? Icon(
|
||||
fallbackIcon,
|
||||
size: AppDimens.navIcon,
|
||||
color: color,
|
||||
)
|
||||
: ColorFiltered(
|
||||
colorFilter: ColorFilter.mode(
|
||||
color,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
),
|
||||
)),
|
||||
child: Image.asset(
|
||||
assetPath,
|
||||
width: AppDimens.navIcon,
|
||||
height: AppDimens.navIcon,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, _, _) => Icon(
|
||||
fallbackIcon,
|
||||
size: AppDimens.navIcon,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimens.navLabelGap),
|
||||
Text(
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
const double _pressedScale = 0.97;
|
||||
|
||||
/// 为可点击内容提供轻量、短促的按压缩放反馈。
|
||||
class AppPressScale extends StatefulWidget {
|
||||
const AppPressScale({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.onTap,
|
||||
this.scale = _pressedScale,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final VoidCallback? onTap;
|
||||
final double scale;
|
||||
|
||||
@override
|
||||
State<AppPressScale> createState() => _AppPressScaleState();
|
||||
}
|
||||
|
||||
class _AppPressScaleState extends State<AppPressScale> {
|
||||
bool _pressed = false;
|
||||
|
||||
void _setPressed(bool pressed) {
|
||||
if (_pressed == pressed) {
|
||||
return;
|
||||
}
|
||||
setState(() => _pressed = pressed);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scale = _pressed ? widget.scale : 1.0;
|
||||
final feedback = Listener(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onPointerDown: (_) => _setPressed(true),
|
||||
onPointerUp: (_) => _setPressed(false),
|
||||
onPointerCancel: (_) => _setPressed(false),
|
||||
child: Transform.scale(scale: scale, child: widget.child),
|
||||
);
|
||||
if (widget.onTap == null) {
|
||||
return feedback;
|
||||
}
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: widget.onTap,
|
||||
child: feedback,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ const double _maxPullDistance = 96;
|
||||
const double _refreshThreshold = 56;
|
||||
const double _indicatorHeight = 56;
|
||||
const double _indicatorDisplacement = 40;
|
||||
const double _indicatorTopPadding = 8;
|
||||
const Duration _settleDuration = Duration(milliseconds: 200);
|
||||
|
||||
enum _PullState { idle, pull, armed, refreshing }
|
||||
@@ -27,6 +28,10 @@ class AppPullToRefresh extends StatefulWidget {
|
||||
static const AlwaysScrollableScrollPhysics physics =
|
||||
AlwaysScrollableScrollPhysics(parent: BouncingScrollPhysics());
|
||||
|
||||
/// 达到刷新阈值时,指示器顶部相对容器顶部的下移距离。
|
||||
static const double indicatorRevealOffset =
|
||||
_indicatorDisplacement + _indicatorTopPadding;
|
||||
|
||||
final Widget child;
|
||||
final Future<void> Function() onRefresh;
|
||||
final double edgeOffset;
|
||||
@@ -191,7 +196,7 @@ class _AppPullToRefreshState extends State<AppPullToRefresh>
|
||||
child: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
padding: const EdgeInsets.only(top: _indicatorTopPadding),
|
||||
child: _indicator(),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'root_nav_glyph.dart';
|
||||
enum AppRootTab {
|
||||
home(),
|
||||
recommend(),
|
||||
reward(),
|
||||
following(),
|
||||
mine();
|
||||
|
||||
@@ -21,6 +22,7 @@ extension AppRootTabUi on AppRootTab {
|
||||
RootNavGlyphKind get glyphKind => switch (this) {
|
||||
AppRootTab.home => RootNavGlyphKind.home,
|
||||
AppRootTab.recommend => RootNavGlyphKind.recommend,
|
||||
AppRootTab.reward => RootNavGlyphKind.reward,
|
||||
AppRootTab.following => RootNavGlyphKind.following,
|
||||
AppRootTab.mine => RootNavGlyphKind.mine,
|
||||
};
|
||||
@@ -28,6 +30,7 @@ extension AppRootTabUi on AppRootTab {
|
||||
Key get tabKey => switch (this) {
|
||||
AppRootTab.home => const Key('tab-home'),
|
||||
AppRootTab.recommend => const Key('tab-recommend'),
|
||||
AppRootTab.reward => const Key('tab-reward'),
|
||||
AppRootTab.following => const Key('tab-follow'),
|
||||
AppRootTab.mine => const Key('tab-mine'),
|
||||
};
|
||||
@@ -35,6 +38,7 @@ extension AppRootTabUi on AppRootTab {
|
||||
IconData get icon => switch (this) {
|
||||
AppRootTab.home => Icons.home_outlined,
|
||||
AppRootTab.recommend => Icons.smart_display_outlined,
|
||||
AppRootTab.reward => Icons.card_giftcard_outlined,
|
||||
AppRootTab.following => Icons.bookmark_border_rounded,
|
||||
AppRootTab.mine => Icons.person_outline_rounded,
|
||||
};
|
||||
@@ -42,6 +46,7 @@ extension AppRootTabUi on AppRootTab {
|
||||
IconData get selectedIcon => switch (this) {
|
||||
AppRootTab.home => Icons.home_rounded,
|
||||
AppRootTab.recommend => Icons.smart_display_rounded,
|
||||
AppRootTab.reward => Icons.card_giftcard_rounded,
|
||||
AppRootTab.following => Icons.bookmark_rounded,
|
||||
AppRootTab.mine => Icons.person_rounded,
|
||||
};
|
||||
@@ -49,12 +54,13 @@ extension AppRootTabUi on AppRootTab {
|
||||
String label(AppLocalizations l10n) => switch (this) {
|
||||
AppRootTab.home => l10n.homeTab,
|
||||
AppRootTab.recommend => l10n.recommendedTab,
|
||||
AppRootTab.reward => l10n.rewardTab,
|
||||
AppRootTab.following => l10n.followingTab,
|
||||
AppRootTab.mine => l10n.myTab,
|
||||
};
|
||||
}
|
||||
|
||||
/// 根导航唯一入口,确保四个主页面使用完全一致的底栏配置。
|
||||
/// 根导航唯一入口,确保五个主页面使用完全一致的底栏配置。
|
||||
class AppRootBottomNav extends StatelessWidget {
|
||||
const AppRootBottomNav({
|
||||
super.key,
|
||||
|
||||
@@ -168,7 +168,7 @@ class _SkeletonBlock extends StatelessWidget {
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.skeletonSurface,
|
||||
borderRadius: BorderRadius.circular(AppDimens.homeCardRadius),
|
||||
borderRadius: BorderRadius.circular(AppDimens.coverRadius),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ import '../theme/app_colors.dart';
|
||||
|
||||
const double _designSize = 30;
|
||||
|
||||
/// 根导航四个入口对应的图标种类。
|
||||
enum RootNavGlyphKind { home, recommend, following, mine }
|
||||
/// 根导航五个入口对应的图标种类。
|
||||
enum RootNavGlyphKind { home, recommend, reward, following, mine }
|
||||
|
||||
/// 根导航插画图标,根据选中态使用品牌红和中性灰白配色。
|
||||
class RootNavGlyph extends StatelessWidget {
|
||||
@@ -52,6 +52,8 @@ class _RootNavGlyphPainter extends CustomPainter {
|
||||
_paintHome(canvas);
|
||||
case RootNavGlyphKind.recommend:
|
||||
_paintRecommend(canvas);
|
||||
case RootNavGlyphKind.reward:
|
||||
_paintReward(canvas);
|
||||
case RootNavGlyphKind.following:
|
||||
_paintFollowing(canvas);
|
||||
case RootNavGlyphKind.mine:
|
||||
@@ -151,6 +153,32 @@ class _RootNavGlyphPainter extends CustomPainter {
|
||||
canvas.drawPath(bookmark, Paint()..color = _light);
|
||||
}
|
||||
|
||||
void _paintReward(Canvas canvas) {
|
||||
final box = RRect.fromRectAndRadius(
|
||||
const Rect.fromLTRB(4, 10, 26, 26),
|
||||
const Radius.circular(4),
|
||||
);
|
||||
canvas.drawShadow(Path()..addRRect(box), AppColors.shadow, 2, false);
|
||||
canvas.drawRRect(box, _gradient(const Rect.fromLTWH(4, 10, 22, 16)));
|
||||
canvas.drawRect(const Rect.fromLTRB(3, 8, 27, 13), Paint()..color = _main);
|
||||
canvas.drawRect(
|
||||
const Rect.fromLTRB(13, 8, 17, 26),
|
||||
Paint()..color = _light,
|
||||
);
|
||||
final left = Path()
|
||||
..moveTo(15, 9)
|
||||
..cubicTo(8, 9, 7, 3, 11, 3)
|
||||
..cubicTo(14, 3, 15, 7, 15, 9)
|
||||
..close();
|
||||
final right = Path()
|
||||
..moveTo(15, 9)
|
||||
..cubicTo(22, 9, 23, 3, 19, 3)
|
||||
..cubicTo(16, 3, 15, 7, 15, 9)
|
||||
..close();
|
||||
canvas.drawPath(left, Paint()..color = _light);
|
||||
canvas.drawPath(right, Paint()..color = _light);
|
||||
}
|
||||
|
||||
void _paintMine(Canvas canvas) {
|
||||
final bounds = Rect.fromCircle(center: const Offset(15, 15), radius: 13);
|
||||
canvas.drawShadow(Path()..addOval(bounds), AppColors.shadow, 2, false);
|
||||
|
||||
@@ -860,6 +860,47 @@
|
||||
"aboutUs": "About us",
|
||||
"aboutBrandName": "Your mini business",
|
||||
"aboutDescription": "A focused, immersive place to watch short dramas.",
|
||||
"rewardTab": "Rewards",
|
||||
"rewardCenterTitle": "Reward Center",
|
||||
"rewardTasksTab": "Tasks",
|
||||
"rewardActivitiesTab": "Events",
|
||||
"rewardMyCoins": "My coins",
|
||||
"rewardCheckInTitle": "Check in for rewards",
|
||||
"rewardCheckInSubtitle": "Check in daily to collect reward coins",
|
||||
"rewardBoostCheckIn": "Boost check-in reward",
|
||||
"rewardCheckInAction": "Check in",
|
||||
"rewardCheckInActionWithCoins": "Check in +{coins} coins",
|
||||
"@rewardCheckInActionWithCoins": {
|
||||
"placeholders": {"coins": {"type": "int"}}
|
||||
},
|
||||
"rewardCheckedIn": "Checked in",
|
||||
"rewardToday": "Today",
|
||||
"rewardDayLabel": "Day {day}",
|
||||
"@rewardDayLabel": {
|
||||
"placeholders": {"day": {"type": "int"}}
|
||||
},
|
||||
"rewardClaimAction": "Claim",
|
||||
"rewardClaimed": "Claimed",
|
||||
"rewardGoAction": "Go",
|
||||
"rewardDailySectionSubtitle": "Complete daily tasks to collect reward coins",
|
||||
"rewardNewbieSectionSubtitle": "Available once only",
|
||||
"rewardInviteSectionSubtitle": "Invite friends and earn rewards together",
|
||||
"rewardHelpTitle": "About rewards",
|
||||
"rewardHelpBody": "Complete check-ins and tasks to earn reward coins. Rewards and task rules are shown on this page.",
|
||||
"rewardComingSoon": "Coming soon",
|
||||
"rewardEmptyTasks": "No reward tasks available",
|
||||
"rewardEmptyActivities": "No events available",
|
||||
"rewardRetry": "Retry",
|
||||
"rewardCheckInSuccess": "+{coins} coins for checking in",
|
||||
"@rewardCheckInSuccess": {
|
||||
"placeholders": {"coins": {"type": "int"}}
|
||||
},
|
||||
"rewardClaimSuccess": "+{coins} coins claimed",
|
||||
"@rewardClaimSuccess": {
|
||||
"placeholders": {"coins": {"type": "int"}}
|
||||
},
|
||||
"rewardLoadFailed": "Couldn't load rewards",
|
||||
"rewardActionFailed": "Action failed. Please try again.",
|
||||
"versionLabel": "Version {version}",
|
||||
"@versionLabel": {
|
||||
"placeholders": {
|
||||
|
||||
@@ -817,6 +817,47 @@
|
||||
"aboutUs": "Tentang kami",
|
||||
"aboutBrandName": "Your mini business",
|
||||
"aboutDescription": "Tempat fokus dan imersif untuk menonton drama pendek.",
|
||||
"rewardTab": "Hadiah",
|
||||
"rewardCenterTitle": "Pusat Hadiah",
|
||||
"rewardTasksTab": "Tugas",
|
||||
"rewardActivitiesTab": "Acara",
|
||||
"rewardMyCoins": "Koin saya",
|
||||
"rewardCheckInTitle": "Check-in untuk hadiah",
|
||||
"rewardCheckInSubtitle": "Check-in setiap hari untuk mengumpulkan koin hadiah",
|
||||
"rewardBoostCheckIn": "Tingkatkan hadiah check-in",
|
||||
"rewardCheckInAction": "Check-in",
|
||||
"rewardCheckInActionWithCoins": "Check-in +{coins} koin",
|
||||
"@rewardCheckInActionWithCoins": {
|
||||
"placeholders": {"coins": {"type": "int"}}
|
||||
},
|
||||
"rewardCheckedIn": "Sudah check-in",
|
||||
"rewardToday": "Hari ini",
|
||||
"rewardDayLabel": "Hari {day}",
|
||||
"@rewardDayLabel": {
|
||||
"placeholders": {"day": {"type": "int"}}
|
||||
},
|
||||
"rewardClaimAction": "Ambil",
|
||||
"rewardClaimed": "Sudah diambil",
|
||||
"rewardGoAction": "Mulai",
|
||||
"rewardDailySectionSubtitle": "Selesaikan tugas harian untuk mendapat koin hadiah",
|
||||
"rewardNewbieSectionSubtitle": "Hanya tersedia satu kali",
|
||||
"rewardInviteSectionSubtitle": "Undang teman dan dapatkan hadiah bersama",
|
||||
"rewardHelpTitle": "Tentang hadiah",
|
||||
"rewardHelpBody": "Selesaikan check-in dan tugas untuk mendapat koin hadiah. Hadiah dan aturan tugas mengikuti halaman ini.",
|
||||
"rewardComingSoon": "Segera hadir",
|
||||
"rewardEmptyTasks": "Belum ada tugas hadiah",
|
||||
"rewardEmptyActivities": "Belum ada acara",
|
||||
"rewardRetry": "Coba lagi",
|
||||
"rewardCheckInSuccess": "+{coins} koin dari check-in",
|
||||
"@rewardCheckInSuccess": {
|
||||
"placeholders": {"coins": {"type": "int"}}
|
||||
},
|
||||
"rewardClaimSuccess": "+{coins} koin berhasil diambil",
|
||||
"@rewardClaimSuccess": {
|
||||
"placeholders": {"coins": {"type": "int"}}
|
||||
},
|
||||
"rewardLoadFailed": "Hadiah tidak dapat dimuat",
|
||||
"rewardActionFailed": "Tindakan gagal. Silakan coba lagi.",
|
||||
"versionLabel": "Versi {version}",
|
||||
"@versionLabel": {
|
||||
"placeholders": {
|
||||
|
||||
@@ -3616,6 +3616,180 @@ abstract class AppLocalizations {
|
||||
/// **'A focused, immersive place to watch short dramas.'**
|
||||
String get aboutDescription;
|
||||
|
||||
/// No description provided for @rewardTab.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Rewards'**
|
||||
String get rewardTab;
|
||||
|
||||
/// No description provided for @rewardCenterTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Reward Center'**
|
||||
String get rewardCenterTitle;
|
||||
|
||||
/// No description provided for @rewardTasksTab.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Tasks'**
|
||||
String get rewardTasksTab;
|
||||
|
||||
/// No description provided for @rewardActivitiesTab.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Events'**
|
||||
String get rewardActivitiesTab;
|
||||
|
||||
/// No description provided for @rewardMyCoins.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'My coins'**
|
||||
String get rewardMyCoins;
|
||||
|
||||
/// No description provided for @rewardCheckInTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Check in for rewards'**
|
||||
String get rewardCheckInTitle;
|
||||
|
||||
/// No description provided for @rewardCheckInSubtitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Check in daily to collect reward coins'**
|
||||
String get rewardCheckInSubtitle;
|
||||
|
||||
/// No description provided for @rewardBoostCheckIn.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Boost check-in reward'**
|
||||
String get rewardBoostCheckIn;
|
||||
|
||||
/// No description provided for @rewardCheckInAction.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Check in'**
|
||||
String get rewardCheckInAction;
|
||||
|
||||
/// No description provided for @rewardCheckInActionWithCoins.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Check in +{coins} coins'**
|
||||
String rewardCheckInActionWithCoins(int coins);
|
||||
|
||||
/// No description provided for @rewardCheckedIn.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Checked in'**
|
||||
String get rewardCheckedIn;
|
||||
|
||||
/// No description provided for @rewardToday.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Today'**
|
||||
String get rewardToday;
|
||||
|
||||
/// No description provided for @rewardDayLabel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Day {day}'**
|
||||
String rewardDayLabel(int day);
|
||||
|
||||
/// No description provided for @rewardClaimAction.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Claim'**
|
||||
String get rewardClaimAction;
|
||||
|
||||
/// No description provided for @rewardClaimed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Claimed'**
|
||||
String get rewardClaimed;
|
||||
|
||||
/// No description provided for @rewardGoAction.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Go'**
|
||||
String get rewardGoAction;
|
||||
|
||||
/// No description provided for @rewardDailySectionSubtitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Complete daily tasks to collect reward coins'**
|
||||
String get rewardDailySectionSubtitle;
|
||||
|
||||
/// No description provided for @rewardNewbieSectionSubtitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Available once only'**
|
||||
String get rewardNewbieSectionSubtitle;
|
||||
|
||||
/// No description provided for @rewardInviteSectionSubtitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Invite friends and earn rewards together'**
|
||||
String get rewardInviteSectionSubtitle;
|
||||
|
||||
/// No description provided for @rewardHelpTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'About rewards'**
|
||||
String get rewardHelpTitle;
|
||||
|
||||
/// No description provided for @rewardHelpBody.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Complete check-ins and tasks to earn reward coins. Rewards and task rules are shown on this page.'**
|
||||
String get rewardHelpBody;
|
||||
|
||||
/// No description provided for @rewardComingSoon.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Coming soon'**
|
||||
String get rewardComingSoon;
|
||||
|
||||
/// No description provided for @rewardEmptyTasks.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No reward tasks available'**
|
||||
String get rewardEmptyTasks;
|
||||
|
||||
/// No description provided for @rewardEmptyActivities.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No events available'**
|
||||
String get rewardEmptyActivities;
|
||||
|
||||
/// No description provided for @rewardRetry.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Retry'**
|
||||
String get rewardRetry;
|
||||
|
||||
/// No description provided for @rewardCheckInSuccess.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'+{coins} coins for checking in'**
|
||||
String rewardCheckInSuccess(int coins);
|
||||
|
||||
/// No description provided for @rewardClaimSuccess.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'+{coins} coins claimed'**
|
||||
String rewardClaimSuccess(int coins);
|
||||
|
||||
/// No description provided for @rewardLoadFailed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Couldn\'t load rewards'**
|
||||
String get rewardLoadFailed;
|
||||
|
||||
/// No description provided for @rewardActionFailed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Action failed. Please try again.'**
|
||||
String get rewardActionFailed;
|
||||
|
||||
/// No description provided for @versionLabel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
||||
@@ -1901,6 +1901,104 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get aboutDescription =>
|
||||
'A focused, immersive place to watch short dramas.';
|
||||
|
||||
@override
|
||||
String get rewardTab => 'Rewards';
|
||||
|
||||
@override
|
||||
String get rewardCenterTitle => 'Reward Center';
|
||||
|
||||
@override
|
||||
String get rewardTasksTab => 'Tasks';
|
||||
|
||||
@override
|
||||
String get rewardActivitiesTab => 'Events';
|
||||
|
||||
@override
|
||||
String get rewardMyCoins => 'My coins';
|
||||
|
||||
@override
|
||||
String get rewardCheckInTitle => 'Check in for rewards';
|
||||
|
||||
@override
|
||||
String get rewardCheckInSubtitle => 'Check in daily to collect reward coins';
|
||||
|
||||
@override
|
||||
String get rewardBoostCheckIn => 'Boost check-in reward';
|
||||
|
||||
@override
|
||||
String get rewardCheckInAction => 'Check in';
|
||||
|
||||
@override
|
||||
String rewardCheckInActionWithCoins(int coins) {
|
||||
return 'Check in +$coins coins';
|
||||
}
|
||||
|
||||
@override
|
||||
String get rewardCheckedIn => 'Checked in';
|
||||
|
||||
@override
|
||||
String get rewardToday => 'Today';
|
||||
|
||||
@override
|
||||
String rewardDayLabel(int day) {
|
||||
return 'Day $day';
|
||||
}
|
||||
|
||||
@override
|
||||
String get rewardClaimAction => 'Claim';
|
||||
|
||||
@override
|
||||
String get rewardClaimed => 'Claimed';
|
||||
|
||||
@override
|
||||
String get rewardGoAction => 'Go';
|
||||
|
||||
@override
|
||||
String get rewardDailySectionSubtitle =>
|
||||
'Complete daily tasks to collect reward coins';
|
||||
|
||||
@override
|
||||
String get rewardNewbieSectionSubtitle => 'Available once only';
|
||||
|
||||
@override
|
||||
String get rewardInviteSectionSubtitle =>
|
||||
'Invite friends and earn rewards together';
|
||||
|
||||
@override
|
||||
String get rewardHelpTitle => 'About rewards';
|
||||
|
||||
@override
|
||||
String get rewardHelpBody =>
|
||||
'Complete check-ins and tasks to earn reward coins. Rewards and task rules are shown on this page.';
|
||||
|
||||
@override
|
||||
String get rewardComingSoon => 'Coming soon';
|
||||
|
||||
@override
|
||||
String get rewardEmptyTasks => 'No reward tasks available';
|
||||
|
||||
@override
|
||||
String get rewardEmptyActivities => 'No events available';
|
||||
|
||||
@override
|
||||
String get rewardRetry => 'Retry';
|
||||
|
||||
@override
|
||||
String rewardCheckInSuccess(int coins) {
|
||||
return '+$coins coins for checking in';
|
||||
}
|
||||
|
||||
@override
|
||||
String rewardClaimSuccess(int coins) {
|
||||
return '+$coins coins claimed';
|
||||
}
|
||||
|
||||
@override
|
||||
String get rewardLoadFailed => 'Couldn\'t load rewards';
|
||||
|
||||
@override
|
||||
String get rewardActionFailed => 'Action failed. Please try again.';
|
||||
|
||||
@override
|
||||
String versionLabel(String version) {
|
||||
return 'Version $version';
|
||||
|
||||
@@ -1903,6 +1903,105 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get aboutDescription =>
|
||||
'Tempat fokus dan imersif untuk menonton drama pendek.';
|
||||
|
||||
@override
|
||||
String get rewardTab => 'Hadiah';
|
||||
|
||||
@override
|
||||
String get rewardCenterTitle => 'Pusat Hadiah';
|
||||
|
||||
@override
|
||||
String get rewardTasksTab => 'Tugas';
|
||||
|
||||
@override
|
||||
String get rewardActivitiesTab => 'Acara';
|
||||
|
||||
@override
|
||||
String get rewardMyCoins => 'Koin saya';
|
||||
|
||||
@override
|
||||
String get rewardCheckInTitle => 'Check-in untuk hadiah';
|
||||
|
||||
@override
|
||||
String get rewardCheckInSubtitle =>
|
||||
'Check-in setiap hari untuk mengumpulkan koin hadiah';
|
||||
|
||||
@override
|
||||
String get rewardBoostCheckIn => 'Tingkatkan hadiah check-in';
|
||||
|
||||
@override
|
||||
String get rewardCheckInAction => 'Check-in';
|
||||
|
||||
@override
|
||||
String rewardCheckInActionWithCoins(int coins) {
|
||||
return 'Check-in +$coins koin';
|
||||
}
|
||||
|
||||
@override
|
||||
String get rewardCheckedIn => 'Sudah check-in';
|
||||
|
||||
@override
|
||||
String get rewardToday => 'Hari ini';
|
||||
|
||||
@override
|
||||
String rewardDayLabel(int day) {
|
||||
return 'Hari $day';
|
||||
}
|
||||
|
||||
@override
|
||||
String get rewardClaimAction => 'Ambil';
|
||||
|
||||
@override
|
||||
String get rewardClaimed => 'Sudah diambil';
|
||||
|
||||
@override
|
||||
String get rewardGoAction => 'Mulai';
|
||||
|
||||
@override
|
||||
String get rewardDailySectionSubtitle =>
|
||||
'Selesaikan tugas harian untuk mendapat koin hadiah';
|
||||
|
||||
@override
|
||||
String get rewardNewbieSectionSubtitle => 'Hanya tersedia satu kali';
|
||||
|
||||
@override
|
||||
String get rewardInviteSectionSubtitle =>
|
||||
'Undang teman dan dapatkan hadiah bersama';
|
||||
|
||||
@override
|
||||
String get rewardHelpTitle => 'Tentang hadiah';
|
||||
|
||||
@override
|
||||
String get rewardHelpBody =>
|
||||
'Selesaikan check-in dan tugas untuk mendapat koin hadiah. Hadiah dan aturan tugas mengikuti halaman ini.';
|
||||
|
||||
@override
|
||||
String get rewardComingSoon => 'Segera hadir';
|
||||
|
||||
@override
|
||||
String get rewardEmptyTasks => 'Belum ada tugas hadiah';
|
||||
|
||||
@override
|
||||
String get rewardEmptyActivities => 'Belum ada acara';
|
||||
|
||||
@override
|
||||
String get rewardRetry => 'Coba lagi';
|
||||
|
||||
@override
|
||||
String rewardCheckInSuccess(int coins) {
|
||||
return '+$coins koin dari check-in';
|
||||
}
|
||||
|
||||
@override
|
||||
String rewardClaimSuccess(int coins) {
|
||||
return '+$coins koin berhasil diambil';
|
||||
}
|
||||
|
||||
@override
|
||||
String get rewardLoadFailed => 'Hadiah tidak dapat dimuat';
|
||||
|
||||
@override
|
||||
String get rewardActionFailed => 'Tindakan gagal. Silakan coba lagi.';
|
||||
|
||||
@override
|
||||
String versionLabel(String version) {
|
||||
return 'Versi $version';
|
||||
|
||||
@@ -1875,6 +1875,101 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get aboutDescription => '专注于短剧观看与沉浸式追剧体验。';
|
||||
|
||||
@override
|
||||
String get rewardTab => '奖励';
|
||||
|
||||
@override
|
||||
String get rewardCenterTitle => '奖励中心';
|
||||
|
||||
@override
|
||||
String get rewardTasksTab => '任务';
|
||||
|
||||
@override
|
||||
String get rewardActivitiesTab => '活动';
|
||||
|
||||
@override
|
||||
String get rewardMyCoins => '我的金币';
|
||||
|
||||
@override
|
||||
String get rewardCheckInTitle => '签到领奖励';
|
||||
|
||||
@override
|
||||
String get rewardCheckInSubtitle => '每日签到,领取奖励币';
|
||||
|
||||
@override
|
||||
String get rewardBoostCheckIn => '提升签到奖励';
|
||||
|
||||
@override
|
||||
String get rewardCheckInAction => '立即签到';
|
||||
|
||||
@override
|
||||
String rewardCheckInActionWithCoins(int coins) {
|
||||
return '签到 +$coins 奖励币';
|
||||
}
|
||||
|
||||
@override
|
||||
String get rewardCheckedIn => '今日已签到';
|
||||
|
||||
@override
|
||||
String get rewardToday => '今天';
|
||||
|
||||
@override
|
||||
String rewardDayLabel(int day) {
|
||||
return '第 $day 天';
|
||||
}
|
||||
|
||||
@override
|
||||
String get rewardClaimAction => '领取';
|
||||
|
||||
@override
|
||||
String get rewardClaimed => '已领取';
|
||||
|
||||
@override
|
||||
String get rewardGoAction => '去完成';
|
||||
|
||||
@override
|
||||
String get rewardDailySectionSubtitle => '每日完成任务,领取奖励币';
|
||||
|
||||
@override
|
||||
String get rewardNewbieSectionSubtitle => '仅有一次机会';
|
||||
|
||||
@override
|
||||
String get rewardInviteSectionSubtitle => '邀请好友一起获得奖励';
|
||||
|
||||
@override
|
||||
String get rewardHelpTitle => '奖励中心说明';
|
||||
|
||||
@override
|
||||
String get rewardHelpBody => '完成签到和任务可获得奖励币,具体奖励与任务规则以页面展示为准。';
|
||||
|
||||
@override
|
||||
String get rewardComingSoon => '功能即将开放';
|
||||
|
||||
@override
|
||||
String get rewardEmptyTasks => '暂无奖励任务';
|
||||
|
||||
@override
|
||||
String get rewardEmptyActivities => '暂无活动';
|
||||
|
||||
@override
|
||||
String get rewardRetry => '重新加载';
|
||||
|
||||
@override
|
||||
String rewardCheckInSuccess(int coins) {
|
||||
return '签到成功,获得 $coins 金币';
|
||||
}
|
||||
|
||||
@override
|
||||
String rewardClaimSuccess(int coins) {
|
||||
return '领取成功,获得 $coins 金币';
|
||||
}
|
||||
|
||||
@override
|
||||
String get rewardLoadFailed => '奖励数据加载失败';
|
||||
|
||||
@override
|
||||
String get rewardActionFailed => '操作失败,请稍后重试';
|
||||
|
||||
@override
|
||||
String versionLabel(String version) {
|
||||
return '版本 $version';
|
||||
|
||||
@@ -779,6 +779,47 @@
|
||||
"aboutUs": "关于我们",
|
||||
"aboutBrandName": "Your mini business",
|
||||
"aboutDescription": "专注于短剧观看与沉浸式追剧体验。",
|
||||
"rewardTab": "奖励",
|
||||
"rewardCenterTitle": "奖励中心",
|
||||
"rewardTasksTab": "任务",
|
||||
"rewardActivitiesTab": "活动",
|
||||
"rewardMyCoins": "我的金币",
|
||||
"rewardCheckInTitle": "签到领奖励",
|
||||
"rewardCheckInSubtitle": "每日签到,领取奖励币",
|
||||
"rewardBoostCheckIn": "提升签到奖励",
|
||||
"rewardCheckInAction": "立即签到",
|
||||
"rewardCheckInActionWithCoins": "签到 +{coins} 奖励币",
|
||||
"@rewardCheckInActionWithCoins": {
|
||||
"placeholders": {"coins": {"type": "int"}}
|
||||
},
|
||||
"rewardCheckedIn": "今日已签到",
|
||||
"rewardToday": "今天",
|
||||
"rewardDayLabel": "第 {day} 天",
|
||||
"@rewardDayLabel": {
|
||||
"placeholders": {"day": {"type": "int"}}
|
||||
},
|
||||
"rewardClaimAction": "领取",
|
||||
"rewardClaimed": "已领取",
|
||||
"rewardGoAction": "去完成",
|
||||
"rewardDailySectionSubtitle": "每日完成任务,领取奖励币",
|
||||
"rewardNewbieSectionSubtitle": "仅有一次机会",
|
||||
"rewardInviteSectionSubtitle": "邀请好友一起获得奖励",
|
||||
"rewardHelpTitle": "奖励中心说明",
|
||||
"rewardHelpBody": "完成签到和任务可获得奖励币,具体奖励与任务规则以页面展示为准。",
|
||||
"rewardComingSoon": "功能即将开放",
|
||||
"rewardEmptyTasks": "暂无奖励任务",
|
||||
"rewardEmptyActivities": "暂无活动",
|
||||
"rewardRetry": "重新加载",
|
||||
"rewardCheckInSuccess": "签到成功,获得 {coins} 金币",
|
||||
"@rewardCheckInSuccess": {
|
||||
"placeholders": {"coins": {"type": "int"}}
|
||||
},
|
||||
"rewardClaimSuccess": "领取成功,获得 {coins} 金币",
|
||||
"@rewardClaimSuccess": {
|
||||
"placeholders": {"coins": {"type": "int"}}
|
||||
},
|
||||
"rewardLoadFailed": "奖励数据加载失败",
|
||||
"rewardActionFailed": "操作失败,请稍后重试",
|
||||
"versionLabel": "版本 {version}",
|
||||
"@versionLabel": {
|
||||
"placeholders": {
|
||||
|
||||
@@ -5,7 +5,7 @@ import 'package:flutter/foundation.dart';
|
||||
import '../../core/data/models/drama.dart';
|
||||
|
||||
/// 推荐页与根导航之间的动作。
|
||||
enum DiscoverPageAction { home, recommend, following, mine }
|
||||
enum DiscoverPageAction { home, recommend, reward, following, mine }
|
||||
|
||||
/// 剧场页导航回调;没有 `onAction` 时按独立页面处理。
|
||||
class DiscoverPageCallbacks {
|
||||
|
||||
@@ -472,6 +472,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
DiscoverPageAction _actionOf(AppRootTab tab) => switch (tab) {
|
||||
AppRootTab.home => DiscoverPageAction.home,
|
||||
AppRootTab.recommend => DiscoverPageAction.recommend,
|
||||
AppRootTab.reward => DiscoverPageAction.reward,
|
||||
AppRootTab.following => DiscoverPageAction.following,
|
||||
AppRootTab.mine => DiscoverPageAction.mine,
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@ const double _columnGap = 8;
|
||||
const double _cardGap = 14;
|
||||
const double _gridHorizontalPadding = 12;
|
||||
const double _coverAspectRatio = 3 / 4;
|
||||
const double _coverRadius = 10;
|
||||
const double _coverRadius = AppDimens.coverRadius;
|
||||
const double _titleGap = 8;
|
||||
const double _tagsGap = 6;
|
||||
const double _titleFontSize = 15;
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
|
||||
import '../../../core/data/models/drama.dart';
|
||||
import '../../../core/formatting/localized_content.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/widgets/remote_image.dart';
|
||||
import '../../../core/widgets/subpage_header.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
@@ -13,7 +14,7 @@ const double _horizontal = 14;
|
||||
const double _topGap = 8;
|
||||
const double _posterWidth = 96;
|
||||
const double _posterHeight = 128;
|
||||
const double _posterRadius = 12;
|
||||
const double _posterRadius = AppDimens.coverRadius;
|
||||
const double _infoGap = 14;
|
||||
const double _favoriteHeight = 34;
|
||||
const double _favoriteRadius = 17;
|
||||
|
||||
@@ -28,7 +28,7 @@ const double _cardRadius = AppDimens.cardRadius;
|
||||
const double _cardPadding = 10;
|
||||
const double _coverWidth = 80;
|
||||
const double _coverHeight = 106;
|
||||
const double _coverRadius = 10;
|
||||
const double _coverRadius = AppDimens.coverRadius;
|
||||
const double _removeSize = 28;
|
||||
const double _removeIconSize = 20;
|
||||
const double _statsFontSize = 12;
|
||||
|
||||
@@ -7,11 +7,12 @@ import 'package:flutter/material.dart';
|
||||
import '../../../core/data/models/drama.dart';
|
||||
import '../../../core/formatting/count_format.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/widgets/remote_image.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import '../../player/open_player.dart';
|
||||
|
||||
const double _coverRadius = 10;
|
||||
const double _coverRadius = AppDimens.coverRadius;
|
||||
const double _titleGap = 6;
|
||||
const double _metaGap = 2;
|
||||
const double _badgeHorizontal = 5;
|
||||
|
||||
@@ -58,9 +58,7 @@ class FollowingGridCard extends StatelessWidget {
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppDimens.homeCardRadius,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(AppDimens.coverRadius),
|
||||
child: RemoteImage(
|
||||
posterUrl,
|
||||
fit: BoxFit.cover,
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_dimens.dart';
|
||||
import '../../core/widgets/app_press_scale.dart';
|
||||
import '../../core/widgets/cover_play_count.dart';
|
||||
import '../../core/widgets/remote_image.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
@@ -45,15 +46,17 @@ class FollowingHistoryCard extends StatelessWidget {
|
||||
AppDimens.followingHistoryCoverRadius,
|
||||
),
|
||||
onTap: onTap,
|
||||
child: SizedBox(
|
||||
height: AppDimens.followingHistoryCoverHeight,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
_cover(),
|
||||
const SizedBox(width: AppDimens.followingHistoryContentGap),
|
||||
Expanded(child: _details(l10n)),
|
||||
],
|
||||
child: AppPressScale(
|
||||
child: SizedBox(
|
||||
height: AppDimens.followingHistoryCoverHeight,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
_cover(),
|
||||
const SizedBox(width: AppDimens.followingHistoryContentGap),
|
||||
Expanded(child: _details(l10n)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ import '../../core/formatting/error_text.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_dimens.dart';
|
||||
import '../../core/widgets/empty_state.dart';
|
||||
import '../../core/widgets/app_press_scale.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../discover/search_page.dart';
|
||||
import '../player/open_player.dart';
|
||||
@@ -274,9 +275,9 @@ class _FollowingPageState extends State<FollowingPage> {
|
||||
key: const Key('following-search'),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: () {
|
||||
Navigator.of(context).push<void>(
|
||||
MaterialPageRoute(builder: (_) => const SearchPage()),
|
||||
);
|
||||
Navigator.of(
|
||||
context,
|
||||
).push<void>(MaterialPageRoute(builder: (_) => const SearchPage()));
|
||||
},
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(4),
|
||||
@@ -293,19 +294,21 @@ class _FollowingPageState extends State<FollowingPage> {
|
||||
return ValueListenableBuilder<bool>(
|
||||
valueListenable: _editing,
|
||||
builder: (context, editing, _) {
|
||||
return InkWell(
|
||||
key: const Key('following-edit'),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: _toggleEditing,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 8),
|
||||
child: Text(
|
||||
editing ? l10n.done : l10n.manage,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: AppDimens.followingManageFont,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.0,
|
||||
return AppPressScale(
|
||||
child: InkWell(
|
||||
key: const Key('following-edit'),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: _toggleEditing,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 8),
|
||||
child: Text(
|
||||
editing ? l10n.done : l10n.manage,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: AppDimens.followingManageFont,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -320,40 +323,40 @@ class _FollowingPageState extends State<FollowingPage> {
|
||||
String keyName,
|
||||
bool selected,
|
||||
) {
|
||||
return InkWell(
|
||||
key: Key('following-tab-$keyName'),
|
||||
onTap: () => _selectTab(tab),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
AnimatedDefaultTextStyle(
|
||||
duration: AppDimens.followingTabAnimationDuration,
|
||||
style: TextStyle(
|
||||
color: selected
|
||||
? AppColors.textPrimary
|
||||
: AppColors.textTabInactive,
|
||||
fontSize: AppDimens.followingSegmentFont,
|
||||
fontWeight: FontWeight.w400,
|
||||
return AppPressScale(
|
||||
child: InkWell(
|
||||
key: Key('following-tab-$keyName'),
|
||||
onTap: () => _selectTab(tab),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
AnimatedDefaultTextStyle(
|
||||
duration: AppDimens.followingTabAnimationDuration,
|
||||
style: TextStyle(
|
||||
color: selected
|
||||
? AppColors.textPrimary
|
||||
: AppColors.textTabInactive,
|
||||
fontSize: AppDimens.followingSegmentFont,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
child: label,
|
||||
),
|
||||
child: label,
|
||||
),
|
||||
const SizedBox(height: AppDimens.followingSegmentIndicatorTopGap),
|
||||
AnimatedContainer(
|
||||
duration: AppDimens.followingTabAnimationDuration,
|
||||
curve: Curves.easeOut,
|
||||
width: selected
|
||||
? AppDimens.followingSegmentIndicatorWidth
|
||||
: 0,
|
||||
height: AppDimens.followingSegmentIndicatorHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.brandRed,
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppDimens.followingSegmentIndicatorRadius,
|
||||
const SizedBox(height: AppDimens.followingSegmentIndicatorTopGap),
|
||||
AnimatedContainer(
|
||||
duration: AppDimens.followingTabAnimationDuration,
|
||||
curve: Curves.easeOut,
|
||||
width: selected ? AppDimens.followingSegmentIndicatorWidth : 0,
|
||||
height: AppDimens.followingSegmentIndicatorHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.brandRed,
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppDimens.followingSegmentIndicatorRadius,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ class HomeLogic extends ChangeNotifier {
|
||||
String? episodeId,
|
||||
}) => playDramaFromCatalog(context, drama, episodeId: episodeId);
|
||||
|
||||
void openProfile() => select(3);
|
||||
void openProfile() => select(4);
|
||||
|
||||
void discoverAction(DiscoverPageAction action) {
|
||||
switch (action) {
|
||||
@@ -83,10 +83,12 @@ class HomeLogic extends ChangeNotifier {
|
||||
select(0);
|
||||
case DiscoverPageAction.recommend:
|
||||
select(1);
|
||||
case DiscoverPageAction.following:
|
||||
case DiscoverPageAction.reward:
|
||||
select(2);
|
||||
case DiscoverPageAction.mine:
|
||||
case DiscoverPageAction.following:
|
||||
select(3);
|
||||
case DiscoverPageAction.mine:
|
||||
select(4);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +105,7 @@ class HomeLogic extends ChangeNotifier {
|
||||
}
|
||||
|
||||
void openFollowingHistory() {
|
||||
tab = 2;
|
||||
tab = 3;
|
||||
followingHistoryRequest += 1;
|
||||
notify();
|
||||
}
|
||||
@@ -114,10 +116,12 @@ class HomeLogic extends ChangeNotifier {
|
||||
select(0);
|
||||
case MyPageAction.recommend:
|
||||
select(1);
|
||||
case MyPageAction.following:
|
||||
case MyPageAction.reward:
|
||||
select(2);
|
||||
case MyPageAction.mine:
|
||||
case MyPageAction.following:
|
||||
select(3);
|
||||
case MyPageAction.mine:
|
||||
select(4);
|
||||
case MyPageAction.resume:
|
||||
select(0, resume: true);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:provider/provider.dart';
|
||||
|
||||
import '../../core/data/remote/catalog_api.dart';
|
||||
import '../../core/data/services/home_feed_controller.dart';
|
||||
import '../../core/data/models/reward.dart';
|
||||
import '../../core/data/services/catalog_controller.dart';
|
||||
import '../../core/data/services/category_controller.dart';
|
||||
import '../../core/data/services/favorite_controller.dart';
|
||||
@@ -21,6 +22,7 @@ import '../following/following_page.dart';
|
||||
import '../mine/mine_types.dart';
|
||||
import '../recommend/recommend_page.dart';
|
||||
import '../recommend/recommend_discover_page.dart';
|
||||
import '../reward/reward_page.dart';
|
||||
import '../mine/mine_view.dart';
|
||||
import '../wallet/wallet_page.dart';
|
||||
import 'home_binding.dart';
|
||||
@@ -105,7 +107,7 @@ class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
|
||||
}
|
||||
}
|
||||
|
||||
/// 四个根页面共享同一路由并保持各自状态。
|
||||
/// 五个根页面共享同一路由并保持各自状态。
|
||||
class _HomeTabs extends StatefulWidget {
|
||||
const _HomeTabs({super.key, required this.feedController});
|
||||
|
||||
@@ -164,11 +166,12 @@ class _HomeTabsState extends State<_HomeTabs> {
|
||||
final builders = <WidgetBuilder>[
|
||||
_recommend,
|
||||
_recommendDiscover,
|
||||
_reward,
|
||||
(context) => FollowingPage(
|
||||
onGoHome: () => logic.select(0),
|
||||
favoriteController: context.read<FavoriteController>(),
|
||||
historyRequest: logic.followingHistoryRequest,
|
||||
visible: logic.tab == 2,
|
||||
visible: logic.tab == 3,
|
||||
),
|
||||
_my,
|
||||
];
|
||||
@@ -218,7 +221,7 @@ class _HomeTabsState extends State<_HomeTabs> {
|
||||
|
||||
Widget _my(BuildContext context) {
|
||||
return MyPage(
|
||||
active: logic.tab == 3,
|
||||
active: logic.tab == 4,
|
||||
callbacks: MyPageCallbacks(
|
||||
onAction: logic.myAction,
|
||||
onOpenHistoryTab: logic.openFollowingHistory,
|
||||
@@ -226,6 +229,26 @@ class _HomeTabsState extends State<_HomeTabs> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _reward(BuildContext context) {
|
||||
return RewardPage(
|
||||
active: logic.tab == 2,
|
||||
onBack: () => logic.select(0),
|
||||
onOpenWallet: () => unawaited(_openWallet(context)),
|
||||
onTaskAction: (task) => _handleRewardAction(context, task),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleRewardAction(BuildContext context, RewardTask task) {
|
||||
switch (task.actionType) {
|
||||
case 'OPEN_PROFILE':
|
||||
logic.select(4);
|
||||
case 'OPEN_WALLET':
|
||||
unawaited(_openWallet(context));
|
||||
default:
|
||||
logic.select(0);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _nav(BuildContext context) {
|
||||
return AppRootBottomNav(
|
||||
barKey: const Key('home-tabs-bar'),
|
||||
@@ -243,9 +266,9 @@ class _HomeTabsState extends State<_HomeTabs> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 四个根 tab 的容器:只有被访问过的 tab 才真正构建并常驻。
|
||||
/// 五个根 tab 的容器:只有被访问过的 tab 才真正构建并常驻。
|
||||
///
|
||||
/// 冷启动时只建推荐页,其余 tab 等第一次切过去再建,省掉三棵子树和它们各自的请求。
|
||||
/// 冷启动时只建推荐页,其余 tab 等第一次切过去再建,减少首屏子树和请求。
|
||||
class _LazyTabStack extends StatefulWidget {
|
||||
const _LazyTabStack({required this.index, required this.builders});
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// 「我的」页与根导航之间的返回动作。
|
||||
enum MyPageAction { home, recommend, following, mine, resume }
|
||||
enum MyPageAction { home, recommend, reward, following, mine, resume }
|
||||
|
||||
/// 「我的」页可进入的功能目的地。
|
||||
enum MineDestination {
|
||||
|
||||
@@ -333,6 +333,7 @@ class _MyPageState extends State<MyPage> {
|
||||
MyPageAction _actionOf(AppRootTab tab) => switch (tab) {
|
||||
AppRootTab.home => MyPageAction.home,
|
||||
AppRootTab.recommend => MyPageAction.recommend,
|
||||
AppRootTab.reward => MyPageAction.reward,
|
||||
AppRootTab.following => MyPageAction.following,
|
||||
AppRootTab.mine => MyPageAction.mine,
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/widgets/app_press_scale.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import '../mine_types.dart';
|
||||
|
||||
@@ -31,7 +32,7 @@ class MineActionGrid extends StatelessWidget {
|
||||
key: const Key('mine-menu'),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.mineGridSurface,
|
||||
border: Border.all(color: AppColors.contentBorder),
|
||||
border: Border.all(color: AppColors.glassHairline),
|
||||
borderRadius: BorderRadius.circular(AppDimens.mineGridRadius),
|
||||
),
|
||||
child: ClipRRect(
|
||||
@@ -176,16 +177,18 @@ class _MineActionCell extends StatelessWidget {
|
||||
child: InkWell(
|
||||
key: item.key,
|
||||
onTap: item.onTap,
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: item.label,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_iconSurface(),
|
||||
const SizedBox(height: AppDimens.spaceSm),
|
||||
_label(),
|
||||
],
|
||||
child: AppPressScale(
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: item.label,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_iconSurface(),
|
||||
const SizedBox(height: AppDimens.spaceSm),
|
||||
_label(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -208,7 +211,7 @@ class _MineActionCell extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: AppColors.glassBorder),
|
||||
border: Border.all(color: AppColors.glassHairline),
|
||||
boxShadow: [
|
||||
const BoxShadow(
|
||||
color: AppColors.shadowSoft,
|
||||
|
||||
@@ -123,7 +123,7 @@ class MineContinueWatching extends StatelessWidget {
|
||||
Positioned.fill(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppDimens.homeCardRadius,
|
||||
AppDimens.coverRadius,
|
||||
),
|
||||
child: RemoteImage(
|
||||
record.dramaCover ?? '',
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/widgets/app_press_scale.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
|
||||
const double _vipMaxTextScale = 1.15;
|
||||
@@ -28,16 +29,18 @@ class MineVipBanner extends StatelessWidget {
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onOpen,
|
||||
child: MediaQuery.withClampedTextScaling(
|
||||
maxScaleFactor: _vipMaxTextScale,
|
||||
child: Stack(
|
||||
children: [
|
||||
const Positioned.fill(child: _VipAmbientGlow()),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(AppDimens.mineVipPadding),
|
||||
child: _VipContent(l10n: l10n, onOpen: onOpen),
|
||||
),
|
||||
],
|
||||
child: AppPressScale(
|
||||
child: MediaQuery.withClampedTextScaling(
|
||||
maxScaleFactor: _vipMaxTextScale,
|
||||
child: Stack(
|
||||
children: [
|
||||
const Positioned.fill(child: _VipAmbientGlow()),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(AppDimens.mineVipPadding),
|
||||
child: _VipContent(l10n: l10n, onOpen: onOpen),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -10,6 +10,7 @@ import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/services/catalog_controller.dart';
|
||||
import '../../core/formatting/count_format.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_dimens.dart';
|
||||
import '../../core/widgets/app_pull_to_refresh.dart';
|
||||
import '../../core/widgets/paged_local_list.dart';
|
||||
import '../../core/widgets/remote_image.dart';
|
||||
@@ -437,7 +438,7 @@ class _NewReleasesPageState extends State<NewReleasesPage> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderRadius: BorderRadius.circular(AppDimens.coverRadius),
|
||||
child: SizedBox(
|
||||
width: 108,
|
||||
height: 144,
|
||||
|
||||
@@ -26,7 +26,7 @@ const double _sheetRadius = 20;
|
||||
const double _pageHorizontal = 20;
|
||||
const double _tabGap = 20;
|
||||
const double _posterWidth = 86;
|
||||
const double _posterRadius = 8;
|
||||
const double _posterRadius = AppDimens.coverRadius;
|
||||
const double _relatedWidth = 104;
|
||||
const double _relatedGap = 10;
|
||||
const int _synopsisLines = 3;
|
||||
@@ -457,7 +457,7 @@ class _DramaInfoSheetState extends State<DramaInfoSheet> {
|
||||
widget.onOpenRelated?.call(drama);
|
||||
},
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderRadius: BorderRadius.circular(AppDimens.coverRadius),
|
||||
child: SizedBox(
|
||||
width: _relatedWidth,
|
||||
child: AspectRatio(
|
||||
|
||||
@@ -8,6 +8,7 @@ import '../../../core/data/models/drama.dart';
|
||||
import '../../../core/data/models/locale_text.dart';
|
||||
import '../../../core/formatting/localized_content.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/widgets/app_press_scale.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import 'expandable_synopsis.dart';
|
||||
import 'interaction_rail.dart';
|
||||
@@ -220,7 +221,7 @@ class HomeFeedWatchAllBar extends StatelessWidget {
|
||||
child: InkWell(
|
||||
key: const Key('home-feed-full-series'),
|
||||
onTap: onTap,
|
||||
child: _content(l10n),
|
||||
child: AppPressScale(child: _content(l10n)),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -7,8 +7,12 @@ import '../../../core/theme/app_colors.dart';
|
||||
const double _toggleSize = 68;
|
||||
const double _iconSize = 36;
|
||||
|
||||
/// 播放/暂停压在画面上,垫一层半透明深色圆底保证亮画面下也能看见。
|
||||
const Color _toggleBackground = Color(0x66000000);
|
||||
/// 播放/暂停压在画面上,垫一层半透明玻璃圆面保证亮画面下也能看见。
|
||||
const RadialGradient _toggleGradient = RadialGradient(
|
||||
center: Alignment(-0.35, -0.4),
|
||||
radius: 0.9,
|
||||
colors: [Color(0x80FFFFFF), Color(0x52000000), Color(0x70000000)],
|
||||
);
|
||||
const List<Shadow> _iconShadows = [
|
||||
Shadow(color: AppColors.shadow, blurRadius: 12),
|
||||
];
|
||||
@@ -31,18 +35,23 @@ class PlayerCenterToggle extends StatelessWidget {
|
||||
label: playing ? 'Pause' : 'Play',
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
child: SizedBox(
|
||||
width: _toggleSize,
|
||||
height: _toggleSize,
|
||||
decoration: const BoxDecoration(
|
||||
color: _toggleBackground,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
playing ? Icons.pause_rounded : Icons.play_arrow_rounded,
|
||||
color: AppColors.onPrimary,
|
||||
size: _iconSize,
|
||||
shadows: _iconShadows,
|
||||
child: DecoratedBox(
|
||||
key: const Key('player-center-toggle-surface'),
|
||||
decoration: const BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: _toggleGradient,
|
||||
),
|
||||
child: Center(
|
||||
child: Icon(
|
||||
playing ? Icons.pause_rounded : Icons.play_arrow_rounded,
|
||||
color: AppColors.onPrimary,
|
||||
size: _iconSize,
|
||||
shadows: _iconShadows,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -10,6 +10,7 @@ import '../../../core/data/services/catalog_controller.dart';
|
||||
import '../../../core/formatting/localized_content.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/widgets/app_press_scale.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import 'expandable_synopsis.dart';
|
||||
import 'player_center_toggle.dart';
|
||||
@@ -389,44 +390,46 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
||||
// 选集条给一层浅底色:视频画面亮时纯文字不好认,也顺带标出可点区域。
|
||||
child: Material(
|
||||
key: const Key('player-episodes-pill'),
|
||||
color: AppColors.textPrimary.withValues(alpha: 0.14),
|
||||
color: AppColors.glassSurface,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
key: const Key('player-episodes'),
|
||||
onTap: actions.onEpisodes,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: _bottomActionInset,
|
||||
),
|
||||
child: SizedBox(
|
||||
height: _bottomActionHeight,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${data.l10n.episodes} · '
|
||||
'${data.l10n.allEpisodesBadge(data.totalEpisodes)} · '
|
||||
'${data.l10n.freeWatch}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onPrimary,
|
||||
fontSize: 13.5,
|
||||
fontWeight: FontWeight.w400,
|
||||
shadows: _playerTextShadows,
|
||||
child: AppPressScale(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: _bottomActionInset,
|
||||
),
|
||||
child: SizedBox(
|
||||
height: _bottomActionHeight,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${data.l10n.episodes} · '
|
||||
'${data.l10n.allEpisodesBadge(data.totalEpisodes)} · '
|
||||
'${data.l10n.freeWatch}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onPrimary,
|
||||
fontSize: 13.5,
|
||||
fontWeight: FontWeight.w400,
|
||||
shadows: _playerTextShadows,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Icon(
|
||||
Icons.keyboard_arrow_up_rounded,
|
||||
color: AppColors.onPrimary,
|
||||
size: 18,
|
||||
shadows: _playerTextShadows,
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 8),
|
||||
const Icon(
|
||||
Icons.keyboard_arrow_up_rounded,
|
||||
color: AppColors.onPrimary,
|
||||
size: 18,
|
||||
shadows: _playerTextShadows,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -414,6 +414,10 @@ class _ChannelFeedState extends State<_ChannelFeed>
|
||||
return false;
|
||||
},
|
||||
child: AppPullToRefresh(
|
||||
edgeOffset:
|
||||
widget.mediaTop +
|
||||
RecommendChannelNav.height -
|
||||
AppPullToRefresh.indicatorRevealOffset,
|
||||
onRefresh: widget.onRefresh,
|
||||
child: CustomScrollView(
|
||||
key: PageStorageKey<String>('recommend-feed-${widget.categoryId}'),
|
||||
|
||||
@@ -14,6 +14,7 @@ import '../../core/data/services/home_feed_controller.dart';
|
||||
import '../../core/data/services/playlist_controller.dart';
|
||||
import '../../core/data/services/wallet_controller.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_dimens.dart';
|
||||
import '../../core/widgets/empty_state.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../home/home_feed_channel.dart';
|
||||
|
||||
@@ -4,7 +4,7 @@ part of 'recommend_page.dart';
|
||||
|
||||
const double _posterWidth = 132;
|
||||
const double _posterHeight = 176;
|
||||
const double _posterRadius = 12;
|
||||
const double _posterRadius = AppDimens.coverRadius;
|
||||
const double _copyLeft = 14;
|
||||
const double _copyRight = 72;
|
||||
const double _copyBottom = 42;
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/data/models/drama.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/widgets/remote_image.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
|
||||
@@ -13,7 +14,7 @@ const double _pageSide = 14;
|
||||
const double _cardWidth = 110;
|
||||
const double _cardCoverHeight = 157;
|
||||
const double _cardGap = 9;
|
||||
const double _cardRadius = 12;
|
||||
const double _cardRadius = AppDimens.coverRadius;
|
||||
|
||||
/// 海报卡封面以下的高度:上间距 8 + 两行剧名 + 2 + 集数行 + 余量。
|
||||
///
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
|
||||
import '../../../core/data/models/drama.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/widgets/app_press_scale.dart';
|
||||
import '../../../core/widgets/cover_play_count.dart';
|
||||
import '../../../core/widgets/drama_cover_badge_ribbon.dart';
|
||||
import '../../../core/widgets/remote_image.dart';
|
||||
@@ -28,75 +29,77 @@ class RecommendSectionCard extends StatelessWidget {
|
||||
final tagLabel = drama.localizedTag(
|
||||
Localizations.localeOf(context).languageCode,
|
||||
);
|
||||
return SizedBox(
|
||||
width: AppDimens.recommendCardWidth,
|
||||
child: InkWell(
|
||||
key: Key('recommend-card-$sectionKey-${drama.id}'),
|
||||
borderRadius: BorderRadius.circular(AppDimens.cardCoverRadius),
|
||||
onTap: onTap,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(AppDimens.cardCoverRadius),
|
||||
child: AspectRatio(
|
||||
aspectRatio: AppDimens.cardCoverAspectRatio,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
RemoteImage(
|
||||
drama.posterUrl,
|
||||
fit: BoxFit.cover,
|
||||
fallbackColor: AppColors.contentSurface,
|
||||
),
|
||||
if (tagLabel.isNotEmpty)
|
||||
Positioned(
|
||||
top: 0,
|
||||
return AppPressScale(
|
||||
child: SizedBox(
|
||||
width: AppDimens.recommendCardWidth,
|
||||
child: InkWell(
|
||||
key: Key('recommend-card-$sectionKey-${drama.id}'),
|
||||
borderRadius: BorderRadius.circular(AppDimens.cardCoverRadius),
|
||||
onTap: onTap,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(AppDimens.cardCoverRadius),
|
||||
child: AspectRatio(
|
||||
aspectRatio: AppDimens.cardCoverAspectRatio,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
RemoteImage(
|
||||
drama.posterUrl,
|
||||
fit: BoxFit.cover,
|
||||
fallbackColor: AppColors.contentSurface,
|
||||
),
|
||||
if (tagLabel.isNotEmpty)
|
||||
Positioned(
|
||||
top: 0,
|
||||
right: 0,
|
||||
child: DramaCoverBadgeRibbon(
|
||||
label: tagLabel,
|
||||
color: drama.tagRibbonColor(AppColors.badgeHot),
|
||||
keyPrefix: '$sectionKey-${drama.id}-tag',
|
||||
),
|
||||
),
|
||||
const Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: DramaCoverBadgeRibbon(
|
||||
label: tagLabel,
|
||||
color: drama.tagRibbonColor(AppColors.badgeHot),
|
||||
keyPrefix: '$sectionKey-${drama.id}-tag',
|
||||
bottom: 0,
|
||||
child: CoverBottomScrim(),
|
||||
),
|
||||
Positioned(
|
||||
right: AppDimens.cardPlayCountRight,
|
||||
bottom: AppDimens.cardPlayCountBottom,
|
||||
child: CoverPlayCount(
|
||||
key: Key('recommend-views-$sectionKey-${drama.id}'),
|
||||
count: drama.viewCount,
|
||||
),
|
||||
),
|
||||
const Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: CoverBottomScrim(),
|
||||
),
|
||||
Positioned(
|
||||
right: AppDimens.cardPlayCountRight,
|
||||
bottom: AppDimens.cardPlayCountBottom,
|
||||
child: CoverPlayCount(
|
||||
key: Key('recommend-views-$sectionKey-${drama.id}'),
|
||||
count: drama.viewCount,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimens.cardCoverTitleGap),
|
||||
Text(
|
||||
drama.localizedTitle(
|
||||
Localizations.localeOf(context).languageCode,
|
||||
const SizedBox(height: AppDimens.cardCoverTitleGap),
|
||||
Text(
|
||||
drama.localizedTitle(
|
||||
Localizations.localeOf(context).languageCode,
|
||||
),
|
||||
key: Key('recommend-title-$sectionKey-${drama.id}'),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: AppDimens.cardTitleFont,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: AppDimens.cardTitleLineHeight,
|
||||
),
|
||||
),
|
||||
key: Key('recommend-title-$sectionKey-${drama.id}'),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: AppDimens.cardTitleFont,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: AppDimens.cardTitleLineHeight,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimens.cardTitleTagGap),
|
||||
_tags(context),
|
||||
],
|
||||
const SizedBox(height: AppDimens.cardTitleTagGap),
|
||||
_tags(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/data/models/reward.dart';
|
||||
import '../../core/data/services/reward_controller.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_dimens.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import 'reward_visuals.dart';
|
||||
|
||||
const double _cardRadius = 14;
|
||||
const double _cardBorderWidth = 1;
|
||||
const double _titleFont = 20;
|
||||
const double _boostFont = 12;
|
||||
const double _subtitleFont = 13;
|
||||
const double _dayHeight = 80;
|
||||
const double _dayRadius = 9;
|
||||
const double _dayGap = 5;
|
||||
const double _dayRewardFont = 13;
|
||||
const double _dayLabelFont = 11;
|
||||
const double _dayCoinSize = 24;
|
||||
const double _buttonHeight = 48;
|
||||
const double _buttonFont = 16;
|
||||
const double _loadingSize = 20;
|
||||
const double _loadingStroke = 2;
|
||||
const Color _cardStart = Color(0xFF1A1A1C);
|
||||
const Color _cardEnd = Color(0xFF101010);
|
||||
const Color _cardBorder = Color(0xFF3A3A3C);
|
||||
const Color _daySurface = Color(0xFF202022);
|
||||
const Color _dayBorder = Color(0xFF363638);
|
||||
const Color _currentSurfaceStart = Color(0xFF3E0813);
|
||||
const Color _currentSurfaceEnd = Color(0xFF24070D);
|
||||
const Color _currentBorder = Color(0xFFFF4860);
|
||||
const Color _buttonEnd = Color(0xFFFF565B);
|
||||
|
||||
/// 奖励中心签到卡,展示七日规则与签到操作。
|
||||
class RewardCheckinCard extends StatelessWidget {
|
||||
const RewardCheckinCard({
|
||||
super.key,
|
||||
required this.l10n,
|
||||
required this.rewards,
|
||||
required this.checkin,
|
||||
required this.onCheckIn,
|
||||
required this.onBoost,
|
||||
});
|
||||
|
||||
final AppLocalizations l10n;
|
||||
final RewardController rewards;
|
||||
final RewardCheckinState checkin;
|
||||
final Future<void> Function() onCheckIn;
|
||||
final VoidCallback onBoost;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
key: const Key('reward-checkin-card'),
|
||||
padding: const EdgeInsets.all(AppDimens.cardPadding),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(colors: [_cardStart, _cardEnd]),
|
||||
borderRadius: BorderRadius.circular(_cardRadius),
|
||||
border: Border.all(color: _cardBorder, width: _cardBorderWidth),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_header(),
|
||||
const SizedBox(height: AppDimens.spaceXs),
|
||||
Text(
|
||||
l10n.rewardCheckInSubtitle,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: _subtitleFont,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimens.spaceLg),
|
||||
_days(),
|
||||
const SizedBox(height: AppDimens.spaceLg),
|
||||
_action(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _header() {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.rewardCheckInTitle,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: _titleFont,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
InkWell(
|
||||
onTap: onBoost,
|
||||
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: AppDimens.spaceSm),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
l10n.rewardBoostCheckIn,
|
||||
style: const TextStyle(
|
||||
color: AppColors.brandRed,
|
||||
fontSize: _boostFont,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
color: AppColors.brandRed,
|
||||
size: 18,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _days() {
|
||||
if (checkin.days.isEmpty) return const SizedBox.shrink();
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (var index = 0; index < checkin.days.length; index++) ...[
|
||||
Expanded(
|
||||
child: _RewardDay(l10n: l10n, day: checkin.days[index]),
|
||||
),
|
||||
if (index < checkin.days.length - 1) const SizedBox(width: _dayGap),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _action() {
|
||||
final disabled = checkin.checkedInToday || rewards.checkingIn;
|
||||
return Container(
|
||||
key: const Key('reward-checkin-action'),
|
||||
height: _buttonHeight,
|
||||
decoration: BoxDecoration(
|
||||
gradient: disabled
|
||||
? const LinearGradient(
|
||||
colors: [AppColors.contentChip, AppColors.contentChip],
|
||||
)
|
||||
: const LinearGradient(colors: [AppColors.brandRed, _buttonEnd]),
|
||||
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
|
||||
),
|
||||
child: Material(
|
||||
color: AppColors.transparent,
|
||||
child: InkWell(
|
||||
onTap: disabled ? null : () => unawaited(onCheckIn()),
|
||||
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
|
||||
child: Center(child: _actionContent()),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _actionContent() {
|
||||
if (rewards.checkingIn) {
|
||||
return const SizedBox.square(
|
||||
dimension: _loadingSize,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: _loadingStroke,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
);
|
||||
}
|
||||
final label = checkin.checkedInToday
|
||||
? l10n.rewardCheckedIn
|
||||
: l10n.rewardCheckInActionWithCoins(_currentReward());
|
||||
return Row(
|
||||
key: const Key('reward-checkin-action-content'),
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (!checkin.checkedInToday) ...[
|
||||
const RewardAdIcon(),
|
||||
const SizedBox(width: AppDimens.spaceMd),
|
||||
],
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 220),
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: _buttonFont,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
int _currentReward() {
|
||||
for (final day in checkin.days) {
|
||||
if (day.current) return day.rewardCoin;
|
||||
}
|
||||
return checkin.days.isEmpty ? 0 : checkin.days.first.rewardCoin;
|
||||
}
|
||||
}
|
||||
|
||||
class _RewardDay extends StatelessWidget {
|
||||
const _RewardDay({required this.l10n, required this.day});
|
||||
|
||||
final AppLocalizations l10n;
|
||||
final RewardCheckinDay day;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: _dayHeight,
|
||||
decoration: BoxDecoration(
|
||||
gradient: day.current
|
||||
? const LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [_currentSurfaceStart, _currentSurfaceEnd],
|
||||
)
|
||||
: null,
|
||||
color: day.current ? null : _daySurface,
|
||||
borderRadius: BorderRadius.circular(_dayRadius),
|
||||
border: Border.all(color: day.current ? _currentBorder : _dayBorder),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
'+${day.rewardCoin}',
|
||||
style: TextStyle(
|
||||
color: day.current
|
||||
? const Color(0xFFFFE18C)
|
||||
: AppColors.textPrimary,
|
||||
fontSize: _dayRewardFont,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimens.spaceXs),
|
||||
const RewardCoinIcon(size: _dayCoinSize),
|
||||
const SizedBox(height: AppDimens.spaceXs),
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
day.current
|
||||
? l10n.rewardToday
|
||||
: l10n.rewardDayLabel(day.dayIndex),
|
||||
style: TextStyle(
|
||||
color: day.checked
|
||||
? AppColors.success
|
||||
: AppColors.textSecondary,
|
||||
fontSize: _dayLabelFont,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../core/data/models/reward.dart';
|
||||
import '../../core/data/services/auth_controller.dart';
|
||||
import '../../core/data/services/reward_controller.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_dimens.dart';
|
||||
import '../../core/widgets/auth_gate.dart';
|
||||
import '../../core/widgets/app_glow_panel.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import 'reward_tasks_view.dart';
|
||||
|
||||
const double _activityHeight = 128;
|
||||
const double _headerHeight = 52;
|
||||
const double _headerIconSize = 16;
|
||||
const double _headerTouchTargetSize = 44;
|
||||
const double _headerTitleFont = 21;
|
||||
const double _tabHeight = 56;
|
||||
const double _tabFont = 18;
|
||||
const double _tabIndicatorWidth = 24;
|
||||
const double _tabIndicatorHeight = 3;
|
||||
const double _helpButtonSize = 24;
|
||||
const double _helpBorderWidth = 1;
|
||||
const double _helpFont = 14;
|
||||
const double _helpDialogMaxWidth = 320;
|
||||
const double _helpDialogRadius = 20;
|
||||
const double _helpDialogIconSize = 48;
|
||||
const double _helpDialogIconFont = 24;
|
||||
const double _helpDialogTitleFont = 18;
|
||||
const double _helpDialogBodyFont = 14;
|
||||
const double _helpDialogButtonHeight = 44;
|
||||
const int _loadDelayMilliseconds = 1;
|
||||
const String _loadKeySeparator = '|';
|
||||
const Color _helpBorder = Color(0xFFE9E9EA);
|
||||
const Color _tabInactive = Color(0xFF9B9B9F);
|
||||
|
||||
/// 奖励中心根页面,展示余额、签到、任务与活动。
|
||||
class RewardPage extends StatefulWidget {
|
||||
const RewardPage({
|
||||
super.key,
|
||||
required this.active,
|
||||
this.onBack,
|
||||
this.onHelp,
|
||||
this.onTaskAction,
|
||||
this.onOpenWallet,
|
||||
});
|
||||
|
||||
final bool active;
|
||||
final VoidCallback? onBack;
|
||||
final VoidCallback? onHelp;
|
||||
final ValueChanged<RewardTask>? onTaskAction;
|
||||
final VoidCallback? onOpenWallet;
|
||||
|
||||
@override
|
||||
State<RewardPage> createState() => _RewardPageState();
|
||||
}
|
||||
|
||||
class _RewardPageState extends State<RewardPage> {
|
||||
bool _showActivities = false;
|
||||
bool _loadScheduled = false;
|
||||
String? _autoLoadKey;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_scheduleLoad();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant RewardPage oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.active && !oldWidget.active) _scheduleLoad(force: true);
|
||||
}
|
||||
|
||||
void _scheduleLoad({bool force = false}) {
|
||||
if (!widget.active || _loadScheduled) return;
|
||||
final rewards = context.read<RewardController>();
|
||||
final auth = context.read<AuthController>();
|
||||
final locale = Localizations.localeOf(context).languageCode;
|
||||
final loadKey = '$locale$_loadKeySeparator${auth.isSignedIn}';
|
||||
if (!force && _autoLoadKey == loadKey) return;
|
||||
_autoLoadKey = loadKey;
|
||||
_loadScheduled = true;
|
||||
Future<void>.delayed(
|
||||
const Duration(milliseconds: _loadDelayMilliseconds),
|
||||
() async {
|
||||
if (!mounted) return;
|
||||
_loadScheduled = false;
|
||||
if (!widget.active) return;
|
||||
await rewards.load(locale);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final rewards = context.watch<RewardController>();
|
||||
context.watch<AuthController>();
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return DecoratedBox(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [AppColors.followingGlow, AppColors.ink, AppColors.ink],
|
||||
stops: [0, 0.3, 1],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: Column(
|
||||
children: [
|
||||
_header(l10n),
|
||||
Expanded(child: _body(l10n, rewards)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _header(AppLocalizations l10n) {
|
||||
return Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: _headerHeight,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Positioned(left: 4, child: _backButton()),
|
||||
Text(
|
||||
l10n.rewardCenterTitle,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: _headerTitleFont,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
Positioned(right: 8, child: _helpButton()),
|
||||
],
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _tab(
|
||||
key: const Key('reward-tab-tasks'),
|
||||
indicatorKey: const Key('reward-tab-indicator-tasks'),
|
||||
label: l10n.rewardTasksTab,
|
||||
selected: !_showActivities,
|
||||
showActivities: false,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _tab(
|
||||
key: const Key('reward-tab-activities'),
|
||||
indicatorKey: const Key('reward-tab-indicator-activities'),
|
||||
label: l10n.rewardActivitiesTab,
|
||||
selected: _showActivities,
|
||||
showActivities: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _backButton() {
|
||||
return IconButton(
|
||||
key: const Key('reward-back-button'),
|
||||
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
|
||||
onPressed: widget.onBack ?? () {},
|
||||
icon: const Icon(
|
||||
Icons.arrow_back_ios_new_rounded,
|
||||
color: AppColors.textPrimary,
|
||||
size: _headerIconSize,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _helpButton() {
|
||||
return IconButton(
|
||||
key: const Key('reward-help-button'),
|
||||
tooltip: AppLocalizations.of(context)!.rewardHelpTitle,
|
||||
onPressed: widget.onHelp ?? _showHelp,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints.tightFor(
|
||||
width: _headerTouchTargetSize,
|
||||
height: _headerTouchTargetSize,
|
||||
),
|
||||
icon: Container(
|
||||
key: const Key('reward-help-icon'),
|
||||
width: _helpButtonSize,
|
||||
height: _helpButtonSize,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: _helpBorder, width: _helpBorderWidth),
|
||||
),
|
||||
child: const Text(
|
||||
'?',
|
||||
style: TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: _helpFont,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _tab({
|
||||
required Key key,
|
||||
required Key indicatorKey,
|
||||
required String label,
|
||||
required bool selected,
|
||||
required bool showActivities,
|
||||
}) {
|
||||
return InkWell(
|
||||
key: key,
|
||||
onTap: () => setState(() => _showActivities = showActivities),
|
||||
child: SizedBox(
|
||||
height: _tabHeight,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: selected ? AppColors.textPrimary : _tabInactive,
|
||||
fontSize: _tabFont,
|
||||
fontWeight: selected ? FontWeight.w800 : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimens.spaceSm),
|
||||
AnimatedContainer(
|
||||
key: indicatorKey,
|
||||
duration: AppDimens.homePrimaryTabAnimationDuration,
|
||||
width: selected ? _tabIndicatorWidth : 0,
|
||||
height: _tabIndicatorHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.brandRed,
|
||||
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _body(AppLocalizations l10n, RewardController rewards) {
|
||||
if (rewards.loading && rewards.overview == null) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.brandRed),
|
||||
);
|
||||
}
|
||||
if (rewards.error != null && rewards.overview == null) {
|
||||
return _errorState(l10n, rewards);
|
||||
}
|
||||
final overview = rewards.overview;
|
||||
if (overview == null) return const SizedBox.shrink();
|
||||
return _showActivities
|
||||
? _activities(l10n, overview)
|
||||
: RewardTasksView(
|
||||
l10n: l10n,
|
||||
rewards: rewards,
|
||||
overview: overview,
|
||||
onRefresh: () =>
|
||||
rewards.load(Localizations.localeOf(context).languageCode),
|
||||
onCheckIn: () => _checkIn(l10n, rewards),
|
||||
onTaskAction: (task) => _handleTask(l10n, rewards, task),
|
||||
onOpenWallet: widget.onOpenWallet,
|
||||
onBoostCheckIn: () => _showResult(l10n.rewardComingSoon),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _activities(AppLocalizations l10n, RewardOverview overview) {
|
||||
return ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.all(AppDimens.pageHorizontal),
|
||||
children: overview.activities.isEmpty
|
||||
? [_empty(l10n.rewardEmptyActivities)]
|
||||
: [for (final activity in overview.activities) _activity(activity)],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _activity(RewardActivity activity) {
|
||||
return Container(
|
||||
height: _activityHeight,
|
||||
margin: const EdgeInsets.only(bottom: AppDimens.spaceLg),
|
||||
padding: const EdgeInsets.all(AppDimens.cardPadding),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [AppColors.vipCardStart, AppColors.contentCard],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(AppDimens.cardRadius),
|
||||
border: Border.all(color: AppColors.vipCardBorder),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
activity.title,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: AppDimens.pageTitle,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimens.spaceSm),
|
||||
Text(
|
||||
activity.summary,
|
||||
style: const TextStyle(color: AppColors.textSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _errorState(AppLocalizations l10n, RewardController rewards) {
|
||||
return Center(
|
||||
child: FilledButton(
|
||||
onPressed: () =>
|
||||
rewards.load(Localizations.localeOf(context).languageCode),
|
||||
child: Text(l10n.rewardRetry),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _empty(String label) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: AppDimens.spaceXl),
|
||||
child: Center(
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(color: AppColors.textTertiary),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _checkIn(AppLocalizations l10n, RewardController rewards) async {
|
||||
if (!await ensureSignedIn(context) || !mounted) return;
|
||||
final result = await rewards.checkIn();
|
||||
if (!mounted) return;
|
||||
_showResult(
|
||||
result == null
|
||||
? _message(l10n, rewards.actionError)
|
||||
: l10n.rewardCheckInSuccess(result.rewardCoin),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleTask(
|
||||
AppLocalizations l10n,
|
||||
RewardController rewards,
|
||||
RewardTask task,
|
||||
) async {
|
||||
if (!task.claimable) {
|
||||
widget.onTaskAction?.call(task);
|
||||
return;
|
||||
}
|
||||
if (!await ensureSignedIn(context) || !mounted) return;
|
||||
final result = await rewards.claim(task.id);
|
||||
if (!mounted) return;
|
||||
_showResult(
|
||||
result == null
|
||||
? _message(l10n, rewards.actionError)
|
||||
: l10n.rewardClaimSuccess(result.rewardCoin),
|
||||
);
|
||||
}
|
||||
|
||||
String? _message(AppLocalizations l10n, String? message) {
|
||||
return switch (message) {
|
||||
'rewardActionFailed' => l10n.rewardActionFailed,
|
||||
_ => message,
|
||||
};
|
||||
}
|
||||
|
||||
void _showResult(String? message) {
|
||||
if (message == null || message.isEmpty) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
|
||||
void _showHelp() {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) => RepaintBoundary(
|
||||
key: const Key('reward-help-dialog'),
|
||||
child: _RewardHelpDialog(
|
||||
title: l10n.rewardHelpTitle,
|
||||
body: l10n.rewardHelpBody,
|
||||
confirmLabel: MaterialLocalizations.of(dialogContext).okButtonLabel,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RewardHelpDialog extends StatelessWidget {
|
||||
const _RewardHelpDialog({
|
||||
required this.title,
|
||||
required this.body,
|
||||
required this.confirmLabel,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String body;
|
||||
final String confirmLabel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
backgroundColor: AppColors.transparent,
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: AppDimens.spaceXl),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: _helpDialogMaxWidth),
|
||||
child: AppGlowPanel(
|
||||
borderRadius: BorderRadius.circular(_helpDialogRadius),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppDimens.spaceXl,
|
||||
AppDimens.spaceXl,
|
||||
AppDimens.spaceXl,
|
||||
AppDimens.spaceLg,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const _HelpDialogIcon(),
|
||||
const SizedBox(height: AppDimens.spaceMd),
|
||||
Text(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: _helpDialogTitleFont,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimens.spaceSm),
|
||||
Text(
|
||||
body,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: _helpDialogBodyFont,
|
||||
height: 1.45,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimens.spaceXl),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: _helpDialogButtonHeight,
|
||||
child: FilledButton(
|
||||
key: const Key('reward-help-close'),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.brandRed,
|
||||
foregroundColor: AppColors.onPrimary,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppDimens.buttonRadius,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
confirmLabel,
|
||||
style: const TextStyle(
|
||||
fontSize: AppDimens.body,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HelpDialogIcon extends StatelessWidget {
|
||||
const _HelpDialogIcon();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: _helpDialogIconSize,
|
||||
height: _helpDialogIconSize,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.filterChipSelected,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: AppColors.brandRed, width: 1),
|
||||
),
|
||||
child: const Text(
|
||||
'?',
|
||||
style: TextStyle(
|
||||
color: AppColors.brandRed,
|
||||
fontSize: _helpDialogIconFont,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/data/models/reward.dart';
|
||||
import '../../core/data/services/reward_controller.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_dimens.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import 'reward_checkin_card.dart';
|
||||
import 'reward_visuals.dart';
|
||||
|
||||
const double _balanceLabelFont = 18;
|
||||
const double _balanceFont = 36;
|
||||
const double _sectionTitleFont = 20;
|
||||
const double _sectionSubtitleFont = 13;
|
||||
const double _taskRowMinHeight = 68;
|
||||
const double _taskTitleFont = 15;
|
||||
const double _taskSubtitleFont = 12;
|
||||
const double _taskRewardFont = 14;
|
||||
const double _taskButtonHeight = 36;
|
||||
const double _taskButtonMinWidth = 80;
|
||||
const double _taskButtonFont = 13;
|
||||
const double _taskCardRadius = 14;
|
||||
const double _taskCardBorderWidth = 1;
|
||||
const Color _taskCardStart = Color(0xFF1A1A1C);
|
||||
const Color _taskCardEnd = Color(0xFF101010);
|
||||
const Color _taskCardBorder = Color(0xFF29292B);
|
||||
const Color _mutedActionStart = Color(0xFF3C171C);
|
||||
const Color _divider = Color(0xFF333336);
|
||||
const String _checkInSectionCode = 'CHECK_IN';
|
||||
|
||||
/// 奖励任务内容,负责余额、签到卡与可配置任务列表。
|
||||
class RewardTasksView extends StatelessWidget {
|
||||
const RewardTasksView({
|
||||
super.key,
|
||||
required this.l10n,
|
||||
required this.rewards,
|
||||
required this.overview,
|
||||
required this.onRefresh,
|
||||
required this.onCheckIn,
|
||||
required this.onTaskAction,
|
||||
this.onOpenWallet,
|
||||
this.onBoostCheckIn,
|
||||
});
|
||||
|
||||
final AppLocalizations l10n;
|
||||
final RewardController rewards;
|
||||
final RewardOverview overview;
|
||||
final Future<void> Function() onRefresh;
|
||||
final Future<void> Function() onCheckIn;
|
||||
final Future<void> Function(RewardTask task) onTaskAction;
|
||||
final VoidCallback? onOpenWallet;
|
||||
final VoidCallback? onBoostCheckIn;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return RefreshIndicator(
|
||||
color: AppColors.brandRed,
|
||||
backgroundColor: AppColors.contentCard,
|
||||
onRefresh: onRefresh,
|
||||
child: ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppDimens.pageHorizontal,
|
||||
AppDimens.spaceLg,
|
||||
AppDimens.pageHorizontal,
|
||||
AppDimens.spaceXl,
|
||||
),
|
||||
children: [
|
||||
_Balance(
|
||||
key: const Key('reward-balance'),
|
||||
balance: overview.coinBalance,
|
||||
onOpenWallet: onOpenWallet,
|
||||
),
|
||||
const SizedBox(height: AppDimens.spaceLg),
|
||||
RewardCheckinCard(
|
||||
l10n: l10n,
|
||||
rewards: rewards,
|
||||
checkin: overview.checkin,
|
||||
onCheckIn: onCheckIn,
|
||||
onBoost: onBoostCheckIn ?? () {},
|
||||
),
|
||||
for (final section in overview.sections)
|
||||
if (_isVisibleSection(section)) ...[
|
||||
const SizedBox(height: AppDimens.spaceXl),
|
||||
_SectionHeader(
|
||||
key: Key('reward-section-${section.code}'),
|
||||
l10n: l10n,
|
||||
section: section,
|
||||
),
|
||||
const SizedBox(height: AppDimens.spaceMd),
|
||||
_TaskGroupCard(
|
||||
key: Key('reward-task-card-${section.code}'),
|
||||
l10n: l10n,
|
||||
rewards: rewards,
|
||||
section: section,
|
||||
onTaskAction: onTaskAction,
|
||||
),
|
||||
],
|
||||
if (!overview.sections.any(_isVisibleSection))
|
||||
_Empty(label: l10n.rewardEmptyTasks),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
bool _isVisibleSection(RewardSection section) {
|
||||
return section.code.toUpperCase() != _checkInSectionCode &&
|
||||
section.tasks.isNotEmpty;
|
||||
}
|
||||
|
||||
class _Balance extends StatelessWidget {
|
||||
const _Balance({super.key, required this.balance, this.onOpenWallet});
|
||||
|
||||
final int balance;
|
||||
final VoidCallback? onOpenWallet;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final content = Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Flexible(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
AppLocalizations.of(context)!.rewardMyCoins,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: _balanceLabelFont,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppDimens.spaceMd),
|
||||
Text(
|
||||
'$balance',
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: _balanceFont,
|
||||
fontWeight: FontWeight.w800,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppDimens.spaceSm),
|
||||
const RewardCoinIcon(size: 32),
|
||||
],
|
||||
);
|
||||
if (onOpenWallet == null) return content;
|
||||
return InkWell(
|
||||
onTap: onOpenWallet,
|
||||
borderRadius: BorderRadius.circular(AppDimens.cardRadius),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: AppDimens.spaceXs),
|
||||
child: content,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
const _SectionHeader({super.key, required this.l10n, required this.section});
|
||||
|
||||
final AppLocalizations l10n;
|
||||
final RewardSection section;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final subtitle = _sectionSubtitle(l10n, section.code);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
section.title,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: _sectionTitleFont,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
if (subtitle.isNotEmpty) ...[
|
||||
const SizedBox(height: AppDimens.spaceXs),
|
||||
Text(
|
||||
subtitle,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: _sectionSubtitleFont,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _sectionSubtitle(AppLocalizations l10n, String code) {
|
||||
return switch (code.toUpperCase()) {
|
||||
'DAILY' => l10n.rewardDailySectionSubtitle,
|
||||
'NEWBIE' => l10n.rewardNewbieSectionSubtitle,
|
||||
'INVITE' || 'INVITATION' => l10n.rewardInviteSectionSubtitle,
|
||||
_ => '',
|
||||
};
|
||||
}
|
||||
|
||||
class _TaskGroupCard extends StatelessWidget {
|
||||
const _TaskGroupCard({
|
||||
super.key,
|
||||
required this.l10n,
|
||||
required this.rewards,
|
||||
required this.section,
|
||||
required this.onTaskAction,
|
||||
});
|
||||
|
||||
final AppLocalizations l10n;
|
||||
final RewardController rewards;
|
||||
final RewardSection section;
|
||||
final Future<void> Function(RewardTask task) onTaskAction;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
key: Key('reward-task-surface-${section.code}'),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(colors: [_taskCardStart, _taskCardEnd]),
|
||||
borderRadius: BorderRadius.circular(_taskCardRadius),
|
||||
border: Border.all(color: _taskCardBorder, width: _taskCardBorderWidth),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
for (var index = 0; index < section.tasks.length; index++) ...[
|
||||
_TaskRow(
|
||||
l10n: l10n,
|
||||
rewards: rewards,
|
||||
sectionCode: section.code,
|
||||
task: section.tasks[index],
|
||||
onTaskAction: onTaskAction,
|
||||
),
|
||||
if (index < section.tasks.length - 1)
|
||||
const Divider(
|
||||
color: _divider,
|
||||
height: 1,
|
||||
indent: 16,
|
||||
endIndent: 16,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TaskRow extends StatelessWidget {
|
||||
const _TaskRow({
|
||||
required this.l10n,
|
||||
required this.rewards,
|
||||
required this.sectionCode,
|
||||
required this.task,
|
||||
required this.onTaskAction,
|
||||
});
|
||||
|
||||
final AppLocalizations l10n;
|
||||
final RewardController rewards;
|
||||
final String sectionCode;
|
||||
final RewardTask task;
|
||||
final Future<void> Function(RewardTask task) onTaskAction;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: _taskRowMinHeight),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppDimens.cardPadding),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
RewardTaskLeading(task: task),
|
||||
const SizedBox(width: AppDimens.spaceMd),
|
||||
Expanded(child: _copy()),
|
||||
const SizedBox(width: AppDimens.spaceMd),
|
||||
_TaskButton(
|
||||
l10n: l10n,
|
||||
rewards: rewards,
|
||||
sectionCode: sectionCode,
|
||||
task: task,
|
||||
onTaskAction: onTaskAction,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _copy() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
task.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: _taskTitleFont,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (task.subtitle.isNotEmpty) ...[
|
||||
const SizedBox(height: AppDimens.spaceXs),
|
||||
Text(
|
||||
task.subtitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: _taskSubtitleFont,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (task.rewardCoin > 0) ...[
|
||||
const SizedBox(height: AppDimens.spaceXs),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'+${task.rewardCoin}',
|
||||
style: const TextStyle(
|
||||
color: AppColors.brandRed,
|
||||
fontSize: _taskRewardFont,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppDimens.spaceXs),
|
||||
const RewardCoinIcon(size: 20),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TaskButton extends StatelessWidget {
|
||||
const _TaskButton({
|
||||
required this.l10n,
|
||||
required this.rewards,
|
||||
required this.sectionCode,
|
||||
required this.task,
|
||||
required this.onTaskAction,
|
||||
});
|
||||
|
||||
final AppLocalizations l10n;
|
||||
final RewardController rewards;
|
||||
final String sectionCode;
|
||||
final RewardTask task;
|
||||
final Future<void> Function(RewardTask task) onTaskAction;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final loading = rewards.claimingTaskId == task.id;
|
||||
final dark = sectionCode.toUpperCase() == 'NEWBIE' && !task.claimable;
|
||||
final label = task.claimed
|
||||
? l10n.rewardClaimed
|
||||
: task.claimable
|
||||
? l10n.rewardClaimAction
|
||||
: (task.ctaText.isEmpty ? l10n.rewardGoAction : task.ctaText);
|
||||
return ConstrainedBox(
|
||||
key: Key('reward-task-action-${task.id}'),
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: _taskButtonMinWidth,
|
||||
minHeight: _taskButtonHeight,
|
||||
),
|
||||
child: FilledButton(
|
||||
onPressed: task.claimed || loading
|
||||
? null
|
||||
: () => unawaited(onTaskAction(task)),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: dark ? _mutedActionStart : AppColors.brandRed,
|
||||
disabledBackgroundColor: AppColors.contentChip,
|
||||
minimumSize: const Size(_taskButtonMinWidth, _taskButtonHeight),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppDimens.spaceSm),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
|
||||
),
|
||||
),
|
||||
child: loading
|
||||
? const SizedBox.square(
|
||||
dimension: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Text(label, style: const TextStyle(fontSize: _taskButtonFont)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Empty extends StatelessWidget {
|
||||
const _Empty({required this.label});
|
||||
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: AppDimens.spaceXl),
|
||||
child: Center(
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(color: AppColors.textTertiary),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/data/models/reward.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
|
||||
const double _coinInnerInsetRatio = 0.1;
|
||||
const double _coinLetterRatio = 0.46;
|
||||
const double _taskIconSize = 46;
|
||||
const double _taskIconRadius = 12;
|
||||
const double _taskGlyphSize = 24;
|
||||
const double _adIconWidth = 28;
|
||||
const double _adIconHeight = 22;
|
||||
const double _adIconRadius = 4;
|
||||
const double _adUnderlineWidth = 12;
|
||||
const double _adUnderlineHeight = 2;
|
||||
const double _adUnderlineBottom = -5;
|
||||
const Color _coinOuter = Color(0xFFF6B84E);
|
||||
const Color _coinInnerStart = Color(0xFFFFD98B);
|
||||
const Color _coinInnerEnd = Color(0xFFE39B31);
|
||||
const Color _coinBorder = Color(0xFFFFE0A1);
|
||||
const Color _taskIconStart = Color(0xFF4C171E);
|
||||
const Color _taskIconEnd = Color(0xFF281014);
|
||||
const Color _taskIconSurface = Color(0xFF202022);
|
||||
|
||||
/// 奖励中心统一金币图标。
|
||||
class RewardCoinIcon extends StatelessWidget {
|
||||
const RewardCoinIcon({super.key, required this.size});
|
||||
|
||||
final double size;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final inset = size * _coinInnerInsetRatio;
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
padding: EdgeInsets.all(inset),
|
||||
decoration: const BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: _coinOuter,
|
||||
boxShadow: [BoxShadow(color: Color(0x4DF6B84E), blurRadius: 6)],
|
||||
),
|
||||
child: Container(
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [_coinInnerStart, _coinInnerEnd],
|
||||
),
|
||||
border: Border.all(color: _coinBorder),
|
||||
),
|
||||
child: Text(
|
||||
'N',
|
||||
style: TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: size * _coinLetterRatio,
|
||||
fontWeight: FontWeight.w900,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 奖励中心签到按钮的广告标识。
|
||||
class RewardAdIcon extends StatelessWidget {
|
||||
const RewardAdIcon({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: _adIconWidth,
|
||||
height: _adIconHeight,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: _adIconWidth,
|
||||
height: _adIconHeight,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: AppColors.textPrimary, width: 2),
|
||||
borderRadius: BorderRadius.circular(_adIconRadius),
|
||||
),
|
||||
child: const Text(
|
||||
'AD',
|
||||
style: TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w900,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: _adUnderlineBottom,
|
||||
child: Container(
|
||||
width: _adUnderlineWidth,
|
||||
height: _adUnderlineHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.textPrimary,
|
||||
borderRadius: BorderRadius.circular(_adUnderlineHeight),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据后台任务类型绘制任务图标,不绑定具体任务文案。
|
||||
class RewardTaskLeading extends StatelessWidget {
|
||||
const RewardTaskLeading({super.key, required this.task});
|
||||
|
||||
final RewardTask task;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final invite = _isInviteTask(task);
|
||||
return Container(
|
||||
width: _taskIconSize,
|
||||
height: _taskIconSize,
|
||||
decoration: BoxDecoration(
|
||||
shape: invite ? BoxShape.circle : BoxShape.rectangle,
|
||||
borderRadius: invite ? null : BorderRadius.circular(_taskIconRadius),
|
||||
gradient: invite
|
||||
? const LinearGradient(colors: [_taskIconStart, _taskIconEnd])
|
||||
: null,
|
||||
color: invite ? null : _taskIconSurface,
|
||||
),
|
||||
child: Icon(
|
||||
_taskIcon(task),
|
||||
color: AppColors.textPrimary,
|
||||
size: _taskGlyphSize,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
bool _isInviteTask(RewardTask task) {
|
||||
final code = task.code.toUpperCase();
|
||||
return task.type == 'INVITE_FRIEND' || code.contains('INVITE');
|
||||
}
|
||||
|
||||
IconData _taskIcon(RewardTask task) {
|
||||
final code = task.code.toUpperCase();
|
||||
if (code.contains('FACEBOOK')) return Icons.facebook_rounded;
|
||||
if (code.contains('YOUTUBE')) return Icons.smart_display_rounded;
|
||||
return switch (task.type) {
|
||||
'WATCH_DURATION' => Icons.play_circle_outline_rounded,
|
||||
'WATCH_EPISODES' => Icons.video_collection_outlined,
|
||||
'COMPLETE_PROFILE' => Icons.person_outline_rounded,
|
||||
'FIRST_PURCHASE' => Icons.shopping_bag_outlined,
|
||||
'INVITE_FRIEND' => Icons.card_giftcard_rounded,
|
||||
_ => Icons.stars_rounded,
|
||||
};
|
||||
}
|
||||
@@ -13,7 +13,7 @@ const double _cardRadius = AppDimens.cardRadius;
|
||||
const double _cardPadding = 10;
|
||||
const double _coverWidth = 72;
|
||||
const double _coverHeight = 96;
|
||||
const double _coverRadius = 10;
|
||||
const double _coverRadius = AppDimens.coverRadius;
|
||||
const double _removeSize = 32;
|
||||
const double _removeIconSize = 18;
|
||||
const double _progressHeight = 4;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/widgets/app_press_scale.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('should_scale_down_on_pointer_down_and_settle_on_release', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Center(
|
||||
child: AppPressScale(
|
||||
key: Key('press-scale'),
|
||||
child: SizedBox.square(key: Key('press-target'), dimension: 80),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final transformFinder = find.descendant(
|
||||
of: find.byKey(const Key('press-scale')),
|
||||
matching: find.byType(Transform),
|
||||
);
|
||||
expect(tester.widget<Transform>(transformFinder).transform.entry(0, 0), 1);
|
||||
|
||||
final gesture = await tester.startGesture(
|
||||
tester.getCenter(find.byKey(const Key('press-target'))),
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 90));
|
||||
expect(
|
||||
tester.widget<Transform>(transformFinder).transform.entry(0, 0),
|
||||
lessThan(1),
|
||||
);
|
||||
|
||||
await gesture.up();
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.widget<Transform>(transformFinder).transform.entry(0, 0), 1);
|
||||
});
|
||||
}
|
||||
@@ -114,7 +114,7 @@ void main() {
|
||||
);
|
||||
expect(
|
||||
coverClip.borderRadius,
|
||||
BorderRadius.circular(AppDimens.homeCardRadius),
|
||||
BorderRadius.circular(AppDimens.coverRadius),
|
||||
);
|
||||
final titleFinder = find.descendant(
|
||||
of: card,
|
||||
|
||||
@@ -63,7 +63,7 @@ void main() {
|
||||
|
||||
logic.openProfile();
|
||||
|
||||
expect(logic.tab, 3);
|
||||
expect(logic.tab, 4);
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
@@ -159,10 +159,10 @@ void main() {
|
||||
expect(logic.tab, 1);
|
||||
|
||||
logic.myAction(MyPageAction.following);
|
||||
expect(logic.tab, 2);
|
||||
expect(logic.tab, 3);
|
||||
|
||||
logic.openFollowingHistory();
|
||||
expect(logic.tab, 2);
|
||||
expect(logic.tab, 3);
|
||||
expect(logic.followingHistoryRequest, 1);
|
||||
|
||||
logic.myAction(MyPageAction.resume);
|
||||
@@ -179,10 +179,10 @@ void main() {
|
||||
final logic = HomeLogic(dramas);
|
||||
|
||||
logic.discoverAction(DiscoverPageAction.mine);
|
||||
expect(logic.tab, 3);
|
||||
expect(logic.tab, 4);
|
||||
|
||||
logic.discoverAction(DiscoverPageAction.following);
|
||||
expect(logic.tab, 2);
|
||||
expect(logic.tab, 3);
|
||||
|
||||
logic.discoverAction(DiscoverPageAction.home);
|
||||
expect(logic.tab, 0);
|
||||
|
||||
+138
-64
@@ -3,10 +3,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/app.dart';
|
||||
import 'package:fruitshorts/core/data/models/reward.dart';
|
||||
import 'package:fruitshorts/core/data/models/wallet.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/commerce_repository.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/reward_repository.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
|
||||
import 'package:fruitshorts/core/theme/app_colors.dart';
|
||||
import 'package:fruitshorts/core/theme/app_dimens.dart';
|
||||
@@ -26,6 +28,7 @@ const _keys = [
|
||||
Key('home-tabs-bar'),
|
||||
Key('tab-home'),
|
||||
Key('tab-recommend'),
|
||||
Key('tab-reward'),
|
||||
Key('tab-follow'),
|
||||
Key('tab-mine'),
|
||||
];
|
||||
@@ -40,7 +43,7 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('should_theme_four_tabs_and_switch_selected_state_when_tapped', (
|
||||
testWidgets('should_theme_five_tabs_and_switch_selected_state_when_tapped', (
|
||||
tester,
|
||||
) async {
|
||||
final haptics = FakeHaptics();
|
||||
@@ -83,6 +86,13 @@ void main() {
|
||||
.map((key) => tester.getCenter(find.byKey(key)).dx)
|
||||
.toList();
|
||||
expect(tabCenters, orderedEquals(tabCenters.toList()..sort()));
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byKey(const Key('tab-reward')),
|
||||
matching: find.text('Rewards'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byKey(const Key('tab-home')),
|
||||
@@ -90,6 +100,13 @@ void main() {
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byKey(const Key('tab-reward')),
|
||||
matching: find.byType(RootNavGlyph),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byKey(const Key('tab-recommend')),
|
||||
@@ -128,17 +145,6 @@ void main() {
|
||||
?.color,
|
||||
AppColors.navUnselected,
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
.widget<AnimatedScale>(
|
||||
find.descendant(
|
||||
of: find.byKey(const Key('tab-home')),
|
||||
matching: find.byType(AnimatedScale),
|
||||
),
|
||||
)
|
||||
.duration,
|
||||
lessThanOrEqualTo(const Duration(milliseconds: 200)),
|
||||
);
|
||||
final selectedHomeIcon = tester.widget<RootNavGlyph>(
|
||||
find.descendant(
|
||||
of: find.byKey(const Key('tab-home')),
|
||||
@@ -239,64 +245,72 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('should_map_four_tabs_to_home_recommend_following_and_me', (
|
||||
tester,
|
||||
) async {
|
||||
// 追剧页头部的双语分段文案在测试字体(Ahem)下比真机字体宽约 98pt,
|
||||
// 这里放宽视口宽度,避免测字体撑破 390 宽度下的分段行。
|
||||
await _pumpApp(tester, size: const Size(520, 844));
|
||||
testWidgets(
|
||||
'should_map_five_tabs_to_home_recommend_reward_following_and_me',
|
||||
(tester) async {
|
||||
// 追剧页头部的双语分段文案在测试字体(Ahem)下比真机字体宽约 98pt,
|
||||
// 这里放宽视口宽度,避免测字体撑破 390 宽度下的分段行。
|
||||
await _pumpApp(tester, size: const Size(520, 844));
|
||||
|
||||
expect(find.byKey(const Key('tab-home')), findsOneWidget);
|
||||
expect(find.byKey(const Key('tab-recommend')), findsOneWidget);
|
||||
expect(find.byKey(const Key('tab-follow')), findsOneWidget);
|
||||
expect(find.byKey(const Key('tab-mine')), findsOneWidget);
|
||||
expect(find.byKey(const Key('tab-create')), findsNothing);
|
||||
expect(find.byKey(const Key('recommend-feed-player')), findsOneWidget);
|
||||
expect(find.byKey(const Key('tab-home')), findsOneWidget);
|
||||
expect(find.byKey(const Key('tab-recommend')), findsOneWidget);
|
||||
expect(find.byKey(const Key('tab-reward')), findsOneWidget);
|
||||
expect(find.byKey(const Key('tab-follow')), findsOneWidget);
|
||||
expect(find.byKey(const Key('tab-mine')), findsOneWidget);
|
||||
expect(find.byKey(const Key('tab-create')), findsNothing);
|
||||
expect(find.byKey(const Key('recommend-feed-player')), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byKey(const Key('tab-recommend')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const Key('recommend-channel-nav')), findsOneWidget);
|
||||
_expectNoFloatingOverlays();
|
||||
await tester.tap(find.byKey(const Key('tab-recommend')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const Key('recommend-channel-nav')), findsOneWidget);
|
||||
_expectNoFloatingOverlays();
|
||||
|
||||
await tester.tap(find.byKey(const Key('tab-follow')));
|
||||
await tester.pumpAndSettle();
|
||||
final favorites = find.byType(FollowingPage);
|
||||
expect(favorites, findsOneWidget);
|
||||
expect(find.byKey(const Key('following-page')), findsOneWidget);
|
||||
expect(find.byType(AppBottomNav), findsOneWidget);
|
||||
expect(
|
||||
find.descendant(of: favorites, matching: find.byType(Scaffold)),
|
||||
findsNothing,
|
||||
);
|
||||
expect(Navigator.canPop(tester.element(favorites)), isFalse);
|
||||
_expectNoFloatingOverlays();
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byKey(const Key('tab-follow')),
|
||||
matching: find.byType(RootNavGlyph),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
await tester.tap(find.byKey(const Key('tab-reward')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Reward Center'), findsOneWidget);
|
||||
expect(find.text('Daily task'), findsOneWidget);
|
||||
_expectNoFloatingOverlays();
|
||||
|
||||
await tester.tap(find.byKey(const Key('tab-mine')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Viewer'), findsOneWidget);
|
||||
expect(find.byKey(const Key('mine-menu')), findsOneWidget);
|
||||
expect(find.byKey(const Key('mine-wallet')), findsOneWidget);
|
||||
expect(find.byKey(const Key('mine-edit')), findsOneWidget);
|
||||
expect(find.byKey(const Key('mine-library-tab-0')), findsNothing);
|
||||
_expectNoFloatingOverlays();
|
||||
await tester.tap(find.byKey(const Key('tab-follow')));
|
||||
await tester.pumpAndSettle();
|
||||
final favorites = find.byType(FollowingPage);
|
||||
expect(favorites, findsOneWidget);
|
||||
expect(find.byKey(const Key('following-page')), findsOneWidget);
|
||||
expect(find.byType(AppBottomNav), findsOneWidget);
|
||||
expect(
|
||||
find.descendant(of: favorites, matching: find.byType(Scaffold)),
|
||||
findsNothing,
|
||||
);
|
||||
expect(Navigator.canPop(tester.element(favorites)), isFalse);
|
||||
_expectNoFloatingOverlays();
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byKey(const Key('tab-follow')),
|
||||
matching: find.byType(RootNavGlyph),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.tap(find.byKey(const Key('tab-follow')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(favorites, findsOneWidget);
|
||||
expect(find.byType(AppBottomNav), findsOneWidget);
|
||||
expect(Navigator.canPop(tester.element(favorites)), isFalse);
|
||||
await tester.tap(find.byKey(const Key('tab-mine')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Viewer'), findsOneWidget);
|
||||
expect(find.byKey(const Key('mine-menu')), findsOneWidget);
|
||||
expect(find.byKey(const Key('mine-wallet')), findsOneWidget);
|
||||
expect(find.byKey(const Key('mine-edit')), findsOneWidget);
|
||||
expect(find.byKey(const Key('mine-library-tab-0')), findsNothing);
|
||||
_expectNoFloatingOverlays();
|
||||
|
||||
await tester.tap(find.byKey(const Key('tab-home')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const Key('recommend-feed-player')), findsOneWidget);
|
||||
});
|
||||
await tester.tap(find.byKey(const Key('tab-follow')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(favorites, findsOneWidget);
|
||||
expect(find.byType(AppBottomNav), findsOneWidget);
|
||||
expect(Navigator.canPop(tester.element(favorites)), isFalse);
|
||||
|
||||
await tester.tap(find.byKey(const Key('tab-home')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const Key('recommend-feed-player')), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('should_not_haptic_when_ordinary_list_item_is_opened', (
|
||||
tester,
|
||||
@@ -337,6 +351,7 @@ Future<void> _pumpApp(
|
||||
commentRepository: FakeCommentRepository(),
|
||||
interactionRepository: FakeInteractionRepository(),
|
||||
commerceRepository: const _WalletCommerceRepository(),
|
||||
rewardRepository: const _RewardRepository(),
|
||||
haptics: haptics ?? FakeHaptics(),
|
||||
),
|
||||
);
|
||||
@@ -371,6 +386,65 @@ class _WalletCommerceRepository implements CommerceRepository {
|
||||
}
|
||||
}
|
||||
|
||||
class _RewardRepository implements RewardRepository {
|
||||
const _RewardRepository();
|
||||
|
||||
@override
|
||||
Future<RewardOverview> fetchOverview(String locale) async {
|
||||
return const RewardOverview(
|
||||
serverDate: '2026-11-05',
|
||||
timezone: 'Asia/Jakarta',
|
||||
coinBalance: 120,
|
||||
checkin: RewardCheckinState(
|
||||
taskId: 'checkin',
|
||||
checkedInToday: false,
|
||||
streak: 1,
|
||||
currentDay: 2,
|
||||
),
|
||||
sections: [
|
||||
RewardSection(
|
||||
code: 'DAILY',
|
||||
title: 'Daily task',
|
||||
tasks: [
|
||||
RewardTask(
|
||||
id: 'watch',
|
||||
code: 'WATCH',
|
||||
type: 'WATCH_DURATION',
|
||||
title: 'Watch a drama',
|
||||
subtitle: 'Watch for five minutes',
|
||||
ctaText: 'Go',
|
||||
actionType: 'OPEN_HOME',
|
||||
actionUrl: '',
|
||||
progressValue: 0,
|
||||
targetValue: 300,
|
||||
rewardCoin: 20,
|
||||
status: 'IN_PROGRESS',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<RewardActionResult> checkIn() async {
|
||||
return const RewardActionResult(
|
||||
rewardCoin: 10,
|
||||
coinBalance: 130,
|
||||
alreadyCompleted: false,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<RewardActionResult> claim(String taskId) async {
|
||||
return const RewardActionResult(
|
||||
rewardCoin: 20,
|
||||
coinBalance: 140,
|
||||
alreadyCompleted: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _expectNoFloatingOverlays() {
|
||||
expect(find.byKey(const Key('mine-mini-player')), findsNothing);
|
||||
expect(find.byType(FloatingActionButton), findsNothing);
|
||||
|
||||
@@ -24,7 +24,7 @@ void main() {
|
||||
InMemorySharedPreferencesAsync.empty();
|
||||
});
|
||||
|
||||
testWidgets('should_render_four_tabs_and_open_following_page', (
|
||||
testWidgets('should_render_five_tabs_and_open_following_page', (
|
||||
tester,
|
||||
) async {
|
||||
// 追剧页头部双语分段在测试字体(Ahem)下比真机字体宽约 98pt,
|
||||
@@ -189,6 +189,7 @@ void main() {
|
||||
const _tabKeys = [
|
||||
Key('tab-home'),
|
||||
Key('tab-recommend'),
|
||||
Key('tab-reward'),
|
||||
Key('tab-follow'),
|
||||
Key('tab-mine'),
|
||||
];
|
||||
|
||||
@@ -8,7 +8,7 @@ import 'package:fruitshorts/l10n/app_localizations.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets(
|
||||
'should_render_four_ordered_glyphs_with_home_selected_when_root_nav_builds',
|
||||
'should_render_five_ordered_glyphs_with_home_selected_when_root_nav_builds',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
@@ -32,7 +32,19 @@ void main() {
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
]);
|
||||
final tabKeys = [
|
||||
const Key('tab-home'),
|
||||
const Key('tab-recommend'),
|
||||
const Key('tab-reward'),
|
||||
const Key('tab-follow'),
|
||||
const Key('tab-mine'),
|
||||
];
|
||||
final centers = tabKeys
|
||||
.map((key) => tester.getCenter(find.byKey(key)).dx)
|
||||
.toList();
|
||||
expect(centers, orderedEquals(centers.toList()..sort()));
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -35,13 +35,19 @@ void main() {
|
||||
Key('player-play-toggle'),
|
||||
]) {
|
||||
expect(tester.getSize(find.byKey(key)), const Size(68, 68));
|
||||
// 圆形深色底:压在亮画面上也能看见播放/暂停。
|
||||
final container = tester.widget<Container>(
|
||||
find.descendant(of: find.byKey(key), matching: find.byType(Container)),
|
||||
// 半透明玻璃圆面:压在亮画面上也能看见播放/暂停。
|
||||
final surface = tester.widget<DecoratedBox>(
|
||||
find
|
||||
.descendant(
|
||||
of: find.byKey(key),
|
||||
matching: find.byKey(const Key('player-center-toggle-surface')),
|
||||
)
|
||||
.first,
|
||||
);
|
||||
final decoration = container.decoration! as BoxDecoration;
|
||||
expect(decoration.color, const Color(0x66000000));
|
||||
final decoration = surface.decoration as BoxDecoration;
|
||||
expect(decoration.gradient, isA<RadialGradient>());
|
||||
expect(decoration.shape, BoxShape.circle);
|
||||
expect(decoration.border, isNull);
|
||||
final icon = tester.widget<Icon>(
|
||||
find.descendant(of: find.byKey(key), matching: find.byType(Icon)),
|
||||
);
|
||||
|
||||
@@ -15,6 +15,60 @@ import '../support/fake_catalog.dart';
|
||||
import '../support/fake_category.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('should_keep_refresh_indicator_below_category_nav_when_pulling', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(390, 844);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
final controller = CategoryController(
|
||||
FakeCategoryRepository(catalog: FakeCatalogRepository()),
|
||||
);
|
||||
final dramas = [for (var index = 1; index <= 12; index++) _drama('$index')];
|
||||
controller.categories = const [ContentCategory(id: '7', name: 'Romance')];
|
||||
controller.dramaPageLoader =
|
||||
(categoryId, {pageIndex = 1, pageSize = 20}) async =>
|
||||
PageResult(total: dramas.length, list: dramas);
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
locale: const Locale('en'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: ChangeNotifierProvider<CategoryController>.value(
|
||||
value: controller,
|
||||
child: const RecommendDiscoverPage(active: true, onSearch: _noop),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final nav = tester.getRect(find.byKey(const Key('recommend-tab-7')));
|
||||
final scroll = find.byKey(const PageStorageKey<String>('recommend-feed-7'));
|
||||
final scrollable = tester.state<ScrollableState>(
|
||||
find.descendant(of: scroll, matching: find.byType(Scrollable)).first,
|
||||
);
|
||||
expect(scrollable.position.pixels, 0);
|
||||
final scrollRect = tester.getRect(scroll);
|
||||
final gesture = await tester.startGesture(
|
||||
Offset(scrollRect.center.dx, scrollRect.top + 120),
|
||||
);
|
||||
await gesture.moveBy(const Offset(0, 160));
|
||||
await tester.pump();
|
||||
|
||||
final indicator = find.byKey(const Key('pull-refresh-indicator'));
|
||||
expect(indicator, findsOneWidget);
|
||||
expect(tester.getRect(indicator).top, greaterThanOrEqualTo(nav.bottom));
|
||||
|
||||
await gesture.up();
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
testWidgets('should_center_empty_state_when_category_has_no_dramas', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/data/models/reward.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/reward_repository.dart';
|
||||
import 'package:fruitshorts/core/data/services/reward_controller.dart';
|
||||
|
||||
void main() {
|
||||
test('should_refresh_balance_when_check_in_succeeds', () async {
|
||||
final repository = _RewardRepository();
|
||||
final controller = RewardController(repository);
|
||||
|
||||
await controller.load('id');
|
||||
final result = await controller.checkIn();
|
||||
|
||||
expect(result?.rewardCoin, 35);
|
||||
expect(controller.overview?.coinBalance, 35);
|
||||
expect(repository.overviewCalls, 2);
|
||||
});
|
||||
|
||||
test('should_mark_claim_in_flight_when_task_is_claimed', () async {
|
||||
final repository = _RewardRepository();
|
||||
final controller = RewardController(repository);
|
||||
|
||||
final future = controller.claim('task-1');
|
||||
expect(controller.claimingTaskId, 'task-1');
|
||||
await future;
|
||||
|
||||
expect(controller.claimingTaskId, isNull);
|
||||
expect(repository.claimedTaskId, 'task-1');
|
||||
});
|
||||
}
|
||||
|
||||
class _RewardRepository implements RewardRepository {
|
||||
int overviewCalls = 0;
|
||||
String? claimedTaskId;
|
||||
|
||||
@override
|
||||
Future<RewardOverview> fetchOverview(String locale) async {
|
||||
overviewCalls += 1;
|
||||
return RewardOverview(
|
||||
serverDate: '2026-11-05',
|
||||
timezone: 'Asia/Jakarta',
|
||||
coinBalance: overviewCalls > 1 ? 35 : 0,
|
||||
checkin: const RewardCheckinState(
|
||||
taskId: '1',
|
||||
checkedInToday: false,
|
||||
streak: 0,
|
||||
currentDay: 1,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<RewardActionResult> checkIn() async {
|
||||
return const RewardActionResult(
|
||||
rewardCoin: 35,
|
||||
coinBalance: 35,
|
||||
alreadyCompleted: false,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<RewardActionResult> claim(String taskId) async {
|
||||
claimedTaskId = taskId;
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
return const RewardActionResult(
|
||||
rewardCoin: 20,
|
||||
coinBalance: 55,
|
||||
alreadyCompleted: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/data/models/reward.dart';
|
||||
|
||||
void main() {
|
||||
test('should_parse_string_numbers_when_reward_overview_is_received', () {
|
||||
final overview = RewardOverview.fromJson({
|
||||
'serverDate': '2026-11-05',
|
||||
'timezone': 'Asia/Jakarta',
|
||||
'coinBalance': '135',
|
||||
'checkin': {
|
||||
'taskId': '9',
|
||||
'checkedInToday': true,
|
||||
'streak': 2,
|
||||
'currentDay': 3,
|
||||
'days': [
|
||||
{'dayIndex': 1, 'rewardCoin': '35', 'checked': true},
|
||||
],
|
||||
},
|
||||
'sections': [
|
||||
{
|
||||
'code': 'DAILY',
|
||||
'title': 'Tugas harian',
|
||||
'tasks': [
|
||||
{
|
||||
'id': '18',
|
||||
'targetValue': '300',
|
||||
'progressValue': '120',
|
||||
'rewardCoin': '20',
|
||||
'status': 'IN_PROGRESS',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(overview.coinBalance, 135);
|
||||
expect(overview.checkin.days.single.rewardCoin, 35);
|
||||
expect(overview.sections.single.tasks.single.progress, 0.4);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
|
||||
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
|
||||
|
||||
import 'package:fruitshorts/core/data/models/reward.dart';
|
||||
import 'package:fruitshorts/core/data/remote/api_client.dart';
|
||||
import 'package:fruitshorts/core/data/remote/auth_api.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/reward_repository.dart';
|
||||
import 'package:fruitshorts/core/data/services/auth_controller.dart';
|
||||
import 'package:fruitshorts/core/data/services/auth_session_store.dart';
|
||||
import 'package:fruitshorts/core/data/services/reward_controller.dart';
|
||||
import 'package:fruitshorts/l10n/app_localizations.dart';
|
||||
import 'package:fruitshorts/pages/reward/reward_page.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('should_render_tasks_and_switch_to_activities_when_signed_in', (
|
||||
tester,
|
||||
) async {
|
||||
SharedPreferencesAsyncPlatform.instance =
|
||||
InMemorySharedPreferencesAsync.empty();
|
||||
final auth = _TestAuthController(signedIn: true);
|
||||
final rewards = RewardController(_RewardPageRepository());
|
||||
|
||||
await tester.pumpWidget(
|
||||
MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<AuthController>.value(value: auth),
|
||||
ChangeNotifierProvider<RewardController>.value(value: rewards),
|
||||
],
|
||||
child: MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const Scaffold(body: RewardPage(active: true)),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 2));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Reward Center'), findsOneWidget);
|
||||
expect(find.text('My coins'), findsOneWidget);
|
||||
expect(find.text('Daily task'), findsOneWidget);
|
||||
expect(find.text('Check in +10 coins'), findsOneWidget);
|
||||
expect(find.byKey(const Key('reward-back-button')), findsOneWidget);
|
||||
expect(find.byKey(const Key('reward-help-button')), findsOneWidget);
|
||||
expect(find.byKey(const Key('reward-balance')), findsOneWidget);
|
||||
expect(find.byKey(const Key('reward-checkin-card')), findsOneWidget);
|
||||
expect(find.byKey(const Key('reward-checkin-action')), findsOneWidget);
|
||||
expect(find.byKey(const Key('reward-section-DAILY')), findsOneWidget);
|
||||
expect(find.byKey(const Key('reward-task-card-DAILY')), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byKey(const Key('reward-tab-activities')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Spring event'), findsOneWidget);
|
||||
expect(find.text('Daily task'), findsNothing);
|
||||
|
||||
await tester.tap(find.byKey(const Key('reward-tab-tasks')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Daily task'), findsOneWidget);
|
||||
expect(find.text('Spring event'), findsNothing);
|
||||
expect(
|
||||
tester.getSize(find.byKey(const Key('reward-tab-indicator-tasks'))).width,
|
||||
24,
|
||||
);
|
||||
|
||||
auth.dispose();
|
||||
rewards.dispose();
|
||||
});
|
||||
|
||||
testWidgets('should_show_reward_content_and_gate_check_in_when_signed_out', (
|
||||
tester,
|
||||
) async {
|
||||
SharedPreferencesAsyncPlatform.instance =
|
||||
InMemorySharedPreferencesAsync.empty();
|
||||
final auth = _TestAuthController(signedIn: false);
|
||||
final repository = _RewardPageRepository();
|
||||
final rewards = RewardController(repository);
|
||||
|
||||
await _pumpRewardPage(tester, auth: auth, rewards: rewards);
|
||||
|
||||
expect(find.byKey(const Key('auth-gate-panel')), findsNothing);
|
||||
expect(find.text('Daily task'), findsOneWidget);
|
||||
expect(find.text('Check in +10 coins'), findsOneWidget);
|
||||
expect(repository.overviewCalls, 1);
|
||||
|
||||
await tester.tap(find.text('Check in +10 coins'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Sign in to continue'), findsOneWidget);
|
||||
expect(repository.checkInCalls, 0);
|
||||
|
||||
auth.dispose();
|
||||
rewards.dispose();
|
||||
});
|
||||
|
||||
testWidgets('should_use_reward_dialog_style_when_help_is_opened', (
|
||||
tester,
|
||||
) async {
|
||||
SharedPreferencesAsyncPlatform.instance =
|
||||
InMemorySharedPreferencesAsync.empty();
|
||||
final auth = _TestAuthController(signedIn: false);
|
||||
final rewards = RewardController(_RewardPageRepository());
|
||||
|
||||
await _pumpRewardPage(tester, auth: auth, rewards: rewards);
|
||||
|
||||
await tester.tap(find.byKey(const Key('reward-help-button')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('reward-help-dialog')), findsOneWidget);
|
||||
expect(find.text('About rewards'), findsOneWidget);
|
||||
expect(
|
||||
find.text(
|
||||
'Complete check-ins and tasks to earn reward coins. Rewards and task rules are shown on this page.',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.byKey(const Key('reward-help-close')), findsOneWidget);
|
||||
expect(find.byType(AlertDialog), findsNothing);
|
||||
|
||||
await tester.tap(find.byKey(const Key('reward-help-close')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const Key('reward-help-dialog')), findsNothing);
|
||||
|
||||
auth.dispose();
|
||||
rewards.dispose();
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'should_show_retry_without_reloading_forever_when_overview_fails',
|
||||
(tester) async {
|
||||
SharedPreferencesAsyncPlatform.instance =
|
||||
InMemorySharedPreferencesAsync.empty();
|
||||
final auth = _TestAuthController(signedIn: false);
|
||||
final repository = _FailingRewardPageRepository();
|
||||
final rewards = RewardController(repository);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<AuthController>.value(value: auth),
|
||||
ChangeNotifierProvider<RewardController>.value(value: rewards),
|
||||
],
|
||||
child: MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const Scaffold(body: RewardPage(active: true)),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 2));
|
||||
await tester.pump(const Duration(milliseconds: 2));
|
||||
|
||||
expect(repository.overviewCalls, 1);
|
||||
expect(find.byType(CircularProgressIndicator), findsNothing);
|
||||
expect(find.byType(FilledButton), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byType(FilledButton));
|
||||
await tester.pump(const Duration(milliseconds: 2));
|
||||
|
||||
expect(repository.overviewCalls, 2);
|
||||
expect(find.byType(CircularProgressIndicator), findsNothing);
|
||||
|
||||
auth.dispose();
|
||||
rewards.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('should_reload_overview_when_auth_state_changes', (tester) async {
|
||||
SharedPreferencesAsyncPlatform.instance =
|
||||
InMemorySharedPreferencesAsync.empty();
|
||||
final auth = _TestAuthController(signedIn: false);
|
||||
final repository = _RewardPageRepository();
|
||||
final rewards = RewardController(repository);
|
||||
|
||||
await _pumpRewardPage(tester, auth: auth, rewards: rewards);
|
||||
expect(repository.overviewCalls, 1);
|
||||
|
||||
auth.updateSignedIn(true);
|
||||
await tester.pump(const Duration(milliseconds: 2));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(repository.overviewCalls, 2);
|
||||
|
||||
auth.dispose();
|
||||
rewards.dispose();
|
||||
});
|
||||
|
||||
testWidgets('should_place_header_actions_at_edges_when_width_is_compact', (
|
||||
tester,
|
||||
) async {
|
||||
SharedPreferencesAsyncPlatform.instance =
|
||||
InMemorySharedPreferencesAsync.empty();
|
||||
await tester.binding.setSurfaceSize(const Size(393, 852));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
final auth = _TestAuthController(signedIn: false);
|
||||
final rewards = RewardController(_RewardPageRepository());
|
||||
|
||||
await _pumpRewardPage(tester, auth: auth, rewards: rewards);
|
||||
|
||||
final back = tester.getCenter(find.byKey(const Key('reward-back-button')));
|
||||
final help = tester.getCenter(find.byKey(const Key('reward-help-button')));
|
||||
expect(back.dx, lessThan(40));
|
||||
expect(help.dx, greaterThan(350));
|
||||
expect(find.text('Hidden check-in task'), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
auth.dispose();
|
||||
rewards.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _pumpRewardPage(
|
||||
WidgetTester tester, {
|
||||
required AuthController auth,
|
||||
required RewardController rewards,
|
||||
}) async {
|
||||
await tester.pumpWidget(
|
||||
MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<AuthController>.value(value: auth),
|
||||
ChangeNotifierProvider<RewardController>.value(value: rewards),
|
||||
],
|
||||
child: MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const Scaffold(body: RewardPage(active: true)),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 2));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
class _TestAuthController extends AuthController {
|
||||
_TestAuthController({required this.signedIn})
|
||||
: super(
|
||||
AuthApi(ApiClient(baseUrl: 'http://127.0.0.1:8081')),
|
||||
AuthSessionStore(),
|
||||
);
|
||||
|
||||
bool signedIn;
|
||||
|
||||
@override
|
||||
bool get isSignedIn => signedIn;
|
||||
|
||||
void updateSignedIn(bool value) {
|
||||
signedIn = value;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
class _RewardPageRepository implements RewardRepository {
|
||||
int overviewCalls = 0;
|
||||
int checkInCalls = 0;
|
||||
|
||||
@override
|
||||
Future<RewardOverview> fetchOverview(String locale) async {
|
||||
overviewCalls += 1;
|
||||
return const RewardOverview(
|
||||
serverDate: '2026-11-05',
|
||||
timezone: 'Asia/Jakarta',
|
||||
coinBalance: 120,
|
||||
checkin: RewardCheckinState(
|
||||
taskId: 'checkin',
|
||||
checkedInToday: false,
|
||||
streak: 2,
|
||||
currentDay: 3,
|
||||
days: [
|
||||
RewardCheckinDay(
|
||||
dayIndex: 1,
|
||||
rewardCoin: 10,
|
||||
checked: false,
|
||||
current: true,
|
||||
),
|
||||
RewardCheckinDay(
|
||||
dayIndex: 2,
|
||||
rewardCoin: 10,
|
||||
checked: false,
|
||||
current: false,
|
||||
),
|
||||
RewardCheckinDay(
|
||||
dayIndex: 3,
|
||||
rewardCoin: 10,
|
||||
checked: false,
|
||||
current: false,
|
||||
),
|
||||
RewardCheckinDay(
|
||||
dayIndex: 4,
|
||||
rewardCoin: 10,
|
||||
checked: false,
|
||||
current: false,
|
||||
),
|
||||
RewardCheckinDay(
|
||||
dayIndex: 5,
|
||||
rewardCoin: 10,
|
||||
checked: false,
|
||||
current: false,
|
||||
),
|
||||
RewardCheckinDay(
|
||||
dayIndex: 6,
|
||||
rewardCoin: 10,
|
||||
checked: false,
|
||||
current: false,
|
||||
),
|
||||
RewardCheckinDay(
|
||||
dayIndex: 7,
|
||||
rewardCoin: 10,
|
||||
checked: false,
|
||||
current: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
sections: [
|
||||
RewardSection(
|
||||
code: 'CHECK_IN',
|
||||
title: 'Check-in section',
|
||||
tasks: [
|
||||
RewardTask(
|
||||
id: 'checkin-task',
|
||||
code: 'CHECK_IN',
|
||||
type: 'DAILY_CHECK_IN',
|
||||
title: 'Hidden check-in task',
|
||||
subtitle: '',
|
||||
ctaText: 'Check in',
|
||||
actionType: 'NONE',
|
||||
actionUrl: '',
|
||||
progressValue: 0,
|
||||
targetValue: 1,
|
||||
rewardCoin: 10,
|
||||
status: 'IN_PROGRESS',
|
||||
),
|
||||
],
|
||||
),
|
||||
RewardSection(
|
||||
code: 'DAILY',
|
||||
title: 'Daily task',
|
||||
tasks: [
|
||||
RewardTask(
|
||||
id: 'watch',
|
||||
code: 'WATCH',
|
||||
type: 'WATCH_DURATION',
|
||||
title: 'Watch a drama',
|
||||
subtitle: 'Watch for five minutes',
|
||||
ctaText: '',
|
||||
actionType: 'OPEN_HOME',
|
||||
actionUrl: '',
|
||||
progressValue: 2,
|
||||
targetValue: 5,
|
||||
rewardCoin: 20,
|
||||
status: 'IN_PROGRESS',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
activities: [
|
||||
RewardActivity(
|
||||
id: 'spring',
|
||||
code: 'SPRING',
|
||||
title: 'Spring event',
|
||||
summary: 'Earn extra coins this week',
|
||||
ctaText: '',
|
||||
actionType: '',
|
||||
actionUrl: '',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<RewardActionResult> checkIn() async {
|
||||
checkInCalls += 1;
|
||||
return const RewardActionResult(
|
||||
rewardCoin: 10,
|
||||
coinBalance: 130,
|
||||
alreadyCompleted: false,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<RewardActionResult> claim(String taskId) async {
|
||||
return const RewardActionResult(
|
||||
rewardCoin: 20,
|
||||
coinBalance: 140,
|
||||
alreadyCompleted: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FailingRewardPageRepository implements RewardRepository {
|
||||
int overviewCalls = 0;
|
||||
|
||||
@override
|
||||
Future<RewardOverview> fetchOverview(String locale) async {
|
||||
overviewCalls += 1;
|
||||
throw ApiException(500, 'offline');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<RewardActionResult> checkIn() async {
|
||||
throw ApiException(401, 'login');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<RewardActionResult> claim(String taskId) async {
|
||||
throw ApiException(401, 'login');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
// Author: fruit.com.cn
|
||||
// 奖励中心设计图比例与关键布局视觉回归。
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
|
||||
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
|
||||
|
||||
import 'package:fruitshorts/core/data/models/reward.dart';
|
||||
import 'package:fruitshorts/core/data/remote/api_client.dart';
|
||||
import 'package:fruitshorts/core/data/remote/auth_api.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/reward_repository.dart';
|
||||
import 'package:fruitshorts/core/data/services/auth_controller.dart';
|
||||
import 'package:fruitshorts/core/data/services/auth_session_store.dart';
|
||||
import 'package:fruitshorts/core/data/services/reward_controller.dart';
|
||||
import 'package:fruitshorts/core/theme/app_theme.dart';
|
||||
import 'package:fruitshorts/l10n/app_localizations.dart';
|
||||
import 'package:fruitshorts/pages/reward/reward_page.dart';
|
||||
|
||||
import '../support/widget_capture.dart';
|
||||
|
||||
const bool _captureEnabled = bool.fromEnvironment('REWARD_CAPTURE');
|
||||
const Size _screenSize = Size(393, 852);
|
||||
const Key _captureKey = Key('reward-visual-capture');
|
||||
const String _capturePath = '/tmp/fruitshorts-reward-before.png';
|
||||
const String _helpCapturePath = '/tmp/fruitshorts-reward-help-dialog.png';
|
||||
|
||||
void main() {
|
||||
testWidgets('should_render_reward_design_fixture_without_overflow', (
|
||||
tester,
|
||||
) async {
|
||||
SharedPreferencesAsyncPlatform.instance =
|
||||
InMemorySharedPreferencesAsync.empty();
|
||||
await tester.binding.setSurfaceSize(_screenSize);
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
if (_captureEnabled) await loadCaptureFonts(tester);
|
||||
final auth = _VisualAuthController();
|
||||
final rewards = RewardController(_VisualRewardRepository());
|
||||
|
||||
await tester.pumpWidget(_host(auth: auth, rewards: rewards));
|
||||
await tester.pump(const Duration(milliseconds: 2));
|
||||
await tester.pumpAndSettle();
|
||||
if (_captureEnabled) {
|
||||
await saveWidgetCapture(tester, _captureKey, _capturePath);
|
||||
}
|
||||
|
||||
expect(find.byKey(const Key('reward-checkin-card')), findsOneWidget);
|
||||
expect(find.byKey(const Key('reward-task-card-INVITE')), findsOneWidget);
|
||||
expect(find.byKey(const Key('reward-task-card-NEWBIE')), findsOneWidget);
|
||||
expect(find.text('+0'), findsNothing);
|
||||
final action = tester.getSize(
|
||||
find.byKey(const Key('reward-checkin-action')),
|
||||
);
|
||||
final actionContent = tester.getSize(
|
||||
find.byKey(const Key('reward-checkin-action-content')),
|
||||
);
|
||||
expect(actionContent.width, lessThan(action.width * 0.8));
|
||||
expect(_radius(tester, const Key('reward-checkin-card')), 14);
|
||||
expect(_radius(tester, const Key('reward-task-surface-INVITE')), 14);
|
||||
final backIcon = tester.widget<Icon>(
|
||||
find.descendant(
|
||||
of: find.byKey(const Key('reward-back-button')),
|
||||
matching: find.byIcon(Icons.arrow_back_ios_new_rounded),
|
||||
),
|
||||
);
|
||||
expect(backIcon.size, 16);
|
||||
final helpButton = tester.getSize(
|
||||
find.byKey(const Key('reward-help-icon')),
|
||||
);
|
||||
expect(helpButton.width, 24);
|
||||
expect(helpButton.height, 24);
|
||||
final helpHitTarget = tester.getSize(
|
||||
find.byKey(const Key('reward-help-button')),
|
||||
);
|
||||
expect(helpHitTarget.width, 48);
|
||||
expect(helpHitTarget.height, 48);
|
||||
final taskButton = tester.getSize(
|
||||
find.byKey(const Key('reward-task-action-invite')),
|
||||
);
|
||||
expect(taskButton.height, 36);
|
||||
expect(taskButton.width, 80);
|
||||
if (_captureEnabled) {
|
||||
await tester.tap(find.byKey(const Key('reward-help-button')));
|
||||
await tester.pumpAndSettle();
|
||||
await saveWidgetCapture(
|
||||
tester,
|
||||
const Key('reward-help-dialog'),
|
||||
_helpCapturePath,
|
||||
);
|
||||
await tester.tap(find.byKey(const Key('reward-help-close')));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
auth.dispose();
|
||||
rewards.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
double _radius(WidgetTester tester, Key key) {
|
||||
final container = tester.widget<Container>(find.byKey(key));
|
||||
final decoration = container.decoration! as BoxDecoration;
|
||||
return decoration.borderRadius!.resolve(TextDirection.ltr).topLeft.x;
|
||||
}
|
||||
|
||||
Widget _host({
|
||||
required AuthController auth,
|
||||
required RewardController rewards,
|
||||
}) {
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<AuthController>.value(value: auth),
|
||||
ChangeNotifierProvider<RewardController>.value(value: rewards),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: AppTheme.dark(),
|
||||
locale: const Locale('zh'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: MediaQuery(
|
||||
data: const MediaQueryData(
|
||||
size: _screenSize,
|
||||
padding: EdgeInsets.only(top: 24, bottom: 24),
|
||||
),
|
||||
child: const RepaintBoundary(
|
||||
key: _captureKey,
|
||||
child: Scaffold(body: RewardPage(active: true)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _VisualAuthController extends AuthController {
|
||||
_VisualAuthController()
|
||||
: super(
|
||||
AuthApi(ApiClient(baseUrl: 'http://127.0.0.1:8081')),
|
||||
AuthSessionStore(),
|
||||
);
|
||||
|
||||
@override
|
||||
bool get isSignedIn => false;
|
||||
}
|
||||
|
||||
class _VisualRewardRepository implements RewardRepository {
|
||||
@override
|
||||
Future<RewardOverview> fetchOverview(String locale) async {
|
||||
return const RewardOverview(
|
||||
serverDate: '2026-11-09',
|
||||
timezone: 'Asia/Jakarta',
|
||||
coinBalance: 0,
|
||||
checkin: RewardCheckinState(
|
||||
taskId: 'checkin',
|
||||
checkedInToday: false,
|
||||
streak: 0,
|
||||
currentDay: 1,
|
||||
days: [
|
||||
RewardCheckinDay(
|
||||
dayIndex: 1,
|
||||
rewardCoin: 55,
|
||||
checked: false,
|
||||
current: true,
|
||||
),
|
||||
RewardCheckinDay(
|
||||
dayIndex: 2,
|
||||
rewardCoin: 35,
|
||||
checked: false,
|
||||
current: false,
|
||||
),
|
||||
RewardCheckinDay(
|
||||
dayIndex: 3,
|
||||
rewardCoin: 35,
|
||||
checked: false,
|
||||
current: false,
|
||||
),
|
||||
RewardCheckinDay(
|
||||
dayIndex: 4,
|
||||
rewardCoin: 35,
|
||||
checked: false,
|
||||
current: false,
|
||||
),
|
||||
RewardCheckinDay(
|
||||
dayIndex: 5,
|
||||
rewardCoin: 35,
|
||||
checked: false,
|
||||
current: false,
|
||||
),
|
||||
RewardCheckinDay(
|
||||
dayIndex: 6,
|
||||
rewardCoin: 55,
|
||||
checked: false,
|
||||
current: false,
|
||||
),
|
||||
RewardCheckinDay(
|
||||
dayIndex: 7,
|
||||
rewardCoin: 55,
|
||||
checked: false,
|
||||
current: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
sections: [
|
||||
RewardSection(
|
||||
code: 'INVITE',
|
||||
title: '邀请任务',
|
||||
tasks: [
|
||||
RewardTask(
|
||||
id: 'invite',
|
||||
code: 'INVITE_FRIEND',
|
||||
type: 'INVITE_FRIEND',
|
||||
title: '领1小时免费看剧',
|
||||
subtitle: '邀请成功 +1小时',
|
||||
ctaText: '去完成',
|
||||
actionType: 'OPEN_URL',
|
||||
actionUrl: '',
|
||||
progressValue: 0,
|
||||
targetValue: 1,
|
||||
rewardCoin: 0,
|
||||
status: 'IN_PROGRESS',
|
||||
),
|
||||
],
|
||||
),
|
||||
RewardSection(
|
||||
code: 'NEWBIE',
|
||||
title: '新手专属任务',
|
||||
tasks: [
|
||||
RewardTask(
|
||||
id: 'facebook',
|
||||
code: 'FOLLOW_FACEBOOK',
|
||||
type: 'FOLLOW_SOCIAL',
|
||||
title: '关注 Facebook 账号',
|
||||
subtitle: '',
|
||||
ctaText: '去完成',
|
||||
actionType: 'OPEN_URL',
|
||||
actionUrl: '',
|
||||
progressValue: 0,
|
||||
targetValue: 1,
|
||||
rewardCoin: 50,
|
||||
status: 'IN_PROGRESS',
|
||||
),
|
||||
RewardTask(
|
||||
id: 'youtube',
|
||||
code: 'FOLLOW_YOUTUBE',
|
||||
type: 'FOLLOW_SOCIAL',
|
||||
title: '关注 YouTube 账号',
|
||||
subtitle: '',
|
||||
ctaText: '去完成',
|
||||
actionType: 'OPEN_URL',
|
||||
actionUrl: '',
|
||||
progressValue: 0,
|
||||
targetValue: 1,
|
||||
rewardCoin: 50,
|
||||
status: 'IN_PROGRESS',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<RewardActionResult> checkIn() async {
|
||||
return const RewardActionResult(
|
||||
rewardCoin: 55,
|
||||
coinBalance: 55,
|
||||
alreadyCompleted: false,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<RewardActionResult> claim(String taskId) async {
|
||||
return const RewardActionResult(
|
||||
rewardCoin: 50,
|
||||
coinBalance: 50,
|
||||
alreadyCompleted: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,10 @@ void main() {
|
||||
test('should_expose_dark_theme_radius_and_spacing_scale', () {
|
||||
expect(AppDimens.pageHorizontal, 16);
|
||||
expect(AppDimens.cardRadius, 12);
|
||||
expect(AppDimens.coverRadius, 12);
|
||||
expect(AppDimens.coverRadius, 8);
|
||||
expect(AppDimens.cardCoverRadius, AppDimens.coverRadius);
|
||||
expect(AppDimens.rankingCoverRadius, AppDimens.coverRadius);
|
||||
expect(AppDimens.followingHistoryCoverRadius, AppDimens.coverRadius);
|
||||
expect(AppDimens.authCardRadius, 12);
|
||||
expect(AppDimens.buttonRadius, 10);
|
||||
expect(AppDimens.inputRadius, 10);
|
||||
|
||||
@@ -94,7 +94,7 @@ void main() {
|
||||
expect(find.byKey(const Key('player-controls-top')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('should_keep_four_root_tabs_on_one_route_and_return_home', (
|
||||
testWidgets('should_keep_five_root_tabs_on_one_route_and_return_home', (
|
||||
tester,
|
||||
) async {
|
||||
// 追剧页头部双语分段在测试字体(Ahem)下更宽,放宽视口避免撑破分段行。
|
||||
@@ -269,9 +269,7 @@ void main() {
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('should_like_without_login_when_signed_out', (
|
||||
tester,
|
||||
) async {
|
||||
testWidgets('should_like_without_login_when_signed_out', (tester) async {
|
||||
SharedPreferencesAsyncPlatform.instance =
|
||||
InMemorySharedPreferencesAsync.empty();
|
||||
final interactions = FakeInteractionRepository();
|
||||
|
||||
Reference in new issue
Block a user