520 lines
16 KiB
Dart
520 lines
16 KiB
Dart
// 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,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|