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:
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user