feat(client): Flutter 逐屏还原 + iPad ≥900 断点 (tsk_gpPXi-icyeOE)
对照 design/ui_kits mobile+tablet 原型逐屏补齐 Flutter 客户端: - l10n 资源层(strings_zh/en,单显)+ Riverpod 状态层(连接状态机/免费额度/ 节点选择/语言/主题),UI 与数据解耦,mock 数据接 API 不动 UI。 - 连接键严格三态(off 虚线轨道环 / connecting 旋转弧 / on 满环+计时+光晕), 状态来自 connectionProvider,点击只派发事件——禁止乐观显示。 - 节点页置顶「智能选择」推荐卡(clay 渐变 zap + 推荐胶囊,默认选中); 免费额度卡(剩余分钟+进度条 ≤3 分钟切 warning + 看广告解锁变绿)。 - Tab 左右滑动切换(手势竞技场仲裁,子页滚动不误触发,200ms 方向感知滑入)。 - iPad/宽屏 ≥900 LayoutBuilder 切侧栏分栏(导航行高 ≥48,连接页双栏/节点双列网格), 复用同一批原子组件,不 fork 页面。 - 语义 token 零硬编码;文案全部走 l10n;套餐数字对齐 §7;无支付表单/emoji 国旗。 - CI 红线词扫描扩展到 client/lib;新增 Flutter analyze+test CI 任务。 - 测试:连接状态机/额度/节点单测、连接键三态与卡片组件测试、 golden(连接键三态/推荐卡/额度卡 × 明暗)。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,387 @@
|
||||
// account_page.dart — 账户页(套餐横幅 + 账户信息 + 设备/兑换/联系 + 语言/主题)
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../l10n/app_text.dart';
|
||||
import '../pangolin_theme.dart';
|
||||
import '../state/app_providers.dart';
|
||||
import '../widgets/account_screens.dart';
|
||||
import '../widgets/app_top_bar.dart';
|
||||
import '../widgets/pangolin_icons.dart';
|
||||
|
||||
const String kDemoEmail = 'me@pangolin.vpn';
|
||||
|
||||
class AccountPage extends ConsumerWidget {
|
||||
const AccountPage({super.key, required this.isWide});
|
||||
final bool isWide;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final c = context.pangolin;
|
||||
final t = ref.watch(appTextProvider);
|
||||
final isFree = ref.watch(isFreePlanProvider);
|
||||
final lang = ref.watch(localeProvider);
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final pad = isWide ? 28.0 : 20.0;
|
||||
|
||||
void push(Widget page) =>
|
||||
Navigator.of(context).push(MaterialPageRoute(builder: (_) => page));
|
||||
|
||||
final body = ListView(
|
||||
padding: EdgeInsets.fromLTRB(pad, isWide ? 6 : 0, pad, 24),
|
||||
children: [
|
||||
_PlanBanner(t: t, isFree: isFree, onUpgrade: () => push(PlansScreen(t: t))),
|
||||
const SizedBox(height: 18),
|
||||
// 账户信息
|
||||
_SectionLabel(text: t.accInfoTitle),
|
||||
_Card(children: [
|
||||
_InfoRow(icon: PangolinIcons.mail, title: t.accEmail, value: kDemoEmail, trailing: _changeHint(c, t)),
|
||||
Divider(height: 1, color: c.border),
|
||||
_InfoRow(icon: PangolinIcons.lock, title: t.accPassword, value: '••••••••••', trailing: _changeHint(c, t)),
|
||||
]),
|
||||
const SizedBox(height: 16),
|
||||
// 设备 / 兑换 / 联系
|
||||
_Card(children: [
|
||||
_NavRow(icon: PangolinIcons.monitorSmartphone, title: t.myDevices, onTap: () => push(DevicesScreen(t: t))),
|
||||
Divider(height: 1, color: c.border),
|
||||
_NavRow(icon: PangolinIcons.shoppingBag, title: t.redeemEntry, onTap: () => push(RedeemScreen(t: t))),
|
||||
Divider(height: 1, color: c.border),
|
||||
_NavRow(icon: PangolinIcons.messageCircle, title: t.contactEntry, onTap: () => push(ContactScreen(t: t))),
|
||||
]),
|
||||
const SizedBox(height: 16),
|
||||
// 语言 / 主题 / 协议
|
||||
_Card(children: [
|
||||
_CustomRow(
|
||||
icon: PangolinIcons.globe,
|
||||
title: t.language,
|
||||
trailing: _LangSwitch(lang: lang, onChange: (l) => ref.read(localeProvider.notifier).state = l),
|
||||
),
|
||||
Divider(height: 1, color: c.border),
|
||||
_CustomRow(
|
||||
icon: isDark ? PangolinIcons.moon : PangolinIcons.sun,
|
||||
title: t.darkAppearance,
|
||||
subtitle: isDark ? t.stateOn : t.followLight,
|
||||
trailing: _PangolinSwitch(
|
||||
value: isDark,
|
||||
onChanged: (v) => ref.read(themeModeProvider.notifier).state = v ? ThemeMode.dark : ThemeMode.light,
|
||||
),
|
||||
),
|
||||
Divider(height: 1, color: c.border),
|
||||
_CustomRow(
|
||||
icon: PangolinIcons.shield,
|
||||
title: t.protocol,
|
||||
trailing: Text('WireGuard', style: PangolinText.mono.copyWith(color: c.fg3, fontSize: 13)),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 16),
|
||||
// 演示:免费版视角开关
|
||||
_Card(children: [
|
||||
_CustomRow(
|
||||
icon: PangolinIcons.clock,
|
||||
title: lang == AppLang.zh ? '演示:免费版视角' : 'Demo: free-tier view',
|
||||
subtitle: lang == AppLang.zh ? '切换额度卡与套餐横幅' : 'Toggles quota UI & plan banner',
|
||||
trailing: _PangolinSwitch(
|
||||
value: isFree,
|
||||
onChanged: (v) => ref.read(isFreePlanProvider.notifier).state = v,
|
||||
),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 16),
|
||||
_Card(children: [
|
||||
_NavRow(icon: PangolinIcons.logOut, title: t.signOut, danger: true, onTap: () {}),
|
||||
]),
|
||||
],
|
||||
);
|
||||
|
||||
if (isWide) return body;
|
||||
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
AppTopBar(brand: t.brand),
|
||||
PageTitle(title: t.meTitle),
|
||||
Expanded(child: body),
|
||||
]);
|
||||
}
|
||||
|
||||
Widget _changeHint(PangolinScheme c, AppText t) =>
|
||||
Text(t.accChange, style: PangolinText.sm.copyWith(color: c.accent, fontWeight: FontWeight.w600, fontSize: 13));
|
||||
}
|
||||
|
||||
class _PlanBanner extends StatelessWidget {
|
||||
const _PlanBanner({required this.t, required this.isFree, required this.onUpgrade});
|
||||
final AppText t;
|
||||
final bool isFree;
|
||||
final VoidCallback onUpgrade;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
if (isFree) {
|
||||
return 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: [
|
||||
Row(children: [
|
||||
Container(
|
||||
width: 44, height: 44,
|
||||
decoration: BoxDecoration(color: c.bgSubtle, shape: BoxShape.circle),
|
||||
child: Icon(PangolinIcons.user, size: 22, color: c.fg2),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(kDemoEmail, style: PangolinText.body.copyWith(color: c.fg1, fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 2),
|
||||
decoration: BoxDecoration(color: c.bgSubtle, borderRadius: BorderRadius.circular(PangolinRadius.full)),
|
||||
child: Text(t.freePlanName, style: PangolinText.caption.copyWith(color: c.fg2, fontWeight: FontWeight.w600, fontSize: 11)),
|
||||
),
|
||||
])),
|
||||
_UpgradeButton(label: t.upgradeBtn, onTap: onUpgrade),
|
||||
]),
|
||||
]),
|
||||
);
|
||||
}
|
||||
// PRO 渐变横幅
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(18),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topLeft, end: Alignment.bottomRight,
|
||||
colors: [PangolinColors.clay600, PangolinColors.clay800],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(PangolinRadius.xl),
|
||||
boxShadow: PangolinShadow.md,
|
||||
),
|
||||
child: Row(children: [
|
||||
Container(
|
||||
width: 44, height: 44,
|
||||
decoration: BoxDecoration(color: PangolinColors.white.withOpacity(0.18), shape: BoxShape.circle),
|
||||
child: const Icon(PangolinIcons.crown, size: 22, color: PangolinColors.white),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(kDemoEmail, style: const TextStyle(color: PangolinColors.white, fontWeight: FontWeight.w700, fontSize: 16)),
|
||||
const SizedBox(height: 4),
|
||||
Text('${t.proMember} · 2026-12-31',
|
||||
style: TextStyle(color: PangolinColors.white.withOpacity(0.85), fontSize: 12)),
|
||||
])),
|
||||
FilledButton(
|
||||
onPressed: onUpgrade,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: PangolinColors.white,
|
||||
foregroundColor: PangolinColors.clay700,
|
||||
shape: const StadiumBorder(),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
),
|
||||
child: Text(t.upgradeBtn, style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 13)),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _UpgradeButton extends StatelessWidget {
|
||||
const _UpgradeButton({required this.label, required this.onTap});
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
return FilledButton(
|
||||
onPressed: onTap,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: c.accent,
|
||||
foregroundColor: c.fgOnAccent,
|
||||
shape: const StadiumBorder(),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
),
|
||||
child: Text(label, style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 13)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionLabel extends StatelessWidget {
|
||||
const _SectionLabel({required this.text});
|
||||
final String text;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 0, 4, 10),
|
||||
child: Text(text, style: PangolinText.sm.copyWith(color: c.fg2, fontWeight: FontWeight.w700, fontSize: 13)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Card extends StatelessWidget {
|
||||
const _Card({required this.children});
|
||||
final List<Widget> children;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
return 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: children),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoRow extends StatelessWidget {
|
||||
const _InfoRow({required this.icon, required this.title, required this.value, this.trailing});
|
||||
final IconData icon;
|
||||
final String title, value;
|
||||
final Widget? trailing;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13),
|
||||
child: Row(children: [
|
||||
_IconBox(icon: icon),
|
||||
const SizedBox(width: 13),
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(title, style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w500, fontSize: 15)),
|
||||
Text(value, style: PangolinText.caption.copyWith(color: c.fg3)),
|
||||
])),
|
||||
if (trailing != null) trailing!,
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NavRow extends StatelessWidget {
|
||||
const _NavRow({required this.icon, required this.title, required this.onTap, this.danger = false});
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final VoidCallback onTap;
|
||||
final bool danger;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
child: Row(children: [
|
||||
Icon(icon, size: 20, color: danger ? c.danger : c.accent),
|
||||
const SizedBox(width: 13),
|
||||
Expanded(child: Text(title, style: PangolinText.body.copyWith(color: danger ? c.danger : c.fg1, fontWeight: FontWeight.w500))),
|
||||
if (!danger) Icon(PangolinIcons.chevronRight, size: 18, color: c.fg3),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CustomRow extends StatelessWidget {
|
||||
const _CustomRow({required this.icon, required this.title, this.subtitle, required this.trailing});
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final Widget trailing;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13),
|
||||
child: Row(children: [
|
||||
_IconBox(icon: icon),
|
||||
const SizedBox(width: 13),
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(title, style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w500, fontSize: 15)),
|
||||
if (subtitle != null) Text(subtitle!, style: PangolinText.caption.copyWith(color: c.fg3)),
|
||||
])),
|
||||
trailing,
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _IconBox extends StatelessWidget {
|
||||
const _IconBox({required this.icon});
|
||||
final IconData icon;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
return Container(
|
||||
width: 32, height: 32,
|
||||
decoration: BoxDecoration(color: c.accentSubtle, borderRadius: BorderRadius.circular(PangolinRadius.sm)),
|
||||
child: Icon(icon, size: 17, color: c.accent),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 段控:中文 / EN(单显切换)。
|
||||
class _LangSwitch extends StatelessWidget {
|
||||
const _LangSwitch({required this.lang, required this.onChange});
|
||||
final AppLang lang;
|
||||
final ValueChanged<AppLang> onChange;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
Widget seg(AppLang v, String label) {
|
||||
final active = lang == v;
|
||||
return GestureDetector(
|
||||
onTap: () => onChange(v),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: active ? c.accent : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(PangolinRadius.full),
|
||||
),
|
||||
child: Text(label,
|
||||
style: PangolinText.caption.copyWith(
|
||||
color: active ? c.fgOnAccent : c.fg3, fontWeight: FontWeight.w700, fontSize: 12.5)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(3),
|
||||
decoration: BoxDecoration(color: c.bgSubtle, borderRadius: BorderRadius.circular(PangolinRadius.full)),
|
||||
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
seg(AppLang.zh, '中文'),
|
||||
const SizedBox(width: 2),
|
||||
seg(AppLang.en, 'EN'),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PangolinSwitch extends StatelessWidget {
|
||||
const _PangolinSwitch({required this.value, required this.onChanged});
|
||||
final bool value;
|
||||
final ValueChanged<bool> onChanged;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
return GestureDetector(
|
||||
onTap: () => onChanged(!value),
|
||||
child: AnimatedContainer(
|
||||
duration: PangolinMotion.base,
|
||||
curve: PangolinMotion.easeOut,
|
||||
width: 46, height: 28,
|
||||
decoration: BoxDecoration(color: value ? c.accent : c.borderStrong, borderRadius: BorderRadius.circular(PangolinRadius.full)),
|
||||
child: AnimatedAlign(
|
||||
duration: PangolinMotion.base,
|
||||
curve: PangolinMotion.easeOut,
|
||||
alignment: value ? Alignment.centerRight : Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(3),
|
||||
child: Container(width: 22, height: 22, decoration: const BoxDecoration(color: PangolinColors.white, shape: BoxShape.circle)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
// connect_page.dart — 连接页(窄屏单栏 / 宽屏双栏,同一份代码按断点切换)
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../l10n/app_text.dart';
|
||||
import '../models/node.dart';
|
||||
import '../pangolin_theme.dart';
|
||||
import '../state/app_providers.dart';
|
||||
import '../state/connection_provider.dart';
|
||||
import '../state/nodes_provider.dart';
|
||||
import '../state/quota_provider.dart';
|
||||
import '../widgets/app_top_bar.dart';
|
||||
import '../widgets/connect_button.dart';
|
||||
import '../widgets/country_code.dart';
|
||||
import '../widgets/pangolin_icons.dart';
|
||||
import '../widgets/quota_card.dart';
|
||||
|
||||
class ConnectPage extends ConsumerWidget {
|
||||
const ConnectPage({super.key, required this.isWide, required this.onOpenNodes});
|
||||
|
||||
final bool isWide;
|
||||
final VoidCallback onOpenNodes;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final c = context.pangolin;
|
||||
final t = ref.watch(appTextProvider);
|
||||
final conn = ref.watch(connectionProvider);
|
||||
final node = ref.watch(effectiveNodeProvider);
|
||||
final smart = ref.watch(isSmartSelectProvider);
|
||||
final isFree = ref.watch(isFreePlanProvider);
|
||||
final quota = ref.watch(quotaProvider);
|
||||
|
||||
final caption = switch (conn.phase) {
|
||||
VpnPhase.off => t.capOff,
|
||||
VpnPhase.connecting => t.capConnecting,
|
||||
VpnPhase.on => t.capOn,
|
||||
};
|
||||
|
||||
final button = ConnectButton(
|
||||
phase: conn.phase,
|
||||
elapsed: conn.elapsed,
|
||||
offLabel: t.connectNow,
|
||||
secureLabel: t.secure,
|
||||
onTap: () => ref.read(connectionProvider.notifier).toggle(),
|
||||
);
|
||||
|
||||
final captionWidget = Text(caption,
|
||||
style: PangolinText.body.copyWith(color: c.fg2, fontWeight: FontWeight.w600));
|
||||
|
||||
final infoChildren = <Widget>[
|
||||
if (isFree)
|
||||
QuotaCard(quota: quota, t: t, onWatchAd: () => ref.read(quotaProvider.notifier).watchAd()),
|
||||
if (conn.phase == VpnPhase.on) _SpeedRow(t: t, node: node),
|
||||
_CurrentNodeCard(t: t, node: node, smart: smart, showLabel: isWide, onTap: onOpenNodes),
|
||||
];
|
||||
|
||||
final statusTrailing = Text(
|
||||
conn.phase == VpnPhase.on ? t.online : t.offline,
|
||||
style: PangolinText.mono.copyWith(
|
||||
fontSize: 12,
|
||||
color: conn.phase == VpnPhase.on ? c.success : c.fg3,
|
||||
fontWeight: FontWeight.w600),
|
||||
);
|
||||
|
||||
if (isWide) {
|
||||
// 宽屏双栏:左大连接键 / 右信息列(额度卡→当前节点→速率)
|
||||
return Column(children: [
|
||||
Expanded(
|
||||
child: Row(children: [
|
||||
Expanded(
|
||||
flex: 5,
|
||||
child: Center(
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
button,
|
||||
const SizedBox(height: 24),
|
||||
captionWidget,
|
||||
]),
|
||||
),
|
||||
),
|
||||
Container(width: 1, color: c.border),
|
||||
SizedBox(
|
||||
width: 332,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (final w in infoChildren) Padding(padding: const EdgeInsets.only(bottom: 14), child: w),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
// 窄屏单栏
|
||||
return Column(children: [
|
||||
AppTopBar(brand: t.brand, trailing: statusTrailing),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
button,
|
||||
const SizedBox(height: 24),
|
||||
captionWidget,
|
||||
]),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 18),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
|
||||
for (final w in infoChildren) Padding(padding: const EdgeInsets.only(bottom: 12), child: w),
|
||||
]),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/// 实时速率行(连接成功时显示;演示值)。
|
||||
class _SpeedRow extends StatelessWidget {
|
||||
const _SpeedRow({required this.t, required this.node});
|
||||
final AppText t;
|
||||
final Node node;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
final metrics = [
|
||||
(PangolinIcons.arrowDown, t.download, '86.4', 'Mb/s'),
|
||||
(PangolinIcons.arrowUp, t.upload, '12.1', 'Mb/s'),
|
||||
(PangolinIcons.zap, t.latency, '${node.ping}', 'ms'),
|
||||
];
|
||||
return Row(children: [
|
||||
for (var i = 0; i < metrics.length; i++)
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(right: i < metrics.length - 1 ? 12 : 0),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(13, 11, 13, 11),
|
||||
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: [
|
||||
Row(children: [
|
||||
Icon(metrics[i].$1, size: 13, color: c.accent),
|
||||
const SizedBox(width: 5),
|
||||
Flexible(
|
||||
child: Text(metrics[i].$2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w600, fontSize: 11)),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 4),
|
||||
Text.rich(TextSpan(
|
||||
text: metrics[i].$3,
|
||||
style: PangolinText.mono.copyWith(fontSize: 16, color: c.fg1, fontWeight: FontWeight.w500),
|
||||
children: [TextSpan(text: ' ${metrics[i].$4}', style: PangolinText.caption.copyWith(color: c.fg3, fontSize: 10.5))],
|
||||
)),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前节点卡(智能选择时展示「智能选择 + zap」)。
|
||||
class _CurrentNodeCard extends StatelessWidget {
|
||||
const _CurrentNodeCard({
|
||||
required this.t,
|
||||
required this.node,
|
||||
required this.smart,
|
||||
required this.showLabel,
|
||||
required this.onTap,
|
||||
});
|
||||
final AppText t;
|
||||
final Node node;
|
||||
final bool smart;
|
||||
final bool showLabel;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
final title = smart ? t.smartSelect : node.localizedName(t.lang);
|
||||
final sub = smart
|
||||
? '${node.localizedName(t.lang)} · ${node.ping}ms'
|
||||
: '${node.localizedSub(t.lang)} · ${node.ping}ms';
|
||||
return Material(
|
||||
color: c.surface,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(PangolinRadius.lg),
|
||||
side: BorderSide(color: c.border),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(children: [
|
||||
CountryCode(code: node.code, active: true),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
if (showLabel)
|
||||
Text(t.currentNode,
|
||||
style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w600, fontSize: 11)),
|
||||
Row(children: [
|
||||
Flexible(
|
||||
child: Text(title,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: PangolinText.body.copyWith(color: c.fg1, fontWeight: FontWeight.w600)),
|
||||
),
|
||||
if (smart) ...[const SizedBox(width: 6), Icon(PangolinIcons.zap, size: 13, color: c.accent)],
|
||||
]),
|
||||
Text(sub, style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)),
|
||||
]),
|
||||
),
|
||||
Icon(PangolinIcons.chevronRight, size: 20, color: c.fg3),
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
// nodes_page.dart — 节点页(置顶智能选择推荐卡 + 搜索 + 列表/双列网格)
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../l10n/app_text.dart';
|
||||
import '../models/node.dart';
|
||||
import '../pangolin_theme.dart';
|
||||
import '../state/app_providers.dart';
|
||||
import '../state/connection_provider.dart';
|
||||
import '../state/nodes_provider.dart';
|
||||
import '../widgets/app_top_bar.dart';
|
||||
import '../widgets/country_code.dart';
|
||||
import '../widgets/pangolin_icons.dart';
|
||||
import '../widgets/server_tile.dart';
|
||||
import '../widgets/smart_select_card.dart';
|
||||
|
||||
class NodesPage extends ConsumerStatefulWidget {
|
||||
const NodesPage({super.key, required this.isWide, required this.onPicked});
|
||||
final bool isWide;
|
||||
final VoidCallback onPicked;
|
||||
|
||||
@override
|
||||
ConsumerState<NodesPage> createState() => _NodesPageState();
|
||||
}
|
||||
|
||||
class _NodesPageState extends ConsumerState<NodesPage> {
|
||||
String _q = '';
|
||||
|
||||
void _pick(String code) {
|
||||
ref.read(selectedNodeCodeProvider.notifier).state = code;
|
||||
ref.read(connectionProvider.notifier).onNodeChanged();
|
||||
widget.onPicked();
|
||||
}
|
||||
|
||||
List<Node> _filtered(List<Node> nodes) {
|
||||
if (_q.trim().isEmpty) return nodes;
|
||||
final q = _q.toLowerCase();
|
||||
return nodes
|
||||
.where((n) => (n.nameZh + n.nameEn + n.code).toLowerCase().contains(q))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
final t = ref.watch(appTextProvider);
|
||||
final selected = ref.watch(selectedNodeCodeProvider);
|
||||
final nodes = _filtered(ref.watch(nodesProvider));
|
||||
final smart = selected == kSmartNodeCode;
|
||||
|
||||
final smartCard = SmartSelectCard(t: t, selected: smart, onTap: () => _pick(kSmartNodeCode));
|
||||
final search = _SearchBox(hint: t.searchPh, onChanged: (v) => setState(() => _q = v));
|
||||
|
||||
if (widget.isWide) {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(28, 6, 28, 24),
|
||||
children: [
|
||||
smartCard,
|
||||
const SizedBox(height: 14),
|
||||
ConstrainedBox(constraints: const BoxConstraints(maxWidth: 380), child: search),
|
||||
const SizedBox(height: 14),
|
||||
GridView.count(
|
||||
crossAxisCount: 2,
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
mainAxisSpacing: 11,
|
||||
crossAxisSpacing: 11,
|
||||
childAspectRatio: 4.4,
|
||||
children: [
|
||||
for (final n in nodes)
|
||||
_NodeGridTile(node: n, lang: t.lang, active: !smart && n.code == selected, onTap: () => _pick(n.code)),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// 窄屏:顶栏 + 标题 + 推荐卡 + 搜索 + 分组列表
|
||||
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
AppTopBar(brand: t.brand),
|
||||
PageTitle(title: t.chooseNode),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
|
||||
children: [
|
||||
smartCard,
|
||||
const SizedBox(height: 12),
|
||||
search,
|
||||
const SizedBox(height: 12),
|
||||
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 < nodes.length; i++) ...[
|
||||
ServerTile(
|
||||
node: nodes[i],
|
||||
lang: t.lang,
|
||||
active: !smart && nodes[i].code == selected,
|
||||
onTap: () => _pick(nodes[i].code),
|
||||
),
|
||||
if (i < nodes.length - 1) Divider(height: 1, thickness: 1, color: c.border),
|
||||
],
|
||||
]),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
class _SearchBox extends StatelessWidget {
|
||||
const _SearchBox({required this.hint, required this.onChanged});
|
||||
final String hint;
|
||||
final ValueChanged<String> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
return Container(
|
||||
decoration: BoxDecoration(color: c.bgSubtle, borderRadius: BorderRadius.circular(PangolinRadius.md)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
child: Row(children: [
|
||||
Icon(PangolinIcons.search, size: 18, color: c.fg3),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
onChanged: onChanged,
|
||||
style: PangolinText.sm.copyWith(color: c.fg1, fontSize: 15),
|
||||
decoration: InputDecoration(
|
||||
isCollapsed: true,
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 12),
|
||||
border: InputBorder.none,
|
||||
hintText: hint,
|
||||
hintStyle: PangolinText.sm.copyWith(color: c.fg3, fontSize: 15),
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 宽屏双列网格中的节点块。
|
||||
class _NodeGridTile extends StatelessWidget {
|
||||
const _NodeGridTile({required this.node, required this.lang, required this.active, required this.onTap});
|
||||
final Node node;
|
||||
final AppLang lang;
|
||||
final bool active;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
return Material(
|
||||
color: active ? c.accentSubtle : c.surface,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(PangolinRadius.lg),
|
||||
side: BorderSide(color: active ? c.accentBorder : c.border, width: 1.5),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 11),
|
||||
child: Row(children: [
|
||||
CountryCode(code: node.code, active: active),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [
|
||||
Text(node.localizedName(lang),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w600, fontSize: 14.5)),
|
||||
Text(node.localizedSub(lang),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)),
|
||||
]),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, children: [
|
||||
Text('${node.ping}ms', style: PangolinText.mono.copyWith(color: c.fg2, fontSize: 12)),
|
||||
const SizedBox(height: 5),
|
||||
SignalBars(ping: node.ping),
|
||||
]),
|
||||
if (active) ...[const SizedBox(width: 8), Icon(PangolinIcons.check, size: 18, color: c.accent)],
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// stats_page.dart — 统计页(指标卡 + 本周柱状图)
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../pangolin_theme.dart';
|
||||
import '../state/app_providers.dart';
|
||||
import '../widgets/app_top_bar.dart';
|
||||
|
||||
class StatsPage extends ConsumerWidget {
|
||||
const StatsPage({super.key, required this.isWide});
|
||||
final bool isWide;
|
||||
|
||||
static const _vals = [2.1, 3.4, 1.8, 4.6, 5.2, 6.1, 3.0];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final c = context.pangolin;
|
||||
final t = ref.watch(appTextProvider);
|
||||
final maxV = _vals.reduce((a, b) => a > b ? a : b);
|
||||
final pad = isWide ? 28.0 : 20.0;
|
||||
|
||||
final metrics = [
|
||||
(t.trafficMonth, '42.6', 'GB'),
|
||||
(t.avgPing, '29', 'ms'),
|
||||
(t.durMonth, '86.4', 'h'),
|
||||
];
|
||||
|
||||
final body = ListView(
|
||||
padding: EdgeInsets.fromLTRB(pad, isWide ? 6 : 0, pad, 24),
|
||||
children: [
|
||||
Row(children: [
|
||||
for (var i = 0; i < metrics.length; i++)
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(right: i < metrics.length - 1 ? 12 : 0),
|
||||
child: _MetricCard(label: metrics[i].$1, value: metrics[i].$2, unit: metrics[i].$3),
|
||||
),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(18),
|
||||
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.weekTraffic, style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 18),
|
||||
SizedBox(
|
||||
height: isWide ? 150 : 120,
|
||||
child: Row(crossAxisAlignment: CrossAxisAlignment.end, children: [
|
||||
for (var i = 0; i < _vals.length; i++)
|
||||
Expanded(
|
||||
child: Column(mainAxisAlignment: MainAxisAlignment.end, children: [
|
||||
Text('${_vals[i]}', style: PangolinText.mono.copyWith(fontSize: 11, color: c.fg3)),
|
||||
const SizedBox(height: 6),
|
||||
Container(
|
||||
height: (isWide ? 104 : 84) * (_vals[i] / maxV),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: c.accent.withOpacity(0.85),
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(6)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(t.days7[i], style: PangolinText.caption.copyWith(color: c.fg3)),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
if (isWide) return body;
|
||||
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
AppTopBar(brand: t.brand),
|
||||
PageTitle(title: t.statsTitle),
|
||||
Expanded(child: body),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
class _MetricCard extends StatelessWidget {
|
||||
const _MetricCard({required this.label, required this.value, required this.unit});
|
||||
final String label, value, unit;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
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(label, overflow: TextOverflow.ellipsis, style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 6),
|
||||
Text.rich(TextSpan(
|
||||
text: value,
|
||||
style: PangolinText.mono.copyWith(fontSize: 20, color: c.fg1, fontWeight: FontWeight.w500),
|
||||
children: [TextSpan(text: ' $unit', style: PangolinText.caption.copyWith(color: c.fg3))],
|
||||
)),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user