Files
pangolin/client/lib/widgets/quota_card.dart
T
wangjia a274cc40c8 fix(client): 额度卡「今日剩余」标签不再被省略 + 短「免费版」标签(1.0.45+46)
连接页额度卡头部一行放不下「今日剩余 X 分钟 + 免费版·每日10分钟」时,此前 Flexible
落在标签上,导致「今日剩余」被挤成「今日…」。改为:标签固定绝不省略,数值作最后收缩项;
右上角标签缩短为「免费版」(每日额度由卡片本身「今日剩余 X 分钟」表达,不再挤进胶囊)。
新增 planFreeTag l10n(zh 免费版 / en Free),quotaFree 仍保留供账户页 plan_badge_card。
golden 基线(quota_low/exhausted)在 Linux 权威环境重生成。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 01:40:50 +08:00

167 lines
6.2 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 让「今日剩余」被挤成「今日…」)。
Text(label,
style: PangolinText.caption.copyWith(color: c.fg2, fontWeight: FontWeight.w600, fontSize: 12)),
if (value.isNotEmpty) ...[
const SizedBox(width: 7),
// 数值可作为最后收缩项(极端窄屏/三位数分钟才会省略)。
Flexible(
child: Text(value,
overflow: TextOverflow.ellipsis,
style: PangolinText.mono.copyWith(color: c.fg1, fontSize: 14, fontWeight: FontWeight.w600)),
),
],
const SizedBox(width: 8),
const Spacer(),
// 短标签「免费版」——每日额度由卡片本身表达,不再挤进右上角。
Container(
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3),
decoration: BoxDecoration(color: c.bgSubtle, borderRadius: BorderRadius.circular(PangolinRadius.full)),
child: Text(t.planFreeTag,
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)),
]),
),
),
),
);
}
}