421 lines
18 KiB
Dart
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;
|
|
}
|