feat(client): 三端布局架构 + macOS 桌面端 + app 图标
三端布局(mobile/tablet/desktop): - core/responsive/form_factor.dart 形态判定 + shell/ 分发器(home_shell→desktop/mobile) - desktop_shell 对照 ui_kits/desktop/dapp.jsx: 侧栏204·6项 + 套餐卡 + 顶栏(标题/状态/主题切换) + 连接页居中单列 - 新增组件 nav_sidebar / plan_badge_card / content_top_bar / bottom_tab_bar - 新增一级页 contact_page / settings_page; navigation_provider(NavView) - 删除旧 widgets/home_shell.dart(逻辑迁入 shell/) macOS 桌面端: - 窗口默认 920×600 + 最小 720×560(MainFlutterWindow.swift) - app 图标替换为穿山甲(AppIcon.appiconset 全套, 由 app-icon.svg 渲染) 其余(本会话): - Phase2 接线: auth_api/token_store/auth_provider/vpn_bridge_provider + 真实 connection/nodes - lucide_icons 兼容补丁(packages/lucide_icons_patched) 修复 IconData final 报错 - 测试修复: connect_passthrough(UTF-8) / harness / golden @Skip - l10n 新增 settingsTitle Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
// connect_page.dart — 连接页(窄屏单栏 / 宽屏双栏,同一份代码按断点切换)
|
||||
// connect_page.dart — 连接页(三端:desktop 居中单列 / tablet 双栏 / mobile 单列滚动)
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../core/responsive/form_factor.dart';
|
||||
import '../l10n/app_text.dart';
|
||||
import '../models/node.dart';
|
||||
import '../pangolin_theme.dart';
|
||||
@@ -63,6 +64,42 @@ class ConnectPage extends ConsumerWidget {
|
||||
fontWeight: FontWeight.w600),
|
||||
);
|
||||
|
||||
if (context.formFactor == FormFactor.desktop) {
|
||||
// 桌面居中单列(对照 ui_kits/desktop/dapp.jsx DConnect)
|
||||
return Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 40),
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
ConnectButton(
|
||||
phase: conn.phase,
|
||||
elapsed: conn.elapsed,
|
||||
offLabel: t.connectNow,
|
||||
secureLabel: t.secure,
|
||||
size: 176,
|
||||
onTap: () => ref.read(connectionProvider.notifier).toggle(),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(caption,
|
||||
textAlign: TextAlign.center,
|
||||
style: PangolinText.display.copyWith(color: c.fg1, fontSize: 22, fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 10),
|
||||
_NodePill(t: t, node: node, smart: smart),
|
||||
if (isFree) ...[
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: 340,
|
||||
child: QuotaCard(quota: quota, t: t, onWatchAd: () => ref.read(quotaProvider.notifier).watchAd()),
|
||||
),
|
||||
],
|
||||
if (conn.phase == VpnPhase.on) ...[
|
||||
const SizedBox(height: 16),
|
||||
_SpeedRow(t: t, node: node),
|
||||
],
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (isWide) {
|
||||
// 宽屏双栏:左大连接键 / 右信息列(额度卡→当前节点→速率)
|
||||
return Column(children: [
|
||||
@@ -97,24 +134,42 @@ class ConnectPage extends ConsumerWidget {
|
||||
]);
|
||||
}
|
||||
|
||||
// 窄屏单栏
|
||||
// 窄屏单栏(可滚动,避免窗口偏矮时底部溢出):
|
||||
// 顶栏固定,内容区用 SingleChildScrollView + ConstrainedBox(minHeight) +
|
||||
// IntrinsicHeight,高度足够时连接键居中,高度不足时整体滚动而非溢出。
|
||||
return Column(children: [
|
||||
AppTopBar(brand: t.brand, trailing: statusTrailing),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
button,
|
||||
const SizedBox(height: 24),
|
||||
captionWidget,
|
||||
]),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) => SingleChildScrollView(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minHeight: constraints.maxHeight),
|
||||
child: IntrinsicHeight(
|
||||
child: Column(children: [
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||
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),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
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),
|
||||
]),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -229,3 +284,32 @@ class _CurrentNodeCard extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 桌面连接页节点胶囊([zap] 智能选择 · 节点名 · 延迟ms)。
|
||||
class _NodePill extends StatelessWidget {
|
||||
const _NodePill({required this.t, required this.node, required this.smart});
|
||||
final AppText t;
|
||||
final Node node;
|
||||
final bool smart;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
final label = smart
|
||||
? '${t.smartSelect} · ${node.localizedName(t.lang)} · ${node.ping}ms'
|
||||
: '${node.localizedName(t.lang)} · ${node.ping}ms';
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: c.surface,
|
||||
border: Border.all(color: c.border),
|
||||
borderRadius: BorderRadius.circular(PangolinRadius.full),
|
||||
),
|
||||
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
if (smart) ...[Icon(PangolinIcons.zap, size: 13, color: c.accent), const SizedBox(width: 7)],
|
||||
Text(label,
|
||||
style: PangolinText.caption.copyWith(color: c.fg2, fontSize: 12.5, fontWeight: FontWeight.w600)),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
// contact_page.dart — 联系我们(desktop/tablet 一级页)
|
||||
//
|
||||
// 对照 ui_kits/desktop/dapp.jsx DContactView:渠道卡列表 + 服务时间。
|
||||
// 演示渠道占位见 design/CLAUDE.md §8。
|
||||
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/pangolin_icons.dart';
|
||||
|
||||
class ContactPage extends ConsumerWidget {
|
||||
const ContactPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final c = context.pangolin;
|
||||
final t = ref.watch(appTextProvider);
|
||||
|
||||
final channels = <({IconData icon, String name, String sub, bool accent})>[
|
||||
(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 SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(32, 16, 32, 24),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 560),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
|
||||
Text(t.contactIntro, style: PangolinText.sm.copyWith(color: c.fg2)),
|
||||
const SizedBox(height: 16),
|
||||
for (final ch in channels) ...[
|
||||
_ChannelCard(channel: ch),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
const SizedBox(height: 6),
|
||||
_HoursCard(t: t),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChannelCard extends StatelessWidget {
|
||||
const _ChannelCard({required this.channel});
|
||||
final ({IconData icon, String name, String sub, bool accent}) channel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: c.surface,
|
||||
border: Border.all(color: c.border),
|
||||
borderRadius: BorderRadius.circular(PangolinRadius.lg),
|
||||
boxShadow: PangolinShadow.sm,
|
||||
),
|
||||
child: Row(children: [
|
||||
Container(
|
||||
width: 38,
|
||||
height: 38,
|
||||
decoration: BoxDecoration(
|
||||
color: channel.accent ? c.accentSubtle : c.bgSubtle,
|
||||
borderRadius: BorderRadius.circular(PangolinRadius.md),
|
||||
),
|
||||
child: Icon(channel.icon, size: 18, color: channel.accent ? c.accent : c.fg2),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(channel.name, style: PangolinText.body.copyWith(color: c.fg1, fontWeight: FontWeight.w600)),
|
||||
Text(channel.sub, style: PangolinText.caption.copyWith(color: c.fg3)),
|
||||
]),
|
||||
),
|
||||
Icon(PangolinIcons.chevronRight, size: 18, color: c.fg3),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HoursCard extends StatelessWidget {
|
||||
const _HoursCard({required this.t});
|
||||
final AppText t;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: c.bgSubtle,
|
||||
borderRadius: BorderRadius.circular(PangolinRadius.lg),
|
||||
),
|
||||
child: Row(children: [
|
||||
Icon(PangolinIcons.clock, size: 16, color: c.accent),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(t.contactHoursTitle, style: PangolinText.sm.copyWith(color: c.fg2, fontWeight: FontWeight.w600)),
|
||||
Text(t.contactHours, style: PangolinText.caption.copyWith(color: c.fg3)),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,7 @@ class _NodesPageState extends ConsumerState<NodesPage> {
|
||||
final c = context.pangolin;
|
||||
final t = ref.watch(appTextProvider);
|
||||
final selected = ref.watch(selectedNodeCodeProvider);
|
||||
final nodes = _filtered(ref.watch(nodesProvider));
|
||||
final nodes = _filtered(ref.watch(nodesProvider).valueOrNull ?? kDemoNodes);
|
||||
final smart = selected == kSmartNodeCode;
|
||||
|
||||
final smartCard = SmartSelectCard(t: t, selected: smart, onTap: () => _pick(kSmartNodeCode));
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
// settings_page.dart — 设置(desktop 一级页)
|
||||
//
|
||||
// 对照 ui_kits/desktop/dapp.jsx DSettings(精简):兑换入口 + 语言段控 +
|
||||
// 深色外观 + 协议 + 版本。开关组(开机自启/智能分流/Kill-switch)待 l10n 补齐后加。
|
||||
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/pangolin_icons.dart';
|
||||
|
||||
class SettingsPage extends ConsumerWidget {
|
||||
const SettingsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final c = context.pangolin;
|
||||
final t = ref.watch(appTextProvider);
|
||||
final lang = ref.watch(localeProvider);
|
||||
final mode = ref.watch(themeModeProvider);
|
||||
final isDark = mode == ThemeMode.dark;
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(32, 16, 32, 24),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 560),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
|
||||
// 兑换入口
|
||||
_Card(children: [
|
||||
_Row(title: t.redeemEntry, sub: t.proMember, last: true,
|
||||
right: Icon(PangolinIcons.chevronRight, size: 18, color: c.fg3), onTap: () {}),
|
||||
]),
|
||||
const SizedBox(height: 16),
|
||||
// 配置组
|
||||
_Card(children: [
|
||||
_Row(title: t.language, right: _LangSwitch(lang: lang, onPick: (l) => ref.read(localeProvider.notifier).state = l)),
|
||||
_Row(
|
||||
title: t.darkAppearance,
|
||||
sub: isDark ? t.stateOn : t.followLight,
|
||||
right: Switch(
|
||||
value: isDark,
|
||||
activeThumbColor: c.accent,
|
||||
onChanged: (v) => ref.read(themeModeProvider.notifier).state = v ? ThemeMode.dark : ThemeMode.light,
|
||||
),
|
||||
),
|
||||
_Row(title: t.protocol, right: Text('WireGuard', style: PangolinText.mono.copyWith(fontSize: 13, color: c.fg3))),
|
||||
_Row(title: 'Version', last: true, right: Text('v1.0.0', style: PangolinText.mono.copyWith(fontSize: 13, color: c.fg3))),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
border: Border.all(color: c.border),
|
||||
borderRadius: BorderRadius.circular(PangolinRadius.lg),
|
||||
boxShadow: PangolinShadow.sm,
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(children: children),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Row extends StatelessWidget {
|
||||
const _Row({required this.title, this.sub, required this.right, this.last = false, this.onTap});
|
||||
final String title;
|
||||
final String? sub;
|
||||
final Widget right;
|
||||
final bool last;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
border: last ? null : Border(bottom: BorderSide(color: c.border)),
|
||||
),
|
||||
child: Row(children: [
|
||||
Expanded(
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(title, style: PangolinText.body.copyWith(color: c.fg1, fontSize: 14, fontWeight: FontWeight.w500)),
|
||||
if (sub != null) Text(sub!, style: PangolinText.caption.copyWith(color: c.fg3)),
|
||||
]),
|
||||
),
|
||||
right,
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LangSwitch extends StatelessWidget {
|
||||
const _LangSwitch({required this.lang, required this.onPick});
|
||||
final AppLang lang;
|
||||
final ValueChanged<AppLang> onPick;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
Widget seg(AppLang v, String label) {
|
||||
final on = lang == v;
|
||||
return GestureDetector(
|
||||
onTap: () => onPick(v),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: on ? c.accent : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(PangolinRadius.full),
|
||||
),
|
||||
child: Text(label,
|
||||
style: PangolinText.caption.copyWith(
|
||||
color: on ? c.fgOnAccent : c.fg3, fontWeight: FontWeight.w700, fontSize: 12)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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, '中文'), seg(AppLang.en, 'EN')]),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user