Files
ReelShort/lib/pages/player/widgets/player_unlock_sheet.dart
T

374 lines
11 KiB
Dart

// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../../core/data/models/drama.dart';
import '../../../core/data/models/play_ticket.dart';
import '../../../core/formatting/error_text.dart';
import '../../../core/theme/app_colors.dart';
import '../../../l10n/app_localizations.dart';
const double _cardRadius = 20;
const double _optionRadius = 14;
const double _sheetWidth = 360;
/// 视频区占位提示:由后端票据决定是解锁、无片源还是会员限制。
class PlayerBlockedNotice extends StatelessWidget {
/// 创建不可播放提示。
const PlayerBlockedNotice({
super.key,
required this.l10n,
required this.episode,
required this.accessType,
required this.unlockCoin,
required this.unlocking,
required this.error,
required this.onOpenUnlock,
});
/// 当前语言包。
final AppLocalizations l10n;
/// 当前剧集。
final Episode episode;
/// 后端下发的内容访问类型。
final String? accessType;
/// 金币解锁价格。
final int unlockCoin;
/// 是否正在解锁。
final bool unlocking;
/// 最近一次解锁失败信息。
final String? error;
/// 打开内容解锁面板。
final VoidCallback? onOpenUnlock;
bool get _needsUnlock =>
accessType == PlayTicket.coinRequired ||
accessType == PlayTicket.vipRequired;
@override
Widget build(BuildContext context) {
final title = _needsUnlock
? l10n.lockedTitle(episode.number)
: accessType == PlayTicket.noMedia || !episode.locked
? l10n.noMediaNotice
: l10n.lockedTitle(episode.number);
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: _sheetWidth),
child: Container(
key: const Key('player-unlock-notice'),
margin: const EdgeInsets.symmetric(horizontal: 24),
padding: const EdgeInsets.fromLTRB(20, 22, 20, 18),
decoration: BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.circular(_cardRadius),
boxShadow: const [
BoxShadow(
color: AppColors.cardShadow,
blurRadius: 18,
offset: Offset(0, 6),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_needsUnlock ? Icons.lock_rounded : Icons.movie_filter_outlined,
color: _needsUnlock ? AppColors.blue : AppColors.muted,
size: 30,
),
const SizedBox(height: 12),
Text(
title,
textAlign: TextAlign.center,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 16,
fontWeight: FontWeight.w800,
),
),
if (_needsUnlock && unlockCoin > 0) ...[
const SizedBox(height: 10),
_coinLabel(l10n),
],
if (error != null) ...[
const SizedBox(height: 8),
Text(
errorText(l10n, error, l10n.unlockFailed),
textAlign: TextAlign.center,
style: const TextStyle(
color: AppColors.badgePinkText,
fontSize: 12,
),
),
],
if (_needsUnlock) ...[
const SizedBox(height: 14),
SizedBox(
width: double.infinity,
child: FilledButton(
key: const Key('player-open-unlock'),
onPressed: unlocking ? null : onOpenUnlock,
child: unlocking
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(l10n.unlockEpisode),
),
),
],
],
),
),
),
);
}
Widget _coinLabel(AppLocalizations l10n) => DecoratedBox(
decoration: BoxDecoration(
color: AppColors.goldCard,
borderRadius: BorderRadius.circular(999),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Text(
l10n.walletBalanceShort(unlockCoin),
style: const TextStyle(
color: AppColors.onGold,
fontSize: 12,
fontWeight: FontWeight.w800,
),
),
),
);
}
/// 内容解锁面板:金币可用,广告与会员在契约缺失时明确禁用。
class PlayerUnlockSheet extends StatefulWidget {
/// 创建内容解锁面板。
const PlayerUnlockSheet({
super.key,
required this.l10n,
required this.episodeNumber,
required this.coinCost,
required this.balance,
required this.onUnlock,
});
/// 当前语言包。
final AppLocalizations l10n;
/// 当前集号。
final int episodeNumber;
/// 金币解锁价格。
final int coinCost;
/// 当前金币余额;未知时隐藏。
final int? balance;
/// 金币解锁动作,成功后返回 true。
final Future<bool> Function()? onUnlock;
@override
State<PlayerUnlockSheet> createState() => _PlayerUnlockSheetState();
}
class _PlayerUnlockSheetState extends State<PlayerUnlockSheet> {
bool _busy = false;
String? _error;
Future<void> _unlock() async {
final action = widget.onUnlock;
if (action == null || _busy) return;
setState(() {
_busy = true;
_error = null;
});
final success = await action();
if (!mounted) return;
if (success) {
Navigator.of(context).pop(true);
return;
}
setState(() {
_busy = false;
_error = widget.l10n.unlockFailed;
});
}
@override
Widget build(BuildContext context) {
final l10n = widget.l10n;
return Container(
key: const Key('player-unlock-panel'),
decoration: const BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.vertical(top: Radius.circular(_cardRadius)),
),
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
l10n.lockedTitle(widget.episodeNumber),
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 20,
fontWeight: FontWeight.w800,
),
),
),
IconButton(
key: const Key('player-unlock-close'),
tooltip: l10n.close,
onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.close_rounded),
),
],
),
const SizedBox(height: 8),
_UnlockOption(
optionKey: const Key('player-unlock-coins'),
icon: Icons.toll_rounded,
title: l10n.unlockWithCoins,
subtitle: widget.balance == null
? l10n.walletBalanceShort(widget.coinCost)
: '${l10n.walletBalanceShort(widget.coinCost)} · '
'${l10n.walletBalanceShort(widget.balance!)}',
enabled: widget.onUnlock != null && !_busy,
busy: _busy,
onTap: _unlock,
),
const SizedBox(height: 10),
_UnlockOption(
optionKey: const Key('player-unlock-ad'),
icon: Icons.ondemand_video_rounded,
title: l10n.watchAdToUnlock,
subtitle: l10n.comingSoon,
enabled: false,
onTap: null,
),
const SizedBox(height: 10),
_UnlockOption(
optionKey: const Key('player-unlock-vip'),
icon: Icons.workspace_premium_rounded,
title: l10n.unlockWithVip,
subtitle: l10n.comingSoon,
enabled: false,
onTap: null,
),
if (_error != null) ...[
const SizedBox(height: 10),
Text(
_error!,
style: const TextStyle(
color: AppColors.badgePinkText,
fontSize: 12,
),
),
],
const SizedBox(height: 14),
Text(
l10n.unlockMethodsVary,
style: const TextStyle(color: AppColors.muted, fontSize: 12),
),
],
),
),
),
);
}
}
class _UnlockOption extends StatelessWidget {
const _UnlockOption({
required this.optionKey,
required this.icon,
required this.title,
required this.subtitle,
required this.enabled,
this.busy = false,
this.onTap,
});
final Key optionKey;
final IconData icon;
final String title;
final String subtitle;
final bool enabled;
final bool busy;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return Material(
key: optionKey,
color: enabled ? AppColors.quick : AppColors.raised,
borderRadius: BorderRadius.circular(_optionRadius),
child: InkWell(
borderRadius: BorderRadius.circular(_optionRadius),
onTap: enabled ? onTap : null,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
child: Row(
children: [
Icon(
icon,
color: enabled ? AppColors.blue : AppColors.subtle,
size: 22,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
color: enabled ? AppColors.onSurface : AppColors.subtle,
fontSize: 14,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 2),
Text(
subtitle,
style: const TextStyle(
color: AppColors.muted,
fontSize: 11,
),
),
],
),
),
if (busy)
const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
else if (enabled)
const Icon(Icons.chevron_right_rounded, color: AppColors.blue),
],
),
),
),
);
}
}