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

+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,
};
}