feat(client): 通用自适应弹出菜单(按屏幕位置上/下弹,不挡触发器)

新增 AdaptiveMenuButton:GlobalKey 量触发器矩形,据上下剩余空间决定向下/向上
弹出、右对齐裁剪到屏内,Overlay 全屏 barrier + 淡入缩放动画。「我的设备」⋯
菜单由 PopupMenuButton 换成它(重命名 + 强制退出/清除),修正小屏被遮挡问题。
devices_screen_test 同步改用 AdaptiveMenuButton 触发器断言。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-29 10:11:20 +08:00
parent 880fa32793
commit 6788501e4a
3 changed files with 244 additions and 45 deletions
+36 -40
View File
@@ -9,6 +9,7 @@ import '../l10n/app_text.dart';
import '../models/device.dart';
import '../pangolin_theme.dart';
import '../services/device_identity.dart';
import 'adaptive_menu.dart';
import '../services/auth_api.dart';
import '../state/account_providers.dart';
import 'pangolin_button.dart';
@@ -215,50 +216,45 @@ class DevicesScreen extends ConsumerWidget {
}
// 本机:只给「重命名」(自我踢下线/清除没意义);其他设备:重命名+强制退出+清除。
Widget _menu(BuildContext context, WidgetRef ref, PangolinScheme c, Device d, bool isLocal) => PopupMenuButton<String>(
icon: Icon(PangolinIcons.moreVertical, size: 18, color: c.fg3),
color: c.surface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(PangolinRadius.md), side: BorderSide(color: c.border)),
itemBuilder: (_) => [
PopupMenuItem(value: 'rename', child: Row(children: [
Icon(PangolinIcons.edit, size: 16, color: c.fg2), const SizedBox(width: 10),
Text(t.devRename, style: PangolinText.sm.copyWith(color: c.fg1)),
])),
if (!isLocal) ...[
PopupMenuItem(value: 'logout', child: Row(children: [
Icon(PangolinIcons.logOut, size: 16, color: c.fg2), const SizedBox(width: 10),
Text(t.devForceLogout, style: PangolinText.sm.copyWith(color: c.fg1)),
])),
PopupMenuItem(value: 'clear', child: Row(children: [
Icon(PangolinIcons.trash, size: 16, color: c.danger), const SizedBox(width: 10),
Text(t.devClearLogin, style: PangolinText.sm.copyWith(color: c.danger)),
])),
],
],
onSelected: (v) async {
if (v == 'rename') {
// 用 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.edit,
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());
}
return;
}
final isClear = v == 'clear';
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);
}
},
);
},
),
if (!isLocal) ...[
AdaptiveMenuItem(icon: PangolinIcons.logOut, label: t.devForceLogout, onSelected: () => confirmAction(isClear: false)),
AdaptiveMenuItem(icon: PangolinIcons.trash, label: t.devClearLogin, danger: true, onSelected: () => confirmAction(isClear: true)),
],
],
);
}
// 危险操作二次确认弹窗(走 token 配色)。
Future<bool> _confirm(BuildContext context, PangolinScheme c,