Files
pangolin/client/lib/widgets/account_screens.dart
T

421 lines
18 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 '../services/channel_launch.dart';
import '../services/contact_channels.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 'sub_scaffold.dart';
/// ── 设置(移动端从「我的」下钻)── 复用桌面 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/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) {
if (ts == null) return t.devNeverLogin;
return t.lang.relativeTime(DateTime.now().difference(ts.toLocal()));
}
@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.loadFailedRetry, 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.noDevices,
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.pencil,
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.trash2, 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;
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)),
],
]),
),
]),
);
}
}
/// ── 联系我们 ──
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 = kContactChannels
.map((ch) => (
icon: ch.icon,
name: switch (ch.nameKey) {
'telegram' => 'Telegram',
'email' => t.contactEmail,
'website' => t.contactWebsite,
_ => ch.nameKey,
},
sub: ch.sub,
accent: ch.accent,
))
.toList();
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: () => launchChannel(sub),
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;
}