196 lines
7.8 KiB
Dart
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,
|
|
};
|
|
}
|