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

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

No files matched your search

+1 -1
View File
@@ -5,7 +5,7 @@ import 'package:flutter/foundation.dart';
import '../../core/data/models/drama.dart';
/// 推荐页与根导航之间的动作。
enum DiscoverPageAction { home, recommend, following, mine }
enum DiscoverPageAction { home, recommend, reward, following, mine }
/// 剧场页导航回调;没有 `onAction` 时按独立页面处理。
class DiscoverPageCallbacks {
+1
View File
@@ -472,6 +472,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
DiscoverPageAction _actionOf(AppRootTab tab) => switch (tab) {
AppRootTab.home => DiscoverPageAction.home,
AppRootTab.recommend => DiscoverPageAction.recommend,
AppRootTab.reward => DiscoverPageAction.reward,
AppRootTab.following => DiscoverPageAction.following,
AppRootTab.mine => DiscoverPageAction.mine,
};
+1 -1
View File
@@ -14,7 +14,7 @@ const double _columnGap = 8;
const double _cardGap = 14;
const double _gridHorizontalPadding = 12;
const double _coverAspectRatio = 3 / 4;
const double _coverRadius = 10;
const double _coverRadius = AppDimens.coverRadius;
const double _titleGap = 8;
const double _tagsGap = 6;
const double _titleFontSize = 15;
@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import '../../../core/data/models/drama.dart';
import '../../../core/formatting/localized_content.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/widgets/remote_image.dart';
import '../../../core/widgets/subpage_header.dart';
import '../../../l10n/app_localizations.dart';
@@ -13,7 +14,7 @@ const double _horizontal = 14;
const double _topGap = 8;
const double _posterWidth = 96;
const double _posterHeight = 128;
const double _posterRadius = 12;
const double _posterRadius = AppDimens.coverRadius;
const double _infoGap = 14;
const double _favoriteHeight = 34;
const double _favoriteRadius = 17;
+1 -1
View File
@@ -28,7 +28,7 @@ const double _cardRadius = AppDimens.cardRadius;
const double _cardPadding = 10;
const double _coverWidth = 80;
const double _coverHeight = 106;
const double _coverRadius = 10;
const double _coverRadius = AppDimens.coverRadius;
const double _removeSize = 28;
const double _removeIconSize = 20;
const double _statsFontSize = 12;
@@ -7,11 +7,12 @@ import 'package:flutter/material.dart';
import '../../../core/data/models/drama.dart';
import '../../../core/formatting/count_format.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/widgets/remote_image.dart';
import '../../../l10n/app_localizations.dart';
import '../../player/open_player.dart';
const double _coverRadius = 10;
const double _coverRadius = AppDimens.coverRadius;
const double _titleGap = 6;
const double _metaGap = 2;
const double _badgeHorizontal = 5;
+1 -3
View File
@@ -58,9 +58,7 @@ class FollowingGridCard extends StatelessWidget {
children: [
Positioned.fill(
child: ClipRRect(
borderRadius: BorderRadius.circular(
AppDimens.homeCardRadius,
),
borderRadius: BorderRadius.circular(AppDimens.coverRadius),
child: RemoteImage(
posterUrl,
fit: BoxFit.cover,
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_dimens.dart';
import '../../core/widgets/app_press_scale.dart';
import '../../core/widgets/cover_play_count.dart';
import '../../core/widgets/remote_image.dart';
import '../../l10n/app_localizations.dart';
@@ -45,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)),
],
),
),
),
);
+50 -47
View File
@@ -17,6 +17,7 @@ import '../../core/formatting/error_text.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_dimens.dart';
import '../../core/widgets/empty_state.dart';
import '../../core/widgets/app_press_scale.dart';
import '../../l10n/app_localizations.dart';
import '../discover/search_page.dart';
import '../player/open_player.dart';
@@ -274,9 +275,9 @@ class _FollowingPageState extends State<FollowingPage> {
key: const Key('following-search'),
borderRadius: BorderRadius.circular(8),
onTap: () {
Navigator.of(context).push<void>(
MaterialPageRoute(builder: (_) => const SearchPage()),
);
Navigator.of(
context,
).push<void>(MaterialPageRoute(builder: (_) => const SearchPage()));
},
child: Padding(
padding: EdgeInsets.all(4),
@@ -293,19 +294,21 @@ class _FollowingPageState extends State<FollowingPage> {
return ValueListenableBuilder<bool>(
valueListenable: _editing,
builder: (context, editing, _) {
return InkWell(
key: const Key('following-edit'),
borderRadius: BorderRadius.circular(8),
onTap: _toggleEditing,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 8),
child: Text(
editing ? l10n.done : l10n.manage,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: AppDimens.followingManageFont,
fontWeight: FontWeight.w400,
height: 1.0,
return AppPressScale(
child: InkWell(
key: const Key('following-edit'),
borderRadius: BorderRadius.circular(8),
onTap: _toggleEditing,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 8),
child: Text(
editing ? l10n.done : l10n.manage,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: AppDimens.followingManageFont,
fontWeight: FontWeight.w400,
height: 1.0,
),
),
),
),
@@ -320,40 +323,40 @@ class _FollowingPageState extends State<FollowingPage> {
String keyName,
bool selected,
) {
return InkWell(
key: Key('following-tab-$keyName'),
onTap: () => _selectTab(tab),
borderRadius: BorderRadius.circular(8),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AnimatedDefaultTextStyle(
duration: AppDimens.followingTabAnimationDuration,
style: TextStyle(
color: selected
? AppColors.textPrimary
: AppColors.textTabInactive,
fontSize: AppDimens.followingSegmentFont,
fontWeight: FontWeight.w400,
return AppPressScale(
child: InkWell(
key: Key('following-tab-$keyName'),
onTap: () => _selectTab(tab),
borderRadius: BorderRadius.circular(8),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AnimatedDefaultTextStyle(
duration: AppDimens.followingTabAnimationDuration,
style: TextStyle(
color: selected
? AppColors.textPrimary
: AppColors.textTabInactive,
fontSize: AppDimens.followingSegmentFont,
fontWeight: FontWeight.w400,
),
child: label,
),
child: label,
),
const SizedBox(height: AppDimens.followingSegmentIndicatorTopGap),
AnimatedContainer(
duration: AppDimens.followingTabAnimationDuration,
curve: Curves.easeOut,
width: selected
? AppDimens.followingSegmentIndicatorWidth
: 0,
height: AppDimens.followingSegmentIndicatorHeight,
decoration: BoxDecoration(
color: AppColors.brandRed,
borderRadius: BorderRadius.circular(
AppDimens.followingSegmentIndicatorRadius,
const SizedBox(height: AppDimens.followingSegmentIndicatorTopGap),
AnimatedContainer(
duration: AppDimens.followingTabAnimationDuration,
curve: Curves.easeOut,
width: selected ? AppDimens.followingSegmentIndicatorWidth : 0,
height: AppDimens.followingSegmentIndicatorHeight,
decoration: BoxDecoration(
color: AppColors.brandRed,
borderRadius: BorderRadius.circular(
AppDimens.followingSegmentIndicatorRadius,
),
),
),
),
],
],
),
),
);
}
+10 -6
View File
@@ -75,7 +75,7 @@ class HomeLogic extends ChangeNotifier {
String? episodeId,
}) => playDramaFromCatalog(context, drama, episodeId: episodeId);
void openProfile() => select(3);
void openProfile() => select(4);
void discoverAction(DiscoverPageAction action) {
switch (action) {
@@ -83,10 +83,12 @@ class HomeLogic extends ChangeNotifier {
select(0);
case DiscoverPageAction.recommend:
select(1);
case DiscoverPageAction.following:
case DiscoverPageAction.reward:
select(2);
case DiscoverPageAction.mine:
case DiscoverPageAction.following:
select(3);
case DiscoverPageAction.mine:
select(4);
}
}
@@ -103,7 +105,7 @@ class HomeLogic extends ChangeNotifier {
}
void openFollowingHistory() {
tab = 2;
tab = 3;
followingHistoryRequest += 1;
notify();
}
@@ -114,10 +116,12 @@ class HomeLogic extends ChangeNotifier {
select(0);
case MyPageAction.recommend:
select(1);
case MyPageAction.following:
case MyPageAction.reward:
select(2);
case MyPageAction.mine:
case MyPageAction.following:
select(3);
case MyPageAction.mine:
select(4);
case MyPageAction.resume:
select(0, resume: true);
}
+28 -5
View File
@@ -8,6 +8,7 @@ import 'package:provider/provider.dart';
import '../../core/data/remote/catalog_api.dart';
import '../../core/data/services/home_feed_controller.dart';
import '../../core/data/models/reward.dart';
import '../../core/data/services/catalog_controller.dart';
import '../../core/data/services/category_controller.dart';
import '../../core/data/services/favorite_controller.dart';
@@ -21,6 +22,7 @@ import '../following/following_page.dart';
import '../mine/mine_types.dart';
import '../recommend/recommend_page.dart';
import '../recommend/recommend_discover_page.dart';
import '../reward/reward_page.dart';
import '../mine/mine_view.dart';
import '../wallet/wallet_page.dart';
import 'home_binding.dart';
@@ -105,7 +107,7 @@ class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
}
}
/// 四个根页面共享同一路由并保持各自状态。
/// 五个根页面共享同一路由并保持各自状态。
class _HomeTabs extends StatefulWidget {
const _HomeTabs({super.key, required this.feedController});
@@ -164,11 +166,12 @@ class _HomeTabsState extends State<_HomeTabs> {
final builders = <WidgetBuilder>[
_recommend,
_recommendDiscover,
_reward,
(context) => FollowingPage(
onGoHome: () => logic.select(0),
favoriteController: context.read<FavoriteController>(),
historyRequest: logic.followingHistoryRequest,
visible: logic.tab == 2,
visible: logic.tab == 3,
),
_my,
];
@@ -218,7 +221,7 @@ class _HomeTabsState extends State<_HomeTabs> {
Widget _my(BuildContext context) {
return MyPage(
active: logic.tab == 3,
active: logic.tab == 4,
callbacks: MyPageCallbacks(
onAction: logic.myAction,
onOpenHistoryTab: logic.openFollowingHistory,
@@ -226,6 +229,26 @@ class _HomeTabsState extends State<_HomeTabs> {
);
}
Widget _reward(BuildContext context) {
return RewardPage(
active: logic.tab == 2,
onBack: () => logic.select(0),
onOpenWallet: () => unawaited(_openWallet(context)),
onTaskAction: (task) => _handleRewardAction(context, task),
);
}
void _handleRewardAction(BuildContext context, RewardTask task) {
switch (task.actionType) {
case 'OPEN_PROFILE':
logic.select(4);
case 'OPEN_WALLET':
unawaited(_openWallet(context));
default:
logic.select(0);
}
}
Widget _nav(BuildContext context) {
return AppRootBottomNav(
barKey: const Key('home-tabs-bar'),
@@ -243,9 +266,9 @@ class _HomeTabsState extends State<_HomeTabs> {
}
}
/// 四个根 tab 的容器:只有被访问过的 tab 才真正构建并常驻。
/// 五个根 tab 的容器:只有被访问过的 tab 才真正构建并常驻。
///
/// 冷启动时只建推荐页,其余 tab 等第一次切过去再建,省掉三棵子树和它们各自的请求。
/// 冷启动时只建推荐页,其余 tab 等第一次切过去再建,减少首屏子树和请求。
class _LazyTabStack extends StatefulWidget {
const _LazyTabStack({required this.index, required this.builders});
+1 -1
View File
@@ -3,7 +3,7 @@
import 'package:flutter/foundation.dart';
/// 「我的」页与根导航之间的返回动作。
enum MyPageAction { home, recommend, following, mine, resume }
enum MyPageAction { home, recommend, reward, following, mine, resume }
/// 「我的」页可进入的功能目的地。
enum MineDestination {
+1
View File
@@ -333,6 +333,7 @@ class _MyPageState extends State<MyPage> {
MyPageAction _actionOf(AppRootTab tab) => switch (tab) {
AppRootTab.home => MyPageAction.home,
AppRootTab.recommend => MyPageAction.recommend,
AppRootTab.reward => MyPageAction.reward,
AppRootTab.following => MyPageAction.following,
AppRootTab.mine => MyPageAction.mine,
};
+15 -12
View File
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/widgets/app_press_scale.dart';
import '../../../l10n/app_localizations.dart';
import '../mine_types.dart';
@@ -31,7 +32,7 @@ class MineActionGrid extends StatelessWidget {
key: const Key('mine-menu'),
decoration: BoxDecoration(
color: AppColors.mineGridSurface,
border: Border.all(color: AppColors.contentBorder),
border: Border.all(color: AppColors.glassHairline),
borderRadius: BorderRadius.circular(AppDimens.mineGridRadius),
),
child: ClipRRect(
@@ -176,16 +177,18 @@ class _MineActionCell extends StatelessWidget {
child: InkWell(
key: item.key,
onTap: item.onTap,
child: Semantics(
button: true,
label: item.label,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_iconSurface(),
const SizedBox(height: AppDimens.spaceSm),
_label(),
],
child: AppPressScale(
child: Semantics(
button: true,
label: item.label,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_iconSurface(),
const SizedBox(height: AppDimens.spaceSm),
_label(),
],
),
),
),
),
@@ -208,7 +211,7 @@ class _MineActionCell extends StatelessWidget {
],
),
shape: BoxShape.circle,
border: Border.all(color: AppColors.glassBorder),
border: Border.all(color: AppColors.glassHairline),
boxShadow: [
const BoxShadow(
color: AppColors.shadowSoft,
@@ -123,7 +123,7 @@ class MineContinueWatching extends StatelessWidget {
Positioned.fill(
child: ClipRRect(
borderRadius: BorderRadius.circular(
AppDimens.homeCardRadius,
AppDimens.coverRadius,
),
child: RemoteImage(
record.dramaCover ?? '',
+13 -10
View File
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/widgets/app_press_scale.dart';
import '../../../l10n/app_localizations.dart';
const double _vipMaxTextScale = 1.15;
@@ -28,16 +29,18 @@ class MineVipBanner extends StatelessWidget {
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onOpen,
child: MediaQuery.withClampedTextScaling(
maxScaleFactor: _vipMaxTextScale,
child: Stack(
children: [
const Positioned.fill(child: _VipAmbientGlow()),
Padding(
padding: const EdgeInsets.all(AppDimens.mineVipPadding),
child: _VipContent(l10n: l10n, onOpen: onOpen),
),
],
child: AppPressScale(
child: MediaQuery.withClampedTextScaling(
maxScaleFactor: _vipMaxTextScale,
child: Stack(
children: [
const Positioned.fill(child: _VipAmbientGlow()),
Padding(
padding: const EdgeInsets.all(AppDimens.mineVipPadding),
child: _VipContent(l10n: l10n, onOpen: onOpen),
),
],
),
),
),
),
@@ -10,6 +10,7 @@ import '../../core/data/models/drama.dart';
import '../../core/data/services/catalog_controller.dart';
import '../../core/formatting/count_format.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_dimens.dart';
import '../../core/widgets/app_pull_to_refresh.dart';
import '../../core/widgets/paged_local_list.dart';
import '../../core/widgets/remote_image.dart';
@@ -437,7 +438,7 @@ class _NewReleasesPageState extends State<NewReleasesPage> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(AppDimens.coverRadius),
child: SizedBox(
width: 108,
height: 144,
@@ -26,7 +26,7 @@ const double _sheetRadius = 20;
const double _pageHorizontal = 20;
const double _tabGap = 20;
const double _posterWidth = 86;
const double _posterRadius = 8;
const double _posterRadius = AppDimens.coverRadius;
const double _relatedWidth = 104;
const double _relatedGap = 10;
const int _synopsisLines = 3;
@@ -457,7 +457,7 @@ class _DramaInfoSheetState extends State<DramaInfoSheet> {
widget.onOpenRelated?.call(drama);
},
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
borderRadius: BorderRadius.circular(AppDimens.coverRadius),
child: SizedBox(
width: _relatedWidth,
child: AspectRatio(
@@ -8,6 +8,7 @@ import '../../../core/data/models/drama.dart';
import '../../../core/data/models/locale_text.dart';
import '../../../core/formatting/localized_content.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/widgets/app_press_scale.dart';
import '../../../l10n/app_localizations.dart';
import 'expandable_synopsis.dart';
import 'interaction_rail.dart';
@@ -220,7 +221,7 @@ class HomeFeedWatchAllBar extends StatelessWidget {
child: InkWell(
key: const Key('home-feed-full-series'),
onTap: onTap,
child: _content(l10n),
child: AppPressScale(child: _content(l10n)),
),
),
),
@@ -7,8 +7,12 @@ import '../../../core/theme/app_colors.dart';
const double _toggleSize = 68;
const double _iconSize = 36;
/// 播放/暂停压在画面上,垫一层半透明深色圆底保证亮画面下也能看见。
const Color _toggleBackground = Color(0x66000000);
/// 播放/暂停压在画面上,垫一层半透明玻璃圆面保证亮画面下也能看见。
const RadialGradient _toggleGradient = RadialGradient(
center: Alignment(-0.35, -0.4),
radius: 0.9,
colors: [Color(0x80FFFFFF), Color(0x52000000), Color(0x70000000)],
);
const List<Shadow> _iconShadows = [
Shadow(color: AppColors.shadow, blurRadius: 12),
];
@@ -31,18 +35,23 @@ class PlayerCenterToggle extends StatelessWidget {
label: playing ? 'Pause' : 'Play',
child: GestureDetector(
onTap: onTap,
child: Container(
child: SizedBox(
width: _toggleSize,
height: _toggleSize,
decoration: const BoxDecoration(
color: _toggleBackground,
shape: BoxShape.circle,
),
child: Icon(
playing ? Icons.pause_rounded : Icons.play_arrow_rounded,
color: AppColors.onPrimary,
size: _iconSize,
shadows: _iconShadows,
child: DecoratedBox(
key: const Key('player-center-toggle-surface'),
decoration: const BoxDecoration(
shape: BoxShape.circle,
gradient: _toggleGradient,
),
child: Center(
child: Icon(
playing ? Icons.pause_rounded : Icons.play_arrow_rounded,
color: AppColors.onPrimary,
size: _iconSize,
shadows: _iconShadows,
),
),
),
),
),
+33 -30
View File
@@ -10,6 +10,7 @@ import '../../../core/data/services/catalog_controller.dart';
import '../../../core/formatting/localized_content.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/widgets/app_press_scale.dart';
import '../../../l10n/app_localizations.dart';
import 'expandable_synopsis.dart';
import 'player_center_toggle.dart';
@@ -389,44 +390,46 @@ class PlayerPlaybackOverlay extends StatelessWidget {
// 选集条给一层浅底色:视频画面亮时纯文字不好认,也顺带标出可点区域。
child: Material(
key: const Key('player-episodes-pill'),
color: AppColors.textPrimary.withValues(alpha: 0.14),
color: AppColors.glassSurface,
borderRadius: BorderRadius.circular(8),
clipBehavior: Clip.antiAlias,
child: InkWell(
key: const Key('player-episodes'),
onTap: actions.onEpisodes,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: _bottomActionInset,
),
child: SizedBox(
height: _bottomActionHeight,
child: Row(
children: [
Expanded(
child: Text(
'${data.l10n.episodes} · '
'${data.l10n.allEpisodesBadge(data.totalEpisodes)} · '
'${data.l10n.freeWatch}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onPrimary,
fontSize: 13.5,
fontWeight: FontWeight.w400,
shadows: _playerTextShadows,
child: AppPressScale(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: _bottomActionInset,
),
child: SizedBox(
height: _bottomActionHeight,
child: Row(
children: [
Expanded(
child: Text(
'${data.l10n.episodes} · '
'${data.l10n.allEpisodesBadge(data.totalEpisodes)} · '
'${data.l10n.freeWatch}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onPrimary,
fontSize: 13.5,
fontWeight: FontWeight.w400,
shadows: _playerTextShadows,
),
),
),
),
const SizedBox(width: 8),
const Icon(
Icons.keyboard_arrow_up_rounded,
color: AppColors.onPrimary,
size: 18,
shadows: _playerTextShadows,
),
],
const SizedBox(width: 8),
const Icon(
Icons.keyboard_arrow_up_rounded,
color: AppColors.onPrimary,
size: 18,
shadows: _playerTextShadows,
),
],
),
),
),
),
@@ -414,6 +414,10 @@ class _ChannelFeedState extends State<_ChannelFeed>
return false;
},
child: AppPullToRefresh(
edgeOffset:
widget.mediaTop +
RecommendChannelNav.height -
AppPullToRefresh.indicatorRevealOffset,
onRefresh: widget.onRefresh,
child: CustomScrollView(
key: PageStorageKey<String>('recommend-feed-${widget.categoryId}'),
+1
View File
@@ -14,6 +14,7 @@ import '../../core/data/services/home_feed_controller.dart';
import '../../core/data/services/playlist_controller.dart';
import '../../core/data/services/wallet_controller.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_dimens.dart';
import '../../core/widgets/empty_state.dart';
import '../../l10n/app_localizations.dart';
import '../home/home_feed_channel.dart';
@@ -4,7 +4,7 @@ part of 'recommend_page.dart';
const double _posterWidth = 132;
const double _posterHeight = 176;
const double _posterRadius = 12;
const double _posterRadius = AppDimens.coverRadius;
const double _copyLeft = 14;
const double _copyRight = 72;
const double _copyBottom = 42;
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import '../../../core/data/models/drama.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/widgets/remote_image.dart';
import '../../../l10n/app_localizations.dart';
@@ -13,7 +14,7 @@ const double _pageSide = 14;
const double _cardWidth = 110;
const double _cardCoverHeight = 157;
const double _cardGap = 9;
const double _cardRadius = 12;
const double _cardRadius = AppDimens.coverRadius;
/// 海报卡封面以下的高度:上间距 8 + 两行剧名 + 2 + 集数行 + 余量。
///
@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import '../../../core/data/models/drama.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/widgets/app_press_scale.dart';
import '../../../core/widgets/cover_play_count.dart';
import '../../../core/widgets/drama_cover_badge_ribbon.dart';
import '../../../core/widgets/remote_image.dart';
@@ -28,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),
],
),
),
),
);
+273
View File
@@ -0,0 +1,273 @@
// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter/material.dart';
import '../../core/data/models/reward.dart';
import '../../core/data/services/reward_controller.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_dimens.dart';
import '../../l10n/app_localizations.dart';
import 'reward_visuals.dart';
const double _cardRadius = 14;
const double _cardBorderWidth = 1;
const double _titleFont = 20;
const double _boostFont = 12;
const double _subtitleFont = 13;
const double _dayHeight = 80;
const double _dayRadius = 9;
const double _dayGap = 5;
const double _dayRewardFont = 13;
const double _dayLabelFont = 11;
const double _dayCoinSize = 24;
const double _buttonHeight = 48;
const double _buttonFont = 16;
const double _loadingSize = 20;
const double _loadingStroke = 2;
const Color _cardStart = Color(0xFF1A1A1C);
const Color _cardEnd = Color(0xFF101010);
const Color _cardBorder = Color(0xFF3A3A3C);
const Color _daySurface = Color(0xFF202022);
const Color _dayBorder = Color(0xFF363638);
const Color _currentSurfaceStart = Color(0xFF3E0813);
const Color _currentSurfaceEnd = Color(0xFF24070D);
const Color _currentBorder = Color(0xFFFF4860);
const Color _buttonEnd = Color(0xFFFF565B);
/// 奖励中心签到卡,展示七日规则与签到操作。
class RewardCheckinCard extends StatelessWidget {
const RewardCheckinCard({
super.key,
required this.l10n,
required this.rewards,
required this.checkin,
required this.onCheckIn,
required this.onBoost,
});
final AppLocalizations l10n;
final RewardController rewards;
final RewardCheckinState checkin;
final Future<void> Function() onCheckIn;
final VoidCallback onBoost;
@override
Widget build(BuildContext context) {
return Container(
key: const Key('reward-checkin-card'),
padding: const EdgeInsets.all(AppDimens.cardPadding),
decoration: BoxDecoration(
gradient: const LinearGradient(colors: [_cardStart, _cardEnd]),
borderRadius: BorderRadius.circular(_cardRadius),
border: Border.all(color: _cardBorder, width: _cardBorderWidth),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_header(),
const SizedBox(height: AppDimens.spaceXs),
Text(
l10n.rewardCheckInSubtitle,
style: const TextStyle(
color: AppColors.textSecondary,
fontSize: _subtitleFont,
),
),
const SizedBox(height: AppDimens.spaceLg),
_days(),
const SizedBox(height: AppDimens.spaceLg),
_action(),
],
),
);
}
Widget _header() {
return Row(
children: [
Expanded(
child: Text(
l10n.rewardCheckInTitle,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: _titleFont,
fontWeight: FontWeight.w800,
),
),
),
InkWell(
onTap: onBoost,
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: AppDimens.spaceSm),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
l10n.rewardBoostCheckIn,
style: const TextStyle(
color: AppColors.brandRed,
fontSize: _boostFont,
fontWeight: FontWeight.w600,
),
),
const Icon(
Icons.chevron_right_rounded,
color: AppColors.brandRed,
size: 18,
),
],
),
),
),
],
);
}
Widget _days() {
if (checkin.days.isEmpty) return const SizedBox.shrink();
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var index = 0; index < checkin.days.length; index++) ...[
Expanded(
child: _RewardDay(l10n: l10n, day: checkin.days[index]),
),
if (index < checkin.days.length - 1) const SizedBox(width: _dayGap),
],
],
);
}
Widget _action() {
final disabled = checkin.checkedInToday || rewards.checkingIn;
return Container(
key: const Key('reward-checkin-action'),
height: _buttonHeight,
decoration: BoxDecoration(
gradient: disabled
? const LinearGradient(
colors: [AppColors.contentChip, AppColors.contentChip],
)
: const LinearGradient(colors: [AppColors.brandRed, _buttonEnd]),
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
),
child: Material(
color: AppColors.transparent,
child: InkWell(
onTap: disabled ? null : () => unawaited(onCheckIn()),
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
child: Center(child: _actionContent()),
),
),
);
}
Widget _actionContent() {
if (rewards.checkingIn) {
return const SizedBox.square(
dimension: _loadingSize,
child: CircularProgressIndicator(
strokeWidth: _loadingStroke,
color: AppColors.textPrimary,
),
);
}
final label = checkin.checkedInToday
? l10n.rewardCheckedIn
: l10n.rewardCheckInActionWithCoins(_currentReward());
return Row(
key: const Key('reward-checkin-action-content'),
mainAxisSize: MainAxisSize.min,
children: [
if (!checkin.checkedInToday) ...[
const RewardAdIcon(),
const SizedBox(width: AppDimens.spaceMd),
],
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 220),
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: _buttonFont,
fontWeight: FontWeight.w700,
),
),
),
],
);
}
int _currentReward() {
for (final day in checkin.days) {
if (day.current) return day.rewardCoin;
}
return checkin.days.isEmpty ? 0 : checkin.days.first.rewardCoin;
}
}
class _RewardDay extends StatelessWidget {
const _RewardDay({required this.l10n, required this.day});
final AppLocalizations l10n;
final RewardCheckinDay day;
@override
Widget build(BuildContext context) {
return Container(
height: _dayHeight,
decoration: BoxDecoration(
gradient: day.current
? const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [_currentSurfaceStart, _currentSurfaceEnd],
)
: null,
color: day.current ? null : _daySurface,
borderRadius: BorderRadius.circular(_dayRadius),
border: Border.all(color: day.current ? _currentBorder : _dayBorder),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
'+${day.rewardCoin}',
style: TextStyle(
color: day.current
? const Color(0xFFFFE18C)
: AppColors.textPrimary,
fontSize: _dayRewardFont,
fontWeight: FontWeight.w800,
),
),
),
const SizedBox(height: AppDimens.spaceXs),
const RewardCoinIcon(size: _dayCoinSize),
const SizedBox(height: AppDimens.spaceXs),
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
day.current
? l10n.rewardToday
: l10n.rewardDayLabel(day.dayIndex),
style: TextStyle(
color: day.checked
? AppColors.success
: AppColors.textSecondary,
fontSize: _dayLabelFont,
),
),
),
],
),
);
}
}
+519
View File
@@ -0,0 +1,519 @@
// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/data/models/reward.dart';
import '../../core/data/services/auth_controller.dart';
import '../../core/data/services/reward_controller.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_dimens.dart';
import '../../core/widgets/auth_gate.dart';
import '../../core/widgets/app_glow_panel.dart';
import '../../l10n/app_localizations.dart';
import 'reward_tasks_view.dart';
const double _activityHeight = 128;
const double _headerHeight = 52;
const double _headerIconSize = 16;
const double _headerTouchTargetSize = 44;
const double _headerTitleFont = 21;
const double _tabHeight = 56;
const double _tabFont = 18;
const double _tabIndicatorWidth = 24;
const double _tabIndicatorHeight = 3;
const double _helpButtonSize = 24;
const double _helpBorderWidth = 1;
const double _helpFont = 14;
const double _helpDialogMaxWidth = 320;
const double _helpDialogRadius = 20;
const double _helpDialogIconSize = 48;
const double _helpDialogIconFont = 24;
const double _helpDialogTitleFont = 18;
const double _helpDialogBodyFont = 14;
const double _helpDialogButtonHeight = 44;
const int _loadDelayMilliseconds = 1;
const String _loadKeySeparator = '|';
const Color _helpBorder = Color(0xFFE9E9EA);
const Color _tabInactive = Color(0xFF9B9B9F);
/// 奖励中心根页面,展示余额、签到、任务与活动。
class RewardPage extends StatefulWidget {
const RewardPage({
super.key,
required this.active,
this.onBack,
this.onHelp,
this.onTaskAction,
this.onOpenWallet,
});
final bool active;
final VoidCallback? onBack;
final VoidCallback? onHelp;
final ValueChanged<RewardTask>? onTaskAction;
final VoidCallback? onOpenWallet;
@override
State<RewardPage> createState() => _RewardPageState();
}
class _RewardPageState extends State<RewardPage> {
bool _showActivities = false;
bool _loadScheduled = false;
String? _autoLoadKey;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_scheduleLoad();
}
@override
void didUpdateWidget(covariant RewardPage oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.active && !oldWidget.active) _scheduleLoad(force: true);
}
void _scheduleLoad({bool force = false}) {
if (!widget.active || _loadScheduled) return;
final rewards = context.read<RewardController>();
final auth = context.read<AuthController>();
final locale = Localizations.localeOf(context).languageCode;
final loadKey = '$locale$_loadKeySeparator${auth.isSignedIn}';
if (!force && _autoLoadKey == loadKey) return;
_autoLoadKey = loadKey;
_loadScheduled = true;
Future<void>.delayed(
const Duration(milliseconds: _loadDelayMilliseconds),
() async {
if (!mounted) return;
_loadScheduled = false;
if (!widget.active) return;
await rewards.load(locale);
},
);
}
@override
Widget build(BuildContext context) {
final rewards = context.watch<RewardController>();
context.watch<AuthController>();
final l10n = AppLocalizations.of(context)!;
return DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [AppColors.followingGlow, AppColors.ink, AppColors.ink],
stops: [0, 0.3, 1],
),
),
child: SafeArea(
bottom: false,
child: Column(
children: [
_header(l10n),
Expanded(child: _body(l10n, rewards)),
],
),
),
);
}
Widget _header(AppLocalizations l10n) {
return Column(
children: [
SizedBox(
width: double.infinity,
height: _headerHeight,
child: Stack(
alignment: Alignment.center,
children: [
Positioned(left: 4, child: _backButton()),
Text(
l10n.rewardCenterTitle,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: _headerTitleFont,
fontWeight: FontWeight.w800,
),
),
Positioned(right: 8, child: _helpButton()),
],
),
),
Row(
children: [
Expanded(
child: _tab(
key: const Key('reward-tab-tasks'),
indicatorKey: const Key('reward-tab-indicator-tasks'),
label: l10n.rewardTasksTab,
selected: !_showActivities,
showActivities: false,
),
),
Expanded(
child: _tab(
key: const Key('reward-tab-activities'),
indicatorKey: const Key('reward-tab-indicator-activities'),
label: l10n.rewardActivitiesTab,
selected: _showActivities,
showActivities: true,
),
),
],
),
],
);
}
Widget _backButton() {
return IconButton(
key: const Key('reward-back-button'),
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
onPressed: widget.onBack ?? () {},
icon: const Icon(
Icons.arrow_back_ios_new_rounded,
color: AppColors.textPrimary,
size: _headerIconSize,
),
);
}
Widget _helpButton() {
return IconButton(
key: const Key('reward-help-button'),
tooltip: AppLocalizations.of(context)!.rewardHelpTitle,
onPressed: widget.onHelp ?? _showHelp,
padding: EdgeInsets.zero,
constraints: const BoxConstraints.tightFor(
width: _headerTouchTargetSize,
height: _headerTouchTargetSize,
),
icon: Container(
key: const Key('reward-help-icon'),
width: _helpButtonSize,
height: _helpButtonSize,
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: _helpBorder, width: _helpBorderWidth),
),
child: const Text(
'?',
style: TextStyle(
color: AppColors.textPrimary,
fontSize: _helpFont,
fontWeight: FontWeight.w500,
),
),
),
);
}
Widget _tab({
required Key key,
required Key indicatorKey,
required String label,
required bool selected,
required bool showActivities,
}) {
return InkWell(
key: key,
onTap: () => setState(() => _showActivities = showActivities),
child: SizedBox(
height: _tabHeight,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
label,
style: TextStyle(
color: selected ? AppColors.textPrimary : _tabInactive,
fontSize: _tabFont,
fontWeight: selected ? FontWeight.w800 : FontWeight.w500,
),
),
const SizedBox(height: AppDimens.spaceSm),
AnimatedContainer(
key: indicatorKey,
duration: AppDimens.homePrimaryTabAnimationDuration,
width: selected ? _tabIndicatorWidth : 0,
height: _tabIndicatorHeight,
decoration: BoxDecoration(
color: AppColors.brandRed,
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
),
),
],
),
),
);
}
Widget _body(AppLocalizations l10n, RewardController rewards) {
if (rewards.loading && rewards.overview == null) {
return const Center(
child: CircularProgressIndicator(color: AppColors.brandRed),
);
}
if (rewards.error != null && rewards.overview == null) {
return _errorState(l10n, rewards);
}
final overview = rewards.overview;
if (overview == null) return const SizedBox.shrink();
return _showActivities
? _activities(l10n, overview)
: RewardTasksView(
l10n: l10n,
rewards: rewards,
overview: overview,
onRefresh: () =>
rewards.load(Localizations.localeOf(context).languageCode),
onCheckIn: () => _checkIn(l10n, rewards),
onTaskAction: (task) => _handleTask(l10n, rewards, task),
onOpenWallet: widget.onOpenWallet,
onBoostCheckIn: () => _showResult(l10n.rewardComingSoon),
);
}
Widget _activities(AppLocalizations l10n, RewardOverview overview) {
return ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(AppDimens.pageHorizontal),
children: overview.activities.isEmpty
? [_empty(l10n.rewardEmptyActivities)]
: [for (final activity in overview.activities) _activity(activity)],
);
}
Widget _activity(RewardActivity activity) {
return Container(
height: _activityHeight,
margin: const EdgeInsets.only(bottom: AppDimens.spaceLg),
padding: const EdgeInsets.all(AppDimens.cardPadding),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [AppColors.vipCardStart, AppColors.contentCard],
),
borderRadius: BorderRadius.circular(AppDimens.cardRadius),
border: Border.all(color: AppColors.vipCardBorder),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
activity.title,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: AppDimens.pageTitle,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: AppDimens.spaceSm),
Text(
activity.summary,
style: const TextStyle(color: AppColors.textSecondary),
),
],
),
);
}
Widget _errorState(AppLocalizations l10n, RewardController rewards) {
return Center(
child: FilledButton(
onPressed: () =>
rewards.load(Localizations.localeOf(context).languageCode),
child: Text(l10n.rewardRetry),
),
);
}
Widget _empty(String label) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: AppDimens.spaceXl),
child: Center(
child: Text(
label,
style: const TextStyle(color: AppColors.textTertiary),
),
),
);
}
Future<void> _checkIn(AppLocalizations l10n, RewardController rewards) async {
if (!await ensureSignedIn(context) || !mounted) return;
final result = await rewards.checkIn();
if (!mounted) return;
_showResult(
result == null
? _message(l10n, rewards.actionError)
: l10n.rewardCheckInSuccess(result.rewardCoin),
);
}
Future<void> _handleTask(
AppLocalizations l10n,
RewardController rewards,
RewardTask task,
) async {
if (!task.claimable) {
widget.onTaskAction?.call(task);
return;
}
if (!await ensureSignedIn(context) || !mounted) return;
final result = await rewards.claim(task.id);
if (!mounted) return;
_showResult(
result == null
? _message(l10n, rewards.actionError)
: l10n.rewardClaimSuccess(result.rewardCoin),
);
}
String? _message(AppLocalizations l10n, String? message) {
return switch (message) {
'rewardActionFailed' => l10n.rewardActionFailed,
_ => message,
};
}
void _showResult(String? message) {
if (message == null || message.isEmpty) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
void _showHelp() {
final l10n = AppLocalizations.of(context)!;
showDialog<void>(
context: context,
builder: (dialogContext) => RepaintBoundary(
key: const Key('reward-help-dialog'),
child: _RewardHelpDialog(
title: l10n.rewardHelpTitle,
body: l10n.rewardHelpBody,
confirmLabel: MaterialLocalizations.of(dialogContext).okButtonLabel,
),
),
);
}
}
class _RewardHelpDialog extends StatelessWidget {
const _RewardHelpDialog({
required this.title,
required this.body,
required this.confirmLabel,
});
final String title;
final String body;
final String confirmLabel;
@override
Widget build(BuildContext context) {
return Dialog(
backgroundColor: AppColors.transparent,
insetPadding: const EdgeInsets.symmetric(horizontal: AppDimens.spaceXl),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: _helpDialogMaxWidth),
child: AppGlowPanel(
borderRadius: BorderRadius.circular(_helpDialogRadius),
child: Padding(
padding: const EdgeInsets.fromLTRB(
AppDimens.spaceXl,
AppDimens.spaceXl,
AppDimens.spaceXl,
AppDimens.spaceLg,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const _HelpDialogIcon(),
const SizedBox(height: AppDimens.spaceMd),
Text(
title,
textAlign: TextAlign.center,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: _helpDialogTitleFont,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: AppDimens.spaceSm),
Text(
body,
textAlign: TextAlign.center,
style: const TextStyle(
color: AppColors.textSecondary,
fontSize: _helpDialogBodyFont,
height: 1.45,
),
),
const SizedBox(height: AppDimens.spaceXl),
SizedBox(
width: double.infinity,
height: _helpDialogButtonHeight,
child: FilledButton(
key: const Key('reward-help-close'),
onPressed: () => Navigator.of(context).pop(),
style: FilledButton.styleFrom(
backgroundColor: AppColors.brandRed,
foregroundColor: AppColors.onPrimary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
AppDimens.buttonRadius,
),
),
),
child: Text(
confirmLabel,
style: const TextStyle(
fontSize: AppDimens.body,
fontWeight: FontWeight.w700,
),
),
),
),
],
),
),
),
),
);
}
}
class _HelpDialogIcon extends StatelessWidget {
const _HelpDialogIcon();
@override
Widget build(BuildContext context) {
return Container(
width: _helpDialogIconSize,
height: _helpDialogIconSize,
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppColors.filterChipSelected,
shape: BoxShape.circle,
border: Border.all(color: AppColors.brandRed, width: 1),
),
child: const Text(
'?',
style: TextStyle(
color: AppColors.brandRed,
fontSize: _helpDialogIconFont,
fontWeight: FontWeight.w800,
),
),
);
}
}
+421
View File
@@ -0,0 +1,421 @@
// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter/material.dart';
import '../../core/data/models/reward.dart';
import '../../core/data/services/reward_controller.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_dimens.dart';
import '../../l10n/app_localizations.dart';
import 'reward_checkin_card.dart';
import 'reward_visuals.dart';
const double _balanceLabelFont = 18;
const double _balanceFont = 36;
const double _sectionTitleFont = 20;
const double _sectionSubtitleFont = 13;
const double _taskRowMinHeight = 68;
const double _taskTitleFont = 15;
const double _taskSubtitleFont = 12;
const double _taskRewardFont = 14;
const double _taskButtonHeight = 36;
const double _taskButtonMinWidth = 80;
const double _taskButtonFont = 13;
const double _taskCardRadius = 14;
const double _taskCardBorderWidth = 1;
const Color _taskCardStart = Color(0xFF1A1A1C);
const Color _taskCardEnd = Color(0xFF101010);
const Color _taskCardBorder = Color(0xFF29292B);
const Color _mutedActionStart = Color(0xFF3C171C);
const Color _divider = Color(0xFF333336);
const String _checkInSectionCode = 'CHECK_IN';
/// 奖励任务内容,负责余额、签到卡与可配置任务列表。
class RewardTasksView extends StatelessWidget {
const RewardTasksView({
super.key,
required this.l10n,
required this.rewards,
required this.overview,
required this.onRefresh,
required this.onCheckIn,
required this.onTaskAction,
this.onOpenWallet,
this.onBoostCheckIn,
});
final AppLocalizations l10n;
final RewardController rewards;
final RewardOverview overview;
final Future<void> Function() onRefresh;
final Future<void> Function() onCheckIn;
final Future<void> Function(RewardTask task) onTaskAction;
final VoidCallback? onOpenWallet;
final VoidCallback? onBoostCheckIn;
@override
Widget build(BuildContext context) {
return RefreshIndicator(
color: AppColors.brandRed,
backgroundColor: AppColors.contentCard,
onRefresh: onRefresh,
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(
AppDimens.pageHorizontal,
AppDimens.spaceLg,
AppDimens.pageHorizontal,
AppDimens.spaceXl,
),
children: [
_Balance(
key: const Key('reward-balance'),
balance: overview.coinBalance,
onOpenWallet: onOpenWallet,
),
const SizedBox(height: AppDimens.spaceLg),
RewardCheckinCard(
l10n: l10n,
rewards: rewards,
checkin: overview.checkin,
onCheckIn: onCheckIn,
onBoost: onBoostCheckIn ?? () {},
),
for (final section in overview.sections)
if (_isVisibleSection(section)) ...[
const SizedBox(height: AppDimens.spaceXl),
_SectionHeader(
key: Key('reward-section-${section.code}'),
l10n: l10n,
section: section,
),
const SizedBox(height: AppDimens.spaceMd),
_TaskGroupCard(
key: Key('reward-task-card-${section.code}'),
l10n: l10n,
rewards: rewards,
section: section,
onTaskAction: onTaskAction,
),
],
if (!overview.sections.any(_isVisibleSection))
_Empty(label: l10n.rewardEmptyTasks),
],
),
);
}
}
bool _isVisibleSection(RewardSection section) {
return section.code.toUpperCase() != _checkInSectionCode &&
section.tasks.isNotEmpty;
}
class _Balance extends StatelessWidget {
const _Balance({super.key, required this.balance, this.onOpenWallet});
final int balance;
final VoidCallback? onOpenWallet;
@override
Widget build(BuildContext context) {
final content = Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Flexible(
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
AppLocalizations.of(context)!.rewardMyCoins,
style: const TextStyle(
color: AppColors.textSecondary,
fontSize: _balanceLabelFont,
fontWeight: FontWeight.w500,
),
),
),
),
const SizedBox(width: AppDimens.spaceMd),
Text(
'$balance',
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: _balanceFont,
fontWeight: FontWeight.w800,
height: 1,
),
),
const SizedBox(width: AppDimens.spaceSm),
const RewardCoinIcon(size: 32),
],
);
if (onOpenWallet == null) return content;
return InkWell(
onTap: onOpenWallet,
borderRadius: BorderRadius.circular(AppDimens.cardRadius),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: AppDimens.spaceXs),
child: content,
),
);
}
}
class _SectionHeader extends StatelessWidget {
const _SectionHeader({super.key, required this.l10n, required this.section});
final AppLocalizations l10n;
final RewardSection section;
@override
Widget build(BuildContext context) {
final subtitle = _sectionSubtitle(l10n, section.code);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
section.title,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: _sectionTitleFont,
fontWeight: FontWeight.w800,
),
),
if (subtitle.isNotEmpty) ...[
const SizedBox(height: AppDimens.spaceXs),
Text(
subtitle,
style: const TextStyle(
color: AppColors.textSecondary,
fontSize: _sectionSubtitleFont,
),
),
],
],
);
}
}
String _sectionSubtitle(AppLocalizations l10n, String code) {
return switch (code.toUpperCase()) {
'DAILY' => l10n.rewardDailySectionSubtitle,
'NEWBIE' => l10n.rewardNewbieSectionSubtitle,
'INVITE' || 'INVITATION' => l10n.rewardInviteSectionSubtitle,
_ => '',
};
}
class _TaskGroupCard extends StatelessWidget {
const _TaskGroupCard({
super.key,
required this.l10n,
required this.rewards,
required this.section,
required this.onTaskAction,
});
final AppLocalizations l10n;
final RewardController rewards;
final RewardSection section;
final Future<void> Function(RewardTask task) onTaskAction;
@override
Widget build(BuildContext context) {
return Container(
key: Key('reward-task-surface-${section.code}'),
decoration: BoxDecoration(
gradient: const LinearGradient(colors: [_taskCardStart, _taskCardEnd]),
borderRadius: BorderRadius.circular(_taskCardRadius),
border: Border.all(color: _taskCardBorder, width: _taskCardBorderWidth),
),
child: Column(
children: [
for (var index = 0; index < section.tasks.length; index++) ...[
_TaskRow(
l10n: l10n,
rewards: rewards,
sectionCode: section.code,
task: section.tasks[index],
onTaskAction: onTaskAction,
),
if (index < section.tasks.length - 1)
const Divider(
color: _divider,
height: 1,
indent: 16,
endIndent: 16,
),
],
],
),
);
}
}
class _TaskRow extends StatelessWidget {
const _TaskRow({
required this.l10n,
required this.rewards,
required this.sectionCode,
required this.task,
required this.onTaskAction,
});
final AppLocalizations l10n;
final RewardController rewards;
final String sectionCode;
final RewardTask task;
final Future<void> Function(RewardTask task) onTaskAction;
@override
Widget build(BuildContext context) {
return ConstrainedBox(
constraints: const BoxConstraints(minHeight: _taskRowMinHeight),
child: Padding(
padding: const EdgeInsets.all(AppDimens.cardPadding),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
RewardTaskLeading(task: task),
const SizedBox(width: AppDimens.spaceMd),
Expanded(child: _copy()),
const SizedBox(width: AppDimens.spaceMd),
_TaskButton(
l10n: l10n,
rewards: rewards,
sectionCode: sectionCode,
task: task,
onTaskAction: onTaskAction,
),
],
),
),
);
}
Widget _copy() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
task.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: _taskTitleFont,
fontWeight: FontWeight.w600,
),
),
if (task.subtitle.isNotEmpty) ...[
const SizedBox(height: AppDimens.spaceXs),
Text(
task.subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.textSecondary,
fontSize: _taskSubtitleFont,
),
),
],
if (task.rewardCoin > 0) ...[
const SizedBox(height: AppDimens.spaceXs),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'+${task.rewardCoin}',
style: const TextStyle(
color: AppColors.brandRed,
fontSize: _taskRewardFont,
fontWeight: FontWeight.w700,
),
),
const SizedBox(width: AppDimens.spaceXs),
const RewardCoinIcon(size: 20),
],
),
],
],
);
}
}
class _TaskButton extends StatelessWidget {
const _TaskButton({
required this.l10n,
required this.rewards,
required this.sectionCode,
required this.task,
required this.onTaskAction,
});
final AppLocalizations l10n;
final RewardController rewards;
final String sectionCode;
final RewardTask task;
final Future<void> Function(RewardTask task) onTaskAction;
@override
Widget build(BuildContext context) {
final loading = rewards.claimingTaskId == task.id;
final dark = sectionCode.toUpperCase() == 'NEWBIE' && !task.claimable;
final label = task.claimed
? l10n.rewardClaimed
: task.claimable
? l10n.rewardClaimAction
: (task.ctaText.isEmpty ? l10n.rewardGoAction : task.ctaText);
return ConstrainedBox(
key: Key('reward-task-action-${task.id}'),
constraints: const BoxConstraints(
minWidth: _taskButtonMinWidth,
minHeight: _taskButtonHeight,
),
child: FilledButton(
onPressed: task.claimed || loading
? null
: () => unawaited(onTaskAction(task)),
style: FilledButton.styleFrom(
backgroundColor: dark ? _mutedActionStart : AppColors.brandRed,
disabledBackgroundColor: AppColors.contentChip,
minimumSize: const Size(_taskButtonMinWidth, _taskButtonHeight),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
padding: const EdgeInsets.symmetric(horizontal: AppDimens.spaceSm),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
),
),
child: loading
? const SizedBox.square(
dimension: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(label, style: const TextStyle(fontSize: _taskButtonFont)),
),
);
}
}
class _Empty extends StatelessWidget {
const _Empty({required this.label});
final String label;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: AppDimens.spaceXl),
child: Center(
child: Text(
label,
style: const TextStyle(color: AppColors.textTertiary),
),
),
);
}
}
+164
View File
@@ -0,0 +1,164 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../core/data/models/reward.dart';
import '../../core/theme/app_colors.dart';
const double _coinInnerInsetRatio = 0.1;
const double _coinLetterRatio = 0.46;
const double _taskIconSize = 46;
const double _taskIconRadius = 12;
const double _taskGlyphSize = 24;
const double _adIconWidth = 28;
const double _adIconHeight = 22;
const double _adIconRadius = 4;
const double _adUnderlineWidth = 12;
const double _adUnderlineHeight = 2;
const double _adUnderlineBottom = -5;
const Color _coinOuter = Color(0xFFF6B84E);
const Color _coinInnerStart = Color(0xFFFFD98B);
const Color _coinInnerEnd = Color(0xFFE39B31);
const Color _coinBorder = Color(0xFFFFE0A1);
const Color _taskIconStart = Color(0xFF4C171E);
const Color _taskIconEnd = Color(0xFF281014);
const Color _taskIconSurface = Color(0xFF202022);
/// 奖励中心统一金币图标。
class RewardCoinIcon extends StatelessWidget {
const RewardCoinIcon({super.key, required this.size});
final double size;
@override
Widget build(BuildContext context) {
final inset = size * _coinInnerInsetRatio;
return Container(
width: size,
height: size,
padding: EdgeInsets.all(inset),
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: _coinOuter,
boxShadow: [BoxShadow(color: Color(0x4DF6B84E), blurRadius: 6)],
),
child: Container(
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [_coinInnerStart, _coinInnerEnd],
),
border: Border.all(color: _coinBorder),
),
child: Text(
'N',
style: TextStyle(
color: AppColors.textPrimary,
fontSize: size * _coinLetterRatio,
fontWeight: FontWeight.w900,
height: 1,
),
),
),
);
}
}
/// 奖励中心签到按钮的广告标识。
class RewardAdIcon extends StatelessWidget {
const RewardAdIcon({super.key});
@override
Widget build(BuildContext context) {
return SizedBox(
width: _adIconWidth,
height: _adIconHeight,
child: Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
Container(
width: _adIconWidth,
height: _adIconHeight,
alignment: Alignment.center,
decoration: BoxDecoration(
border: Border.all(color: AppColors.textPrimary, width: 2),
borderRadius: BorderRadius.circular(_adIconRadius),
),
child: const Text(
'AD',
style: TextStyle(
color: AppColors.textPrimary,
fontSize: 11,
fontWeight: FontWeight.w900,
height: 1,
),
),
),
Positioned(
bottom: _adUnderlineBottom,
child: Container(
width: _adUnderlineWidth,
height: _adUnderlineHeight,
decoration: BoxDecoration(
color: AppColors.textPrimary,
borderRadius: BorderRadius.circular(_adUnderlineHeight),
),
),
),
],
),
);
}
}
/// 根据后台任务类型绘制任务图标,不绑定具体任务文案。
class RewardTaskLeading extends StatelessWidget {
const RewardTaskLeading({super.key, required this.task});
final RewardTask task;
@override
Widget build(BuildContext context) {
final invite = _isInviteTask(task);
return Container(
width: _taskIconSize,
height: _taskIconSize,
decoration: BoxDecoration(
shape: invite ? BoxShape.circle : BoxShape.rectangle,
borderRadius: invite ? null : BorderRadius.circular(_taskIconRadius),
gradient: invite
? const LinearGradient(colors: [_taskIconStart, _taskIconEnd])
: null,
color: invite ? null : _taskIconSurface,
),
child: Icon(
_taskIcon(task),
color: AppColors.textPrimary,
size: _taskGlyphSize,
),
);
}
}
bool _isInviteTask(RewardTask task) {
final code = task.code.toUpperCase();
return task.type == 'INVITE_FRIEND' || code.contains('INVITE');
}
IconData _taskIcon(RewardTask task) {
final code = task.code.toUpperCase();
if (code.contains('FACEBOOK')) return Icons.facebook_rounded;
if (code.contains('YOUTUBE')) return Icons.smart_display_rounded;
return switch (task.type) {
'WATCH_DURATION' => Icons.play_circle_outline_rounded,
'WATCH_EPISODES' => Icons.video_collection_outlined,
'COMPLETE_PROFILE' => Icons.person_outline_rounded,
'FIRST_PURCHASE' => Icons.shopping_bag_outlined,
'INVITE_FRIEND' => Icons.card_giftcard_rounded,
_ => Icons.stars_rounded,
};
}
@@ -13,7 +13,7 @@ const double _cardRadius = AppDimens.cardRadius;
const double _cardPadding = 10;
const double _coverWidth = 72;
const double _coverHeight = 96;
const double _coverRadius = 10;
const double _coverRadius = AppDimens.coverRadius;
const double _removeSize = 32;
const double _removeIconSize = 18;
const double _progressHeight = 4;