feat(reward): 新增客户端奖励中心
This commit is contained in:
1 parent
67670d46b1
commit
bea08110c9
68 files changed
+3987
-332
No files matched your search
@@ -0,0 +1,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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user