b461a476a2
四端共享 Dart 实现,配合 #21 后端账户级卡控: - me.dart: 加 quota_cap_min(当日总额度 = daily + 看广告 bonus,进度条分母)。 - quota_provider: total 取 quotaCapMin;isExhausted;markExhausted(倒计时归零本地置耗尽 + 登录态拉 me 校准);watchAd() async 调 /ads/unlock(占位 ad_token=uuid)→ 刷新 me,返回 granted。 - connection_provider: 连接时锁定 _freeRemainingSec(会员 null 不倒计时);复用 elapsed 计时器 每 tick 算 freeCountdown,归零 → _onFreeQuotaExhausted(主动切断不报节点异常 + markExhausted); 倒计时用墙上时钟(后台漏跳回前台补上、准时切);连接遇后端 QUOTA_EXHAUSTED 兜底置耗尽。 - connect_button: enabled/onDisabledTap —— 额度耗尽 off 态灰化(锁图标),点击弹加时。 - quota_card: 三态(连接倒计时 mm:ss / 未连接剩余分钟 / 耗尽今日已用完);移动「看广告加时」、桌面「升级会员」。 - ad_reward_dialog(新): 移动端占位广告(播放中→3s→加时→奖励);桌面版硬 10 分钟提示去移动端/升级。 - l10n(zh/en): 倒计时/已用完/看广告加时/占位广告/桌面升级 双语。 - 测试: quota isExhausted/markExhausted;连接倒计时归零自动切断+置耗尽(注入时钟); 额度卡三态 widget;/me 契约含 quota_cap_min;golden 更新(Linux 权威基线 quota_low/exhausted)。 - 文档: docs/free-quota-ad.html + 登记 docs/index.html。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
163 lines
5.9 KiB
Dart
163 lines
5.9 KiB
Dart
// quota_card.dart — 免费版每日额度卡
|
|
//
|
|
// 三态展示(额度全账户共享):
|
|
// ① 连接中(countdown != null):显示剩余倒计时 mm:ss + 进度条随之收缩。
|
|
// ② 未连接·有余额:显示今日剩余分钟 + 进度条;移动端附「看广告加时」。
|
|
// ③ 未连接·已耗尽:显示「今日已用完」+ 移动端「看广告加时」/ 桌面「升级会员」。
|
|
// 状态由 quota_provider(剩余)+ connection_provider(倒计时)注入,本地仅展示。
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../l10n/app_text.dart';
|
|
import '../pangolin_theme.dart';
|
|
import '../state/quota_provider.dart';
|
|
import 'pangolin_icons.dart';
|
|
|
|
class QuotaCard extends StatelessWidget {
|
|
const QuotaCard({
|
|
super.key,
|
|
required this.quota,
|
|
required this.t,
|
|
required this.onWatchAd,
|
|
this.countdown,
|
|
this.isDesktop = false,
|
|
});
|
|
|
|
final FreeQuotaState quota;
|
|
final AppText t;
|
|
|
|
/// 额度耗尽时的加时入口(移动端占位广告 / 桌面升级提示)。
|
|
final VoidCallback onWatchAd;
|
|
|
|
/// 连接期剩余倒计时;null = 未连接(展示今日剩余分钟)。
|
|
final Duration? countdown;
|
|
|
|
/// 桌面端(Windows/macOS):免费硬 10 分钟不可延,不显示看广告按钮。
|
|
final bool isDesktop;
|
|
|
|
static String _mmss(Duration d) {
|
|
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
|
|
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
|
|
return '$m:$s';
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final c = context.pangolin;
|
|
final connected = countdown != null;
|
|
final exhausted = quota.isExhausted && !connected;
|
|
|
|
// 进度与主值:连接期用倒计时,未连接用剩余分钟。
|
|
final double progress;
|
|
final String label;
|
|
final String value;
|
|
if (connected) {
|
|
final totalSec = quota.totalMinutes * 60;
|
|
progress = totalSec == 0 ? 0 : (countdown!.inSeconds / totalSec).clamp(0.0, 1.0);
|
|
label = t.quotaLeftLabel;
|
|
value = _mmss(countdown!);
|
|
} else {
|
|
progress = quota.progress;
|
|
label = exhausted ? t.quotaUsedUp : t.quotaToday;
|
|
value = exhausted ? '' : '${quota.remainingMinutes} ${t.minutes}';
|
|
}
|
|
final low = connected ? countdown!.inMinutes < 3 : quota.isLow;
|
|
final barColor = exhausted ? c.danger : (low ? c.warning : c.accent);
|
|
|
|
return Container(
|
|
padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
|
|
decoration: BoxDecoration(
|
|
color: c.surface,
|
|
borderRadius: BorderRadius.circular(PangolinRadius.lg),
|
|
border: Border.all(color: c.border),
|
|
boxShadow: PangolinShadow.sm,
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Row(children: [
|
|
Icon(PangolinIcons.clock, size: 15, color: exhausted ? c.danger : c.accent),
|
|
const SizedBox(width: 7),
|
|
Flexible(
|
|
child: Text(label,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: PangolinText.caption.copyWith(color: c.fg2, fontWeight: FontWeight.w600, fontSize: 12)),
|
|
),
|
|
if (value.isNotEmpty) ...[
|
|
const SizedBox(width: 7),
|
|
Text(value,
|
|
style: PangolinText.mono.copyWith(color: c.fg1, fontSize: 14, fontWeight: FontWeight.w600)),
|
|
],
|
|
const Spacer(),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3),
|
|
decoration: BoxDecoration(color: c.bgSubtle, borderRadius: BorderRadius.circular(PangolinRadius.full)),
|
|
child: Text(t.quotaFree,
|
|
style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w600, fontSize: 10.5)),
|
|
),
|
|
]),
|
|
const SizedBox(height: 9),
|
|
// 进度条:剩余比例;≤3 分钟 warning、耗尽 danger(满宽轨道 + 比例填充)。
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(3),
|
|
child: SizedBox(
|
|
height: 6,
|
|
child: Stack(children: [
|
|
Positioned.fill(child: ColoredBox(color: c.bgSubtle)),
|
|
FractionallySizedBox(
|
|
alignment: Alignment.centerLeft,
|
|
widthFactor: progress,
|
|
child: AnimatedContainer(
|
|
duration: PangolinMotion.base,
|
|
curve: PangolinMotion.easeOut,
|
|
decoration: BoxDecoration(color: barColor, borderRadius: BorderRadius.circular(3)),
|
|
),
|
|
),
|
|
]),
|
|
),
|
|
),
|
|
// 加时按钮:未连接时显示。移动端「看广告加时」;桌面仅耗尽时给「升级会员」。
|
|
if (!connected && (!isDesktop || exhausted)) ...[
|
|
const SizedBox(height: 11),
|
|
_ActionButton(
|
|
label: isDesktop ? t.upgrade : t.watchAdMore,
|
|
icon: isDesktop ? PangolinIcons.zap : PangolinIcons.playCircle,
|
|
onTap: onWatchAd,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ActionButton extends StatelessWidget {
|
|
const _ActionButton({required this.label, required this.icon, required this.onTap});
|
|
final String label;
|
|
final IconData icon;
|
|
final VoidCallback onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final c = context.pangolin;
|
|
return Material(
|
|
color: c.accentSubtle,
|
|
shape: StadiumBorder(side: BorderSide(color: c.accentBorder, width: 1.5)),
|
|
clipBehavior: Clip.antiAlias,
|
|
child: InkWell(
|
|
onTap: onTap,
|
|
child: SizedBox(
|
|
height: 38,
|
|
child: Center(
|
|
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
|
Icon(icon, size: 16, color: c.accent),
|
|
const SizedBox(width: 7),
|
|
Text(label,
|
|
style: PangolinText.sm.copyWith(color: c.accent, fontWeight: FontWeight.w700, fontSize: 13)),
|
|
]),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|