Files
ReelShort/lib/pages/reward/reward_page.dart
T

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