diff --git a/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..0e9c078 --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -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 +} diff --git a/lib/app.dart b/lib/app.dart index 47ea1e3..422737b 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -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 { _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 { unawaited(_favoriteController.load()); _downloadController.reset(); _walletController.reset(); + _rewardController.reset(); } } @@ -258,6 +271,7 @@ class _LumaDramaAppState extends State { _favoriteController.dispose(); _downloadController.dispose(); _walletController.dispose(); + _rewardController.dispose(); _interactionController.dispose(); _localeController.dispose(); _appConfigController.dispose(); @@ -313,6 +327,7 @@ class _LumaDramaAppState extends State { 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), diff --git a/lib/core/config/dev_config.dart b/lib/core/config/dev_config.dart index e98de84..3d471c3 100644 --- a/lib/core/config/dev_config.dart +++ b/lib/core/config/dev_config.dart @@ -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'; } diff --git a/lib/core/data/models/reward.dart b/lib/core/data/models/reward.dart new file mode 100644 index 0000000..fc3195b --- /dev/null +++ b/lib/core/data/models/reward.dart @@ -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 sections; + final List activities; + + factory RewardOverview.fromJson(Map 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 days; + + factory RewardCheckinState.fromJson(Map 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 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 tasks; + + factory RewardSection.fromJson(Map 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 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 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 json) { + return RewardActionResult( + rewardCoin: jsonToInt(json['rewardCoin']), + coinBalance: jsonToInt(json['coinBalance']), + alreadyCompleted: json['alreadyCheckedIn'] == true, + ); + } + + factory RewardActionResult.fromClaim(Map json) { + return RewardActionResult( + rewardCoin: jsonToInt(json['rewardCoin']), + coinBalance: jsonToInt(json['coinBalance']), + alreadyCompleted: json['alreadyGranted'] == true, + ); + } +} + +Map _map(Object? value) { + if (value is Map) return value; + if (value is Map) return value.cast(); + return const {}; +} + +List> _maps(Object? value) { + if (value is! List) return const []; + return value + .whereType() + .map((item) => item.cast()) + .toList(); +} diff --git a/lib/core/data/remote/reward_api.dart b/lib/core/data/remote/reward_api.dart new file mode 100644 index 0000000..87720d9 --- /dev/null +++ b/lib/core/data/remote/reward_api.dart @@ -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 overview(String locale) async { + final data = await _client.get( + '/app/rewards/overview', + query: {'locale': locale}, + ); + return RewardOverview.fromJson(ResponseDecoders.asMap(data)); + } + + Future checkIn() async { + final data = await _client.post('/app/rewards/check-in'); + return RewardActionResult.fromCheckin(ResponseDecoders.asMap(data)); + } + + Future claim(String taskId) async { + final data = await _client.post('/app/rewards/tasks/$taskId/claim'); + return RewardActionResult.fromClaim(ResponseDecoders.asMap(data)); + } +} diff --git a/lib/core/data/repositories/reward_repository.dart b/lib/core/data/repositories/reward_repository.dart new file mode 100644 index 0000000..b59ca05 --- /dev/null +++ b/lib/core/data/repositories/reward_repository.dart @@ -0,0 +1,29 @@ +// Author: fruit.com.cn + +import '../models/reward.dart'; +import '../remote/reward_api.dart'; + +/// 奖励中心数据来源。 +abstract interface class RewardRepository { + Future fetchOverview(String locale); + + Future checkIn(); + + Future claim(String taskId); +} + +/// 奖励中心 HTTP 仓库。 +class HttpRewardRepository implements RewardRepository { + const HttpRewardRepository(this._api); + + final RewardApi _api; + + @override + Future fetchOverview(String locale) => _api.overview(locale); + + @override + Future checkIn() => _api.checkIn(); + + @override + Future claim(String taskId) => _api.claim(taskId); +} diff --git a/lib/core/data/services/google_auth_service.dart b/lib/core/data/services/google_auth_service.dart index aaaa5e6..1e56df6 100644 --- a/lib/core/data/services/google_auth_service.dart +++ b/lib/core/data/services/google_auth_service.dart @@ -1,5 +1,6 @@ // Author: fruit.com.cn +import 'dart:async'; import 'dart:io' show Platform; import 'package:flutter/foundation.dart'; @@ -58,6 +59,10 @@ class GoogleAuthService { static const String _serverClientId = '11115008580-dbqe47fhp5560nhrocrppiq7hcnlvl1s.apps.googleusercontent.com'; + /// 授权等待上限:插件在账号被撤销后可能永不返回,必须有超时兜底, + /// 否则页面 loading 状态会一直卡住、按钮再也点不动。 + static const Duration _authTimeout = Duration(seconds: 60); + final GoogleSignIn _signIn; bool _initialized = false; @@ -68,22 +73,41 @@ class GoogleAuthService { if (!_signIn.supportsAuthenticate()) { return const GoogleSignInOutcome.unavailable(); } - final account = await _signIn.authenticate(); + // 先清掉插件缓存的旧账号:用户可能在 Google 侧撤销过授权或切换过账号, + // 残留状态会让 authenticate 直接返回失效账号或干脆不弹授权页。 + await _clearStaleAccount(); + final account = await _signIn.authenticate().timeout(_authTimeout); final idToken = account.authentication.idToken; if (idToken == null || idToken.isEmpty) { return const GoogleSignInOutcome.failed(); } return GoogleSignInOutcome.success(idToken); + } on TimeoutException { + await _clearStaleAccount(); + return const GoogleSignInOutcome.failed(); } on GoogleSignInException catch (error) { if (error.code == GoogleSignInExceptionCode.canceled) { return const GoogleSignInOutcome.canceled(); } + // 失败多半是插件缓存了已失效的账号(用户手动撤销授权、换账号), + // 清掉本地状态,下次点击才能重新拉起授权页。 + await _clearStaleAccount(); return const GoogleSignInOutcome.failed(); } catch (_) { + await _clearStaleAccount(); return const GoogleSignInOutcome.failed(); } } + /// 清掉插件缓存的 Google 账号;失败不影响登录结果,异常直接吞掉。 + Future _clearStaleAccount() async { + try { + await _signIn.signOut(); + } catch (_) { + // 忽略:清缓存失败不应阻断页面恢复 + } + } + /// 插件要求先 initialize 才能 authenticate;只做一次。 Future _ensureInitialized() async { if (_initialized) return; diff --git a/lib/core/data/services/reward_controller.dart b/lib/core/data/services/reward_controller.dart new file mode 100644 index 0000000..c021453 --- /dev/null +++ b/lib/core/data/services/reward_controller.dart @@ -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 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 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 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 _reload() async { + overview = await _repository.fetchOverview(_locale); + } + + void _notify() { + if (!_disposed) notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } +} diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index 8857f7a..7e3e4e1 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -182,6 +182,7 @@ abstract final class AppColors { static const navBackground = Color(0xFF000000); static const brandRed = Color(0xFFF04E4E); static const brandRedPressed = Color(0xFFD64040); + static const navSelectedGlow = Color(0x33F04E4E); static const navSelected = brandRed; /// 卡片阴影(深色几乎不用阴影)。 @@ -222,6 +223,12 @@ abstract final class AppColors { /// 玻璃面板描边。 static const glassBorder = Color(0x33FFFFFF); + + /// 玻璃控件使用的发丝级轮廓,避免边缘抢过内容。 + static const glassHairline = Color(0x12FFFFFF); + + /// 轻量玻璃控件的白色表面。 + static const glassSurface = Color(0x24FFFFFF); static const glassSurfaceStart = Color(0xEB16161A); static const glassSurfaceEnd = Color(0xD1101010); diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index 8ebd243..4b7cc93 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -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; diff --git a/lib/core/widgets/app_bottom_nav.dart b/lib/core/widgets/app_bottom_nav.dart index dcfef24..8d4d702 100644 --- a/lib/core/widgets/app_bottom_nav.dart +++ b/lib/core/widgets/app_bottom_nav.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import '../theme/app_colors.dart'; import '../theme/app_dimens.dart'; +import 'app_press_scale.dart'; const double _maxNavigationTextScale = 1.3; @@ -32,7 +33,7 @@ class AppBottomNavItem { final bool selected; } -/// 四槽底部导航:纯黑背景,白色选中态,灰色未选中态。 +/// 均分底部导航:纯黑背景,品牌红选中态,白色未选中态。 class AppBottomNav extends StatelessWidget { const AppBottomNav({super.key, required this.items, this.barKey}); @@ -91,35 +92,46 @@ class _NavDestination extends StatelessWidget { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - AnimatedScale( - duration: const Duration(milliseconds: 160), - curve: Curves.easeOut, - scale: item.selected ? 1 : 0.96, - child: - item.customIcon ?? - (assetPath == null - ? Icon( - fallbackIcon, - size: AppDimens.navIcon, - color: color, - ) - : ColorFiltered( - colorFilter: ColorFilter.mode( - color, - BlendMode.srcIn, + AppPressScale( + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(AppDimens.navIcon), + boxShadow: item.selected + ? const [ + BoxShadow( + color: AppColors.navSelectedGlow, + blurRadius: 12, + spreadRadius: 1, ), - child: Image.asset( - assetPath, - width: AppDimens.navIcon, - height: AppDimens.navIcon, - fit: BoxFit.contain, - errorBuilder: (_, _, _) => Icon( - fallbackIcon, - size: AppDimens.navIcon, - color: color, + ] + : const [], + ), + child: + item.customIcon ?? + (assetPath == null + ? Icon( + fallbackIcon, + size: AppDimens.navIcon, + color: color, + ) + : ColorFiltered( + colorFilter: ColorFilter.mode( + color, + BlendMode.srcIn, ), - ), - )), + child: Image.asset( + assetPath, + width: AppDimens.navIcon, + height: AppDimens.navIcon, + fit: BoxFit.contain, + errorBuilder: (_, _, _) => Icon( + fallbackIcon, + size: AppDimens.navIcon, + color: color, + ), + ), + )), + ), ), const SizedBox(height: AppDimens.navLabelGap), Text( diff --git a/lib/core/widgets/app_press_scale.dart b/lib/core/widgets/app_press_scale.dart new file mode 100644 index 0000000..3fda845 --- /dev/null +++ b/lib/core/widgets/app_press_scale.dart @@ -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 createState() => _AppPressScaleState(); +} + +class _AppPressScaleState extends State { + 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, + ); + } +} diff --git a/lib/core/widgets/app_pull_to_refresh.dart b/lib/core/widgets/app_pull_to_refresh.dart index 86aa8cc..cfe550f 100644 --- a/lib/core/widgets/app_pull_to_refresh.dart +++ b/lib/core/widgets/app_pull_to_refresh.dart @@ -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 Function() onRefresh; final double edgeOffset; @@ -191,7 +196,7 @@ class _AppPullToRefreshState extends State child: Align( alignment: Alignment.topCenter, child: Padding( - padding: const EdgeInsets.only(top: 8), + padding: const EdgeInsets.only(top: _indicatorTopPadding), child: _indicator(), ), ), diff --git a/lib/core/widgets/app_root_tab.dart b/lib/core/widgets/app_root_tab.dart index e452908..3c6862d 100644 --- a/lib/core/widgets/app_root_tab.dart +++ b/lib/core/widgets/app_root_tab.dart @@ -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, diff --git a/lib/core/widgets/drama_grid_skeleton.dart b/lib/core/widgets/drama_grid_skeleton.dart index a2deaf6..31ff512 100644 --- a/lib/core/widgets/drama_grid_skeleton.dart +++ b/lib/core/widgets/drama_grid_skeleton.dart @@ -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), ), ); } diff --git a/lib/core/widgets/root_nav_glyph.dart b/lib/core/widgets/root_nav_glyph.dart index 419da8f..efc89bb 100644 --- a/lib/core/widgets/root_nav_glyph.dart +++ b/lib/core/widgets/root_nav_glyph.dart @@ -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); diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 485612e..2bbce74 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -860,6 +860,47 @@ "aboutUs": "About us", "aboutBrandName": "Your mini business", "aboutDescription": "A focused, immersive place to watch short dramas.", + "rewardTab": "Rewards", + "rewardCenterTitle": "Reward Center", + "rewardTasksTab": "Tasks", + "rewardActivitiesTab": "Events", + "rewardMyCoins": "My coins", + "rewardCheckInTitle": "Check in for rewards", + "rewardCheckInSubtitle": "Check in daily to collect reward coins", + "rewardBoostCheckIn": "Boost check-in reward", + "rewardCheckInAction": "Check in", + "rewardCheckInActionWithCoins": "Check in +{coins} coins", + "@rewardCheckInActionWithCoins": { + "placeholders": {"coins": {"type": "int"}} + }, + "rewardCheckedIn": "Checked in", + "rewardToday": "Today", + "rewardDayLabel": "Day {day}", + "@rewardDayLabel": { + "placeholders": {"day": {"type": "int"}} + }, + "rewardClaimAction": "Claim", + "rewardClaimed": "Claimed", + "rewardGoAction": "Go", + "rewardDailySectionSubtitle": "Complete daily tasks to collect reward coins", + "rewardNewbieSectionSubtitle": "Available once only", + "rewardInviteSectionSubtitle": "Invite friends and earn rewards together", + "rewardHelpTitle": "About rewards", + "rewardHelpBody": "Complete check-ins and tasks to earn reward coins. Rewards and task rules are shown on this page.", + "rewardComingSoon": "Coming soon", + "rewardEmptyTasks": "No reward tasks available", + "rewardEmptyActivities": "No events available", + "rewardRetry": "Retry", + "rewardCheckInSuccess": "+{coins} coins for checking in", + "@rewardCheckInSuccess": { + "placeholders": {"coins": {"type": "int"}} + }, + "rewardClaimSuccess": "+{coins} coins claimed", + "@rewardClaimSuccess": { + "placeholders": {"coins": {"type": "int"}} + }, + "rewardLoadFailed": "Couldn't load rewards", + "rewardActionFailed": "Action failed. Please try again.", "versionLabel": "Version {version}", "@versionLabel": { "placeholders": { diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 7bda80a..dcac711 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -817,6 +817,47 @@ "aboutUs": "Tentang kami", "aboutBrandName": "Your mini business", "aboutDescription": "Tempat fokus dan imersif untuk menonton drama pendek.", + "rewardTab": "Hadiah", + "rewardCenterTitle": "Pusat Hadiah", + "rewardTasksTab": "Tugas", + "rewardActivitiesTab": "Acara", + "rewardMyCoins": "Koin saya", + "rewardCheckInTitle": "Check-in untuk hadiah", + "rewardCheckInSubtitle": "Check-in setiap hari untuk mengumpulkan koin hadiah", + "rewardBoostCheckIn": "Tingkatkan hadiah check-in", + "rewardCheckInAction": "Check-in", + "rewardCheckInActionWithCoins": "Check-in +{coins} koin", + "@rewardCheckInActionWithCoins": { + "placeholders": {"coins": {"type": "int"}} + }, + "rewardCheckedIn": "Sudah check-in", + "rewardToday": "Hari ini", + "rewardDayLabel": "Hari {day}", + "@rewardDayLabel": { + "placeholders": {"day": {"type": "int"}} + }, + "rewardClaimAction": "Ambil", + "rewardClaimed": "Sudah diambil", + "rewardGoAction": "Mulai", + "rewardDailySectionSubtitle": "Selesaikan tugas harian untuk mendapat koin hadiah", + "rewardNewbieSectionSubtitle": "Hanya tersedia satu kali", + "rewardInviteSectionSubtitle": "Undang teman dan dapatkan hadiah bersama", + "rewardHelpTitle": "Tentang hadiah", + "rewardHelpBody": "Selesaikan check-in dan tugas untuk mendapat koin hadiah. Hadiah dan aturan tugas mengikuti halaman ini.", + "rewardComingSoon": "Segera hadir", + "rewardEmptyTasks": "Belum ada tugas hadiah", + "rewardEmptyActivities": "Belum ada acara", + "rewardRetry": "Coba lagi", + "rewardCheckInSuccess": "+{coins} koin dari check-in", + "@rewardCheckInSuccess": { + "placeholders": {"coins": {"type": "int"}} + }, + "rewardClaimSuccess": "+{coins} koin berhasil diambil", + "@rewardClaimSuccess": { + "placeholders": {"coins": {"type": "int"}} + }, + "rewardLoadFailed": "Hadiah tidak dapat dimuat", + "rewardActionFailed": "Tindakan gagal. Silakan coba lagi.", "versionLabel": "Versi {version}", "@versionLabel": { "placeholders": { diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 99c011d..675eb3c 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -3616,6 +3616,180 @@ abstract class AppLocalizations { /// **'A focused, immersive place to watch short dramas.'** String get aboutDescription; + /// No description provided for @rewardTab. + /// + /// In en, this message translates to: + /// **'Rewards'** + String get rewardTab; + + /// No description provided for @rewardCenterTitle. + /// + /// In en, this message translates to: + /// **'Reward Center'** + String get rewardCenterTitle; + + /// No description provided for @rewardTasksTab. + /// + /// In en, this message translates to: + /// **'Tasks'** + String get rewardTasksTab; + + /// No description provided for @rewardActivitiesTab. + /// + /// In en, this message translates to: + /// **'Events'** + String get rewardActivitiesTab; + + /// No description provided for @rewardMyCoins. + /// + /// In en, this message translates to: + /// **'My coins'** + String get rewardMyCoins; + + /// No description provided for @rewardCheckInTitle. + /// + /// In en, this message translates to: + /// **'Check in for rewards'** + String get rewardCheckInTitle; + + /// No description provided for @rewardCheckInSubtitle. + /// + /// In en, this message translates to: + /// **'Check in daily to collect reward coins'** + String get rewardCheckInSubtitle; + + /// No description provided for @rewardBoostCheckIn. + /// + /// In en, this message translates to: + /// **'Boost check-in reward'** + String get rewardBoostCheckIn; + + /// No description provided for @rewardCheckInAction. + /// + /// In en, this message translates to: + /// **'Check in'** + String get rewardCheckInAction; + + /// No description provided for @rewardCheckInActionWithCoins. + /// + /// In en, this message translates to: + /// **'Check in +{coins} coins'** + String rewardCheckInActionWithCoins(int coins); + + /// No description provided for @rewardCheckedIn. + /// + /// In en, this message translates to: + /// **'Checked in'** + String get rewardCheckedIn; + + /// No description provided for @rewardToday. + /// + /// In en, this message translates to: + /// **'Today'** + String get rewardToday; + + /// No description provided for @rewardDayLabel. + /// + /// In en, this message translates to: + /// **'Day {day}'** + String rewardDayLabel(int day); + + /// No description provided for @rewardClaimAction. + /// + /// In en, this message translates to: + /// **'Claim'** + String get rewardClaimAction; + + /// No description provided for @rewardClaimed. + /// + /// In en, this message translates to: + /// **'Claimed'** + String get rewardClaimed; + + /// No description provided for @rewardGoAction. + /// + /// In en, this message translates to: + /// **'Go'** + String get rewardGoAction; + + /// No description provided for @rewardDailySectionSubtitle. + /// + /// In en, this message translates to: + /// **'Complete daily tasks to collect reward coins'** + String get rewardDailySectionSubtitle; + + /// No description provided for @rewardNewbieSectionSubtitle. + /// + /// In en, this message translates to: + /// **'Available once only'** + String get rewardNewbieSectionSubtitle; + + /// No description provided for @rewardInviteSectionSubtitle. + /// + /// In en, this message translates to: + /// **'Invite friends and earn rewards together'** + String get rewardInviteSectionSubtitle; + + /// No description provided for @rewardHelpTitle. + /// + /// In en, this message translates to: + /// **'About rewards'** + String get rewardHelpTitle; + + /// No description provided for @rewardHelpBody. + /// + /// In en, this message translates to: + /// **'Complete check-ins and tasks to earn reward coins. Rewards and task rules are shown on this page.'** + String get rewardHelpBody; + + /// No description provided for @rewardComingSoon. + /// + /// In en, this message translates to: + /// **'Coming soon'** + String get rewardComingSoon; + + /// No description provided for @rewardEmptyTasks. + /// + /// In en, this message translates to: + /// **'No reward tasks available'** + String get rewardEmptyTasks; + + /// No description provided for @rewardEmptyActivities. + /// + /// In en, this message translates to: + /// **'No events available'** + String get rewardEmptyActivities; + + /// No description provided for @rewardRetry. + /// + /// In en, this message translates to: + /// **'Retry'** + String get rewardRetry; + + /// No description provided for @rewardCheckInSuccess. + /// + /// In en, this message translates to: + /// **'+{coins} coins for checking in'** + String rewardCheckInSuccess(int coins); + + /// No description provided for @rewardClaimSuccess. + /// + /// In en, this message translates to: + /// **'+{coins} coins claimed'** + String rewardClaimSuccess(int coins); + + /// No description provided for @rewardLoadFailed. + /// + /// In en, this message translates to: + /// **'Couldn\'t load rewards'** + String get rewardLoadFailed; + + /// No description provided for @rewardActionFailed. + /// + /// In en, this message translates to: + /// **'Action failed. Please try again.'** + String get rewardActionFailed; + /// No description provided for @versionLabel. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 023579c..a003072 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1901,6 +1901,104 @@ class AppLocalizationsEn extends AppLocalizations { String get aboutDescription => 'A focused, immersive place to watch short dramas.'; + @override + String get rewardTab => 'Rewards'; + + @override + String get rewardCenterTitle => 'Reward Center'; + + @override + String get rewardTasksTab => 'Tasks'; + + @override + String get rewardActivitiesTab => 'Events'; + + @override + String get rewardMyCoins => 'My coins'; + + @override + String get rewardCheckInTitle => 'Check in for rewards'; + + @override + String get rewardCheckInSubtitle => 'Check in daily to collect reward coins'; + + @override + String get rewardBoostCheckIn => 'Boost check-in reward'; + + @override + String get rewardCheckInAction => 'Check in'; + + @override + String rewardCheckInActionWithCoins(int coins) { + return 'Check in +$coins coins'; + } + + @override + String get rewardCheckedIn => 'Checked in'; + + @override + String get rewardToday => 'Today'; + + @override + String rewardDayLabel(int day) { + return 'Day $day'; + } + + @override + String get rewardClaimAction => 'Claim'; + + @override + String get rewardClaimed => 'Claimed'; + + @override + String get rewardGoAction => 'Go'; + + @override + String get rewardDailySectionSubtitle => + 'Complete daily tasks to collect reward coins'; + + @override + String get rewardNewbieSectionSubtitle => 'Available once only'; + + @override + String get rewardInviteSectionSubtitle => + 'Invite friends and earn rewards together'; + + @override + String get rewardHelpTitle => 'About rewards'; + + @override + String get rewardHelpBody => + 'Complete check-ins and tasks to earn reward coins. Rewards and task rules are shown on this page.'; + + @override + String get rewardComingSoon => 'Coming soon'; + + @override + String get rewardEmptyTasks => 'No reward tasks available'; + + @override + String get rewardEmptyActivities => 'No events available'; + + @override + String get rewardRetry => 'Retry'; + + @override + String rewardCheckInSuccess(int coins) { + return '+$coins coins for checking in'; + } + + @override + String rewardClaimSuccess(int coins) { + return '+$coins coins claimed'; + } + + @override + String get rewardLoadFailed => 'Couldn\'t load rewards'; + + @override + String get rewardActionFailed => 'Action failed. Please try again.'; + @override String versionLabel(String version) { return 'Version $version'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 51724b3..437ff27 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -1903,6 +1903,105 @@ class AppLocalizationsId extends AppLocalizations { String get aboutDescription => 'Tempat fokus dan imersif untuk menonton drama pendek.'; + @override + String get rewardTab => 'Hadiah'; + + @override + String get rewardCenterTitle => 'Pusat Hadiah'; + + @override + String get rewardTasksTab => 'Tugas'; + + @override + String get rewardActivitiesTab => 'Acara'; + + @override + String get rewardMyCoins => 'Koin saya'; + + @override + String get rewardCheckInTitle => 'Check-in untuk hadiah'; + + @override + String get rewardCheckInSubtitle => + 'Check-in setiap hari untuk mengumpulkan koin hadiah'; + + @override + String get rewardBoostCheckIn => 'Tingkatkan hadiah check-in'; + + @override + String get rewardCheckInAction => 'Check-in'; + + @override + String rewardCheckInActionWithCoins(int coins) { + return 'Check-in +$coins koin'; + } + + @override + String get rewardCheckedIn => 'Sudah check-in'; + + @override + String get rewardToday => 'Hari ini'; + + @override + String rewardDayLabel(int day) { + return 'Hari $day'; + } + + @override + String get rewardClaimAction => 'Ambil'; + + @override + String get rewardClaimed => 'Sudah diambil'; + + @override + String get rewardGoAction => 'Mulai'; + + @override + String get rewardDailySectionSubtitle => + 'Selesaikan tugas harian untuk mendapat koin hadiah'; + + @override + String get rewardNewbieSectionSubtitle => 'Hanya tersedia satu kali'; + + @override + String get rewardInviteSectionSubtitle => + 'Undang teman dan dapatkan hadiah bersama'; + + @override + String get rewardHelpTitle => 'Tentang hadiah'; + + @override + String get rewardHelpBody => + 'Selesaikan check-in dan tugas untuk mendapat koin hadiah. Hadiah dan aturan tugas mengikuti halaman ini.'; + + @override + String get rewardComingSoon => 'Segera hadir'; + + @override + String get rewardEmptyTasks => 'Belum ada tugas hadiah'; + + @override + String get rewardEmptyActivities => 'Belum ada acara'; + + @override + String get rewardRetry => 'Coba lagi'; + + @override + String rewardCheckInSuccess(int coins) { + return '+$coins koin dari check-in'; + } + + @override + String rewardClaimSuccess(int coins) { + return '+$coins koin berhasil diambil'; + } + + @override + String get rewardLoadFailed => 'Hadiah tidak dapat dimuat'; + + @override + String get rewardActionFailed => 'Tindakan gagal. Silakan coba lagi.'; + @override String versionLabel(String version) { return 'Versi $version'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index f202190..287dd10 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1875,6 +1875,101 @@ class AppLocalizationsZh extends AppLocalizations { @override String get aboutDescription => '专注于短剧观看与沉浸式追剧体验。'; + @override + String get rewardTab => '奖励'; + + @override + String get rewardCenterTitle => '奖励中心'; + + @override + String get rewardTasksTab => '任务'; + + @override + String get rewardActivitiesTab => '活动'; + + @override + String get rewardMyCoins => '我的金币'; + + @override + String get rewardCheckInTitle => '签到领奖励'; + + @override + String get rewardCheckInSubtitle => '每日签到,领取奖励币'; + + @override + String get rewardBoostCheckIn => '提升签到奖励'; + + @override + String get rewardCheckInAction => '立即签到'; + + @override + String rewardCheckInActionWithCoins(int coins) { + return '签到 +$coins 奖励币'; + } + + @override + String get rewardCheckedIn => '今日已签到'; + + @override + String get rewardToday => '今天'; + + @override + String rewardDayLabel(int day) { + return '第 $day 天'; + } + + @override + String get rewardClaimAction => '领取'; + + @override + String get rewardClaimed => '已领取'; + + @override + String get rewardGoAction => '去完成'; + + @override + String get rewardDailySectionSubtitle => '每日完成任务,领取奖励币'; + + @override + String get rewardNewbieSectionSubtitle => '仅有一次机会'; + + @override + String get rewardInviteSectionSubtitle => '邀请好友一起获得奖励'; + + @override + String get rewardHelpTitle => '奖励中心说明'; + + @override + String get rewardHelpBody => '完成签到和任务可获得奖励币,具体奖励与任务规则以页面展示为准。'; + + @override + String get rewardComingSoon => '功能即将开放'; + + @override + String get rewardEmptyTasks => '暂无奖励任务'; + + @override + String get rewardEmptyActivities => '暂无活动'; + + @override + String get rewardRetry => '重新加载'; + + @override + String rewardCheckInSuccess(int coins) { + return '签到成功,获得 $coins 金币'; + } + + @override + String rewardClaimSuccess(int coins) { + return '领取成功,获得 $coins 金币'; + } + + @override + String get rewardLoadFailed => '奖励数据加载失败'; + + @override + String get rewardActionFailed => '操作失败,请稍后重试'; + @override String versionLabel(String version) { return '版本 $version'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 84eec2c..f652e45 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -779,6 +779,47 @@ "aboutUs": "关于我们", "aboutBrandName": "Your mini business", "aboutDescription": "专注于短剧观看与沉浸式追剧体验。", + "rewardTab": "奖励", + "rewardCenterTitle": "奖励中心", + "rewardTasksTab": "任务", + "rewardActivitiesTab": "活动", + "rewardMyCoins": "我的金币", + "rewardCheckInTitle": "签到领奖励", + "rewardCheckInSubtitle": "每日签到,领取奖励币", + "rewardBoostCheckIn": "提升签到奖励", + "rewardCheckInAction": "立即签到", + "rewardCheckInActionWithCoins": "签到 +{coins} 奖励币", + "@rewardCheckInActionWithCoins": { + "placeholders": {"coins": {"type": "int"}} + }, + "rewardCheckedIn": "今日已签到", + "rewardToday": "今天", + "rewardDayLabel": "第 {day} 天", + "@rewardDayLabel": { + "placeholders": {"day": {"type": "int"}} + }, + "rewardClaimAction": "领取", + "rewardClaimed": "已领取", + "rewardGoAction": "去完成", + "rewardDailySectionSubtitle": "每日完成任务,领取奖励币", + "rewardNewbieSectionSubtitle": "仅有一次机会", + "rewardInviteSectionSubtitle": "邀请好友一起获得奖励", + "rewardHelpTitle": "奖励中心说明", + "rewardHelpBody": "完成签到和任务可获得奖励币,具体奖励与任务规则以页面展示为准。", + "rewardComingSoon": "功能即将开放", + "rewardEmptyTasks": "暂无奖励任务", + "rewardEmptyActivities": "暂无活动", + "rewardRetry": "重新加载", + "rewardCheckInSuccess": "签到成功,获得 {coins} 金币", + "@rewardCheckInSuccess": { + "placeholders": {"coins": {"type": "int"}} + }, + "rewardClaimSuccess": "领取成功,获得 {coins} 金币", + "@rewardClaimSuccess": { + "placeholders": {"coins": {"type": "int"}} + }, + "rewardLoadFailed": "奖励数据加载失败", + "rewardActionFailed": "操作失败,请稍后重试", "versionLabel": "版本 {version}", "@versionLabel": { "placeholders": { diff --git a/lib/pages/discover/discover_types.dart b/lib/pages/discover/discover_types.dart index 8443cb6..37eb323 100644 --- a/lib/pages/discover/discover_types.dart +++ b/lib/pages/discover/discover_types.dart @@ -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 { diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index a849f09..a7e9132 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -472,6 +472,7 @@ class _DiscoverPageState extends State { 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, }; diff --git a/lib/pages/discover/widgets/drama_grid.dart b/lib/pages/discover/widgets/drama_grid.dart index 213a1cb..cc65d90 100644 --- a/lib/pages/discover/widgets/drama_grid.dart +++ b/lib/pages/discover/widgets/drama_grid.dart @@ -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; diff --git a/lib/pages/drama_detail/widgets/drama_detail_header.dart b/lib/pages/drama_detail/widgets/drama_detail_header.dart index 7c2dc34..bbb12d0 100644 --- a/lib/pages/drama_detail/widgets/drama_detail_header.dart +++ b/lib/pages/drama_detail/widgets/drama_detail_header.dart @@ -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; diff --git a/lib/pages/favorites/favorites_page.dart b/lib/pages/favorites/favorites_page.dart index d37b9c3..96090db 100644 --- a/lib/pages/favorites/favorites_page.dart +++ b/lib/pages/favorites/favorites_page.dart @@ -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; diff --git a/lib/pages/filter/widgets/filter_result_card.dart b/lib/pages/filter/widgets/filter_result_card.dart index 6d3cba0..95610f6 100644 --- a/lib/pages/filter/widgets/filter_result_card.dart +++ b/lib/pages/filter/widgets/filter_result_card.dart @@ -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; diff --git a/lib/pages/following/following_grid_card.dart b/lib/pages/following/following_grid_card.dart index 639a794..65f2bcb 100644 --- a/lib/pages/following/following_grid_card.dart +++ b/lib/pages/following/following_grid_card.dart @@ -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, diff --git a/lib/pages/following/following_history_card.dart b/lib/pages/following/following_history_card.dart index 43ca618..3334dc1 100644 --- a/lib/pages/following/following_history_card.dart +++ b/lib/pages/following/following_history_card.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; +import '../../core/widgets/app_press_scale.dart'; import '../../core/widgets/cover_play_count.dart'; import '../../core/widgets/remote_image.dart'; import '../../l10n/app_localizations.dart'; @@ -45,15 +46,17 @@ class FollowingHistoryCard extends StatelessWidget { AppDimens.followingHistoryCoverRadius, ), onTap: onTap, - child: SizedBox( - height: AppDimens.followingHistoryCoverHeight, - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - _cover(), - const SizedBox(width: AppDimens.followingHistoryContentGap), - Expanded(child: _details(l10n)), - ], + child: AppPressScale( + child: SizedBox( + height: AppDimens.followingHistoryCoverHeight, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + _cover(), + const SizedBox(width: AppDimens.followingHistoryContentGap), + Expanded(child: _details(l10n)), + ], + ), ), ), ); diff --git a/lib/pages/following/following_page.dart b/lib/pages/following/following_page.dart index e595d8b..2daad82 100644 --- a/lib/pages/following/following_page.dart +++ b/lib/pages/following/following_page.dart @@ -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 { key: const Key('following-search'), borderRadius: BorderRadius.circular(8), onTap: () { - Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const SearchPage()), - ); + Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const SearchPage())); }, child: Padding( padding: EdgeInsets.all(4), @@ -293,19 +294,21 @@ class _FollowingPageState extends State { return ValueListenableBuilder( valueListenable: _editing, builder: (context, editing, _) { - return InkWell( - key: const Key('following-edit'), - borderRadius: BorderRadius.circular(8), - onTap: _toggleEditing, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 8), - child: Text( - editing ? l10n.done : l10n.manage, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: AppDimens.followingManageFont, - fontWeight: FontWeight.w400, - height: 1.0, + return AppPressScale( + child: InkWell( + key: const Key('following-edit'), + borderRadius: BorderRadius.circular(8), + onTap: _toggleEditing, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 8), + child: Text( + editing ? l10n.done : l10n.manage, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: AppDimens.followingManageFont, + fontWeight: FontWeight.w400, + height: 1.0, + ), ), ), ), @@ -320,40 +323,40 @@ class _FollowingPageState extends State { String keyName, bool selected, ) { - return InkWell( - key: Key('following-tab-$keyName'), - onTap: () => _selectTab(tab), - borderRadius: BorderRadius.circular(8), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - AnimatedDefaultTextStyle( - duration: AppDimens.followingTabAnimationDuration, - style: TextStyle( - color: selected - ? AppColors.textPrimary - : AppColors.textTabInactive, - fontSize: AppDimens.followingSegmentFont, - fontWeight: FontWeight.w400, + return AppPressScale( + child: InkWell( + key: Key('following-tab-$keyName'), + onTap: () => _selectTab(tab), + borderRadius: BorderRadius.circular(8), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + AnimatedDefaultTextStyle( + duration: AppDimens.followingTabAnimationDuration, + style: TextStyle( + color: selected + ? AppColors.textPrimary + : AppColors.textTabInactive, + fontSize: AppDimens.followingSegmentFont, + fontWeight: FontWeight.w400, + ), + child: label, ), - child: label, - ), - const SizedBox(height: AppDimens.followingSegmentIndicatorTopGap), - AnimatedContainer( - duration: AppDimens.followingTabAnimationDuration, - curve: Curves.easeOut, - width: selected - ? AppDimens.followingSegmentIndicatorWidth - : 0, - height: AppDimens.followingSegmentIndicatorHeight, - decoration: BoxDecoration( - color: AppColors.brandRed, - borderRadius: BorderRadius.circular( - AppDimens.followingSegmentIndicatorRadius, + const SizedBox(height: AppDimens.followingSegmentIndicatorTopGap), + AnimatedContainer( + duration: AppDimens.followingTabAnimationDuration, + curve: Curves.easeOut, + width: selected ? AppDimens.followingSegmentIndicatorWidth : 0, + height: AppDimens.followingSegmentIndicatorHeight, + decoration: BoxDecoration( + color: AppColors.brandRed, + borderRadius: BorderRadius.circular( + AppDimens.followingSegmentIndicatorRadius, + ), ), ), - ), - ], + ], + ), ), ); } diff --git a/lib/pages/home/home_logic.dart b/lib/pages/home/home_logic.dart index daa8fcc..90dd71e 100644 --- a/lib/pages/home/home_logic.dart +++ b/lib/pages/home/home_logic.dart @@ -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); } diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index 5f6193f..87afffa 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -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 with WidgetsBindingObserver { } } -/// 四个根页面共享同一路由并保持各自状态。 +/// 五个根页面共享同一路由并保持各自状态。 class _HomeTabs extends StatefulWidget { const _HomeTabs({super.key, required this.feedController}); @@ -164,11 +166,12 @@ class _HomeTabsState extends State<_HomeTabs> { final builders = [ _recommend, _recommendDiscover, + _reward, (context) => FollowingPage( onGoHome: () => logic.select(0), favoriteController: context.read(), 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}); diff --git a/lib/pages/mine/mine_types.dart b/lib/pages/mine/mine_types.dart index 4196b8c..6007d74 100644 --- a/lib/pages/mine/mine_types.dart +++ b/lib/pages/mine/mine_types.dart @@ -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 { diff --git a/lib/pages/mine/mine_view.dart b/lib/pages/mine/mine_view.dart index ba8df22..6671fd4 100644 --- a/lib/pages/mine/mine_view.dart +++ b/lib/pages/mine/mine_view.dart @@ -333,6 +333,7 @@ class _MyPageState extends State { 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, }; diff --git a/lib/pages/mine/widgets/mine_action_grid.dart b/lib/pages/mine/widgets/mine_action_grid.dart index 59c1230..9d0b375 100644 --- a/lib/pages/mine/widgets/mine_action_grid.dart +++ b/lib/pages/mine/widgets/mine_action_grid.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; +import '../../../core/widgets/app_press_scale.dart'; import '../../../l10n/app_localizations.dart'; import '../mine_types.dart'; @@ -31,7 +32,7 @@ class MineActionGrid extends StatelessWidget { key: const Key('mine-menu'), decoration: BoxDecoration( color: AppColors.mineGridSurface, - border: Border.all(color: AppColors.contentBorder), + border: Border.all(color: AppColors.glassHairline), borderRadius: BorderRadius.circular(AppDimens.mineGridRadius), ), child: ClipRRect( @@ -176,16 +177,18 @@ class _MineActionCell extends StatelessWidget { child: InkWell( key: item.key, onTap: item.onTap, - child: Semantics( - button: true, - label: item.label, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - _iconSurface(), - const SizedBox(height: AppDimens.spaceSm), - _label(), - ], + child: AppPressScale( + child: Semantics( + button: true, + label: item.label, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _iconSurface(), + const SizedBox(height: AppDimens.spaceSm), + _label(), + ], + ), ), ), ), @@ -208,7 +211,7 @@ class _MineActionCell extends StatelessWidget { ], ), shape: BoxShape.circle, - border: Border.all(color: AppColors.glassBorder), + border: Border.all(color: AppColors.glassHairline), boxShadow: [ const BoxShadow( color: AppColors.shadowSoft, diff --git a/lib/pages/mine/widgets/mine_continue_watching.dart b/lib/pages/mine/widgets/mine_continue_watching.dart index 33a7f67..f144c73 100644 --- a/lib/pages/mine/widgets/mine_continue_watching.dart +++ b/lib/pages/mine/widgets/mine_continue_watching.dart @@ -123,7 +123,7 @@ class MineContinueWatching extends StatelessWidget { Positioned.fill( child: ClipRRect( borderRadius: BorderRadius.circular( - AppDimens.homeCardRadius, + AppDimens.coverRadius, ), child: RemoteImage( record.dramaCover ?? '', diff --git a/lib/pages/mine/widgets/mine_menu.dart b/lib/pages/mine/widgets/mine_menu.dart index 27ffaef..878e2f7 100644 --- a/lib/pages/mine/widgets/mine_menu.dart +++ b/lib/pages/mine/widgets/mine_menu.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; +import '../../../core/widgets/app_press_scale.dart'; import '../../../l10n/app_localizations.dart'; const double _vipMaxTextScale = 1.15; @@ -28,16 +29,18 @@ class MineVipBanner extends StatelessWidget { clipBehavior: Clip.antiAlias, child: InkWell( onTap: onOpen, - child: MediaQuery.withClampedTextScaling( - maxScaleFactor: _vipMaxTextScale, - child: Stack( - children: [ - const Positioned.fill(child: _VipAmbientGlow()), - Padding( - padding: const EdgeInsets.all(AppDimens.mineVipPadding), - child: _VipContent(l10n: l10n, onOpen: onOpen), - ), - ], + child: AppPressScale( + child: MediaQuery.withClampedTextScaling( + maxScaleFactor: _vipMaxTextScale, + child: Stack( + children: [ + const Positioned.fill(child: _VipAmbientGlow()), + Padding( + padding: const EdgeInsets.all(AppDimens.mineVipPadding), + child: _VipContent(l10n: l10n, onOpen: onOpen), + ), + ], + ), ), ), ), diff --git a/lib/pages/new_releases/new_releases_page.dart b/lib/pages/new_releases/new_releases_page.dart index fb0a648..8036f3b 100644 --- a/lib/pages/new_releases/new_releases_page.dart +++ b/lib/pages/new_releases/new_releases_page.dart @@ -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 { crossAxisAlignment: CrossAxisAlignment.start, children: [ ClipRRect( - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(AppDimens.coverRadius), child: SizedBox( width: 108, height: 144, diff --git a/lib/pages/player/widgets/drama_info_sheet.dart b/lib/pages/player/widgets/drama_info_sheet.dart index 03caee8..12fb1c5 100644 --- a/lib/pages/player/widgets/drama_info_sheet.dart +++ b/lib/pages/player/widgets/drama_info_sheet.dart @@ -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 { widget.onOpenRelated?.call(drama); }, child: ClipRRect( - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(AppDimens.coverRadius), child: SizedBox( width: _relatedWidth, child: AspectRatio( diff --git a/lib/pages/player/widgets/home_feed_recommend_panel.dart b/lib/pages/player/widgets/home_feed_recommend_panel.dart index 8e20e73..e69bf68 100644 --- a/lib/pages/player/widgets/home_feed_recommend_panel.dart +++ b/lib/pages/player/widgets/home_feed_recommend_panel.dart @@ -8,6 +8,7 @@ import '../../../core/data/models/drama.dart'; import '../../../core/data/models/locale_text.dart'; import '../../../core/formatting/localized_content.dart'; import '../../../core/theme/app_colors.dart'; +import '../../../core/widgets/app_press_scale.dart'; import '../../../l10n/app_localizations.dart'; import 'expandable_synopsis.dart'; import 'interaction_rail.dart'; @@ -220,7 +221,7 @@ class HomeFeedWatchAllBar extends StatelessWidget { child: InkWell( key: const Key('home-feed-full-series'), onTap: onTap, - child: _content(l10n), + child: AppPressScale(child: _content(l10n)), ), ), ), diff --git a/lib/pages/player/widgets/player_center_toggle.dart b/lib/pages/player/widgets/player_center_toggle.dart index b72ec66..e80366a 100644 --- a/lib/pages/player/widgets/player_center_toggle.dart +++ b/lib/pages/player/widgets/player_center_toggle.dart @@ -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 _iconShadows = [ Shadow(color: AppColors.shadow, blurRadius: 12), ]; @@ -31,18 +35,23 @@ class PlayerCenterToggle extends StatelessWidget { label: playing ? 'Pause' : 'Play', child: GestureDetector( onTap: onTap, - child: Container( + child: SizedBox( width: _toggleSize, height: _toggleSize, - decoration: const BoxDecoration( - color: _toggleBackground, - shape: BoxShape.circle, - ), - child: Icon( - playing ? Icons.pause_rounded : Icons.play_arrow_rounded, - color: AppColors.onPrimary, - size: _iconSize, - shadows: _iconShadows, + child: DecoratedBox( + key: const Key('player-center-toggle-surface'), + decoration: const BoxDecoration( + shape: BoxShape.circle, + gradient: _toggleGradient, + ), + child: Center( + child: Icon( + playing ? Icons.pause_rounded : Icons.play_arrow_rounded, + color: AppColors.onPrimary, + size: _iconSize, + shadows: _iconShadows, + ), + ), ), ), ), diff --git a/lib/pages/player/widgets/player_overlay.dart b/lib/pages/player/widgets/player_overlay.dart index 3f23148..60e99ba 100644 --- a/lib/pages/player/widgets/player_overlay.dart +++ b/lib/pages/player/widgets/player_overlay.dart @@ -10,6 +10,7 @@ import '../../../core/data/services/catalog_controller.dart'; import '../../../core/formatting/localized_content.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; +import '../../../core/widgets/app_press_scale.dart'; import '../../../l10n/app_localizations.dart'; import 'expandable_synopsis.dart'; import 'player_center_toggle.dart'; @@ -389,44 +390,46 @@ class PlayerPlaybackOverlay extends StatelessWidget { // 选集条给一层浅底色:视频画面亮时纯文字不好认,也顺带标出可点区域。 child: Material( key: const Key('player-episodes-pill'), - color: AppColors.textPrimary.withValues(alpha: 0.14), + color: AppColors.glassSurface, borderRadius: BorderRadius.circular(8), clipBehavior: Clip.antiAlias, child: InkWell( key: const Key('player-episodes'), onTap: actions.onEpisodes, borderRadius: BorderRadius.circular(8), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: _bottomActionInset, - ), - child: SizedBox( - height: _bottomActionHeight, - child: Row( - children: [ - Expanded( - child: Text( - '${data.l10n.episodes} · ' - '${data.l10n.allEpisodesBadge(data.totalEpisodes)} · ' - '${data.l10n.freeWatch}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.onPrimary, - fontSize: 13.5, - fontWeight: FontWeight.w400, - shadows: _playerTextShadows, + child: AppPressScale( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: _bottomActionInset, + ), + child: SizedBox( + height: _bottomActionHeight, + child: Row( + children: [ + Expanded( + child: Text( + '${data.l10n.episodes} · ' + '${data.l10n.allEpisodesBadge(data.totalEpisodes)} · ' + '${data.l10n.freeWatch}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onPrimary, + fontSize: 13.5, + fontWeight: FontWeight.w400, + shadows: _playerTextShadows, + ), ), ), - ), - const SizedBox(width: 8), - const Icon( - Icons.keyboard_arrow_up_rounded, - color: AppColors.onPrimary, - size: 18, - shadows: _playerTextShadows, - ), - ], + const SizedBox(width: 8), + const Icon( + Icons.keyboard_arrow_up_rounded, + color: AppColors.onPrimary, + size: 18, + shadows: _playerTextShadows, + ), + ], + ), ), ), ), diff --git a/lib/pages/recommend/recommend_discover_page.dart b/lib/pages/recommend/recommend_discover_page.dart index 948ccb9..dde98ba 100644 --- a/lib/pages/recommend/recommend_discover_page.dart +++ b/lib/pages/recommend/recommend_discover_page.dart @@ -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('recommend-feed-${widget.categoryId}'), diff --git a/lib/pages/recommend/recommend_page.dart b/lib/pages/recommend/recommend_page.dart index 26e9a14..4ea933f 100644 --- a/lib/pages/recommend/recommend_page.dart +++ b/lib/pages/recommend/recommend_page.dart @@ -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'; diff --git a/lib/pages/recommend/recommend_page_parts.dart b/lib/pages/recommend/recommend_page_parts.dart index 1a03f42..2ad5fbb 100644 --- a/lib/pages/recommend/recommend_page_parts.dart +++ b/lib/pages/recommend/recommend_page_parts.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; diff --git a/lib/pages/recommend/widgets/home_recommend_content.dart b/lib/pages/recommend/widgets/home_recommend_content.dart index 8e4cd88..6a128d5 100644 --- a/lib/pages/recommend/widgets/home_recommend_content.dart +++ b/lib/pages/recommend/widgets/home_recommend_content.dart @@ -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 + 集数行 + 余量。 /// diff --git a/lib/pages/recommend/widgets/recommend_section_card.dart b/lib/pages/recommend/widgets/recommend_section_card.dart index 9adb90a..de28654 100644 --- a/lib/pages/recommend/widgets/recommend_section_card.dart +++ b/lib/pages/recommend/widgets/recommend_section_card.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import '../../../core/data/models/drama.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; +import '../../../core/widgets/app_press_scale.dart'; import '../../../core/widgets/cover_play_count.dart'; import '../../../core/widgets/drama_cover_badge_ribbon.dart'; import '../../../core/widgets/remote_image.dart'; @@ -28,75 +29,77 @@ class RecommendSectionCard extends StatelessWidget { final tagLabel = drama.localizedTag( Localizations.localeOf(context).languageCode, ); - return SizedBox( - width: AppDimens.recommendCardWidth, - child: InkWell( - key: Key('recommend-card-$sectionKey-${drama.id}'), - borderRadius: BorderRadius.circular(AppDimens.cardCoverRadius), - onTap: onTap, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(AppDimens.cardCoverRadius), - child: AspectRatio( - aspectRatio: AppDimens.cardCoverAspectRatio, - child: Stack( - fit: StackFit.expand, - children: [ - RemoteImage( - drama.posterUrl, - fit: BoxFit.cover, - fallbackColor: AppColors.contentSurface, - ), - if (tagLabel.isNotEmpty) - Positioned( - top: 0, + return AppPressScale( + child: SizedBox( + width: AppDimens.recommendCardWidth, + child: InkWell( + key: Key('recommend-card-$sectionKey-${drama.id}'), + borderRadius: BorderRadius.circular(AppDimens.cardCoverRadius), + onTap: onTap, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(AppDimens.cardCoverRadius), + child: AspectRatio( + aspectRatio: AppDimens.cardCoverAspectRatio, + child: Stack( + fit: StackFit.expand, + children: [ + RemoteImage( + drama.posterUrl, + fit: BoxFit.cover, + fallbackColor: AppColors.contentSurface, + ), + if (tagLabel.isNotEmpty) + Positioned( + top: 0, + right: 0, + child: DramaCoverBadgeRibbon( + label: tagLabel, + color: drama.tagRibbonColor(AppColors.badgeHot), + keyPrefix: '$sectionKey-${drama.id}-tag', + ), + ), + const Positioned( + left: 0, right: 0, - child: DramaCoverBadgeRibbon( - label: tagLabel, - color: drama.tagRibbonColor(AppColors.badgeHot), - keyPrefix: '$sectionKey-${drama.id}-tag', + bottom: 0, + child: CoverBottomScrim(), + ), + Positioned( + right: AppDimens.cardPlayCountRight, + bottom: AppDimens.cardPlayCountBottom, + child: CoverPlayCount( + key: Key('recommend-views-$sectionKey-${drama.id}'), + count: drama.viewCount, ), ), - const Positioned( - left: 0, - right: 0, - bottom: 0, - child: CoverBottomScrim(), - ), - Positioned( - right: AppDimens.cardPlayCountRight, - bottom: AppDimens.cardPlayCountBottom, - child: CoverPlayCount( - key: Key('recommend-views-$sectionKey-${drama.id}'), - count: drama.viewCount, - ), - ), - ], + ], + ), ), ), - ), - const SizedBox(height: AppDimens.cardCoverTitleGap), - Text( - drama.localizedTitle( - Localizations.localeOf(context).languageCode, + const SizedBox(height: AppDimens.cardCoverTitleGap), + Text( + drama.localizedTitle( + Localizations.localeOf(context).languageCode, + ), + key: Key('recommend-title-$sectionKey-${drama.id}'), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: AppDimens.cardTitleFont, + fontWeight: FontWeight.w500, + height: AppDimens.cardTitleLineHeight, + ), ), - key: Key('recommend-title-$sectionKey-${drama.id}'), - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: AppDimens.cardTitleFont, - fontWeight: FontWeight.w500, - height: AppDimens.cardTitleLineHeight, - ), - ), - const SizedBox(height: AppDimens.cardTitleTagGap), - _tags(context), - ], + const SizedBox(height: AppDimens.cardTitleTagGap), + _tags(context), + ], + ), ), ), ); diff --git a/lib/pages/reward/reward_checkin_card.dart b/lib/pages/reward/reward_checkin_card.dart new file mode 100644 index 0000000..2eb8ea8 --- /dev/null +++ b/lib/pages/reward/reward_checkin_card.dart @@ -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 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, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/reward/reward_page.dart b/lib/pages/reward/reward_page.dart new file mode 100644 index 0000000..a9445d0 --- /dev/null +++ b/lib/pages/reward/reward_page.dart @@ -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? onTaskAction; + final VoidCallback? onOpenWallet; + + @override + State createState() => _RewardPageState(); +} + +class _RewardPageState extends State { + 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(); + final auth = context.read(); + final locale = Localizations.localeOf(context).languageCode; + final loadKey = '$locale$_loadKeySeparator${auth.isSignedIn}'; + if (!force && _autoLoadKey == loadKey) return; + _autoLoadKey = loadKey; + _loadScheduled = true; + Future.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(); + context.watch(); + 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 _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 _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( + 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, + ), + ), + ); + } +} diff --git a/lib/pages/reward/reward_tasks_view.dart b/lib/pages/reward/reward_tasks_view.dart new file mode 100644 index 0000000..25b11ba --- /dev/null +++ b/lib/pages/reward/reward_tasks_view.dart @@ -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 Function() onRefresh; + final Future Function() onCheckIn; + final Future Function(RewardTask task) onTaskAction; + final VoidCallback? onOpenWallet; + final VoidCallback? onBoostCheckIn; + + @override + Widget build(BuildContext context) { + return RefreshIndicator( + color: AppColors.brandRed, + backgroundColor: AppColors.contentCard, + onRefresh: onRefresh, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB( + AppDimens.pageHorizontal, + AppDimens.spaceLg, + AppDimens.pageHorizontal, + AppDimens.spaceXl, + ), + children: [ + _Balance( + key: const Key('reward-balance'), + balance: overview.coinBalance, + onOpenWallet: onOpenWallet, + ), + const SizedBox(height: AppDimens.spaceLg), + RewardCheckinCard( + l10n: l10n, + rewards: rewards, + checkin: overview.checkin, + onCheckIn: onCheckIn, + onBoost: onBoostCheckIn ?? () {}, + ), + for (final section in overview.sections) + if (_isVisibleSection(section)) ...[ + const SizedBox(height: AppDimens.spaceXl), + _SectionHeader( + key: Key('reward-section-${section.code}'), + l10n: l10n, + section: section, + ), + const SizedBox(height: AppDimens.spaceMd), + _TaskGroupCard( + key: Key('reward-task-card-${section.code}'), + l10n: l10n, + rewards: rewards, + section: section, + onTaskAction: onTaskAction, + ), + ], + if (!overview.sections.any(_isVisibleSection)) + _Empty(label: l10n.rewardEmptyTasks), + ], + ), + ); + } +} + +bool _isVisibleSection(RewardSection section) { + return section.code.toUpperCase() != _checkInSectionCode && + section.tasks.isNotEmpty; +} + +class _Balance extends StatelessWidget { + const _Balance({super.key, required this.balance, this.onOpenWallet}); + + final int balance; + final VoidCallback? onOpenWallet; + + @override + Widget build(BuildContext context) { + final content = Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Flexible( + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text( + AppLocalizations.of(context)!.rewardMyCoins, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: _balanceLabelFont, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + const SizedBox(width: AppDimens.spaceMd), + Text( + '$balance', + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: _balanceFont, + fontWeight: FontWeight.w800, + height: 1, + ), + ), + const SizedBox(width: AppDimens.spaceSm), + const RewardCoinIcon(size: 32), + ], + ); + if (onOpenWallet == null) return content; + return InkWell( + onTap: onOpenWallet, + borderRadius: BorderRadius.circular(AppDimens.cardRadius), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: AppDimens.spaceXs), + child: content, + ), + ); + } +} + +class _SectionHeader extends StatelessWidget { + const _SectionHeader({super.key, required this.l10n, required this.section}); + + final AppLocalizations l10n; + final RewardSection section; + + @override + Widget build(BuildContext context) { + final subtitle = _sectionSubtitle(l10n, section.code); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + section.title, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: _sectionTitleFont, + fontWeight: FontWeight.w800, + ), + ), + if (subtitle.isNotEmpty) ...[ + const SizedBox(height: AppDimens.spaceXs), + Text( + subtitle, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: _sectionSubtitleFont, + ), + ), + ], + ], + ); + } +} + +String _sectionSubtitle(AppLocalizations l10n, String code) { + return switch (code.toUpperCase()) { + 'DAILY' => l10n.rewardDailySectionSubtitle, + 'NEWBIE' => l10n.rewardNewbieSectionSubtitle, + 'INVITE' || 'INVITATION' => l10n.rewardInviteSectionSubtitle, + _ => '', + }; +} + +class _TaskGroupCard extends StatelessWidget { + const _TaskGroupCard({ + super.key, + required this.l10n, + required this.rewards, + required this.section, + required this.onTaskAction, + }); + + final AppLocalizations l10n; + final RewardController rewards; + final RewardSection section; + final Future 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 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 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), + ), + ), + ); + } +} diff --git a/lib/pages/reward/reward_visuals.dart b/lib/pages/reward/reward_visuals.dart new file mode 100644 index 0000000..60ccda5 --- /dev/null +++ b/lib/pages/reward/reward_visuals.dart @@ -0,0 +1,164 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../core/data/models/reward.dart'; +import '../../core/theme/app_colors.dart'; + +const double _coinInnerInsetRatio = 0.1; +const double _coinLetterRatio = 0.46; +const double _taskIconSize = 46; +const double _taskIconRadius = 12; +const double _taskGlyphSize = 24; +const double _adIconWidth = 28; +const double _adIconHeight = 22; +const double _adIconRadius = 4; +const double _adUnderlineWidth = 12; +const double _adUnderlineHeight = 2; +const double _adUnderlineBottom = -5; +const Color _coinOuter = Color(0xFFF6B84E); +const Color _coinInnerStart = Color(0xFFFFD98B); +const Color _coinInnerEnd = Color(0xFFE39B31); +const Color _coinBorder = Color(0xFFFFE0A1); +const Color _taskIconStart = Color(0xFF4C171E); +const Color _taskIconEnd = Color(0xFF281014); +const Color _taskIconSurface = Color(0xFF202022); + +/// 奖励中心统一金币图标。 +class RewardCoinIcon extends StatelessWidget { + const RewardCoinIcon({super.key, required this.size}); + + final double size; + + @override + Widget build(BuildContext context) { + final inset = size * _coinInnerInsetRatio; + return Container( + width: size, + height: size, + padding: EdgeInsets.all(inset), + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: _coinOuter, + boxShadow: [BoxShadow(color: Color(0x4DF6B84E), blurRadius: 6)], + ), + child: Container( + alignment: Alignment.center, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [_coinInnerStart, _coinInnerEnd], + ), + border: Border.all(color: _coinBorder), + ), + child: Text( + 'N', + style: TextStyle( + color: AppColors.textPrimary, + fontSize: size * _coinLetterRatio, + fontWeight: FontWeight.w900, + height: 1, + ), + ), + ), + ); + } +} + +/// 奖励中心签到按钮的广告标识。 +class RewardAdIcon extends StatelessWidget { + const RewardAdIcon({super.key}); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: _adIconWidth, + height: _adIconHeight, + child: Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + Container( + width: _adIconWidth, + height: _adIconHeight, + alignment: Alignment.center, + decoration: BoxDecoration( + border: Border.all(color: AppColors.textPrimary, width: 2), + borderRadius: BorderRadius.circular(_adIconRadius), + ), + child: const Text( + 'AD', + style: TextStyle( + color: AppColors.textPrimary, + fontSize: 11, + fontWeight: FontWeight.w900, + height: 1, + ), + ), + ), + Positioned( + bottom: _adUnderlineBottom, + child: Container( + width: _adUnderlineWidth, + height: _adUnderlineHeight, + decoration: BoxDecoration( + color: AppColors.textPrimary, + borderRadius: BorderRadius.circular(_adUnderlineHeight), + ), + ), + ), + ], + ), + ); + } +} + +/// 根据后台任务类型绘制任务图标,不绑定具体任务文案。 +class RewardTaskLeading extends StatelessWidget { + const RewardTaskLeading({super.key, required this.task}); + + final RewardTask task; + + @override + Widget build(BuildContext context) { + final invite = _isInviteTask(task); + return Container( + width: _taskIconSize, + height: _taskIconSize, + decoration: BoxDecoration( + shape: invite ? BoxShape.circle : BoxShape.rectangle, + borderRadius: invite ? null : BorderRadius.circular(_taskIconRadius), + gradient: invite + ? const LinearGradient(colors: [_taskIconStart, _taskIconEnd]) + : null, + color: invite ? null : _taskIconSurface, + ), + child: Icon( + _taskIcon(task), + color: AppColors.textPrimary, + size: _taskGlyphSize, + ), + ); + } +} + +bool _isInviteTask(RewardTask task) { + final code = task.code.toUpperCase(); + return task.type == 'INVITE_FRIEND' || code.contains('INVITE'); +} + +IconData _taskIcon(RewardTask task) { + final code = task.code.toUpperCase(); + if (code.contains('FACEBOOK')) return Icons.facebook_rounded; + if (code.contains('YOUTUBE')) return Icons.smart_display_rounded; + return switch (task.type) { + 'WATCH_DURATION' => Icons.play_circle_outline_rounded, + 'WATCH_EPISODES' => Icons.video_collection_outlined, + 'COMPLETE_PROFILE' => Icons.person_outline_rounded, + 'FIRST_PURCHASE' => Icons.shopping_bag_outlined, + 'INVITE_FRIEND' => Icons.card_giftcard_rounded, + _ => Icons.stars_rounded, + }; +} diff --git a/lib/pages/watch_records/widgets/watch_record_card.dart b/lib/pages/watch_records/widgets/watch_record_card.dart index a784374..0014898 100644 --- a/lib/pages/watch_records/widgets/watch_record_card.dart +++ b/lib/pages/watch_records/widgets/watch_record_card.dart @@ -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; diff --git a/test/core/app_press_scale_test.dart b/test/core/app_press_scale_test.dart new file mode 100644 index 0000000..ac480ea --- /dev/null +++ b/test/core/app_press_scale_test.dart @@ -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(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(transformFinder).transform.entry(0, 0), + lessThan(1), + ); + + await gesture.up(); + await tester.pumpAndSettle(); + expect(tester.widget(transformFinder).transform.entry(0, 0), 1); + }); +} diff --git a/test/following/following_page_test.dart b/test/following/following_page_test.dart index 3f15012..b317768 100644 --- a/test/following/following_page_test.dart +++ b/test/following/following_page_test.dart @@ -114,7 +114,7 @@ void main() { ); expect( coverClip.borderRadius, - BorderRadius.circular(AppDimens.homeCardRadius), + BorderRadius.circular(AppDimens.coverRadius), ); final titleFinder = find.descendant( of: card, diff --git a/test/home/home_logic_test.dart b/test/home/home_logic_test.dart index 2f58977..fc94568 100644 --- a/test/home/home_logic_test.dart +++ b/test/home/home_logic_test.dart @@ -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); diff --git a/test/home/home_nav_ui_test.dart b/test/home/home_nav_ui_test.dart index e91d891..bbaacfa 100644 --- a/test/home/home_nav_ui_test.dart +++ b/test/home/home_nav_ui_test.dart @@ -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( - find.descendant( - of: find.byKey(const Key('tab-home')), - matching: find.byType(AnimatedScale), - ), - ) - .duration, - lessThanOrEqualTo(const Duration(milliseconds: 200)), - ); final selectedHomeIcon = tester.widget( find.descendant( of: find.byKey(const Key('tab-home')), @@ -239,64 +245,72 @@ void main() { ); }); - testWidgets('should_map_four_tabs_to_home_recommend_following_and_me', ( - tester, - ) async { - // 追剧页头部的双语分段文案在测试字体(Ahem)下比真机字体宽约 98pt, - // 这里放宽视口宽度,避免测字体撑破 390 宽度下的分段行。 - await _pumpApp(tester, size: const Size(520, 844)); + testWidgets( + 'should_map_five_tabs_to_home_recommend_reward_following_and_me', + (tester) async { + // 追剧页头部的双语分段文案在测试字体(Ahem)下比真机字体宽约 98pt, + // 这里放宽视口宽度,避免测字体撑破 390 宽度下的分段行。 + await _pumpApp(tester, size: const Size(520, 844)); - expect(find.byKey(const Key('tab-home')), findsOneWidget); - expect(find.byKey(const Key('tab-recommend')), findsOneWidget); - expect(find.byKey(const Key('tab-follow')), findsOneWidget); - expect(find.byKey(const Key('tab-mine')), findsOneWidget); - expect(find.byKey(const Key('tab-create')), findsNothing); - expect(find.byKey(const Key('recommend-feed-player')), findsOneWidget); + expect(find.byKey(const Key('tab-home')), findsOneWidget); + expect(find.byKey(const Key('tab-recommend')), findsOneWidget); + expect(find.byKey(const Key('tab-reward')), findsOneWidget); + expect(find.byKey(const Key('tab-follow')), findsOneWidget); + expect(find.byKey(const Key('tab-mine')), findsOneWidget); + expect(find.byKey(const Key('tab-create')), findsNothing); + expect(find.byKey(const Key('recommend-feed-player')), findsOneWidget); - await tester.tap(find.byKey(const Key('tab-recommend'))); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('recommend-channel-nav')), findsOneWidget); - _expectNoFloatingOverlays(); + await tester.tap(find.byKey(const Key('tab-recommend'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('recommend-channel-nav')), findsOneWidget); + _expectNoFloatingOverlays(); - await tester.tap(find.byKey(const Key('tab-follow'))); - await tester.pumpAndSettle(); - final favorites = find.byType(FollowingPage); - expect(favorites, findsOneWidget); - expect(find.byKey(const Key('following-page')), findsOneWidget); - expect(find.byType(AppBottomNav), findsOneWidget); - expect( - find.descendant(of: favorites, matching: find.byType(Scaffold)), - findsNothing, - ); - expect(Navigator.canPop(tester.element(favorites)), isFalse); - _expectNoFloatingOverlays(); - expect( - find.descendant( - of: find.byKey(const Key('tab-follow')), - matching: find.byType(RootNavGlyph), - ), - findsOneWidget, - ); + await tester.tap(find.byKey(const Key('tab-reward'))); + await tester.pumpAndSettle(); + expect(find.text('Reward Center'), findsOneWidget); + expect(find.text('Daily task'), findsOneWidget); + _expectNoFloatingOverlays(); - await tester.tap(find.byKey(const Key('tab-mine'))); - await tester.pumpAndSettle(); - expect(find.text('Viewer'), findsOneWidget); - expect(find.byKey(const Key('mine-menu')), findsOneWidget); - expect(find.byKey(const Key('mine-wallet')), findsOneWidget); - expect(find.byKey(const Key('mine-edit')), findsOneWidget); - expect(find.byKey(const Key('mine-library-tab-0')), findsNothing); - _expectNoFloatingOverlays(); + await tester.tap(find.byKey(const Key('tab-follow'))); + await tester.pumpAndSettle(); + final favorites = find.byType(FollowingPage); + expect(favorites, findsOneWidget); + expect(find.byKey(const Key('following-page')), findsOneWidget); + expect(find.byType(AppBottomNav), findsOneWidget); + expect( + find.descendant(of: favorites, matching: find.byType(Scaffold)), + findsNothing, + ); + expect(Navigator.canPop(tester.element(favorites)), isFalse); + _expectNoFloatingOverlays(); + expect( + find.descendant( + of: find.byKey(const Key('tab-follow')), + matching: find.byType(RootNavGlyph), + ), + findsOneWidget, + ); - await tester.tap(find.byKey(const Key('tab-follow'))); - await tester.pumpAndSettle(); - expect(favorites, findsOneWidget); - expect(find.byType(AppBottomNav), findsOneWidget); - expect(Navigator.canPop(tester.element(favorites)), isFalse); + await tester.tap(find.byKey(const Key('tab-mine'))); + await tester.pumpAndSettle(); + expect(find.text('Viewer'), findsOneWidget); + expect(find.byKey(const Key('mine-menu')), findsOneWidget); + expect(find.byKey(const Key('mine-wallet')), findsOneWidget); + expect(find.byKey(const Key('mine-edit')), findsOneWidget); + expect(find.byKey(const Key('mine-library-tab-0')), findsNothing); + _expectNoFloatingOverlays(); - await tester.tap(find.byKey(const Key('tab-home'))); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('recommend-feed-player')), findsOneWidget); - }); + await tester.tap(find.byKey(const Key('tab-follow'))); + await tester.pumpAndSettle(); + expect(favorites, findsOneWidget); + expect(find.byType(AppBottomNav), findsOneWidget); + expect(Navigator.canPop(tester.element(favorites)), isFalse); + + await tester.tap(find.byKey(const Key('tab-home'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('recommend-feed-player')), findsOneWidget); + }, + ); testWidgets('should_not_haptic_when_ordinary_list_item_is_opened', ( tester, @@ -337,6 +351,7 @@ Future _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 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 checkIn() async { + return const RewardActionResult( + rewardCoin: 10, + coinBalance: 130, + alreadyCompleted: false, + ); + } + + @override + Future 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); diff --git a/test/home/k28_a_navigation_test.dart b/test/home/k28_a_navigation_test.dart index 9466059..ae97e2d 100644 --- a/test/home/k28_a_navigation_test.dart +++ b/test/home/k28_a_navigation_test.dart @@ -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'), ]; diff --git a/test/home/root_nav_glyph_test.dart b/test/home/root_nav_glyph_test.dart index 993a433..911eb41 100644 --- a/test/home/root_nav_glyph_test.dart +++ b/test/home/root_nav_glyph_test.dart @@ -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())); }, ); } diff --git a/test/player/player_center_toggle_test.dart b/test/player/player_center_toggle_test.dart index 66e643b..d8f7e08 100644 --- a/test/player/player_center_toggle_test.dart +++ b/test/player/player_center_toggle_test.dart @@ -35,13 +35,19 @@ void main() { Key('player-play-toggle'), ]) { expect(tester.getSize(find.byKey(key)), const Size(68, 68)); - // 圆形深色底:压在亮画面上也能看见播放/暂停。 - final container = tester.widget( - find.descendant(of: find.byKey(key), matching: find.byType(Container)), + // 半透明玻璃圆面:压在亮画面上也能看见播放/暂停。 + final surface = tester.widget( + 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()); expect(decoration.shape, BoxShape.circle); + expect(decoration.border, isNull); final icon = tester.widget( find.descendant(of: find.byKey(key), matching: find.byType(Icon)), ); diff --git a/test/recommend/recommend_discover_empty_state_test.dart b/test/recommend/recommend_discover_empty_state_test.dart index 190d6f6..9259834 100644 --- a/test/recommend/recommend_discover_empty_state_test.dart +++ b/test/recommend/recommend_discover_empty_state_test.dart @@ -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.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('recommend-feed-7')); + final scrollable = tester.state( + 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 { diff --git a/test/reward/reward_controller_test.dart b/test/reward/reward_controller_test.dart new file mode 100644 index 0000000..a62eb96 --- /dev/null +++ b/test/reward/reward_controller_test.dart @@ -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 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 checkIn() async { + return const RewardActionResult( + rewardCoin: 35, + coinBalance: 35, + alreadyCompleted: false, + ); + } + + @override + Future claim(String taskId) async { + claimedTaskId = taskId; + await Future.delayed(Duration.zero); + return const RewardActionResult( + rewardCoin: 20, + coinBalance: 55, + alreadyCompleted: false, + ); + } +} diff --git a/test/reward/reward_model_test.dart b/test/reward/reward_model_test.dart new file mode 100644 index 0000000..3867f8a --- /dev/null +++ b/test/reward/reward_model_test.dart @@ -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); + }); +} diff --git a/test/reward/reward_page_test.dart b/test/reward/reward_page_test.dart new file mode 100644 index 0000000..c5ba098 --- /dev/null +++ b/test/reward/reward_page_test.dart @@ -0,0 +1,413 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +import 'package:fruitshorts/core/data/models/reward.dart'; +import 'package:fruitshorts/core/data/remote/api_client.dart'; +import 'package:fruitshorts/core/data/remote/auth_api.dart'; +import 'package:fruitshorts/core/data/repositories/reward_repository.dart'; +import 'package:fruitshorts/core/data/services/auth_controller.dart'; +import 'package:fruitshorts/core/data/services/auth_session_store.dart'; +import 'package:fruitshorts/core/data/services/reward_controller.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/reward/reward_page.dart'; + +void main() { + testWidgets('should_render_tasks_and_switch_to_activities_when_signed_in', ( + tester, + ) async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + final auth = _TestAuthController(signedIn: true); + final rewards = RewardController(_RewardPageRepository()); + + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: auth), + ChangeNotifierProvider.value(value: rewards), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold(body: RewardPage(active: true)), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 2)); + await tester.pumpAndSettle(); + + expect(find.text('Reward Center'), findsOneWidget); + expect(find.text('My coins'), findsOneWidget); + expect(find.text('Daily task'), findsOneWidget); + expect(find.text('Check in +10 coins'), findsOneWidget); + expect(find.byKey(const Key('reward-back-button')), findsOneWidget); + expect(find.byKey(const Key('reward-help-button')), findsOneWidget); + expect(find.byKey(const Key('reward-balance')), findsOneWidget); + expect(find.byKey(const Key('reward-checkin-card')), findsOneWidget); + expect(find.byKey(const Key('reward-checkin-action')), findsOneWidget); + expect(find.byKey(const Key('reward-section-DAILY')), findsOneWidget); + expect(find.byKey(const Key('reward-task-card-DAILY')), findsOneWidget); + + await tester.tap(find.byKey(const Key('reward-tab-activities'))); + await tester.pumpAndSettle(); + + expect(find.text('Spring event'), findsOneWidget); + expect(find.text('Daily task'), findsNothing); + + await tester.tap(find.byKey(const Key('reward-tab-tasks'))); + await tester.pumpAndSettle(); + + expect(find.text('Daily task'), findsOneWidget); + expect(find.text('Spring event'), findsNothing); + expect( + tester.getSize(find.byKey(const Key('reward-tab-indicator-tasks'))).width, + 24, + ); + + auth.dispose(); + rewards.dispose(); + }); + + testWidgets('should_show_reward_content_and_gate_check_in_when_signed_out', ( + tester, + ) async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + final auth = _TestAuthController(signedIn: false); + final repository = _RewardPageRepository(); + final rewards = RewardController(repository); + + await _pumpRewardPage(tester, auth: auth, rewards: rewards); + + expect(find.byKey(const Key('auth-gate-panel')), findsNothing); + expect(find.text('Daily task'), findsOneWidget); + expect(find.text('Check in +10 coins'), findsOneWidget); + expect(repository.overviewCalls, 1); + + await tester.tap(find.text('Check in +10 coins')); + await tester.pumpAndSettle(); + + expect(find.text('Sign in to continue'), findsOneWidget); + expect(repository.checkInCalls, 0); + + auth.dispose(); + rewards.dispose(); + }); + + testWidgets('should_use_reward_dialog_style_when_help_is_opened', ( + tester, + ) async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + final auth = _TestAuthController(signedIn: false); + final rewards = RewardController(_RewardPageRepository()); + + await _pumpRewardPage(tester, auth: auth, rewards: rewards); + + await tester.tap(find.byKey(const Key('reward-help-button'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('reward-help-dialog')), findsOneWidget); + expect(find.text('About rewards'), findsOneWidget); + expect( + find.text( + 'Complete check-ins and tasks to earn reward coins. Rewards and task rules are shown on this page.', + ), + findsOneWidget, + ); + expect(find.byKey(const Key('reward-help-close')), findsOneWidget); + expect(find.byType(AlertDialog), findsNothing); + + await tester.tap(find.byKey(const Key('reward-help-close'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('reward-help-dialog')), findsNothing); + + auth.dispose(); + rewards.dispose(); + }); + + testWidgets( + 'should_show_retry_without_reloading_forever_when_overview_fails', + (tester) async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + final auth = _TestAuthController(signedIn: false); + final repository = _FailingRewardPageRepository(); + final rewards = RewardController(repository); + + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: auth), + ChangeNotifierProvider.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 _pumpRewardPage( + WidgetTester tester, { + required AuthController auth, + required RewardController rewards, +}) async { + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: auth), + ChangeNotifierProvider.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 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 checkIn() async { + checkInCalls += 1; + return const RewardActionResult( + rewardCoin: 10, + coinBalance: 130, + alreadyCompleted: false, + ); + } + + @override + Future claim(String taskId) async { + return const RewardActionResult( + rewardCoin: 20, + coinBalance: 140, + alreadyCompleted: false, + ); + } +} + +class _FailingRewardPageRepository implements RewardRepository { + int overviewCalls = 0; + + @override + Future fetchOverview(String locale) async { + overviewCalls += 1; + throw ApiException(500, 'offline'); + } + + @override + Future checkIn() async { + throw ApiException(401, 'login'); + } + + @override + Future claim(String taskId) async { + throw ApiException(401, 'login'); + } +} diff --git a/test/reward/reward_visual_layout_test.dart b/test/reward/reward_visual_layout_test.dart new file mode 100644 index 0000000..191fb3b --- /dev/null +++ b/test/reward/reward_visual_layout_test.dart @@ -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( + 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(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.value(value: auth), + ChangeNotifierProvider.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 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 checkIn() async { + return const RewardActionResult( + rewardCoin: 55, + coinBalance: 55, + alreadyCompleted: false, + ); + } + + @override + Future claim(String taskId) async { + return const RewardActionResult( + rewardCoin: 50, + coinBalance: 50, + alreadyCompleted: false, + ); + } +} diff --git a/test/theme/app_dimens_test.dart b/test/theme/app_dimens_test.dart index cc63132..f1cf31c 100644 --- a/test/theme/app_dimens_test.dart +++ b/test/theme/app_dimens_test.dart @@ -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); diff --git a/test/widget_test.dart b/test/widget_test.dart index 5315f49..8082228 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -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();