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,
+202
View File
@@ -0,0 +1,202 @@
// adaptive_menu.dart — 自适应方向的弹出菜单(通用组件)。
//
// 行为:点触发按钮后,根据按钮在屏幕的位置自动决定菜单**向下**还是**向上**弹
// (下方空间够就向下,不够就向上;都不够取较大一侧),且**始终留间隙、不遮挡触发
// 按钮本身**。水平方向菜单右缘对齐按钮右缘并夹在屏内。点菜单外区域关闭。
//
// 用 Overlay 自绘(而非 PopupMenuButton——后者不保证翻转、可能盖住按钮)。
import 'package:flutter/material.dart';
import '../pangolin_theme.dart';
/// 一个菜单项。danger=true 用危险色(如删除)。
class AdaptiveMenuItem {
const AdaptiveMenuItem({
required this.icon,
required this.label,
required this.onSelected,
this.danger = false,
});
final IconData icon;
final String label;
final VoidCallback onSelected;
final bool danger;
}
/// 触发按钮(图标)+ 自适应弹出菜单。
class AdaptiveMenuButton extends StatefulWidget {
const AdaptiveMenuButton({
super.key,
required this.items,
required this.icon,
this.iconColor,
this.iconSize = 18,
this.menuWidth = 208,
this.itemHeight = 44,
});
final List<AdaptiveMenuItem> items;
final IconData icon;
final Color? iconColor;
final double iconSize;
final double menuWidth;
final double itemHeight;
@override
State<AdaptiveMenuButton> createState() => _AdaptiveMenuButtonState();
}
class _AdaptiveMenuButtonState extends State<AdaptiveMenuButton> {
final _anchorKey = GlobalKey();
OverlayEntry? _entry;
@override
void dispose() {
_close();
super.dispose();
}
void _close() {
_entry?.remove();
_entry = null;
}
void _open() {
if (widget.items.isEmpty) return;
final box = _anchorKey.currentContext?.findRenderObject() as RenderBox?;
final overlay = Overlay.of(context);
final overlayBox = overlay.context.findRenderObject() as RenderBox?;
if (box == null || overlayBox == null) return;
final topLeft = box.localToGlobal(Offset.zero, ancestor: overlayBox);
final size = box.size;
final screen = overlayBox.size;
final safe = MediaQuery.of(context).padding;
const gap = 6.0;
final menuH = widget.items.length * widget.itemHeight + 10;
final triggerTop = topLeft.dy;
final triggerBottom = topLeft.dy + size.height;
final spaceBelow = screen.height - safe.bottom - 8 - triggerBottom;
final spaceAbove = triggerTop - safe.top - 8;
// 下方放得下就向下;否则若下方比上方大也向下;否则向上。
final openDown = spaceBelow >= menuH + gap || spaceBelow >= spaceAbove;
// 水平:菜单右缘对齐按钮右缘,夹在屏内。
final maxLeft = (screen.width - widget.menuWidth - 8).clamp(8.0, double.infinity);
final left = (topLeft.dx + size.width - widget.menuWidth).clamp(8.0, maxLeft);
// 垂直:向下→按钮底+gap(不遮挡);向上→按钮顶-gap-菜单高(不遮挡)。
final top = openDown
? triggerBottom + gap
: (triggerTop - gap - menuH).clamp(safe.top + 8, double.infinity);
final c = context.pangolin;
_entry = OverlayEntry(
builder: (_) => Stack(children: [
Positioned.fill(
child: GestureDetector(behavior: HitTestBehavior.translucent, onTap: _close),
),
Positioned(
left: left,
top: top,
width: widget.menuWidth,
child: _MenuCard(
scheme: c,
items: widget.items,
itemHeight: widget.itemHeight,
fromTop: openDown,
onPick: (it) {
_close();
it.onSelected();
},
),
),
]),
);
overlay.insert(_entry!);
}
@override
Widget build(BuildContext context) {
final c = context.pangolin;
return InkResponse(
key: _anchorKey,
radius: 22,
onTap: _open,
child: Padding(
padding: const EdgeInsets.all(6),
child: Icon(widget.icon, size: widget.iconSize, color: widget.iconColor ?? c.fg3),
),
);
}
}
class _MenuCard extends StatelessWidget {
const _MenuCard({
required this.scheme,
required this.items,
required this.itemHeight,
required this.fromTop,
required this.onPick,
});
final PangolinScheme scheme;
final List<AdaptiveMenuItem> items;
final double itemHeight;
final bool fromTop; // 向下弹时从顶部展开,向上弹时从底部展开
final ValueChanged<AdaptiveMenuItem> onPick;
@override
Widget build(BuildContext context) {
final c = scheme;
return TweenAnimationBuilder<double>(
duration: const Duration(milliseconds: 130),
curve: Curves.easeOutCubic,
tween: Tween(begin: 0, end: 1),
builder: (_, v, child) => Opacity(
opacity: v.clamp(0.0, 1.0),
child: Transform.scale(
scale: 0.96 + 0.04 * v,
alignment: fromTop ? Alignment.topRight : Alignment.bottomRight,
child: child,
),
),
child: Material(
color: c.surface,
elevation: 6,
shadowColor: Colors.black26,
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(PangolinRadius.md),
side: BorderSide(color: c.border),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 5),
for (final it in items)
InkWell(
onTap: () => onPick(it),
child: SizedBox(
height: itemHeight,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14),
child: Row(children: [
Icon(it.icon, size: 16, color: it.danger ? c.danger : c.fg2),
const SizedBox(width: 11),
Text(it.label,
style: PangolinText.sm.copyWith(color: it.danger ? c.danger : c.fg1)),
]),
),
),
),
const SizedBox(height: 5),
],
),
),
);
}
}
+6 -5
View File
@@ -14,6 +14,7 @@ import 'package:pangolin_vpn/services/token_store.dart';
import 'package:pangolin_vpn/state/account_providers.dart';
import 'package:pangolin_vpn/state/auth_provider.dart';
import 'package:pangolin_vpn/widgets/account_screens.dart';
import 'package:pangolin_vpn/widgets/adaptive_menu.dart';
import '../helpers/harness.dart';
@@ -95,7 +96,7 @@ void main() {
testWidgets('本机 ⋯ 菜单只有「重命名」,无强制退出/清除', (tester) async {
await pump(tester);
// 第一个菜单 = 本机(MacBook Pro)。
await tester.tap(find.byType(PopupMenuButton<String>).first);
await tester.tap(find.byType(AdaptiveMenuButton).first);
await tester.pumpAndSettle();
expect(find.text(t.devRename), findsOneWidget);
expect(find.text(t.devForceLogout), findsNothing);
@@ -105,8 +106,8 @@ void main() {
testWidgets('非本机行 ⋯ 菜单 → 强制退出 → 确认 → 调 logout 接口', (tester) async {
await pump(tester);
// 本机+手机各一个菜单;手机(最后一个)才有强制退出/清除。
expect(find.byType(PopupMenuButton<String>), findsNWidgets(2));
await tester.tap(find.byType(PopupMenuButton<String>).last);
expect(find.byType(AdaptiveMenuButton), findsNWidgets(2));
await tester.tap(find.byType(AdaptiveMenuButton).last);
await tester.pumpAndSettle();
expect(find.text(t.devForceLogout), findsOneWidget);
expect(find.text(t.devClearLogin), findsOneWidget);
@@ -124,7 +125,7 @@ void main() {
testWidgets('清除登录信息 → 确认 → 调 DELETE 接口', (tester) async {
await pump(tester);
await tester.tap(find.byType(PopupMenuButton<String>).last);
await tester.tap(find.byType(AdaptiveMenuButton).last);
await tester.pumpAndSettle();
await tester.tap(find.text(t.devClearLogin));
await tester.pumpAndSettle();
@@ -136,7 +137,7 @@ void main() {
testWidgets('重命名 → 输入新名 → 保存 → 调 rename 接口', (tester) async {
await pump(tester);
await tester.tap(find.byType(PopupMenuButton<String>).last);
await tester.tap(find.byType(AdaptiveMenuButton).last);
await tester.pumpAndSettle();
await tester.tap(find.text(t.devRename));
await tester.pumpAndSettle();