d0d39c93f6
设备列表对离线设备显示「最后登录 X 前」,取的是 last_login(会话创建那一刻)。一直在线 的设备断开后会错显成登录时间(如 13 小时前),与实际「最后活跃」不符。改用 last_seen (连接/用量上报/~15s 会话轮询都会刷新),标签相应改为「最后在线」/「Last online」。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
543 lines
24 KiB
Dart
543 lines
24 KiB
Dart
// account_screens.dart — 账户子页(套餐选择 / 设备管理 / 兑换 & 购买 / 联系我们)
|
||
//
|
||
// 文案全部经 AppText(l10n,单显)。套餐数字以 design/CLAUDE.md §7 为准。
|
||
// App 内无任何支付表单——购买仅引导至外部渠道。
|
||
import 'dart:async';
|
||
|
||
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 '../screens/settings_page.dart';
|
||
import '../services/device_identity.dart';
|
||
import 'adaptive_menu.dart';
|
||
import 'pangolin_field.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 顶栏提供),否则整页 Scaffold(mobile/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),
|
||
]),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// ── 设置(移动端从「我的」下钻)── 复用桌面 SettingsPage 内容,套移动返回栏。
|
||
class SettingsScreen extends StatelessWidget {
|
||
const SettingsScreen({super.key, required this.t, this.onBack});
|
||
final AppText t;
|
||
final VoidCallback? onBack;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return _SubScaffold(title: t.settingsTitle, onBack: onBack, child: const SettingsPage());
|
||
}
|
||
}
|
||
|
||
/// ── 套餐选择 ── (价格/额度来自 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(DevicePlatform p) => switch (p) {
|
||
DevicePlatform.ios || DevicePlatform.android => PangolinIcons.smartphone,
|
||
DevicePlatform.macos || DevicePlatform.windows || DevicePlatform.linux => PangolinIcons.laptop,
|
||
DevicePlatform.unknown => PangolinIcons.monitorSmartphone,
|
||
};
|
||
|
||
// 相对时间(最后登录);null → 从未登录。
|
||
String _rel(DateTime? ts) {
|
||
final zh = t.lang == AppLang.zh;
|
||
if (ts == null) return t.devNeverLogin;
|
||
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);
|
||
final localId = ref.watch(localDeviceIdProvider).valueOrNull ?? '';
|
||
|
||
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(context, ref, c, devices[i], devices[i].uuid == localId, i < devices.length - 1),
|
||
]),
|
||
),
|
||
]),
|
||
);
|
||
|
||
return _SubScaffold(
|
||
title: t.myDevices, onBack: onBack, embedded: embedded, child: _DevicesAutoRefresh(child: content()));
|
||
}
|
||
|
||
Widget _row(BuildContext context, WidgetRef ref, PangolinScheme c, Device d, bool isLocal, bool divider) {
|
||
final name = d.name.isNotEmpty ? d.name : d.platformKind.label;
|
||
final subParts = <String>[d.platformKind.label];
|
||
if (d.clientVersion.isNotEmpty) subParts.add(d.clientVersion);
|
||
// 离线设备显示「最后在线」——用 last_seen(连接/用量/~15s 会话轮询都会刷新),
|
||
// 而非 last_login(仅登录那一刻);否则一直在线的设备会错显成登录时间(如 13 小时前)。
|
||
if (!d.online) subParts.add('${t.devLastOnline} ${_rel(d.lastSeen)}');
|
||
|
||
return Container(
|
||
decoration: BoxDecoration(border: divider ? Border(bottom: BorderSide(color: c.border)) : null),
|
||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13),
|
||
child: Row(children: [
|
||
Container(width: 38, height: 38, decoration: BoxDecoration(color: c.accentSubtle, borderRadius: BorderRadius.circular(PangolinRadius.md)),
|
||
child: Icon(_icon(d.platformKind), size: 19, color: c.accent)),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [
|
||
Row(children: [
|
||
Flexible(child: Text(name, overflow: TextOverflow.ellipsis, style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w600, fontSize: 14.5))),
|
||
if (isLocal) ...[const SizedBox(width: 7), _badge(c, t.thisDevice)],
|
||
]),
|
||
const SizedBox(height: 2),
|
||
Text(subParts.join(' · '), overflow: TextOverflow.ellipsis, style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)),
|
||
]),
|
||
),
|
||
const SizedBox(width: 8),
|
||
_statusDot(c, d.online),
|
||
_menu(context, ref, c, d, isLocal),
|
||
]),
|
||
);
|
||
}
|
||
|
||
Widget _badge(PangolinScheme c, String text) => Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 1),
|
||
decoration: BoxDecoration(color: c.accentSubtle, borderRadius: BorderRadius.circular(6), border: Border.all(color: c.accentBorder)),
|
||
child: Text(text, style: PangolinText.caption.copyWith(color: c.accent, fontWeight: FontWeight.w700, fontSize: 10)),
|
||
);
|
||
|
||
Widget _statusDot(PangolinScheme c, bool online) {
|
||
final col = online ? c.success : c.fg3;
|
||
return Row(mainAxisSize: MainAxisSize.min, children: [
|
||
Container(
|
||
width: 7, height: 7,
|
||
decoration: BoxDecoration(
|
||
color: col, shape: BoxShape.circle,
|
||
boxShadow: online ? [BoxShadow(color: c.success.withValues(alpha: 0.25), spreadRadius: 3)] : null,
|
||
),
|
||
),
|
||
const SizedBox(width: 5),
|
||
Text(online ? t.devOnline : t.devOffline, style: PangolinText.caption.copyWith(color: col, fontWeight: FontWeight.w600, fontSize: 12)),
|
||
]);
|
||
}
|
||
|
||
// 本机:只给「重命名」(自我踢下线/清除没意义);其他设备:重命名+强制退出+清除。
|
||
// 用 AdaptiveMenuButton:菜单按屏幕位置自动上/下弹、不遮挡 ⋯ 按钮。
|
||
Widget _menu(BuildContext context, WidgetRef ref, PangolinScheme c, Device d, bool isLocal) {
|
||
Future<void> confirmAction({required bool isClear}) async {
|
||
final label = isClear ? t.devClearLogin : t.devForceLogout;
|
||
final shown = d.name.isNotEmpty ? d.name : d.platformKind.label;
|
||
final ok = await _confirm(context, c,
|
||
title: '$label「$shown」',
|
||
body: isClear ? t.devClearLoginConfirm : t.devForceLogoutConfirm,
|
||
action: label);
|
||
if (!ok) return;
|
||
final n = ref.read(devicesProvider.notifier);
|
||
if (isClear) {
|
||
await n.remove(d.uuid);
|
||
} else {
|
||
await n.forceLogout(d.uuid);
|
||
}
|
||
}
|
||
|
||
return AdaptiveMenuButton(
|
||
icon: PangolinIcons.moreVertical,
|
||
iconColor: c.fg3,
|
||
items: [
|
||
AdaptiveMenuItem(
|
||
icon: PangolinIcons.edit,
|
||
label: t.devRename,
|
||
onSelected: () async {
|
||
final newName = await _renameDialog(context, c, d);
|
||
if (newName != null && newName.trim().isNotEmpty) {
|
||
await ref.read(devicesProvider.notifier).rename(d.uuid, newName.trim());
|
||
}
|
||
},
|
||
),
|
||
if (!isLocal) ...[
|
||
AdaptiveMenuItem(icon: PangolinIcons.logOut, label: t.devForceLogout, onSelected: () => confirmAction(isClear: false)),
|
||
AdaptiveMenuItem(icon: PangolinIcons.trash, label: t.devClearLogin, danger: true, onSelected: () => confirmAction(isClear: true)),
|
||
],
|
||
],
|
||
);
|
||
}
|
||
|
||
// 危险操作二次确认弹窗(走 token 配色)。
|
||
Future<bool> _confirm(BuildContext context, PangolinScheme c,
|
||
{required String title, required String body, required String action}) async {
|
||
final ok = await showDialog<bool>(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
backgroundColor: c.surface,
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(PangolinRadius.xl)),
|
||
title: Row(children: [
|
||
Container(width: 34, height: 34, decoration: BoxDecoration(color: c.dangerSubtle, shape: BoxShape.circle),
|
||
child: Icon(PangolinIcons.alertTriangle, size: 18, color: c.danger)),
|
||
const SizedBox(width: 12),
|
||
Expanded(child: Text(title, overflow: TextOverflow.ellipsis, style: PangolinText.body.copyWith(color: c.fg1, fontWeight: FontWeight.w700))),
|
||
]),
|
||
content: Text(body, style: PangolinText.sm.copyWith(color: c.fg2, height: 1.5)),
|
||
actions: [
|
||
TextButton(onPressed: () => Navigator.pop(ctx, false),
|
||
child: Text(t.devCancel, style: PangolinText.sm.copyWith(color: c.fg2, fontWeight: FontWeight.w600))),
|
||
TextButton(onPressed: () => Navigator.pop(ctx, true),
|
||
child: Text(action, style: PangolinText.sm.copyWith(color: c.danger, fontWeight: FontWeight.w700))),
|
||
],
|
||
),
|
||
);
|
||
return ok ?? false;
|
||
}
|
||
|
||
// 重命名弹窗:预填当前名,返回新名(取消返回 null)。
|
||
Future<String?> _renameDialog(BuildContext context, PangolinScheme c, Device d) {
|
||
final ctl = TextEditingController(text: d.name);
|
||
return showDialog<String>(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
backgroundColor: c.surface,
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(PangolinRadius.xl)),
|
||
title: Text(t.devRename, style: PangolinText.body.copyWith(color: c.fg1, fontWeight: FontWeight.w700)),
|
||
content: TextField(
|
||
controller: ctl,
|
||
autofocus: true,
|
||
maxLength: 64,
|
||
style: PangolinText.sm.copyWith(color: c.fg1),
|
||
decoration: InputDecoration(
|
||
hintText: t.devRenameHint,
|
||
counterText: '',
|
||
hintStyle: PangolinText.sm.copyWith(color: c.fg3),
|
||
),
|
||
onSubmitted: (v) => Navigator.pop(ctx, v),
|
||
),
|
||
actions: [
|
||
TextButton(onPressed: () => Navigator.pop(ctx),
|
||
child: Text(t.devCancel, style: PangolinText.sm.copyWith(color: c.fg2, fontWeight: FontWeight.w600))),
|
||
TextButton(onPressed: () => Navigator.pop(ctx, ctl.text),
|
||
child: Text(t.devSave, style: PangolinText.sm.copyWith(color: c.accent, fontWeight: FontWeight.w700))),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// ── 兑换 & 购买 ── (兑换码走 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: PangolinFieldBox(
|
||
fill: c.bg,
|
||
child: TextField(
|
||
controller: _code,
|
||
textCapitalization: TextCapitalization.characters,
|
||
style: PangolinText.mono.copyWith(color: c.fg1, letterSpacing: 1.5),
|
||
decoration: bareInputDecoration(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),
|
||
]),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 透传式轮询包装器:在设备页期间每 15s 静默刷新设备列表(不闪 loading),
|
||
/// 让其他设备的在线/状态变化 ~15s 内可见。原样返回 child,不改布局。
|
||
class _DevicesAutoRefresh extends ConsumerStatefulWidget {
|
||
const _DevicesAutoRefresh({required this.child});
|
||
final Widget child;
|
||
@override
|
||
ConsumerState<_DevicesAutoRefresh> createState() => _DevicesAutoRefreshState();
|
||
}
|
||
|
||
class _DevicesAutoRefreshState extends ConsumerState<_DevicesAutoRefresh> {
|
||
Timer? _poll;
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_poll = Timer.periodic(const Duration(seconds: 15), (_) {
|
||
if (mounted) ref.read(devicesProvider.notifier).refresh();
|
||
});
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_poll?.cancel();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) => widget.child;
|
||
}
|