13 Commits
Author SHA1 Message Date
Vicky fe0e77775e merge: 合并奖励中心与我的页视觉优化
flutter-ci / test (push) Failing after 22m27s
2026-11-13 17:42:22 +08:00
Zhou Weidong 6ef5429a52 style(mine): 重构紧凑 VIP 会员入口 2026-11-13 17:41:47 +08:00
Zhou Weidong 9ef78ad670 style(reward): 优化奖励中心金币图标 2026-11-13 17:41:31 +08:00
Zhou Weidong bea08110c9 feat(reward): 新增客户端奖励中心 2026-11-13 13:43:38 +08:00
Vicky 67670d46b1 feat(auth): 接入 Google 登录
- 登录页保留邮箱与 Google 两个入口,Google 按钮按官方规范改为白底四色图标
- 新增 GoogleAuthService:封装 initialize/authenticate,失败以结果对象返回
- AuthApi 增加 googleLogin,AuthController 增加 signInWithGoogle
- iOS 补充 CFBundleURLTypes 与 GIDClientID,Bundle ID 统一为 com.stormg.lumadrama
- Google 请求期间按钮显示转圈并禁止重复点击
2026-11-13 12:30:47 +08:00
Zhou Weidong 7b90bd59b9 style(home): 观看全集按钮轮廓进一步压淡
flutter-ci / test (push) Failing after 19m31s
2026-11-04 15:18:42 +08:00
Zhou Weidong 938435dfb9 style(home): 观看全集按钮改为半透明白玻璃表面
flutter-ci / test (push) Failing after 19m26s
2026-11-04 15:01:36 +08:00
Zhou Weidong b61bfe625c style(home): 观看全集按钮改用深酒红表面
flutter-ci / test (push) Failing after 19m29s
2026-11-04 14:51:20 +08:00
Zhou Weidong a7b4277536 style(home): 观看全集按钮改用暖深炭灰
flutter-ci / test (push) Failing after 19m24s
2026-11-04 14:24:21 +08:00
Zhou Weidong dabff48f7c merge: 首页观看全集配色匹配设计稿
flutter-ci / test (push) Failing after 20m24s
2026-11-04 13:44:42 +08:00
Zhou Weidong 6acc00394b style(home): 匹配观看全集设计稿配色 2026-11-04 13:42:47 +08:00
Zhou Weidong f3cfd0d0ec merge: 客户端播放器交互优化
flutter-ci / test (push) Failing after 19m31s
2026-11-03 18:28:31 +08:00
Zhou Weidong 35cbdd2bd3 perf(player): 优化点击响应与互动反馈 2026-11-03 18:28:14 +08:00
88 changed files with 4335 additions and 361 deletions

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

+3 -3
View File
@@ -387,7 +387,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.stormg.lumadramv;
PRODUCT_BUNDLE_IDENTIFIER = com.stormg.lumadrama;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
@@ -567,7 +567,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.stormg.lumadramv;
PRODUCT_BUNDLE_IDENTIFIER = com.stormg.lumadrama;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -590,7 +590,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.stormg.lumadramv;
PRODUCT_BUNDLE_IDENTIFIER = com.stormg.lumadrama;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
@@ -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
}
+13
View File
@@ -24,6 +24,19 @@
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>com.googleusercontent.apps.11115008580-51gv4sj2c78rud6n127g3shu4d2n96un</string>
</array>
</dict>
</array>
<key>GIDClientID</key>
<string>11115008580-51gv4sj2c78rud6n127g3shu4d2n96un.apps.googleusercontent.com</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>
+15
View File
@@ -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),
+1 -1
View File
@@ -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';
}
+242
View File
@@ -0,0 +1,242 @@
// Author: fruit.com.cn
import 'json_numbers.dart';
/// 奖励中心首屏数据。
class RewardOverview {
const RewardOverview({
required this.serverDate,
required this.timezone,
required this.coinBalance,
required this.checkin,
this.sections = const [],
this.activities = const [],
});
final String serverDate;
final String timezone;
final int coinBalance;
final RewardCheckinState checkin;
final List<RewardSection> sections;
final List<RewardActivity> activities;
factory RewardOverview.fromJson(Map<String, dynamic> json) {
return RewardOverview(
serverDate: json['serverDate'] as String? ?? '',
timezone: json['timezone'] as String? ?? '',
coinBalance: jsonToInt(json['coinBalance']),
checkin: RewardCheckinState.fromJson(_map(json['checkin'])),
sections: _maps(json['sections']).map(RewardSection.fromJson).toList(),
activities: _maps(
json['activities'],
).map(RewardActivity.fromJson).toList(),
);
}
}
/// 七日签到状态。
class RewardCheckinState {
const RewardCheckinState({
required this.taskId,
required this.checkedInToday,
required this.streak,
required this.currentDay,
this.days = const [],
});
final String taskId;
final bool checkedInToday;
final int streak;
final int currentDay;
final List<RewardCheckinDay> days;
factory RewardCheckinState.fromJson(Map<String, dynamic> json) {
return RewardCheckinState(
taskId: (json['taskId'] ?? '').toString(),
checkedInToday: json['checkedInToday'] == true,
streak: jsonToInt(json['streak']),
currentDay: jsonToInt(json['currentDay']),
days: _maps(json['days']).map(RewardCheckinDay.fromJson).toList(),
);
}
}
/// 签到周期中的一天。
class RewardCheckinDay {
const RewardCheckinDay({
required this.dayIndex,
required this.rewardCoin,
required this.checked,
required this.current,
});
final int dayIndex;
final int rewardCoin;
final bool checked;
final bool current;
factory RewardCheckinDay.fromJson(Map<String, dynamic> json) {
return RewardCheckinDay(
dayIndex: jsonToInt(json['dayIndex']),
rewardCoin: jsonToInt(json['rewardCoin']),
checked: json['checked'] == true,
current: json['current'] == true,
);
}
}
/// 一组奖励任务。
class RewardSection {
const RewardSection({
required this.code,
required this.title,
this.tasks = const [],
});
final String code;
final String title;
final List<RewardTask> tasks;
factory RewardSection.fromJson(Map<String, dynamic> json) {
return RewardSection(
code: json['code'] as String? ?? '',
title: json['title'] as String? ?? '',
tasks: _maps(json['tasks']).map(RewardTask.fromJson).toList(),
);
}
}
/// 奖励任务卡片。
class RewardTask {
const RewardTask({
required this.id,
required this.code,
required this.type,
required this.title,
required this.subtitle,
required this.ctaText,
required this.actionType,
required this.actionUrl,
required this.progressValue,
required this.targetValue,
required this.rewardCoin,
required this.status,
this.iconUrl = '',
});
final String id;
final String code;
final String type;
final String title;
final String subtitle;
final String ctaText;
final String actionType;
final String actionUrl;
final String iconUrl;
final int progressValue;
final int targetValue;
final int rewardCoin;
final String status;
bool get claimable => status == 'COMPLETED';
bool get claimed => status == 'CLAIMED';
double get progress =>
targetValue <= 0 ? 0 : (progressValue / targetValue).clamp(0.0, 1.0);
factory RewardTask.fromJson(Map<String, dynamic> json) {
return RewardTask(
id: (json['id'] ?? '').toString(),
code: json['code'] as String? ?? '',
type: json['type'] as String? ?? '',
title: json['title'] as String? ?? '',
subtitle: json['subtitle'] as String? ?? '',
ctaText: json['ctaText'] as String? ?? '',
actionType: json['actionType'] as String? ?? '',
actionUrl: json['actionUrl'] as String? ?? '',
iconUrl: json['iconUrl'] as String? ?? '',
progressValue: jsonToInt(json['progressValue']),
targetValue: jsonToInt(json['targetValue']),
rewardCoin: jsonToInt(json['rewardCoin']),
status: json['status'] as String? ?? 'IN_PROGRESS',
);
}
}
/// 奖励活动展示卡片。
class RewardActivity {
const RewardActivity({
required this.id,
required this.code,
required this.title,
required this.summary,
required this.ctaText,
required this.actionType,
required this.actionUrl,
this.coverUrl = '',
});
final String id;
final String code;
final String title;
final String summary;
final String ctaText;
final String actionType;
final String actionUrl;
final String coverUrl;
factory RewardActivity.fromJson(Map<String, dynamic> json) {
return RewardActivity(
id: (json['id'] ?? '').toString(),
code: json['code'] as String? ?? '',
title: json['title'] as String? ?? '',
summary: json['summary'] as String? ?? '',
ctaText: json['ctaText'] as String? ?? '',
actionType: json['actionType'] as String? ?? '',
actionUrl: json['actionUrl'] as String? ?? '',
coverUrl: json['coverUrl'] as String? ?? '',
);
}
}
/// 签到或任务领取后的余额变化结果。
class RewardActionResult {
const RewardActionResult({
required this.rewardCoin,
required this.coinBalance,
required this.alreadyCompleted,
});
final int rewardCoin;
final int coinBalance;
final bool alreadyCompleted;
factory RewardActionResult.fromCheckin(Map<String, dynamic> json) {
return RewardActionResult(
rewardCoin: jsonToInt(json['rewardCoin']),
coinBalance: jsonToInt(json['coinBalance']),
alreadyCompleted: json['alreadyCheckedIn'] == true,
);
}
factory RewardActionResult.fromClaim(Map<String, dynamic> json) {
return RewardActionResult(
rewardCoin: jsonToInt(json['rewardCoin']),
coinBalance: jsonToInt(json['coinBalance']),
alreadyCompleted: json['alreadyGranted'] == true,
);
}
}
Map<String, dynamic> _map(Object? value) {
if (value is Map<String, dynamic>) return value;
if (value is Map) return value.cast<String, dynamic>();
return const {};
}
List<Map<String, dynamic>> _maps(Object? value) {
if (value is! List) return const [];
return value
.whereType<Map>()
.map((item) => item.cast<String, dynamic>())
.toList();
}
+23
View File
@@ -125,6 +125,29 @@ class AuthApi {
return AuthSession.fromJson((data as Map).cast<String, dynamic>());
}
/// Google 登录:把客户端拿到的 idToken 交给后端校验并换发令牌。
///
/// 账号按 Google 返回的邮箱归并:邮箱已存在则直接登入,不存在则自动建号。
Future<AuthSession> googleLogin({
required String idToken,
required String deviceId,
String platform = '',
String osVersion = '',
String appVersion = '',
}) async {
final data = await _client.post(
'/app/auth/google',
body: {
'idToken': idToken,
'deviceId': deviceId,
'platform': platform,
'osVersion': osVersion,
'appVersion': appVersion,
},
);
return AuthSession.fromJson((data as Map).cast<String, dynamic>());
}
Future<AuthSession> refresh(
String refreshToken, {
String platform = '',
+30
View File
@@ -0,0 +1,30 @@
// Author: fruit.com.cn
import '../models/reward.dart';
import 'api_client.dart';
import 'response_decoders.dart';
/// 奖励中心接口,对应契约 §1.8.1。
class RewardApi {
const RewardApi(this._client);
final ApiClient _client;
Future<RewardOverview> overview(String locale) async {
final data = await _client.get(
'/app/rewards/overview',
query: {'locale': locale},
);
return RewardOverview.fromJson(ResponseDecoders.asMap(data));
}
Future<RewardActionResult> checkIn() async {
final data = await _client.post('/app/rewards/check-in');
return RewardActionResult.fromCheckin(ResponseDecoders.asMap(data));
}
Future<RewardActionResult> claim(String taskId) async {
final data = await _client.post('/app/rewards/tasks/$taskId/claim');
return RewardActionResult.fromClaim(ResponseDecoders.asMap(data));
}
}
@@ -0,0 +1,29 @@
// Author: fruit.com.cn
import '../models/reward.dart';
import '../remote/reward_api.dart';
/// 奖励中心数据来源。
abstract interface class RewardRepository {
Future<RewardOverview> fetchOverview(String locale);
Future<RewardActionResult> checkIn();
Future<RewardActionResult> claim(String taskId);
}
/// 奖励中心 HTTP 仓库。
class HttpRewardRepository implements RewardRepository {
const HttpRewardRepository(this._api);
final RewardApi _api;
@override
Future<RewardOverview> fetchOverview(String locale) => _api.overview(locale);
@override
Future<RewardActionResult> checkIn() => _api.checkIn();
@override
Future<RewardActionResult> claim(String taskId) => _api.claim(taskId);
}
@@ -103,6 +103,17 @@ class AuthController extends ChangeNotifier {
await _apply(session);
}
/// Google 登录:把 Google 返回的 idToken 交给后端校验并落登录态。
Future<void> signInWithGoogle(String idToken) async {
final session = await _api.googleLogin(
idToken: idToken,
deviceId: await _store.deviceId(),
platform: _platform,
osVersion: _osVersion,
);
await _apply(session);
}
Future<void> clear() async {
_session = null;
await _store.clear();
@@ -0,0 +1,121 @@
// Author: fruit.com.cn
import 'dart:async';
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart';
import 'package:google_sign_in/google_sign_in.dart';
/// Google 授权结局。
///
/// 插件调用与 IO 一样用结果对象返回,不把异常抛到页面:
/// 页面只需要区分"成功 / 用户取消 / 不可用 / 失败"四种情况。
class GoogleSignInOutcome {
const GoogleSignInOutcome._({
this.idToken,
this.canceled = false,
this.unavailable = false,
this.failed = false,
});
/// 成功拿到可交给后端校验的 idToken。
const GoogleSignInOutcome.success(String idToken) : this._(idToken: idToken);
/// 用户主动取消授权,页面不应提示错误。
const GoogleSignInOutcome.canceled() : this._(canceled: true);
/// 当前平台或构建不支持交互式登录。
const GoogleSignInOutcome.unavailable() : this._(unavailable: true);
/// 授权过程失败(网络、配置缺失、Google 拒绝等)。
const GoogleSignInOutcome.failed() : this._(failed: true);
/// Google 返回的 idToken;未成功时为 null。
final String? idToken;
/// 用户是否主动取消。
final bool canceled;
/// 平台是否不支持。
final bool unavailable;
/// 是否失败。
final bool failed;
/// 是否拿到了可用的 idToken。
bool get isSuccess => idToken != null && idToken!.isNotEmpty;
}
/// Google 登录客户端封装。
///
/// 只负责拉起 Google 授权并取出 idToken;换发本应用令牌由 AuthController 完成,
/// 插件调用与网络层各自独立,页面通过 [GoogleSignInOutcome] 判断结局。
class GoogleAuthService {
GoogleAuthService({GoogleSignIn? signIn})
: _signIn = signIn ?? GoogleSignIn.instance;
/// Android 必须显式传 serverClientId 才会返回 idToken,且要传 Web 客户端 id;
/// iOS 由 Info.plist 的 GIDClientID 决定,无需再传。
static const String _serverClientId =
'11115008580-dbqe47fhp5560nhrocrppiq7hcnlvl1s.apps.googleusercontent.com';
/// 授权等待上限:插件在账号被撤销后可能永不返回,必须有超时兜底,
/// 否则页面 loading 状态会一直卡住、按钮再也点不动。
static const Duration _authTimeout = Duration(seconds: 60);
final GoogleSignIn _signIn;
bool _initialized = false;
/// 拉起授权并返回结局;不会抛异常。
Future<GoogleSignInOutcome> signIn() async {
try {
await _ensureInitialized();
if (!_signIn.supportsAuthenticate()) {
return const GoogleSignInOutcome.unavailable();
}
// 先清掉插件缓存的旧账号:用户可能在 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;
await _signIn.initialize(
serverClientId: _isAndroid ? _serverClientId : null,
);
_initialized = true;
}
bool get _isAndroid => !kIsWeb && Platform.isAndroid;
}
@@ -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();
}
}
+29 -13
View File
@@ -30,6 +30,12 @@ abstract final class AppColors {
/// 首页及剧集播放页视频信息文案:参考设计图的 65% 半透明白。
static const homeFeedVideoCopy = Color(0xA6FFFFFF);
/// 首页观看全集按钮:与浮层玻璃语言一致的半透明白表面。
static const watchAllSurface = Color(0x26FFFFFF);
/// 观看全集按钮的极淡白色轮廓。
static const watchAllBorder = Color(0x0FFFFFFF);
/// 主操作、选中态、播放进度。旧品牌橙已全量映射为品牌红(2026-10 改版)。
static const blue = Color(0xFFF04E4E);
static const primary = blue;
@@ -51,20 +57,23 @@ abstract final class AppColors {
static const vipGoldStart = Color(0xFFF5D9A0);
static const vipGoldEnd = Color(0xFFE8B65A);
/// 「我的」页 VIP 卡:品牌酒红、暖黑与香槟金共同形成会员层级。
static const vipCardStart = Color(0xFF351318);
static const vipCardMiddle = Color(0xFF1B1112);
static const vipCardEnd = Color(0xFF0D0B0C);
static const vipCardBorder = Color(0x66F5D9A0);
static const vipCardRedGlow = Color(0x52F04E4E);
static const vipCardGoldGlow = Color(0x38F3B34C);
static const vipCardText = Color(0xFFFFF5E6);
static const vipCardMuted = Color(0xB3F5D9A0);
static const vipBadgeSurface = Color(0x24FFFFFF);
/// 「我的」页 VIP 卡:品牌红、暖黑与中性灰形成统一层级。
static const vipCardStart = Color(0xFF21191C);
static const vipCardMiddle = Color(0xFF181719);
static const vipCardEnd = Color(0xFF141416);
static const vipCardBorder = Color(0x2EFFFFFF);
static const vipCardRedGlow = Color(0x2EF04E4E);
static const vipCardGoldGlow = Color(0x16F5D9A0);
static const vipArtGlow = Color(0x55F04E4E);
static const vipArtStart = Color(0xFFE45757);
static const vipArtEnd = Color(0xFF6E1F2D);
static const vipCardText = Color(0xFFF5F5F7);
static const vipCardMuted = Color(0xB3B0B0B0);
static const vipBadgeSurface = Color(0x24F04E4E);
static const vipBenefitSurface = Color(0x12FFFFFF);
static const vipBenefitBorder = Color(0x33F5D9A0);
static const vipButtonStart = brandRed;
static const vipButtonEnd = Color(0xFFFF745E);
static const vipBenefitBorder = Color(0x14FFFFFF);
static const vipButtonStart = Color(0xFFF04E4E);
static const vipButtonEnd = Color(0xFFC93648);
static const bronze = Color(0xFFCD7F32);
static const bronzeDeep = Color(0xFFA9713A);
static const rankSilver = Color(0xFFC0C0C0);
@@ -176,6 +185,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;
/// 卡片阴影(深色几乎不用阴影)。
@@ -216,6 +226,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);
+18 -14
View File
@@ -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;
@@ -273,20 +273,24 @@ abstract final class AppDimens {
static const double mineSectionLinkFont = 12;
static const double mineSectionGap = 12;
/// 「我的」页会员卡。高度由内容撑开,上下内边距恒为 [mineVipPadding]。
/// 「我的」页紧凑会员横幅尺寸。
static const double mineVipRadius = 18;
static const double mineVipPadding = 16;
static const double mineVipTitleFont = 22;
static const double mineVipSubtitleFont = 12;
static const double mineVipPadding = 10;
static const double mineVipTitleFont = 21;
static const double mineVipSubtitleFont = 11;
static const double mineVipEmblem = 48;
static const double mineVipEmblemIcon = 28;
static const double mineVipActionWidth = 100;
static const double mineVipArtWidth = 84;
static const double mineVipArtHeight = 84;
static const double mineVipArtGlow = 56;
static const double mineVipArtSurface = 44;
static const double mineVipArtIcon = 22;
static const double mineVipActionWidth = 86;
static const double mineVipBenefitFont = 10.5;
static const double mineVipBenefitBox = 34;
static const double mineVipBenefitBoxRadius = 11;
static const double mineVipBenefitGlyph = 19;
static const double mineVipBenefitRowGap = 6;
static const double mineVipButtonHeight = tapTarget;
static const double mineVipBenefitSurface = 28;
static const double mineVipBenefitGlyph = 16;
static const double mineVipBenefitRowGap = 2;
static const double mineVipButtonHeight = 26;
static const double mineContinueCardWidth = 96;
static const double mineContinueRowHeight = 172;
static const double mineContinueTitleFont = 13;
+17 -5
View File
@@ -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,10 +92,20 @@ 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,
AppPressScale(
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppDimens.navIcon),
boxShadow: item.selected
? const [
BoxShadow(
color: AppColors.navSelectedGlow,
blurRadius: 12,
spreadRadius: 1,
),
]
: const [],
),
child:
item.customIcon ??
(assetPath == null
@@ -121,6 +132,7 @@ class _NavDestination extends StatelessWidget {
),
)),
),
),
const SizedBox(height: AppDimens.navLabelGap),
Text(
item.label,
+53
View File
@@ -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,
);
}
}
+6 -1
View File
@@ -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(),
),
),
+7 -1
View File
@@ -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,
+1 -1
View File
@@ -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),
),
);
}
+30 -2
View File
@@ -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);
+42
View File
@@ -32,6 +32,7 @@
"authContinueGoogle": "Continue with Google",
"authPhoneSignIn": "Phone sign in",
"authEmailSignIn": "Email sign in",
"authGoogleFailed": "Google sign in failed, please try again",
"suspense": "Suspense",
"romance": "Romance",
"actionGenre": "Action",
@@ -859,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": {
+42
View File
@@ -32,6 +32,7 @@
"authContinueGoogle": "Lanjutkan dengan Google",
"authPhoneSignIn": "Masuk dengan ponsel",
"authEmailSignIn": "Masuk dengan email",
"authGoogleFailed": "Gagal masuk dengan Google, silakan coba lagi",
"suspense": "Thriller",
"romance": "Romantis",
"actionGenre": "Aksi",
@@ -816,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": {
+180
View File
@@ -292,6 +292,12 @@ abstract class AppLocalizations {
/// **'Email sign in'**
String get authEmailSignIn;
/// No description provided for @authGoogleFailed.
///
/// In en, this message translates to:
/// **'Google sign in failed, please try again'**
String get authGoogleFailed;
/// No description provided for @suspense.
///
/// In en, this message translates to:
@@ -3610,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:
+101
View File
@@ -106,6 +106,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get authEmailSignIn => 'Email sign in';
@override
String get authGoogleFailed => 'Google sign in failed, please try again';
@override
String get suspense => 'Suspense';
@@ -1898,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';
+102
View File
@@ -106,6 +106,9 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get authEmailSignIn => 'Masuk dengan email';
@override
String get authGoogleFailed => 'Gagal masuk dengan Google, silakan coba lagi';
@override
String get suspense => 'Thriller';
@@ -1900,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';
+98
View File
@@ -104,6 +104,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get authEmailSignIn => '邮箱登录';
@override
String get authGoogleFailed => 'Google 登录失败,请稍后重试';
@override
String get suspense => '悬疑';
@@ -1872,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';
+42
View File
@@ -32,6 +32,7 @@
"authContinueGoogle": "使用 Google 继续",
"authPhoneSignIn": "手机号登录",
"authEmailSignIn": "邮箱登录",
"authGoogleFailed": "Google 登录失败,请稍后重试",
"suspense": "悬疑",
"romance": "都市爱情",
"actionGenre": "动作",
@@ -778,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": {
+91 -24
View File
@@ -9,6 +9,7 @@ import '../../core/data/remote/api_client.dart';
import '../../core/formatting/error_text.dart';
import '../../core/data/services/app_config_controller.dart';
import '../../core/data/services/auth_controller.dart';
import '../../core/data/services/google_auth_service.dart';
import '../../core/platform/haptics.dart';
import '../../core/platform/keyboard_inset.dart';
import '../../core/theme/app_colors.dart';
@@ -20,7 +21,6 @@ import '../../core/widgets/subpage_header.dart';
import '../../l10n/app_localizations.dart';
import 'auth_legal_links.dart';
import 'auth_logic.dart';
import 'phone_auth_sheet.dart';
part 'auth_widgets.dart';
part 'auth_form_widgets.dart';
@@ -31,6 +31,7 @@ const double _compactBrandMarkSize = 48;
const double _brandMarkRadius = 26;
const double _compactBrandMarkRadius = 14;
const String _appLogoAsset = 'assets/icon/ymb.png';
const String _googleIconAsset = 'assets/icon/google_g.png';
const double _contentMaxWidth = 440;
const double _fieldHeight = 52;
const double _fieldRadius = 12;
@@ -49,6 +50,8 @@ const double _entryButtonHeight = 52;
const double _entryButtonRadius = 26;
const double _entryButtonFontSize = 15;
const double _entryIconSize = 20;
const double _loadingStrokeWidth = 2;
const Color _googleOnLight = Color(0xFF1F1F1F);
const Duration _enterDuration = Duration(milliseconds: 220);
Widget _brandMarkWidget({
@@ -80,6 +83,7 @@ class AuthPage extends StatefulWidget {
class _AuthPageState extends State<AuthPage> {
late AuthLogic _logic;
late final GoogleAuthService _googleAuth;
late final TextEditingController _emailController;
late final TextEditingController _passwordController;
late final FocusNode _emailFocus;
@@ -94,6 +98,7 @@ class _AuthPageState extends State<AuthPage> {
void initState() {
super.initState();
_logic = AuthLogic(signUpMode: widget.signUpMode);
_googleAuth = GoogleAuthService();
_emailController = TextEditingController();
_passwordController = TextEditingController();
_emailFocus = FocusNode(debugLabel: 'auth-email-focus');
@@ -248,6 +253,42 @@ class _AuthPageState extends State<AuthPage> {
showCenteredToast(context, failureMessage);
}
/// Google 登录:先取 Google idToken,再交给后端换发本应用令牌。
///
/// 用户取消不提示错误;平台不支持或授权失败提示重试。
Future<void> _signInWithGoogle() async {
if (_logic.busy) return;
final l10n = AppLocalizations.of(context)!;
unawaited(context.read<Haptics>().lightImpact());
FocusScope.of(context).unfocus();
setState(() {
_logic.busy = true;
_logic.errorMessage = null;
});
final auth = context.read<AuthController>();
String? failureMessage;
try {
final outcome = await _googleAuth.signIn();
if (!mounted) return;
if (outcome.canceled) return;
if (!outcome.isSuccess) {
failureMessage = l10n.authGoogleFailed;
} else {
await auth.signInWithGoogle(outcome.idToken!);
if (!mounted) return;
Navigator.of(context).maybePop();
}
} on ApiException catch (error) {
failureMessage = userFacingApiMessage(l10n, error.code, error.message);
} catch (_) {
failureMessage = l10n.authNetworkError;
} finally {
if (mounted) setState(() => _logic.busy = false);
}
if (!mounted || failureMessage == null) return;
showCenteredToast(context, failureMessage);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
@@ -437,21 +478,23 @@ class _AuthPageState extends State<AuthPage> {
),
),
const Spacer(flex: 2),
_AuthEntryButton(
key: const Key('auth-entry-phone'),
label: l10n.authPhoneSignIn,
icon: Icons.phone_android_rounded,
primary: true,
onTap: _openPhoneSheet,
),
const SizedBox(height: 12),
_AuthEntryButton(
key: const Key('auth-entry-email'),
label: l10n.authEmailSignIn,
icon: Icons.mail_outline_rounded,
primary: false,
primary: true,
onTap: () => setState(() => _emailEntryOpen = true),
),
const SizedBox(height: 12),
_AuthEntryButton(
key: const Key('auth-entry-google'),
label: l10n.authContinueGoogle,
imageAsset: _googleIconAsset,
light: true,
primary: false,
loading: _logic.busy,
onTap: _signInWithGoogle,
),
const SizedBox(height: 14),
const Opacity(opacity: 0.75, child: AuthLegalLinks()),
const Spacer(flex: 6),
@@ -459,14 +502,6 @@ class _AuthPageState extends State<AuthPage> {
),
);
Future<void> _openPhoneSheet() async {
final signedIn = await showPhoneAuthSheet(
context,
signUp: _logic.signUpMode,
);
if (signedIn == true && mounted) Navigator.of(context).maybePop();
}
Widget _authCard(AppLocalizations l10n) => _AuthCard(
state: _AuthCardState(
l10n: l10n,
@@ -513,6 +548,9 @@ class _AuthEntryButton extends StatelessWidget {
required this.primary,
required this.onTap,
this.icon,
this.imageAsset,
this.light = false,
this.loading = false,
});
final String label;
@@ -520,13 +558,24 @@ class _AuthEntryButton extends StatelessWidget {
final VoidCallback onTap;
final IconData? icon;
/// 需要显示彩色品牌图标时用图片;与 [icon] 二选一。
final String? imageAsset;
/// 白底深字样式(Google 官方按钮规范),用于第三方登录入口。
final bool light;
/// 请求进行中:显示转圈并禁止重复点击。
final bool loading;
@override
Widget build(BuildContext context) {
// 主按钮纯品牌红;次按钮为无边框深色玻璃(白 6%),Apple 风格。
// 主按钮纯品牌红;次按钮为无边框深色玻璃(白 6%);第三方登录用白底深字。
final background = primary
? AppColors.brandRed
: Colors.white.withValues(alpha: 0.06);
final foreground = primary ? AppColors.onPrimary : AppColors.onSurface;
: (light ? Colors.white : Colors.white.withValues(alpha: 0.06));
final foreground = primary
? AppColors.onPrimary
: (light ? _googleOnLight : AppColors.onSurface);
return Material(
color: background,
shape: RoundedRectangleBorder(
@@ -534,16 +583,34 @@ class _AuthEntryButton extends StatelessWidget {
),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
onTap: loading ? null : onTap,
borderRadius: BorderRadius.circular(_entryButtonRadius),
child: SizedBox(
height: _entryButtonHeight,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (icon != null)
if (loading) ...[
SizedBox(
width: _entryIconSize,
height: _entryIconSize,
child: CircularProgressIndicator(
strokeWidth: _loadingStrokeWidth,
valueColor: AlwaysStoppedAnimation<Color>(foreground),
),
),
const SizedBox(width: 10),
] else if (imageAsset != null) ...[
Image.asset(
imageAsset!,
width: _entryIconSize,
height: _entryIconSize,
),
const SizedBox(width: 10),
] else if (icon != null) ...[
Icon(icon, size: _entryIconSize, color: foreground),
if (icon != null) const SizedBox(width: 8),
const SizedBox(width: 8),
],
Flexible(
child: Text(
label,
+1 -1
View File
@@ -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 {
+1
View File
@@ -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,
};
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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;
+1 -3
View File
@@ -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,6 +46,7 @@ class FollowingHistoryCard extends StatelessWidget {
AppDimens.followingHistoryCoverRadius,
),
onTap: onTap,
child: AppPressScale(
child: SizedBox(
height: AppDimens.followingHistoryCoverHeight,
child: Row(
@@ -56,6 +58,7 @@ class FollowingHistoryCard extends StatelessWidget {
],
),
),
),
);
}
+11 -8
View File
@@ -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,7 +294,8 @@ class _FollowingPageState extends State<FollowingPage> {
return ValueListenableBuilder<bool>(
valueListenable: _editing,
builder: (context, editing, _) {
return InkWell(
return AppPressScale(
child: InkWell(
key: const Key('following-edit'),
borderRadius: BorderRadius.circular(8),
onTap: _toggleEditing,
@@ -309,6 +311,7 @@ class _FollowingPageState extends State<FollowingPage> {
),
),
),
),
);
},
);
@@ -320,7 +323,8 @@ class _FollowingPageState extends State<FollowingPage> {
String keyName,
bool selected,
) {
return InkWell(
return AppPressScale(
child: InkWell(
key: Key('following-tab-$keyName'),
onTap: () => _selectTab(tab),
borderRadius: BorderRadius.circular(8),
@@ -342,9 +346,7 @@ class _FollowingPageState extends State<FollowingPage> {
AnimatedContainer(
duration: AppDimens.followingTabAnimationDuration,
curve: Curves.easeOut,
width: selected
? AppDimens.followingSegmentIndicatorWidth
: 0,
width: selected ? AppDimens.followingSegmentIndicatorWidth : 0,
height: AppDimens.followingSegmentIndicatorHeight,
decoration: BoxDecoration(
color: AppColors.brandRed,
@@ -355,6 +357,7 @@ class _FollowingPageState extends State<FollowingPage> {
),
],
),
),
);
}
+10 -6
View File
@@ -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);
}
+28 -5
View File
@@ -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});
+1 -1
View File
@@ -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 {
+1
View File
@@ -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,
};
+5 -2
View File
@@ -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,6 +177,7 @@ class _MineActionCell extends StatelessWidget {
child: InkWell(
key: item.key,
onTap: item.onTap,
child: AppPressScale(
child: Semantics(
button: true,
label: item.label,
@@ -189,6 +191,7 @@ class _MineActionCell extends StatelessWidget {
),
),
),
),
);
}
@@ -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 ?? '',
+75 -133
View File
@@ -4,13 +4,15 @@ 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;
const double _vipGradientMiddleStop = 0.48;
const double _vipGradientMiddleStop = 0.62;
const double _vipGlowRadius = 0.9;
const double _vipButtonLetterSpacing = 0.2;
/// 「我的」页高级 VIP 卡:品牌信息、会员徽章、开通按钮与横排权益。
/// 「我的」页 VIP 会员卡:主视觉、权益横排与开通入口。
class MineVipBanner extends StatelessWidget {
const MineVipBanner({super.key, required this.onOpen});
@@ -28,6 +30,7 @@ class MineVipBanner extends StatelessWidget {
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onOpen,
child: AppPressScale(
child: MediaQuery.withClampedTextScaling(
maxScaleFactor: _vipMaxTextScale,
child: Stack(
@@ -42,6 +45,7 @@ class MineVipBanner extends StatelessWidget {
),
),
),
),
);
}
@@ -58,13 +62,7 @@ class MineVipBanner extends StatelessWidget {
stops: [0, _vipGradientMiddleStop, 1],
),
borderRadius: BorderRadius.circular(AppDimens.mineVipRadius),
border: Border.all(color: AppColors.vipCardBorder),
boxShadow: const [
BoxShadow(
color: AppColors.vipCardRedGlow,
blurRadius: AppDimens.spaceXl,
offset: Offset(0, AppDimens.spaceSm),
),
BoxShadow(
color: AppColors.shadowSoft,
blurRadius: AppDimens.spaceLg,
@@ -87,16 +85,16 @@ class _VipContent extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(child: _VipCopy(l10n: l10n)),
Expanded(
child: _VipCopy(l10n: l10n, onOpen: onOpen),
),
const SizedBox(width: AppDimens.spaceSm),
_VipAction(l10n: l10n, onOpen: onOpen),
const _VipArt(),
],
),
const SizedBox(height: AppDimens.spaceSm),
const Divider(height: 1, color: AppColors.vipBenefitBorder),
const SizedBox(height: AppDimens.spaceSm),
const SizedBox(height: AppDimens.spaceXs),
_VipBenefits(l10n: l10n),
],
);
@@ -104,9 +102,10 @@ class _VipContent extends StatelessWidget {
}
class _VipCopy extends StatelessWidget {
const _VipCopy({required this.l10n});
const _VipCopy({required this.l10n, required this.onOpen});
final AppLocalizations l10n;
final VoidCallback onOpen;
@override
Widget build(BuildContext context) {
@@ -114,8 +113,6 @@ class _VipCopy extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_VipPill(label: l10n.vipMembership),
const SizedBox(height: AppDimens.spaceSm),
Text(
l10n.vipBrandName,
maxLines: 1,
@@ -123,14 +120,14 @@ class _VipCopy extends StatelessWidget {
style: const TextStyle(
color: AppColors.vipCardText,
fontSize: AppDimens.mineVipTitleFont,
fontWeight: FontWeight.w700,
letterSpacing: -0.4,
fontWeight: FontWeight.w800,
letterSpacing: 1.0,
),
),
const SizedBox(height: AppDimens.spaceXs),
Text(
l10n.mineVipSubtitle,
maxLines: 2,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.vipCardMuted,
@@ -138,90 +135,28 @@ class _VipCopy extends StatelessWidget {
height: 1.3,
),
),
const SizedBox(height: AppDimens.spaceXs),
_VipOpenButton(label: l10n.vipOpenNow, onOpen: onOpen),
],
);
}
}
class _VipPill extends StatelessWidget {
const _VipPill({required this.label});
final String label;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: AppDimens.spaceSm,
vertical: AppDimens.spaceXs,
),
decoration: BoxDecoration(
color: AppColors.vipBadgeSurface,
border: Border.all(color: AppColors.vipBenefitBorder),
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
),
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.vipGoldStart,
fontSize: AppDimens.chipFont,
fontWeight: FontWeight.w700,
letterSpacing: 1.1,
),
),
);
}
}
class _VipAction extends StatelessWidget {
const _VipAction({required this.l10n, required this.onOpen});
final AppLocalizations l10n;
final VoidCallback onOpen;
class _VipArt extends StatelessWidget {
const _VipArt();
@override
Widget build(BuildContext context) {
return SizedBox(
width: AppDimens.mineVipActionWidth,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const _VipEmblem(),
const SizedBox(height: AppDimens.spaceXs),
_VipOpenButton(label: l10n.vipOpenNow, onOpen: onOpen),
],
),
);
}
}
class _VipEmblem extends StatelessWidget {
const _VipEmblem();
@override
Widget build(BuildContext context) {
return Container(
key: const Key('mine-vip-art'),
width: AppDimens.mineVipArtWidth,
height: AppDimens.mineVipArtHeight,
child: Image.asset(
'assets/icon/vip_membership_badge.png',
key: const Key('mine-vip-emblem'),
width: AppDimens.mineVipEmblem,
height: AppDimens.mineVipEmblem,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [AppColors.vipGoldStart, AppColors.vipGoldEnd],
),
border: Border.all(color: AppColors.vipCardText),
boxShadow: const [
BoxShadow(color: AppColors.vipCardGoldGlow, blurRadius: 18),
],
),
child: const Icon(
Icons.workspace_premium_rounded,
color: AppColors.vipCardStart,
size: AppDimens.mineVipEmblemIcon,
width: AppDimens.mineVipArtWidth,
height: AppDimens.mineVipArtHeight,
fit: BoxFit.contain,
),
);
}
@@ -239,34 +174,32 @@ class _VipOpenButton extends StatelessWidget {
key: const Key('mine-vip-open'),
width: AppDimens.mineVipActionWidth,
height: AppDimens.mineVipButtonHeight,
child: DecoratedBox(
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [AppColors.vipButtonStart, AppColors.vipButtonEnd],
),
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
boxShadow: const [
BoxShadow(color: AppColors.vipCardRedGlow, blurRadius: 14),
],
),
child: Material(
color: AppColors.transparent,
child: InkWell(
onTap: onOpen,
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
child: Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.textPrimary,
color: AppColors.vipGoldStart,
fontSize: AppDimens.body,
fontWeight: FontWeight.w700,
letterSpacing: _vipButtonLetterSpacing,
),
),
),
const SizedBox(width: AppDimens.spaceXs),
const Icon(
Icons.arrow_forward_rounded,
color: AppColors.vipGoldStart,
size: AppDimens.mineVipBenefitGlyph,
),
],
),
),
);
@@ -286,11 +219,20 @@ class _VipBenefits extends StatelessWidget {
(Icons.hd_outlined, l10n.vipBenefitHd),
(Icons.download_for_offline_outlined, l10n.vipBenefitFastCache),
];
return Row(
return Column(
key: const Key('mine-vip-benefits'),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var index = 0; index < items.length; index++)
_row(items, 0),
const SizedBox(height: AppDimens.spaceXs),
_row(items, 2),
],
);
}
Widget _row(List<(IconData, String)> items, int start) {
return Row(
children: [
for (var index = start; index < start + 2; index++)
Expanded(
child: _VipBenefit(
itemKey: Key('mine-vip-benefit-$index'),
@@ -316,32 +258,16 @@ class _VipBenefit extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
return Row(
key: itemKey,
mainAxisAlignment: MainAxisAlignment.start,
children: [
Container(
width: AppDimens.mineVipBenefitBox,
height: AppDimens.mineVipBenefitBox,
decoration: BoxDecoration(
color: AppColors.vipBenefitSurface,
border: Border.all(color: AppColors.vipBenefitBorder),
borderRadius: BorderRadius.circular(
AppDimens.mineVipBenefitBoxRadius,
),
),
child: Icon(
icon,
color: AppColors.vipGoldStart,
size: AppDimens.mineVipBenefitGlyph,
),
),
const SizedBox(height: AppDimens.mineVipBenefitRowGap),
Text(
_VipBenefitGlyph(icon: icon),
const SizedBox(width: AppDimens.spaceXs),
Expanded(
child: Text(
label,
maxLines: 2,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: const TextStyle(
color: AppColors.vipCardMuted,
fontSize: AppDimens.mineVipBenefitFont,
@@ -349,11 +275,27 @@ class _VipBenefit extends StatelessWidget {
height: 1.15,
),
),
),
],
);
}
}
class _VipBenefitGlyph extends StatelessWidget {
const _VipBenefitGlyph({required this.icon});
final IconData icon;
@override
Widget build(BuildContext context) {
return Icon(
icon,
color: AppColors.vipGoldStart,
size: AppDimens.mineVipBenefitGlyph,
);
}
}
class _VipAmbientGlow extends StatelessWidget {
const _VipAmbientGlow();
@@ -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';
@@ -35,13 +36,8 @@ const _metadataStrutStyle = StrutStyle(
const double _watchAllInset = 12;
const double _watchAllRadius = 6;
const double _watchAllFont = 16;
const _watchAllGradient = LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [AppColors.mineStatShade, AppColors.brandRed, AppColors.vipButtonEnd],
);
/// 「观看完整剧集」渐变按钮高度,overlay 用它安排底部避让。
/// 「观看完整剧集」按钮高度,overlay 用它安排底部避让。
const double homeFeedWatchAllHeight = _watchAllHeight;
/// 播放量紧凑展示(跟随语言):zh 20万 / en 200K / id 200 rb。
@@ -212,15 +208,20 @@ class HomeFeedWatchAllBar extends StatelessWidget {
return ClipRRect(
borderRadius: BorderRadius.circular(_watchAllRadius),
child: DecoratedBox(
key: const Key('home-feed-full-series-gradient'),
decoration: const BoxDecoration(gradient: _watchAllGradient),
key: const Key('home-feed-full-series-surface'),
decoration: const BoxDecoration(
color: AppColors.watchAllSurface,
border: Border.fromBorderSide(
BorderSide(color: AppColors.watchAllBorder),
),
),
child: Material(
key: const Key('home-feed-full-series-bg'),
color: AppColors.transparent,
child: InkWell(
key: const Key('home-feed-full-series'),
onTap: onTap,
child: _content(l10n),
child: AppPressScale(child: _content(l10n)),
),
),
),
@@ -12,9 +12,9 @@ const double _defaultIconSize = 32;
const double _defaultCountFont = 12;
const double _defaultIconCountGap = 2;
const double _defaultItemGap = 24;
const _tapDuration = Duration(milliseconds: 360);
const double _popScale = 1.34;
const double _cancelScale = 0.68;
const _tapDuration = Duration(milliseconds: 220);
const double _popScale = 1.14;
const double _cancelScale = 0.86;
const double _popWeight = 40;
const double _settleWeight = 60;
final _growTween = _tapTween(_popScale);
@@ -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,13 +35,16 @@ class PlayerCenterToggle extends StatelessWidget {
label: playing ? 'Pause' : 'Play',
child: GestureDetector(
onTap: onTap,
child: Container(
child: SizedBox(
width: _toggleSize,
height: _toggleSize,
child: DecoratedBox(
key: const Key('player-center-toggle-surface'),
decoration: const BoxDecoration(
color: _toggleBackground,
shape: BoxShape.circle,
gradient: _toggleGradient,
),
child: Center(
child: Icon(
playing ? Icons.pause_rounded : Icons.play_arrow_rounded,
color: AppColors.onPrimary,
@@ -46,6 +53,8 @@ class PlayerCenterToggle extends StatelessWidget {
),
),
),
),
),
);
}
}
+4 -1
View File
@@ -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,13 +390,14 @@ 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: AppPressScale(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: _bottomActionInset,
@@ -433,6 +435,7 @@ class PlayerPlaybackOverlay extends StatelessWidget {
),
),
),
),
const SizedBox(width: 8),
],
_BottomIconButton(
@@ -39,7 +39,7 @@ class PlayerTapGestureRegion extends StatefulWidget {
}
class _PlayerTapGestureRegionState extends State<PlayerTapGestureRegion> {
static const Duration _singleTapDelay = Duration(milliseconds: 320);
static const Duration _singleTapDelay = Duration(milliseconds: 240);
Timer? _singleTapTimer;
DateTime? _lastDownAt;
@@ -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}'),
+1
View File
@@ -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,7 +29,8 @@ class RecommendSectionCard extends StatelessWidget {
final tagLabel = drama.localizedTag(
Localizations.localeOf(context).languageCode,
);
return SizedBox(
return AppPressScale(
child: SizedBox(
width: AppDimens.recommendCardWidth,
child: InkWell(
key: Key('recommend-card-$sectionKey-${drama.id}'),
@@ -99,6 +101,7 @@ class RecommendSectionCard extends StatelessWidget {
],
),
),
),
);
}
+273
View File
@@ -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,
),
),
),
],
),
);
}
}
+519
View File
@@ -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,
),
),
);
}
}
+421
View File
@@ -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(key: Key('reward-balance-coin-icon'), 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),
),
),
);
}
}
+196
View File
@@ -0,0 +1,196 @@
// 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 _coinHighlightRatio = 0.18;
const double _coinHighlightOpacity = 0.54;
const double _coinLetterShadowBlur = 1.5;
const double _coinLetterShadowOffset = 0.8;
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 _coinHighlight = Color(0xFFFFF4CC);
const Color _coinLetterShadow = Color(0xFFB66A16);
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: Stack(
alignment: Alignment.center,
children: [
Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [_coinInnerStart, _coinInnerEnd],
),
border: Border.all(color: _coinBorder),
),
),
Positioned(
top: size * 0.12,
left: size * 0.18,
child: Container(
width: size * _coinHighlightRatio,
height: size * _coinHighlightRatio,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _coinHighlight.withValues(alpha: _coinHighlightOpacity),
),
),
),
Text(
'N',
style: TextStyle(
color: AppColors.textPrimary,
fontSize: size * _coinLetterRatio,
fontWeight: FontWeight.w900,
height: 1,
shadows: const [
Shadow(
color: _coinLetterShadow,
blurRadius: _coinLetterShadowBlur,
offset: Offset(
_coinLetterShadowOffset,
_coinLetterShadowOffset,
),
),
],
),
),
],
),
);
}
}
/// 奖励中心签到按钮的广告标识。
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;
+57 -1
View File
@@ -207,6 +207,54 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
google_identity_services_web:
dependency: transitive
description:
name: google_identity_services_web
sha256: "5d187c46dc59e02646e10fe82665fc3884a9b71bc1c90c2b8b749316d33ee454"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.3.3+1"
google_sign_in:
dependency: "direct main"
description:
name: google_sign_in
sha256: "521031b65853b4409b8213c0387d57edaad7e2a949ce6dea0d8b2afc9cb29763"
url: "https://pub.flutter-io.cn"
source: hosted
version: "7.2.0"
google_sign_in_android:
dependency: transitive
description:
name: google_sign_in_android
sha256: af51a005b7c3c3f54cb5e7d04dd5db96f081e6f92b36468c60d14726a047f918
url: "https://pub.flutter-io.cn"
source: hosted
version: "7.2.19"
google_sign_in_ios:
dependency: transitive
description:
name: google_sign_in_ios
sha256: bc03e8f8cf0e5a5cbbc1d582516e5f792c734f0a41d324d4760dcb8bef523b28
url: "https://pub.flutter-io.cn"
source: hosted
version: "6.4.0"
google_sign_in_platform_interface:
dependency: transitive
description:
name: google_sign_in_platform_interface
sha256: "7f59208c42b415a3cca203571128d6f84f885fead2d5b53eb65a9e27f2965bb5"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.1.0"
google_sign_in_web:
dependency: transitive
description:
name: google_sign_in_web
sha256: d473003eeca892f96a01a64fc803378be765071cb0c265ee872c7f8683245d14
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.3"
hooks:
dependency: transitive
description:
@@ -223,6 +271,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.15.7"
http:
dependency: transitive
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.6.0"
http_parser:
dependency: transitive
description:
@@ -779,4 +835,4 @@ packages:
version: "3.1.4"
sdks:
dart: ">=3.13.0 <4.0.0"
flutter: ">=3.44.0"
flutter: ">=3.47.0"
+1
View File
@@ -51,6 +51,7 @@ dependencies:
floating: ^6.0.0
# 运行时权限:systemAlertWindow(「显示在其他应用上层」)
permission_handler: ^13.0.2
google_sign_in: ^7.2.0
dev_dependencies:
flutter_test:
+2 -9
View File
@@ -53,22 +53,15 @@ void main() {
);
await tester.pumpAndSettle();
expect(find.byKey(const Key('auth-entry-google')), findsNothing);
expect(find.byKey(const Key('auth-entry-phone')), findsOneWidget);
expect(find.byKey(const Key('auth-entry-phone')), findsNothing);
expect(find.byKey(const Key('auth-entry-email')), findsOneWidget);
expect(find.byKey(const Key('auth-entry-google')), findsOneWidget);
expect(find.byKey(const Key('auth-legal')), findsOneWidget);
expect(
tester.getTopLeft(find.byKey(const Key('auth-landing'))).dy,
lessThan(80),
);
await tester.tap(find.byKey(const Key('auth-entry-phone')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('phone-auth-sheet')), findsOneWidget);
await tester.tapAt(const Offset(8, 8));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('auth-entry-email')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('auth-email')), findsOneWidget);
+19 -18
View File
@@ -90,28 +90,17 @@ void main() {
tester.getSize(find.byKey(const Key('auth-brand-logo'))),
const Size(88, 88),
);
expect(find.byKey(const Key('auth-entry-google')), findsNothing);
expect(find.byKey(const Key('auth-entry-phone')), findsOneWidget);
expect(find.byKey(const Key('auth-entry-google')), findsOneWidget);
expect(find.byKey(const Key('auth-entry-phone')), findsNothing);
expect(find.byKey(const Key('auth-entry-email')), findsOneWidget);
expect(find.byKey(const Key('auth-legal')), findsOneWidget);
expect(
tester.getSize(find.byKey(const Key('auth-entry-phone'))).height,
52,
);
expect(
tester.getSize(find.byKey(const Key('auth-entry-email'))).height,
52,
);
final phoneButton = tester.widget<Material>(
find.descendant(
of: find.byKey(const Key('auth-entry-phone')),
matching: find.byType(Material),
),
);
expect(phoneButton.color, AppColors.brandRed);
expect(
(phoneButton.shape! as RoundedRectangleBorder).borderRadius,
BorderRadius.circular(26),
tester.getSize(find.byKey(const Key('auth-entry-google'))).height,
52,
);
final emailButton = tester.widget<Material>(
find.descendant(
@@ -119,8 +108,20 @@ void main() {
matching: find.byType(Material),
),
);
// 次按钮是「白 6%」玻璃拟态,不再有描边。
expect(emailButton.color, Colors.white.withValues(alpha: 0.06));
// 邮箱是主入口,用品牌红。
expect(emailButton.color, AppColors.brandRed);
expect(
(emailButton.shape! as RoundedRectangleBorder).borderRadius,
BorderRadius.circular(26),
);
final googleButton = tester.widget<Material>(
find.descendant(
of: find.byKey(const Key('auth-entry-google')),
matching: find.byType(Material),
),
);
// 第三方登录按 Google 规范用白底深字。
expect(googleButton.color, Colors.white);
final glow = tester.widget<DecoratedBox>(
find.byKey(const Key('auth-red-glow')),
@@ -237,7 +238,7 @@ void main() {
expect(tester.getCenter(logo).dx, closeTo(195, 0.1));
expect(
tester.getBottomLeft(logo).dy,
lessThan(tester.getTopLeft(find.byKey(const Key('auth-entry-phone'))).dy),
lessThan(tester.getTopLeft(find.byKey(const Key('auth-entry-email'))).dy),
);
});
+43
View File
@@ -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);
});
}
+1 -1
View File
@@ -114,7 +114,7 @@ void main() {
);
expect(
coverClip.borderRadius,
BorderRadius.circular(AppDimens.homeCardRadius),
BorderRadius.circular(AppDimens.coverRadius),
);
final titleFinder = find.descendant(
of: card,
+5 -12
View File
@@ -260,23 +260,16 @@ void main() {
_expectNoShadows(tester);
});
testWidgets('should_use_small_radius_and_brand_gradient_when_watching_all', (
tester,
) async {
testWidgets('should_use_reference_surface_when_watching_all', (tester) async {
await _pump(tester);
final background = find.byKey(const Key('home-feed-full-series-bg'));
final decorated = tester.widget<DecoratedBox>(
find.byKey(const Key('home-feed-full-series-gradient')),
find.byKey(const Key('home-feed-full-series-surface')),
);
final decoration = decorated.decoration as BoxDecoration;
expect((decoration.gradient! as LinearGradient).colors, [
AppColors.mineStatShade,
AppColors.brandRed,
AppColors.vipButtonEnd,
]);
final gradient = decoration.gradient! as LinearGradient;
expect(gradient.begin, Alignment.centerLeft);
expect(gradient.end, Alignment.centerRight);
expect(decoration.color, AppColors.watchAllSurface);
expect(decoration.border?.top.color, AppColors.watchAllBorder);
expect(decoration.gradient, isNull);
expect(tester.widget<Text>(find.text('Watch all')).style?.fontSize, 16);
expect(
tester.widget<Text>(find.text('Watch all')).style?.fontWeight,
+26 -11
View File
@@ -1,5 +1,5 @@
// Author: fruit.com.cn
// 职责:检查观看全集按钮实际绘制像素,防止渐变只配置而不可见。
// 职责:检查观看全集按钮的玻璃表面确实被绘制出来。
import 'dart:typed_data';
import 'dart:ui' as ui;
@@ -7,18 +7,19 @@ import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/theme/app_colors.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/player/widgets/home_feed_recommend_panel.dart';
const _boundaryKey = Key('watch-gradient-boundary');
const _boundaryKey = Key('watch-surface-boundary');
const _width = 280.0;
const _sampleInset = 16;
const _sampleY = 4;
const _rgbaChannels = 4;
const _minimumRedDelta = 50;
const _surfaceTolerance = 0.01;
const _drama = Drama(
id: 'gradient',
title: 'Gradient',
id: 'surface',
title: 'Surface',
synopsis: '',
posterUrl: '',
);
@@ -28,17 +29,21 @@ Future<void> _pumpButton(WidgetTester tester) => tester.pumpWidget(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
backgroundColor: Colors.black,
body: Center(
child: RepaintBoundary(
key: _boundaryKey,
child: SizedBox(
width: _width,
child: ColoredBox(
color: Colors.black,
child: HomeFeedWatchAllBar(drama: _drama, onTap: () {}),
),
),
),
),
),
),
);
Color _sample(ByteData bytes, int width, int x) {
@@ -52,7 +57,7 @@ Color _sample(ByteData bytes, int width, int x) {
}
void main() {
testWidgets('should_paint_visible_red_gradient_instead_of_flat_fill', (
testWidgets('should_paint_reference_dark_surface_when_watching_all', (
tester,
) async {
await _pumpButton(tester);
@@ -68,11 +73,21 @@ void main() {
final middle = _sample(bytes, image.width, image.width ~/ 2);
final right = _sample(bytes, image.width, image.width - _sampleInset);
image.dispose();
expect(left.a, 1);
expect(right.a, 1);
expect((right.r - left.r) * 255, greaterThan(_minimumRedDelta));
expect(left.g, lessThan(middle.g));
expect(middle.g, lessThan(right.g));
for (final sample in [left, middle, right]) {
expect(sample.a, 1);
expect(
sample.r,
closeTo(AppColors.watchAllSurface.a, _surfaceTolerance),
);
expect(
sample.g,
closeTo(AppColors.watchAllSurface.a, _surfaceTolerance),
);
expect(
sample.b,
closeTo(AppColors.watchAllSurface.a, _surfaceTolerance),
);
}
});
});
}
+5 -5
View File
@@ -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);
+90 -16
View File
@@ -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,15 +245,16 @@ void main() {
);
});
testWidgets('should_map_four_tabs_to_home_recommend_following_and_me', (
tester,
) async {
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-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);
@@ -258,6 +265,12 @@ void main() {
expect(find.byKey(const Key('recommend-channel-nav')), findsOneWidget);
_expectNoFloatingOverlays();
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-follow')));
await tester.pumpAndSettle();
final favorites = find.byType(FollowingPage);
@@ -296,7 +309,8 @@ void main() {
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);
+2 -1
View File
@@ -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'),
];
+13 -1
View File
@@ -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()));
},
);
}
+19 -4
View File
@@ -13,8 +13,8 @@ import 'package:fruitshorts/pages/mine/widgets/mine_action_grid.dart';
import 'package:fruitshorts/pages/mine/widgets/mine_menu.dart';
const _layoutTolerance = 0.1;
const _minimumVipHeight = 190.0;
const _previousVipPlaceholderHeight = 220.0;
const _minimumVipHeight = 110.0;
const _premiumVipHeightLimit = 150.0;
void main() {
testWidgets('should_keep_equal_visible_vip_padding_and_reduce_height', (
@@ -27,15 +27,30 @@ void main() {
final cardRect = tester.getRect(find.byKey(const Key('mine-vip')));
final emblemRect = tester.getRect(find.byKey(const Key('mine-vip-emblem')));
final emblem = tester.widget<Image>(
find.byKey(const Key('mine-vip-emblem')),
);
expect(
(emblem.image as AssetImage).assetName,
'assets/icon/vip_membership_badge.png',
);
final titleRect = tester.getRect(find.text('VIP'));
final benefitsRect = tester.getRect(
find.byKey(const Key('mine-vip-benefits')),
);
expect(cardRect.height, lessThan(_previousVipPlaceholderHeight));
expect(cardRect.height, lessThan(_premiumVipHeightLimit));
expect(cardRect.height, greaterThanOrEqualTo(_minimumVipHeight));
expect(
emblemRect.top - cardRect.top,
closeTo(cardRect.bottom - benefitsRect.bottom, _layoutTolerance),
greaterThanOrEqualTo(AppDimens.mineVipPadding),
);
expect(benefitsRect.bottom, lessThan(cardRect.bottom));
expect(emblemRect.left, greaterThan(titleRect.right));
final first = tester.getRect(find.byKey(const Key('mine-vip-benefit-0')));
final second = tester.getRect(find.byKey(const Key('mine-vip-benefit-1')));
final third = tester.getRect(find.byKey(const Key('mine-vip-benefit-2')));
expect(first.top, second.top);
expect(third.top, greaterThan(first.top));
});
for (final localeTag in ['en', 'id']) {
+15 -7
View File
@@ -173,10 +173,10 @@ void main() {
AppColors.vipCardMiddle,
AppColors.vipCardEnd,
]);
expect(vipDecoration.border, isNotNull);
expect(vipDecoration.border, isNull);
expect(vipDecoration.boxShadow, isNotEmpty);
// 卡片高度由内容撑开,上下内边距必须一致(边框各占 1pt)。
// 卡片高度由内容撑开,身份头部与底部 CTA 都保留安全内边距。
final vipRect = tester.getRect(find.byKey(const Key('mine-vip')));
final emblemRect = tester.getRect(find.byKey(const Key('mine-vip-emblem')));
final benefitsRect = tester.getRect(
@@ -184,12 +184,12 @@ void main() {
);
final topInset = emblemRect.top - vipRect.top;
final bottomInset = vipRect.bottom - benefitsRect.bottom;
expect(topInset, closeTo(bottomInset, 0.01));
expect(topInset, greaterThanOrEqualTo(AppDimens.mineVipPadding));
expect(bottomInset, greaterThanOrEqualTo(AppDimens.mineVipPadding));
expect(find.byKey(const Key('mine-vip-emblem')), findsOneWidget);
expect(
tester.getSize(find.byKey(const Key('mine-vip-open'))).height,
greaterThanOrEqualTo(AppDimens.tapTarget),
greaterThanOrEqualTo(AppDimens.mineVipButtonHeight),
);
const benefitKeys = [
Key('mine-vip-benefit-0'),
@@ -200,10 +200,18 @@ void main() {
final benefitTops = benefitKeys
.map((key) => tester.getTopLeft(find.byKey(key)))
.toList();
expect(benefitTops.map((position) => position.dy).toSet(), hasLength(1));
expect(
benefitTops.map((position) => position.dx).toSet(),
hasLength(benefitKeys.length),
benefitTops.take(2).map((position) => position.dy).toSet(),
hasLength(1),
);
expect(
benefitTops.skip(2).map((position) => position.dy).toSet(),
hasLength(1),
);
expect(benefitTops[2].dy, greaterThan(benefitTops[0].dy));
expect(
benefitTops.take(2).map((position) => position.dx).toSet(),
hasLength(2),
);
final order = [
+8
View File
@@ -69,6 +69,14 @@ void main() {
expect(tops.toSet(), hasLength(1));
expect(tester.takeException(), isNull);
if (_captureEnabled) {
final context = tester.element(find.byKey(const Key('mine-vip-emblem')));
await tester.runAsync(() async {
await precacheImage(
const AssetImage('assets/icon/vip_membership_badge.png'),
context,
);
});
await tester.pumpAndSettle();
await saveWidgetCapture(tester, _captureKey, _capturePath);
}
});
+9 -7
View File
@@ -16,8 +16,10 @@ import '../support/fake_haptics.dart';
import '../support/fake_interaction_repositories.dart';
void main() {
testWidgets('double tap within 320ms never pauses', (tester) async {
for (final delay in [120, 250, 300]) {
testWidgets('should_like_without_pausing_when_double_tap_within_240ms', (
tester,
) async {
for (final delay in [80, 160, 220]) {
final repository = FakeInteractionRepository();
final haptics = FakeHaptics();
final key = GlobalKey<_HarnessState>();
@@ -53,7 +55,7 @@ void main() {
}
});
testWidgets('taps 500ms apart toggle playback twice', (tester) async {
testWidgets('should_toggle_twice_when_taps_are_260ms_apart', (tester) async {
final key = GlobalKey<_HarnessState>();
final clock = _TestClock();
await _pumpHarness(
@@ -67,15 +69,15 @@ void main() {
await tester.tapAt(
tester.getCenter(find.byKey(const Key('home-feed-gesture'))),
);
clock.advance(const Duration(milliseconds: 500));
await tester.pump(const Duration(milliseconds: 500));
clock.advance(const Duration(milliseconds: 260));
await tester.pump(const Duration(milliseconds: 260));
expect(key.currentState!.pauseToggles, 1);
await tester.tapAt(
tester.getCenter(find.byKey(const Key('home-feed-gesture'))),
);
clock.advance(const Duration(milliseconds: 500));
await tester.pump(const Duration(milliseconds: 500));
clock.advance(const Duration(milliseconds: 260));
await tester.pump(const Duration(milliseconds: 260));
expect(key.currentState!.pauseToggles, 2);
});
+17 -4
View File
@@ -14,11 +14,13 @@ const _popFrame = Duration(milliseconds: 100);
const _buttonSize = 48.0;
const _compactGap = 6.0;
const _epsilon = 0.01;
const _cancelledMaximum = 0.8;
const _popMinimum = 1.22;
const _cancelledMinimum = 0.8;
const _popMinimum = 1.03;
const _popMaximum = 1.18;
const _burstKey = Key('rail-tap-burst');
const _directionFrame = Duration(milliseconds: 60);
const _directionFrames = 4;
const _directionFrames = 2;
const _motionCompletion = Duration(milliseconds: 230);
Future<void> _pumpRail(
WidgetTester tester, {
@@ -115,7 +117,9 @@ void main() {
await tester.pump(_popFrame);
expect(
_scale(tester, key),
selected ? lessThan(_cancelledMaximum) : greaterThan(_popMinimum),
selected
? allOf(greaterThan(_cancelledMinimum), lessThan(1))
: allOf(greaterThan(_popMinimum), lessThan(_popMaximum)),
);
_expectBurst(tester, key, selected);
expect(tester.getRect(_button(key)), rect);
@@ -155,6 +159,15 @@ void main() {
},
);
testWidgets('should_finish_feedback_within_230ms', (tester) async {
await _pumpRail(tester, onTap: () {});
await tester.tap(_button(_likeKey));
await tester.pump();
await tester.pump(_motionCompletion);
expect(find.byKey(_burstKey), findsNothing);
expect(_scale(tester, _likeKey), closeTo(1, _epsilon));
});
testWidgets('should_keep_callbacks_without_motion_when_reduced', (
tester,
) async {
+11 -5
View File
@@ -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 {
+73
View File
@@ -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,
);
}
}
+42
View File
@@ -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);
});
}
+414
View File
@@ -0,0 +1,414 @@
// 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-balance-coin-icon')), 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');
}
}
+279
View File
@@ -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,
);
}
}
+4 -1
View File
@@ -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);
+2 -4
View File
@@ -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();