Files
pangolin/client/lib/widgets/account_screens.dart
T
wangjia 25e8330e3b
ci-pangolin / Lint — shellcheck (push) Successful in 10s
ci-pangolin / OpenAPI Sync Check (push) Successful in 31s
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Successful in 5s
ci-pangolin / Flutter — analyze + test (push) Successful in 24s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (push) Successful in 6s
ci-pangolin / Codegen Drift — token 生成物未漂移 (push) Successful in 4s
ci-pangolin / Go — build + test (push) Successful in 10s
ci-pangolin / E2E Smoke — L4 进程级端到端 (push) Successful in 18s
ci-pangolin / Go — integration (mysql/redis testcontainers) (push) Successful in 4m13s
ci-pangolin / Golden — 视觉回归 (components + auth) (push) Successful in 14s
fix(client): 修聚焦时输入框橙边漏出错位/被裁(登录·注册·搜索·兑换码)
主题 inputDecorationTheme 设了 focusedBorder: OutlineInputBorder(accent),
而登录/注册的邮箱·密码·验证码、节点页搜索框、兑换码框这几处的 TextField
只写 border: InputBorder.none、未覆盖 focusedBorder —— 聚焦瞬间主题的橙色
OutlineInputBorder 漏到内层 TextField 上,与外层自定义容器圆角错位/左右被裁。

统一加共享 kBareInput 装饰(pangolin_theme.dart)关掉所有边框状态泄漏,
四处替换。只压边框、不动 filled/fillColor —— 未聚焦态外观与改前一致,
不动 golden;聚焦指示仍由外层容器自己的 Border.all(accent) 负责。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 22:21:50 +08:00

366 lines
17 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// account_screens.dart — 账户子页(套餐选择 / 设备管理 / 兑换 & 购买 / 联系我们)
//
// 文案全部经 AppText(l10n,单显)。套餐数字以 design/CLAUDE.md §7 为准。
// App 内无任何支付表单——购买仅引导至外部渠道。
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../l10n/app_text.dart';
import '../models/device.dart';
import '../pangolin_theme.dart';
import '../services/auth_api.dart';
import '../state/account_providers.dart';
import 'pangolin_button.dart';
import 'pangolin_icons.dart';
import 'plan_card.dart';
/// 通用:带返回箭头的子页骨架。embedded=true 时只返回内容(desktop 内容区下钻,
/// 返回/标题由外层 shell 顶栏提供),否则整页 Scaffoldmobile/tablet 全屏 push)。
class _SubScaffold extends StatelessWidget {
const _SubScaffold({required this.title, required this.child, this.onBack, this.embedded = false});
final String title;
final Widget child;
final VoidCallback? onBack;
final bool embedded;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
if (embedded) return child;
return Scaffold(
backgroundColor: c.bg,
body: SafeArea(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Padding(
padding: const EdgeInsets.fromLTRB(8, 6, 16, 10),
child: Row(children: [
IconButton(
onPressed: onBack ?? () => Navigator.of(context).maybePop(),
icon: Icon(PangolinIcons.arrowLeft, size: 22, color: c.fg1),
),
Text(title, style: PangolinText.h3.copyWith(color: c.fg1, fontWeight: FontWeight.w700)),
]),
),
Expanded(child: child),
]),
),
);
}
}
/// ── 套餐选择 ── (价格/额度来自 GET /v1/plans;当前档由 me.plan 判定;
/// 功能清单仍取 l10n,后端无此字段)
class PlansScreen extends ConsumerWidget {
const PlansScreen({super.key, required this.t, this.onChoose, this.onBack, this.embedded = false});
final AppText t;
final ValueChanged<String>? onChoose;
final VoidCallback? onBack;
final bool embedded;
List<String> _feats(String code) => switch (code) {
'free' => t.featsFree,
'pro' => t.featsPro,
'team' => t.featsTeam,
_ => const [],
};
// 套餐名是 UI 文案(后端 plans 表无名称列),按 code 取 l10n。
String _name(String code) => switch (code) {
'free' => t.freePlan,
'pro' => t.proPlan,
'team' => t.teamPlan,
_ => code,
};
@override
Widget build(BuildContext context, WidgetRef ref) {
final c = context.pangolin;
final plansAsync = ref.watch(plansProvider);
final myPlan = ref.watch(meProvider).valueOrNull?.plan ?? 'free';
Widget body() => plansAsync.when(
loading: () => const Center(child: Padding(padding: EdgeInsets.all(40), child: CircularProgressIndicator())),
error: (_, __) => Center(
child: Padding(padding: const EdgeInsets.all(40), child: Text(t.lang == AppLang.zh ? '加载失败,请重试' : 'Failed to load, retry', style: PangolinText.body.copyWith(color: c.fg3)))),
data: (plans) => ListView(
padding: const EdgeInsets.fromLTRB(20, 14, 20, 24),
children: [
for (final p in plans)
Padding(
padding: EdgeInsets.only(bottom: 14, top: p.code == 'pro' ? 12 : 0),
child: PlanCard(
name: _name(p.code),
price: p.priceLabel(),
period: t.perMonth,
features: _feats(p.code),
ctaLabel: p.code == myPlan ? t.current : (p.code == 'team' ? t.choose : t.upgrade),
featured: p.code == 'pro',
isCurrent: p.code == myPlan,
popularLabel: p.code == 'pro' ? t.mostPopular : null,
onPressed: () => onChoose?.call(p.code),
),
),
],
),
);
return _SubScaffold(title: t.choosePlan, onBack: onBack, embedded: embedded, child: body());
}
}
/// ── 设备管理 ── (真实 GET /v1/me/devices;移除 = DELETE。本机高亮待 P6 设备 id)
class DevicesScreen extends ConsumerWidget {
const DevicesScreen({super.key, required this.t, this.onBack, this.embedded = false});
final AppText t;
final VoidCallback? onBack;
final bool embedded;
IconData _icon(String platform) => switch (platform.toLowerCase()) {
'ios' || 'android' => PangolinIcons.smartphone,
'macos' || 'windows' || 'linux' => PangolinIcons.laptop,
_ => PangolinIcons.monitorSmartphone,
};
String _lastSeen(DateTime? ts) {
final zh = t.lang == AppLang.zh;
if (ts == null) return zh ? '从未在线' : 'never';
final d = DateTime.now().difference(ts.toLocal());
if (d.inMinutes < 1) return zh ? '刚刚' : 'just now';
if (d.inMinutes < 60) return zh ? '${d.inMinutes} 分钟前' : '${d.inMinutes} min ago';
if (d.inHours < 24) return zh ? '${d.inHours} 小时前' : '${d.inHours}h ago';
return zh ? '${d.inDays} 天前' : '${d.inDays}d ago';
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final c = context.pangolin;
final devicesAsync = ref.watch(devicesProvider);
Widget content() => devicesAsync.when(
loading: () => const Center(child: Padding(padding: EdgeInsets.all(40), child: CircularProgressIndicator())),
error: (_, __) => Center(
child: Padding(padding: const EdgeInsets.all(40), child: Text(t.lang == AppLang.zh ? '加载失败,请重试' : 'Failed to load, retry', style: PangolinText.body.copyWith(color: c.fg3)))),
data: (devices) => ListView(padding: const EdgeInsets.fromLTRB(20, 4, 20, 24), children: [
Text(t.devicesSub, style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)),
const SizedBox(height: 12),
if (devices.isEmpty)
Text(t.lang == AppLang.zh ? '暂无已登录设备' : 'No devices yet',
style: PangolinText.sm.copyWith(color: c.fg3))
else
Container(
decoration: BoxDecoration(color: c.surface, borderRadius: BorderRadius.circular(PangolinRadius.lg), border: Border.all(color: c.border), boxShadow: PangolinShadow.sm),
clipBehavior: Clip.antiAlias,
child: Column(children: [
for (var i = 0; i < devices.length; i++) _row(ref, c, devices[i], i < devices.length - 1),
]),
),
]),
);
return _SubScaffold(title: t.myDevices, onBack: onBack, embedded: embedded, child: content());
}
Widget _row(WidgetRef ref, PangolinScheme c, Device d, bool divider) {
final name = d.name.isNotEmpty ? d.name : d.platform;
return Container(
decoration: BoxDecoration(border: divider ? Border(bottom: BorderSide(color: c.border)) : null),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(children: [
Container(width: 38, height: 38, decoration: BoxDecoration(color: c.accentSubtle, borderRadius: BorderRadius.circular(PangolinRadius.md)),
child: Icon(_icon(d.platform), size: 19, color: c.accent)),
const SizedBox(width: 12),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(name, style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w600, fontSize: 14.5)),
const SizedBox(height: 1),
Text('${d.platform} · ${_lastSeen(d.lastSeen)}', style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)),
])),
OutlinedButton(
onPressed: () => ref.read(devicesProvider.notifier).remove(d.uuid),
style: OutlinedButton.styleFrom(foregroundColor: c.fg2, side: BorderSide(color: c.borderStrong, width: 1.5), shape: const StadiumBorder(), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7), minimumSize: Size.zero),
child: Text(t.remove, style: PangolinText.caption.copyWith(fontWeight: FontWeight.w600)),
),
]),
);
}
}
/// ── 兑换 & 购买 ── (兑换码走 POST /v1/redeem,成功后刷新账户)
class RedeemScreen extends ConsumerStatefulWidget {
const RedeemScreen({super.key, required this.t, this.onBack, this.embedded = false});
final AppText t;
final VoidCallback? onBack;
final bool embedded;
@override
ConsumerState<RedeemScreen> createState() => _RedeemScreenState();
}
class _RedeemScreenState extends ConsumerState<RedeemScreen> {
final _code = TextEditingController();
bool _ok = false;
bool _busy = false;
String? _err;
@override
void dispose() {
_code.dispose();
super.dispose();
}
Future<void> _redeem() async {
final code = _code.text.trim();
if (code.isEmpty) return;
setState(() {
_busy = true;
_err = null;
});
try {
await ref.read(accountApiProvider).redeem(code);
// 订阅变化 → 刷新账户聚合,套餐横幅/额度随之更新。
await ref.read(meProvider.notifier).refresh();
if (mounted) setState(() => _ok = true);
} on AuthApiException catch (e) {
if (mounted) setState(() => _err = widget.t.lang == AppLang.zh ? e.messageZh : e.messageEn);
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
final c = context.pangolin;
final t = widget.t;
final channels = [
(icon: PangolinIcons.shoppingBag, name: t.chStore, sub: 'shop.pangolin.vpn', accent: true),
(icon: PangolinIcons.send, name: 'Telegram', sub: '@PangolinVPN_bot', accent: false),
(icon: PangolinIcons.messageCircle, name: 'LINE', sub: '@pangolinvpn', accent: false),
(icon: PangolinIcons.mail, name: t.chEmail, sub: 'buy@pangolin.vpn', accent: false),
];
return _SubScaffold(
title: t.redeemTitle,
onBack: widget.onBack,
embedded: widget.embedded,
child: ListView(padding: const EdgeInsets.fromLTRB(20, 4, 20, 24), children: [
Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(color: c.surface, borderRadius: BorderRadius.circular(PangolinRadius.xl), border: Border.all(color: c.border), boxShadow: PangolinShadow.sm),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(t.redeemCodeTitle, style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w700, fontSize: 14.5)),
const SizedBox(height: 12),
if (_ok)
Row(children: [
Icon(PangolinIcons.checkCircle, size: 20, color: c.success),
const SizedBox(width: 9),
Text(t.redeemOk, style: PangolinText.body.copyWith(color: c.success, fontWeight: FontWeight.w600)),
])
else
Row(children: [
Expanded(child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: BoxDecoration(color: c.bg, borderRadius: BorderRadius.circular(PangolinRadius.md), border: Border.all(color: c.borderStrong, width: 1.5)),
child: TextField(
controller: _code,
textCapitalization: TextCapitalization.characters,
style: PangolinText.mono.copyWith(color: c.fg1, letterSpacing: 1.5),
// kBareInput 关掉主题边框/填充泄漏(聚焦时橙框错位/被裁),聚焦态交给外层 Border。
decoration: kBareInput.copyWith(contentPadding: const EdgeInsets.symmetric(vertical: 13), hintText: t.redeemPh, hintStyle: PangolinText.sm.copyWith(color: c.fg3)),
onChanged: (_) => setState(() {}),
),
)),
const SizedBox(width: 10),
PangolinButton(label: t.redeemBtn, onPressed: (_busy || _code.text.trim().length < 4) ? null : _redeem),
]),
if (_err != null) ...[
const SizedBox(height: 10),
Text(_err!, style: PangolinText.caption.copyWith(color: c.danger, fontWeight: FontWeight.w500)),
],
]),
),
const SizedBox(height: 22),
Text(t.buyTitle, style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w700, fontSize: 14.5)),
const SizedBox(height: 6),
Text(t.buySub, style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400, height: 1.5)),
const SizedBox(height: 14),
for (final ch in channels) Padding(padding: const EdgeInsets.only(bottom: 10), child: _channel(c, ch.icon, ch.name, ch.sub, ch.accent)),
]),
);
}
Widget _channel(PangolinScheme c, IconData icon, String name, String sub, bool accent) {
return InkWell(
borderRadius: BorderRadius.circular(PangolinRadius.lg),
onTap: () {},
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(color: accent ? c.accentSubtle : c.surface, borderRadius: BorderRadius.circular(PangolinRadius.lg), border: Border.all(color: accent ? c.accentBorder : c.border), boxShadow: PangolinShadow.sm),
child: Row(children: [
Container(width: 40, height: 40, decoration: BoxDecoration(color: accent ? c.accent : c.bgSubtle, borderRadius: BorderRadius.circular(PangolinRadius.md)),
child: Icon(icon, size: 20, color: accent ? PangolinColors.white : c.accent)),
const SizedBox(width: 13),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(name, style: PangolinText.body.copyWith(color: c.fg1, fontWeight: FontWeight.w600)),
Text(sub, style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)),
])),
Icon(PangolinIcons.externalLink, size: 16, color: c.fg3),
]),
),
);
}
}
/// ── 联系我们 ──
class ContactScreen extends StatelessWidget {
const ContactScreen({super.key, required this.t, this.onBack});
final AppText t;
final VoidCallback? onBack;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
final channels = [
(icon: PangolinIcons.send, name: 'Telegram', sub: '@PangolinVPN_bot', accent: true),
(icon: PangolinIcons.messageCircle, name: 'LINE', sub: '@pangolinvpn', accent: false),
(icon: PangolinIcons.mail, name: t.contactEmail, sub: 'support@pangolin.vpn', accent: false),
(icon: PangolinIcons.shoppingBag, name: t.contactStore, sub: 'shop.pangolin.vpn', accent: false),
];
return _SubScaffold(
title: t.contactTitle,
onBack: onBack,
child: ListView(padding: const EdgeInsets.fromLTRB(20, 4, 20, 24), children: [
Text(t.contactIntro, style: PangolinText.sm.copyWith(color: c.fg2, height: 1.6)),
const SizedBox(height: 16),
for (final ch in channels) Padding(padding: const EdgeInsets.only(bottom: 10), child: _contact(c, ch.icon, ch.name, ch.sub, ch.accent)),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(color: c.surface, borderRadius: BorderRadius.circular(PangolinRadius.lg), border: Border.all(color: c.border), boxShadow: PangolinShadow.sm),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(t.contactHoursTitle, style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w600)),
const SizedBox(height: 5),
Text(t.contactHours, style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w600)),
]),
),
]),
);
}
Widget _contact(PangolinScheme c, IconData icon, String name, String sub, bool accent) {
return InkWell(
borderRadius: BorderRadius.circular(PangolinRadius.lg),
onTap: () {},
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(color: accent ? c.accentSubtle : c.surface, borderRadius: BorderRadius.circular(PangolinRadius.lg), border: Border.all(color: accent ? c.accentBorder : c.border), boxShadow: PangolinShadow.sm),
child: Row(children: [
Container(width: 40, height: 40, decoration: BoxDecoration(color: accent ? c.accent : c.bgSubtle, borderRadius: BorderRadius.circular(PangolinRadius.md)),
child: Icon(icon, size: 20, color: accent ? PangolinColors.white : c.accent)),
const SizedBox(width: 13),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(name, style: PangolinText.body.copyWith(color: c.fg1, fontWeight: FontWeight.w600)),
Text(sub, style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)),
])),
Icon(PangolinIcons.externalLink, size: 16, color: c.fg3),
]),
),
);
}
}