diff --git a/client/lib/widgets/account_screens.dart b/client/lib/widgets/account_screens.dart index db444f7..acfb74a 100644 --- a/client/lib/widgets/account_screens.dart +++ b/client/lib/widgets/account_screens.dart @@ -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( - 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 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 _confirm(BuildContext context, PangolinScheme c, diff --git a/client/lib/widgets/adaptive_menu.dart b/client/lib/widgets/adaptive_menu.dart new file mode 100644 index 0000000..678389d --- /dev/null +++ b/client/lib/widgets/adaptive_menu.dart @@ -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 items; + final IconData icon; + final Color? iconColor; + final double iconSize; + final double menuWidth; + final double itemHeight; + + @override + State createState() => _AdaptiveMenuButtonState(); +} + +class _AdaptiveMenuButtonState extends State { + 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 items; + final double itemHeight; + final bool fromTop; // 向下弹时从顶部展开,向上弹时从底部展开 + final ValueChanged onPick; + + @override + Widget build(BuildContext context) { + final c = scheme; + return TweenAnimationBuilder( + 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), + ], + ), + ), + ); + } +} diff --git a/client/test/widget/devices_screen_test.dart b/client/test/widget/devices_screen_test.dart index 97541de..52f691d 100644 --- a/client/test/widget/devices_screen_test.dart +++ b/client/test/widget/devices_screen_test.dart @@ -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).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), findsNWidgets(2)); - await tester.tap(find.byType(PopupMenuButton).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).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).last); + await tester.tap(find.byType(AdaptiveMenuButton).last); await tester.pumpAndSettle(); await tester.tap(find.text(t.devRename)); await tester.pumpAndSettle();