Files
pangolin/client/lib/screens/device_limit_screen.dart
T
wangjia 34d980d875 feat(client): 设备数超限挡板 DeviceLimitScreen(#16 前端)
登录响应带 device_limit 时,顶层路由把用户挡在「移除设备」页,降到上限内才进主界面。

- auth_api.dart:AuthTokens 解析 device_limit;新增 DeviceLimit/DeviceBrief 模型
- auth_provider.dart:deviceLimitProvider(挡板状态);auth_screen 登录后置入
- main.dart:isLoggedIn 后若 deviceLimit!=null → DeviceLimitScreen(挡 HomeShell)
- screens/device_limit_screen.dart(新):设备列表(本机标注不可删)+ 每台移除
  + 一键「移除最久未用」+ 退出登录;降到上限内自动清挡板放行(复用 devicesProvider.remove)
- l10n:deviceLimitTitle/Desc/RemoveOldest(zh/en);顺带修 devicesSub 文案 5→3 台

analyze 干净;client 全量测试 161 通过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 19:25:14 +08:00

202 lines
8.0 KiB
Dart

// device_limit_screen.dart — 设备数超上限挡板(#16)。
//
// 登录成功但账户活跃设备数超套餐上限时,顶层路由把用户挡在此页(登录已生效、token
// 已发,但先移除设备降到上限内才进主界面)。降到上限内后清除 deviceLimitProvider 放行。
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/auth_api.dart';
import '../services/device_identity.dart';
import '../state/account_providers.dart';
import '../state/auth_provider.dart';
class DeviceLimitScreen extends ConsumerStatefulWidget {
const DeviceLimitScreen({super.key, required this.t, required this.limit});
final AppText t;
final DeviceLimit limit;
@override
ConsumerState<DeviceLimitScreen> createState() => _DeviceLimitScreenState();
}
class _DeviceLimitScreenState extends ConsumerState<DeviceLimitScreen> {
String? _busyUuid; // 正在移除的设备 uuid
Future<void> _remove(String uuid) async {
setState(() => _busyUuid = uuid);
try {
await ref.read(devicesProvider.notifier).remove(uuid);
} catch (_) {
// 失败保持在本页;build 会据最新列表决定是否放行。
} finally {
if (mounted) setState(() => _busyUuid = null);
}
}
void _removeOldest(List<Device> devices, String currentId) {
final others = devices.where((d) => d.uuid != currentId).toList()
..sort((a, b) {
final ta = a.lastSeen?.millisecondsSinceEpoch ?? 0;
final tb = b.lastSeen?.millisecondsSinceEpoch ?? 0;
return ta.compareTo(tb); // 最久未用在前
});
if (others.isNotEmpty) _remove(others.first.uuid);
}
Future<void> _logout() async {
ref.read(deviceLimitProvider.notifier).state = null;
await ref.read(authProvider.notifier).logout();
}
@override
Widget build(BuildContext context) {
final t = widget.t;
final c = context.pangolin;
final max = widget.limit.maxDevices;
final currentId = ref.watch(localDeviceIdProvider).valueOrNull ?? '';
final devicesAsync = ref.watch(devicesProvider);
final devices = devicesAsync.valueOrNull ?? const <Device>[];
// 已降到上限内 → 清除挡板放行(下一帧改 provider,避免 build 内写状态)。
if (devicesAsync.hasValue && devices.length <= max) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && ref.read(deviceLimitProvider) != null) {
ref.read(deviceLimitProvider.notifier).state = null;
}
});
}
final removableCount = devices.where((d) => d.uuid != currentId).length;
return Scaffold(
backgroundColor: c.bg,
body: SafeArea(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 460),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 32),
Icon(Icons.devices_other_rounded, size: 40, color: c.accent),
const SizedBox(height: 14),
Text(t.deviceLimitTitle,
textAlign: TextAlign.center,
style: TextStyle(color: c.fg1, fontSize: 20, fontWeight: FontWeight.w700)),
const SizedBox(height: 8),
Text(t.deviceLimitDesc.replaceFirst('%s', '$max'),
textAlign: TextAlign.center,
style: TextStyle(color: c.fg2, fontSize: 14, height: 1.5)),
const SizedBox(height: 18),
if (removableCount > 0)
OutlinedButton.icon(
onPressed: _busyUuid != null ? null : () => _removeOldest(devices, currentId),
icon: const Icon(Icons.history_rounded, size: 18),
label: Text(t.deviceLimitRemoveOldest),
style: OutlinedButton.styleFrom(
foregroundColor: c.accent,
side: BorderSide(color: c.accentBorder),
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
const SizedBox(height: 14),
Expanded(
child: !devicesAsync.hasValue
? Center(child: CircularProgressIndicator(color: c.accent, strokeWidth: 2.4))
: ListView.separated(
itemCount: devices.length,
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (_, i) => _deviceTile(c, t, devices[i], currentId),
),
),
const SizedBox(height: 8),
TextButton(
onPressed: _busyUuid != null ? null : _logout,
style: TextButton.styleFrom(foregroundColor: c.fg3),
child: Text(t.signOut),
),
const SizedBox(height: 12),
],
),
),
),
),
),
);
}
Widget _deviceTile(PangolinScheme c, AppText t, Device d, String currentId) {
final isCurrent = d.uuid == currentId;
final busy = _busyUuid == d.uuid;
final sub = d.online
? t.devOnline
: (d.lastSeen != null ? '${t.devLastOnline} ${_rel(d.lastSeen!)}' : t.devOffline);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: c.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: c.border),
),
child: Row(
children: [
Icon(_platformIcon(d.platformKind), size: 22, color: c.fg2),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(d.name.isEmpty ? d.platformKind.label : d.name,
maxLines: 1, overflow: TextOverflow.ellipsis,
style: TextStyle(color: c.fg1, fontSize: 15, fontWeight: FontWeight.w600)),
const SizedBox(height: 2),
Text(sub, style: TextStyle(color: c.fg2, fontSize: 12)),
],
),
),
const SizedBox(width: 10),
if (isCurrent)
Container(
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4),
decoration: BoxDecoration(
color: c.accentSubtle,
borderRadius: BorderRadius.circular(999),
),
child: Text(t.thisDevice, style: TextStyle(color: c.accent, fontSize: 12, fontWeight: FontWeight.w600)),
)
else if (busy)
SizedBox(width: 20, height: 20, child: CircularProgressIndicator(color: c.danger, strokeWidth: 2.2))
else
TextButton(
onPressed: _busyUuid != null ? null : () => _remove(d.uuid),
style: TextButton.styleFrom(foregroundColor: c.danger),
child: Text(t.remove),
),
],
),
);
}
static IconData _platformIcon(DevicePlatform p) => switch (p) {
DevicePlatform.ios || DevicePlatform.android => Icons.smartphone_rounded,
DevicePlatform.windows || DevicePlatform.linux => Icons.computer_rounded,
DevicePlatform.macos => Icons.laptop_mac_rounded,
DevicePlatform.unknown => Icons.devices_other_rounded,
};
// 相对时间(粗粒度):x 分钟/小时/天前。
static String _rel(DateTime t) {
final d = DateTime.now().toUtc().difference(t.toUtc());
if (d.inMinutes < 1) return '刚刚';
if (d.inMinutes < 60) return '${d.inMinutes} 分钟前';
if (d.inHours < 24) return '${d.inHours} 小时前';
return '${d.inDays} 天前';
}
}