Files
pangolin/client/lib/screens/device_limit_screen.dart
T

196 lines
7.8 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';
import '../widgets/pangolin_icons.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(PangolinIcons.monitorSmartphone, 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(PangolinIcons.clock, 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} ${t.lang.relativeTime(DateTime.now().difference(d.lastSeen!.toLocal()))}'
: 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 => PangolinIcons.smartphone,
DevicePlatform.windows || DevicePlatform.linux => PangolinIcons.monitor,
DevicePlatform.macos => PangolinIcons.laptop,
DevicePlatform.unknown => PangolinIcons.monitorSmartphone,
};
}