// 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 createState() => _DeviceLimitScreenState(); } class _DeviceLimitScreenState extends ConsumerState { String? _busyUuid; // 正在移除的设备 uuid Future _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 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 _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 []; // 已降到上限内 → 清除挡板放行(下一帧改 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} 天前'; } }