refactor: unify request approval menus and naming

This commit is contained in:
21pages committed 2026-11-01 15:45:13 +08:00
1 parent 417dcdf7bb
commit c43443b1a1
67 files changed
+535 -559

No files matched your search

+7 -7
View File
@@ -2330,8 +2330,8 @@ bool handleUriLink({List<String>? cmdArgs, Uri? uri, String? uriString}) {
password = args[i + 1];
i++;
break;
case '--without-password':
loginMethod = 'consent';
case '--request-approval':
loginMethod = 'request-approval';
break;
case '--switch_uuid':
switchUuid = args[i + 1];
@@ -2344,7 +2344,7 @@ bool handleUriLink({List<String>? cmdArgs, Uri? uri, String? uriString}) {
break;
}
}
if (loginMethod == 'consent') {
if (loginMethod == 'request-approval') {
password = null;
}
if (type != null && id != null) {
@@ -2486,8 +2486,8 @@ List<String>? urlLinkToCmdArgs(Uri uri) {
var queryParameters =
uri.queryParameters.map((k, v) => MapEntry(k.toLowerCase(), v));
final withoutPassword = queryParameters['without-password'] == 'true';
if (withoutPassword) {
final requestApproval = queryParameters['request-approval'] == 'true';
if (requestApproval) {
queryParameters.remove('password');
}
@@ -2501,7 +2501,7 @@ List<String>? urlLinkToCmdArgs(Uri uri) {
if (isMobile && id != null) {
final forceRelay = queryParameters["relay"] != null;
final password = queryParameters["password"];
final loginMethod = withoutPassword ? 'consent' : '';
final loginMethod = requestApproval ? 'request-approval' : '';
// Determine connection type based on command
if (command == '--file-transfer') {
@@ -2536,7 +2536,7 @@ List<String>? urlLinkToCmdArgs(Uri uri) {
var param = queryParameters;
String? password = param["password"];
if (password != null) args.addAll(['--password', password]);
if (withoutPassword) args.add('--without-password');
if (requestApproval) args.add('--request-approval');
String? switch_uuid = param["switch_uuid"];
if (switch_uuid != null) args.addAll(['--switch_uuid', switch_uuid]);
if (param["relay"] != null) args.add("--relay");
@@ -1,75 +0,0 @@
import 'package:flutter/material.dart';
import '../../desktop/widgets/material_mod_popup_menu.dart' as mod_menu;
import '../../desktop/widgets/popup_menu.dart';
class LoginMethodMenu extends MenuEntryBase<String> {
final MenuEntryButton<String> defaultEntry;
final List<MenuEntryButton<String>> methods;
LoginMethodMenu({required this.defaultEntry, required this.methods});
@override
List<mod_menu.PopupMenuEntry<String>> build(
BuildContext context, MenuConfig conf) {
return [
_LoginMethodMenuItem(
height: conf.height,
child: IntrinsicHeight(
child:
Row(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
Expanded(
child: MenuEntryButton<String>(
padding: defaultEntry.padding,
enabled: defaultEntry.enabled,
dismissOnClicked: true,
proc: defaultEntry.proc,
childBuilder: defaultEntry.childBuilder,
).build(context, conf).single),
PopupMenuChildrenItem<String>(
height: conf.height,
padding: EdgeInsets.zero,
enabled: defaultEntry.enabled,
itemBuilder: (submenuContext) => methods
.expand((entry) => MenuEntryButton<String>(
childBuilder: entry.childBuilder,
proc: entry.proc,
padding: entry.padding,
enabled: entry.enabled,
dismissOnClicked: true,
dismissCallback: () {
if (Navigator.canPop(context)) Navigator.pop(context);
},
).build(submenuContext, conf))
.toList(),
child: SizedBox(
width: 56,
child: Icon(Icons.keyboard_arrow_right, color: conf.commonColor),
),
),
])),
)
];
}
}
// PopupMenuItem adds a TextButton around its child; these actions need separate hit targets.
class _LoginMethodMenuItem extends mod_menu.PopupMenuEntry<String> {
const _LoginMethodMenuItem({required this.height, required this.child});
final Widget child;
@override
final double height;
@override
bool represents(String? value) => false;
@override
State<_LoginMethodMenuItem> createState() => _LoginMethodMenuItemState();
}
class _LoginMethodMenuItemState extends State<_LoginMethodMenuItem> {
@override
Widget build(BuildContext context) => widget.child;
}
+84 -125
View File
@@ -15,7 +15,7 @@ import '../../models/peer_model.dart';
import '../../models/platform_model.dart';
import '../../desktop/widgets/material_mod_popup_menu.dart' as mod_menu;
import '../../desktop/widgets/popup_menu.dart';
import 'login_method_menu.dart';
import 'request_approval_menu.dart';
import 'dart:math' as math;
typedef PopupMenuEntryBuilder = Future<List<mod_menu.PopupMenuEntry<String>>>
@@ -573,7 +573,36 @@ abstract class BasePeerCard extends StatelessWidget {
@protected
Future<List<MenuEntryBase<String>>> _buildMenuItems(BuildContext context);
MenuEntryBase<String> _connectCommonAction(
@protected
Future<List<MenuEntryBase<String>>> _buildConnectionMenuItems(
BuildContext context) async {
final List<MenuEntryBase<String>> menuItems = [
_connectAction(context),
_transferFileAction(context),
_viewCameraAction(context),
_terminalAction(context),
];
if (peer.platform == kPeerPlatformWindows) {
menuItems.add(_terminalRunAsAdminAction(context));
}
if (isDesktop && peer.platform != kPeerPlatformAndroid) {
menuItems.add(_tcpTunnelingAction(context));
}
if (isWindows && peer.platform == kPeerPlatformWindows) {
menuItems.add(_rdpAction(context, peer.id));
}
menuItems.add(MenuEntryDivider<String>());
menuItems.add(_requestApprovalAction(context));
// menuItems.add(await _openNewConnInOptAction(peer.id));
if (!isWeb) {
menuItems.add(await _forceAlwaysRelayAction(peer.id));
}
return menuItems;
}
MenuEntryButton<String> _connectCommonAction(
BuildContext context,
String title, {
bool isFileTransfer = false,
@@ -582,16 +611,10 @@ abstract class BasePeerCard extends StatelessWidget {
bool isRDP = false,
bool isTerminal = false,
bool isTerminalRunAsAdmin = false,
MenuEntryButton<String>? defaultEntry,
String loginMethod = '',
}) {
action(String text, String loginMethod) => MenuEntryButton<String>(
childBuilder: (TextStyle? style) => loginMethod == 'consent'
? Tooltip(
message: translate(
'Skip saved passwords and ask the remote user to accept the connection.'),
child: Text(text, style: style),
)
: Text(text, style: style),
return MenuEntryButton<String>(
childBuilder: (TextStyle? style) => Text(title, style: style),
proc: () {
if (isTerminalRunAsAdmin) {
setEnvTerminalAdmin();
@@ -611,10 +634,44 @@ abstract class BasePeerCard extends StatelessWidget {
padding: menuPadding,
dismissOnClicked: true,
);
return LoginMethodMenu(
defaultEntry: defaultEntry ?? action(title, ''),
methods: [
action(translate('Without password'), 'consent'),
}
MenuEntryBase<String> _requestApprovalAction(BuildContext context) {
MenuEntryButton<String> action(String title,
{bool isFileTransfer = false,
bool isViewCamera = false,
bool isTcpTunneling = false,
bool isRDP = false,
bool isTerminal = false,
bool isTerminalRunAsAdmin = false}) =>
_connectCommonAction(
context,
isTerminal || isTerminalRunAsAdmin
? '${translate(title)} (beta)'
: translate(title),
isFileTransfer: isFileTransfer,
isViewCamera: isViewCamera,
isTcpTunneling: isTcpTunneling,
isRDP: isRDP,
isTerminal: isTerminal,
isTerminalRunAsAdmin: isTerminalRunAsAdmin,
loginMethod: 'request-approval',
);
return RequestApprovalMenu(
title: translate('Request approval'),
padding: menuPadding,
entries: [
action('Connect'),
action('Transfer file', isFileTransfer: true),
action('View camera', isViewCamera: true),
action('Terminal', isTerminal: true),
if (peer.platform == kPeerPlatformWindows)
action('Terminal (Run as administrator)', isTerminalRunAsAdmin: true),
if (isDesktop && peer.platform != kPeerPlatformAndroid)
action('TCP tunneling', isTcpTunneling: true),
if (isWindows && peer.platform == kPeerPlatformWindows)
action('RDP', isRDP: true),
],
);
}
@@ -676,7 +733,7 @@ abstract class BasePeerCard extends StatelessWidget {
@protected
MenuEntryBase<String> _rdpAction(BuildContext context, String id) {
final action = MenuEntryButton<String>(
return MenuEntryButton<String>(
childBuilder: (TextStyle? style) => Container(
alignment: AlignmentDirectional.center,
height: CustomPopupMenuTheme.height,
@@ -710,8 +767,6 @@ abstract class BasePeerCard extends StatelessWidget {
padding: menuPadding,
dismissOnClicked: true,
);
return _connectCommonAction(context, translate('RDP'),
isRDP: true, defaultEntry: action);
}
@protected
@@ -1014,33 +1069,13 @@ class RecentPeerCard extends BasePeerCard {
@override
Future<List<MenuEntryBase<String>>> _buildMenuItems(
BuildContext context) async {
final List<MenuEntryBase<String>> menuItems = [
_connectAction(context),
_transferFileAction(context),
_viewCameraAction(context),
_terminalAction(context),
];
if (peer.platform == kPeerPlatformWindows) {
menuItems.add(_terminalRunAsAdminAction(context));
}
final List favs = (await bind.mainGetFav()).toList();
if (isDesktop && peer.platform != kPeerPlatformAndroid) {
menuItems.add(_tcpTunnelingAction(context));
}
// menuItems.add(await _openNewConnInOptAction(peer.id));
if (!isWeb) {
menuItems.add(await _forceAlwaysRelayAction(peer.id));
}
if (isWindows && peer.platform == kPeerPlatformWindows) {
menuItems.add(_rdpAction(context, peer.id));
}
final menuItems = await _buildConnectionMenuItems(context);
menuItems.add(MenuEntryDivider());
if (isWindows) {
menuItems.add(_createShortCutAction(peer.id));
}
menuItems.add(MenuEntryDivider());
if (isMobile || isDesktop || isWebDesktop) {
menuItems.add(_renameAction(peer.id));
}
@@ -1079,31 +1114,11 @@ class FavoritePeerCard extends BasePeerCard {
@override
Future<List<MenuEntryBase<String>>> _buildMenuItems(
BuildContext context) async {
final List<MenuEntryBase<String>> menuItems = [
_connectAction(context),
_transferFileAction(context),
_viewCameraAction(context),
_terminalAction(context),
];
if (peer.platform == kPeerPlatformWindows) {
menuItems.add(_terminalRunAsAdminAction(context));
}
if (isDesktop && peer.platform != kPeerPlatformAndroid) {
menuItems.add(_tcpTunnelingAction(context));
}
// menuItems.add(await _openNewConnInOptAction(peer.id));
if (!isWeb) {
menuItems.add(await _forceAlwaysRelayAction(peer.id));
}
if (isWindows && peer.platform == kPeerPlatformWindows) {
menuItems.add(_rdpAction(context, peer.id));
}
final menuItems = await _buildConnectionMenuItems(context);
menuItems.add(MenuEntryDivider());
if (isWindows) {
menuItems.add(_createShortCutAction(peer.id));
}
menuItems.add(MenuEntryDivider());
if (isMobile || isDesktop || isWebDesktop) {
menuItems.add(_renameAction(peer.id));
}
@@ -1139,29 +1154,10 @@ class DiscoveredPeerCard extends BasePeerCard {
@override
Future<List<MenuEntryBase<String>>> _buildMenuItems(
BuildContext context) async {
final List<MenuEntryBase<String>> menuItems = [
_connectAction(context),
_transferFileAction(context),
_viewCameraAction(context),
_terminalAction(context),
];
if (peer.platform == kPeerPlatformWindows) {
menuItems.add(_terminalRunAsAdminAction(context));
}
final List favs = (await bind.mainGetFav()).toList();
if (isDesktop && peer.platform != kPeerPlatformAndroid) {
menuItems.add(_tcpTunnelingAction(context));
}
// menuItems.add(await _openNewConnInOptAction(peer.id));
if (!isWeb) {
menuItems.add(await _forceAlwaysRelayAction(peer.id));
}
if (isWindows && peer.platform == kPeerPlatformWindows) {
menuItems.add(_rdpAction(context, peer.id));
}
final menuItems = await _buildConnectionMenuItems(context);
menuItems.add(MenuEntryDivider());
menuItems.add(_wolAction(peer.id));
if (isWindows) {
menuItems.add(_createShortCutAction(peer.id));
@@ -1198,32 +1194,12 @@ class AddressBookPeerCard extends BasePeerCard {
@override
Future<List<MenuEntryBase<String>>> _buildMenuItems(
BuildContext context) async {
final List<MenuEntryBase<String>> menuItems = [
_connectAction(context),
_transferFileAction(context),
_viewCameraAction(context),
_terminalAction(context),
];
if (peer.platform == kPeerPlatformWindows) {
menuItems.add(_terminalRunAsAdminAction(context));
}
if (isDesktop && peer.platform != kPeerPlatformAndroid) {
menuItems.add(_tcpTunnelingAction(context));
}
// menuItems.add(await _openNewConnInOptAction(peer.id));
if (!isWeb) {
menuItems.add(await _forceAlwaysRelayAction(peer.id));
}
if (isWindows && peer.platform == kPeerPlatformWindows) {
menuItems.add(_rdpAction(context, peer.id));
}
final menuItems = await _buildConnectionMenuItems(context);
menuItems.add(MenuEntryDivider());
if (isWindows) {
menuItems.add(_createShortCutAction(peer.id));
}
if (gFFI.abModel.current.canWrite()) {
menuItems.add(MenuEntryDivider());
if (isMobile || isDesktop || isWebDesktop) {
menuItems.add(_renameAction(peer.id));
}
@@ -1355,26 +1331,9 @@ class MyGroupPeerCard extends BasePeerCard {
@override
Future<List<MenuEntryBase<String>>> _buildMenuItems(
BuildContext context) async {
final List<MenuEntryBase<String>> menuItems = [
_connectAction(context),
_transferFileAction(context),
_viewCameraAction(context),
_terminalAction(context),
];
if (peer.platform == kPeerPlatformWindows) {
menuItems.add(_terminalRunAsAdminAction(context));
}
if (isDesktop && peer.platform != kPeerPlatformAndroid) {
menuItems.add(_tcpTunnelingAction(context));
}
// menuItems.add(await _openNewConnInOptAction(peer.id));
if (!isWeb) {
menuItems.add(await _forceAlwaysRelayAction(peer.id));
}
if (isWindows && peer.platform == kPeerPlatformWindows) {
menuItems.add(_rdpAction(context, peer.id));
final menuItems = await _buildConnectionMenuItems(context);
if (isWindows || gFFI.userModel.userName.isNotEmpty) {
menuItems.add(MenuEntryDivider());
}
if (isWindows) {
menuItems.add(_createShortCutAction(peer.id));
@@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
import '../../desktop/widgets/material_mod_popup_menu.dart' as mod_menu;
import '../../desktop/widgets/popup_menu.dart';
class RequestApprovalMenu extends MenuEntryBase<String> {
final String title;
final List<MenuEntryButton<String>> entries;
final EdgeInsets? padding;
RequestApprovalMenu(
{required this.title, required this.entries, this.padding});
@override
List<mod_menu.PopupMenuEntry<String>> build(
BuildContext context, MenuConfig conf) {
final theme = Theme.of(context);
final buttonPadding =
(TextButtonTheme.of(context).style?.padding?.resolve({}) ??
ButtonStyleButton.scaledPadding(
theme.useMaterial3
? const EdgeInsets.symmetric(horizontal: 12, vertical: 8)
: const EdgeInsets.all(8),
const EdgeInsets.symmetric(horizontal: 8),
const EdgeInsets.symmetric(horizontal: 4),
MediaQuery.textScalerOf(context)
.scale(theme.textTheme.labelLarge?.fontSize ?? 14) /
14,
))
.resolve(Directionality.of(context));
final checkboxTheme = CheckboxTheme.of(context);
final checkboxWidth =
((checkboxTheme.materialTapTargetSize ?? theme.materialTapTargetSize) ==
MaterialTapTargetSize.padded
? kMinInteractiveDimension
: kMinInteractiveDimension - 8) +
(checkboxTheme.visualDensity ?? theme.visualDensity)
.baseSizeAdjustment
.dx;
final arrowInset = (checkboxWidth - (IconTheme.of(context).size ?? 24)) / 2;
return MenuEntrySubMenu<String>(
text: title,
dismissParentOnCanceled: true,
// Normal entries contain two TextButtons; the submenu adds a leading gap.
padding: (padding ?? EdgeInsets.zero)
.add(EdgeInsets.only(
left: buttonPadding.left * 2,
right: buttonPadding.right * 2 + arrowInset))
.subtract(
const EdgeInsetsDirectional.only(start: MenuConfig.midPadding))
.resolve(Directionality.of(context)),
entries: entries
.map((entry) => MenuEntryButton<String>(
childBuilder: entry.childBuilder,
proc: entry.proc,
padding: entry.padding,
enabled: entry.enabled,
dismissOnClicked: true,
dismissCallback: () {
if (Navigator.canPop(context)) Navigator.pop(context);
},
))
.toList(),
).build(context, conf);
}
}
+38 -6
View File
@@ -18,6 +18,7 @@ import '../../common.dart';
import '../../common/formatter/id_formatter.dart';
import '../../common/widgets/peer_tab_page.dart';
import '../../common/widgets/autocomplete.dart';
import '../../common/widgets/request_approval_menu.dart';
import '../../models/platform_model.dart';
import '../../desktop/widgets/material_mod_popup_menu.dart' as mod_menu;
@@ -340,6 +341,37 @@ class _ConnectionPageState extends State<ConnectionPage>
isTcpTunneling: isTcpTunneling);
}
RequestApprovalMenu _requestApprovalMenu(BuildContext context) {
MenuEntryButton<String> action(String title,
{bool isFileTransfer = false,
bool isViewCamera = false,
bool isTerminal = false,
bool isTcpTunneling = false}) =>
MenuEntryButton<String>(
childBuilder: (style) => Text(translate(title), style: style),
proc: () => connect(context, _idController.id,
loginMethod: 'request-approval',
isFileTransfer: isFileTransfer,
isViewCamera: isViewCamera,
isTerminal: isTerminal,
isTcpTunneling: isTcpTunneling),
padding: EdgeInsets.symmetric(horizontal: kDesktopMenuPadding.left),
dismissOnClicked: true,
);
return RequestApprovalMenu(
title: translate('Request approval'),
padding: EdgeInsets.symmetric(horizontal: kDesktopMenuPadding.left),
entries: [
action('Connect'),
action('Transfer file', isFileTransfer: true),
action('View camera', isViewCamera: true),
action('${translate('Terminal')} (beta)', isTerminal: true),
if (isDesktop) action('TCP tunneling', isTcpTunneling: true),
],
);
}
/// UI for the remote ID TextField.
/// Search for a peer.
Widget _buildRemoteIDTextField(BuildContext context) {
@@ -558,11 +590,6 @@ class _ConnectionPageState extends State<ConnectionPage>
context: context,
position: RelativeRect.fromLTRB(x, y, x, y),
items: [
(
'Connect without password',
() => connect(context, _idController.id,
loginMethod: 'consent')
),
(
'Transfer file',
() => onConnect(isFileTransfer: true)
@@ -584,7 +611,8 @@ class _ConnectionPageState extends State<ConnectionPage>
() => onConnect(isTcpTunneling: true)
),
]
.map((e) => MenuEntryButton<String>(
.map<MenuEntryBase<String>>(
(e) => MenuEntryButton<String>(
childBuilder: (TextStyle? style) =>
Text(
translate(e.$1),
@@ -596,6 +624,10 @@ class _ConnectionPageState extends State<ConnectionPage>
kDesktopMenuPadding.left),
dismissOnClicked: true,
))
.followedBy([
MenuEntryDivider<String>(),
_requestApprovalMenu(context),
])
.map((e) => e.build(
context,
const MenuConfig(
+21 -4
View File
@@ -15,6 +15,7 @@ class PopupMenuChildrenItem<T> extends mod_menu.PopupMenuEntry<T> {
this.enabled,
this.textStyle,
this.onTap,
this.dismissParentOnCanceled = false,
this.position = mod_menu.PopupMenuPosition.overSide,
this.offset = Offset.zero,
required this.itemBuilder,
@@ -27,6 +28,7 @@ class PopupMenuChildrenItem<T> extends mod_menu.PopupMenuEntry<T> {
final EdgeInsets? padding;
final RxBool? enabled;
final void Function()? onTap;
final bool dismissParentOnCanceled;
final List<mod_menu.PopupMenuEntry<T>> Function(BuildContext) itemBuilder;
final Widget child;
@@ -69,6 +71,13 @@ class MyPopupMenuItemState<T, W extends PopupMenuChildrenItem<T>>
position: widget.position,
offset: widget.offset,
onSelected: handleTap,
onCanceled: widget.dismissParentOnCanceled
? () {
if (ModalRoute.of(context)?.isCurrent == true) {
Navigator.pop(context);
}
}
: null,
itemBuilder: widget.itemBuilder,
padding: EdgeInsets.zero,
child: AnimatedDefaultTextStyle(
@@ -645,11 +654,13 @@ class MenuEntrySubMenu<T> extends MenuEntryBase<T> {
final String text;
final List<MenuEntryBase<T>> entries;
final EdgeInsets? padding;
final bool dismissParentOnCanceled;
MenuEntrySubMenu({
required this.text,
required this.entries,
this.padding,
this.dismissParentOnCanceled = false,
RxBool? enabled,
}) : super(enabled: enabled);
@@ -662,6 +673,7 @@ class MenuEntrySubMenu<T> extends MenuEntryBase<T> {
enabled: super.enabled,
height: conf.height,
padding: padding,
dismissParentOnCanceled: dismissParentOnCanceled,
position: mod_menu.PopupMenuPosition.overSide,
itemBuilder: (BuildContext context) => entries
.map((entry) => entry.build(context, conf))
@@ -678,10 +690,15 @@ class MenuEntrySubMenu<T> extends MenuEntryBase<T> {
Expanded(
child: Align(
alignment: Alignment.centerRight,
child: Obx(() => Icon(
Icons.keyboard_arrow_right,
color: super.enabled!.value ? conf.commonColor : Colors.grey,
)),
child: Transform.scale(
scale: MenuConfig.iconScale,
child: Obx(() => Icon(
Icons.keyboard_arrow_right,
color: super.enabled!.value
? Theme.of(context).unselectedWidgetColor
: disabledStyle().color,
)),
),
))
]),
)
+2 -2
View File
@@ -241,8 +241,8 @@ class WebHomePage extends StatelessWidget {
password = args[i + 1];
i++;
break;
case '--without-password':
loginMethod = 'consent';
case '--request-approval':
loginMethod = 'request-approval';
break;
default:
break;
+2 -2
View File
@@ -921,9 +921,9 @@ class FfiModel with ChangeNotifier {
} else if (type == 'input-password') {
enterPasswordDialog(sessionId, dialogManager);
} else if (type == 'terminal-admin-login' ||
type == 'terminal-admin-login-consent') {
type == 'terminal-admin-login-request-approval') {
enterUserLoginDialog(sessionId, dialogManager, 'terminal-admin-login-tip',
waitForAccept: type == 'terminal-admin-login-consent');
waitForAccept: type == 'terminal-admin-login-request-approval');
} else if (type == 'terminal-admin-login-password') {
enterUserLoginAndPasswordDialog(
sessionId, dialogManager, 'terminal-admin-login-tip');
-146
View File
@@ -1,146 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_hbb/common/widgets/login_method_menu.dart';
import 'package:flutter_hbb/desktop/widgets/material_mod_popup_menu.dart'
as mod_menu;
import 'package:flutter_hbb/desktop/widgets/popup_menu.dart';
Future<void> openMenu(WidgetTester tester, List<String> actions) async {
MenuEntryButton<String> entry(String title) => MenuEntryButton<String>(
childBuilder: (style) => Text(title, style: style),
proc: () => actions.add(title),
dismissOnClicked: true,
);
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: Builder(builder: (context) {
return TextButton(
onPressed: () => mod_menu.showMenu<String>(
context: context,
position: const RelativeRect.fromLTRB(20, 20, 780, 580),
items: [
LoginMethodMenu(
defaultEntry: entry('Connect'),
methods: [entry('Without password')],
),
LoginMethodMenu(
defaultEntry: entry('View camera'),
methods: [entry('Without password')],
),
LoginMethodMenu(
defaultEntry: MenuEntryButton<String>(
childBuilder: (style) => Row(children: [
Text('RDP', style: style),
const Spacer(),
IconButton(
icon: const Icon(Icons.edit),
onPressed: () {
Navigator.pop(context);
actions.add('Edit RDP');
},
),
]),
proc: () => actions.add('RDP'),
),
methods: [entry('Without password')],
),
entry('Rename'),
]
.expand((entry) => entry.build(context,
const MenuConfig(commonColor: Colors.black, height: 20)))
.toList(),
),
child: const Text('Open menu'),
);
}),
),
));
await tester.tap(find.text('Open menu'));
await tester.pumpAndSettle();
}
void main() {
testWidgets('main rows align and clicking them connects by default',
(tester) async {
for (final title in ['Connect', 'View camera', 'RDP']) {
final actions = <String>[];
await openMenu(tester, actions);
final left = tester.getTopLeft(find.text('Rename')).dx;
for (final row in ['Connect', 'View camera', 'RDP']) {
expect(tester.getTopLeft(find.text(row)).dx, left);
}
await tester.tap(find.text(title));
await tester.pumpAndSettle();
expect(actions, [title]);
expect(find.text('Without password'), findsNothing);
expect(find.text('Rename'), findsNothing);
}
});
testWidgets(
'arrow offers connection without password and dismisses both menus',
(tester) async {
final actions = <String>[];
await openMenu(tester, actions);
await tester.tap(find.byIcon(Icons.keyboard_arrow_right).first);
await tester.pumpAndSettle();
expect(actions, isEmpty);
expect(find.text('Without password'), findsOneWidget);
expect(find.text('Enter password manually'), findsNothing);
await tester.tap(find.text('Without password'));
await tester.pumpAndSettle();
expect(actions, ['Without password']);
expect(find.text('Without password'), findsNothing);
expect(find.text('Rename'), findsNothing);
},
variant: TargetPlatformVariant({
TargetPlatform.android,
TargetPlatform.iOS,
TargetPlatform.linux,
TargetPlatform.macOS,
TargetPlatform.windows,
}));
testWidgets('RDP edit remains independent of the connection action',
(tester) async {
final actions = <String>[];
await openMenu(tester, actions);
await tester.tap(find.byIcon(Icons.edit));
await tester.pumpAndSettle();
expect(actions, ['Edit RDP']);
expect(find.text('Rename'), findsNothing);
});
testWidgets('touches around the arrow open the submenu without connecting',
(tester) async {
tester.view.physicalSize = const Size(360, 640);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
for (final corner in [
Alignment.topLeft,
Alignment.topRight,
Alignment.bottomLeft,
Alignment.bottomRight,
]) {
final actions = <String>[];
await openMenu(tester, actions);
final arrow = find.byIcon(Icons.keyboard_arrow_right).first;
final row = tester
.getRect(find.ancestor(of: arrow, matching: find.byType(Row)).first);
final target = Rect.fromCenter(
center: tester.getCenter(arrow), width: 56, height: row.height);
await tester.tapAt(corner.withinRect(target.deflate(2)));
await tester.pumpAndSettle();
expect(actions, isEmpty);
expect(find.text('Without password'), findsOneWidget);
await tester.tap(find.text('Without password'));
await tester.pumpAndSettle();
expect(actions, ['Without password']);
expect(find.text('Rename'), findsNothing);
}
},
variant:
TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS}));
}
@@ -0,0 +1,222 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_hbb/common/widgets/request_approval_menu.dart';
import 'package:flutter_hbb/desktop/widgets/material_mod_popup_menu.dart'
as mod_menu;
import 'package:flutter_hbb/desktop/widgets/popup_menu.dart';
const connectionTypes = [
'Connect',
'Transfer file',
'View camera',
'Terminal',
'Terminal (Run as administrator)',
'TCP tunneling',
'RDP',
];
Future<void> openMenu(WidgetTester tester, List<String> actions,
{ThemeData? theme, EdgeInsets? padding, bool includeRelay = false}) async {
MenuEntryButton<String> entry(String title, {bool requestApproval = false}) =>
MenuEntryButton<String>(
childBuilder: (style) => Text(title, style: style),
proc: () =>
actions.add(requestApproval ? '$title:request-approval' : title),
padding: padding,
dismissOnClicked: true,
);
await tester.pumpWidget(MaterialApp(
theme: theme,
home: Scaffold(
body: Builder(builder: (context) {
return TextButton(
onPressed: () => mod_menu.showMenu<String>(
context: context,
position: const RelativeRect.fromLTRB(20, 20, 780, 580),
items: [
entry('Connect'),
entry('Transfer file'),
entry('View camera'),
MenuEntryButton<String>(
childBuilder: (style) => Row(children: [
Text('RDP', style: style),
const Spacer(),
IconButton(
icon: const Icon(Icons.edit),
onPressed: () {
Navigator.pop(context);
actions.add('Edit RDP');
},
),
]),
proc: () => actions.add('RDP'),
padding: padding,
dismissOnClicked: true,
),
RequestApprovalMenu(
title: 'Request approval',
padding: padding,
entries: [
for (final title in connectionTypes)
entry(title, requestApproval: true),
],
),
if (includeRelay)
MenuEntrySwitchSync<String>(
switchType: SwitchType.scheckbox,
text: 'Always connect via relay',
currentValue: false,
setter: (_) async {},
padding: padding,
dismissOnClicked: true,
),
entry('Rename'),
]
.expand((entry) => entry.build(context,
const MenuConfig(commonColor: Colors.black, height: 20)))
.toList(),
),
child: const Text('Open menu'),
);
}),
),
));
await tester.pumpAndSettle();
await tester.tap(find.text('Open menu'));
await tester.pumpAndSettle();
}
void main() {
testWidgets('tapping outside the approval submenu closes both menus',
(tester) async {
final actions = <String>[];
await openMenu(tester, actions);
await tester.tap(find.text('Request approval'));
await tester.pumpAndSettle();
await tester.tapAt(const Offset(2, 2));
await tester.pumpAndSettle();
expect(actions, isEmpty);
expect(find.text('Request approval'), findsNothing);
expect(find.text('Rename'), findsNothing);
expect(find.text('Open menu'), findsOneWidget);
});
testWidgets('approval text and arrow align with normal menu items',
(tester) async {
for (final (theme, padding) in [
(ThemeData(), null),
(
ThemeData(
useMaterial3: false,
platform: TargetPlatform.windows,
visualDensity: VisualDensity.compact),
const EdgeInsets.only(left: 12, right: 3)
),
(
ThemeData(useMaterial3: false),
const EdgeInsets.symmetric(horizontal: 12)
),
(
ThemeData(
useMaterial3: false,
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 20)),
)),
null
),
]) {
await openMenu(tester, [],
theme: theme, padding: padding, includeRelay: true);
expect(tester.getTopLeft(find.text('Request approval')).dx,
tester.getTopLeft(find.text('Connect')).dx);
expect(tester.getCenter(find.byIcon(Icons.keyboard_arrow_right)).dx,
closeTo(tester.getCenter(find.byType(Checkbox)).dx, 0.01));
await tester.tap(find.text('Rename'));
await tester.pumpAndSettle();
}
});
testWidgets('normal connections and RDP editing remain direct actions',
(tester) async {
for (final title in [
'Connect',
'Transfer file',
'View camera',
'RDP',
'Edit RDP'
]) {
final actions = <String>[];
await openMenu(tester, actions);
expect(find.byIcon(Icons.keyboard_arrow_right), findsOneWidget);
final left = tester.getTopLeft(find.text('Rename')).dx;
for (final row in ['Connect', 'Transfer file', 'View camera', 'RDP']) {
expect(tester.getTopLeft(find.text(row)).dx, left);
}
await tester.tap(
title == 'Edit RDP' ? find.byIcon(Icons.edit) : find.text(title));
await tester.pumpAndSettle();
expect(actions, [title]);
expect(find.text('Request approval'), findsNothing);
expect(find.text('Rename'), findsNothing);
}
});
testWidgets(
'one approval menu offers all connection types and closes both menus',
(tester) async {
for (var i = 0; i < connectionTypes.length; i++) {
final actions = <String>[];
await openMenu(tester, actions);
await tester.tap(i.isEven
? find.text('Request approval')
: find.byIcon(Icons.keyboard_arrow_right));
await tester.pumpAndSettle();
expect(actions, isEmpty);
for (final title in connectionTypes) {
expect(find.text(title), findsWidgets);
}
await tester.tap(find.text(connectionTypes[i]).last);
await tester.pumpAndSettle();
expect(actions, ['${connectionTypes[i]}:request-approval']);
expect(find.text('Request approval'), findsNothing);
expect(find.text('Rename'), findsNothing);
}
},
variant: TargetPlatformVariant({
TargetPlatform.android,
TargetPlatform.iOS,
TargetPlatform.linux,
TargetPlatform.macOS,
TargetPlatform.windows,
}));
testWidgets('the whole approval row opens the submenu on a narrow screen',
(tester) async {
tester.view.physicalSize = const Size(360, 640);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
for (final corner in [
Alignment.topLeft,
Alignment.topRight,
Alignment.bottomLeft,
Alignment.bottomRight,
]) {
final actions = <String>[];
await openMenu(tester, actions);
final row = tester.getRect(find.byType(PopupMenuChildrenItem<String>));
await tester.tapAt(corner.withinRect(row.deflate(2)));
await tester.pumpAndSettle();
expect(actions, isEmpty);
expect(find.text('Terminal (Run as administrator)'), findsOneWidget);
await tester.tap(find.text('Connect').last);
await tester.pumpAndSettle();
expect(actions, ['Connect:request-approval']);
expect(find.text('Rename'), findsNothing);
}
},
variant:
TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS}));
}
+10 -10
View File
@@ -2,7 +2,7 @@ import 'package:flutter_hbb/common.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
test('without-password links preserve their connection type', () {
test('request-approval links preserve their connection type', () {
for (final type in [
'connect',
'play',
@@ -15,32 +15,32 @@ void main() {
]) {
expect(
urlLinkToCmdArgs(
Uri.parse('rustdesk://$type/123456789?without-password=true')),
['--$type', '123456789', '--without-password'],
Uri.parse('rustdesk://$type/123456789?request-approval=true')),
['--$type', '123456789', '--request-approval'],
);
}
expect(
urlLinkToCmdArgs(Uri.parse('rustdesk://123456789?without-password=true')),
['--connect', '123456789', '--without-password'],
urlLinkToCmdArgs(Uri.parse('rustdesk://123456789?request-approval=true')),
['--connect', '123456789', '--request-approval'],
);
});
test('omitted and empty passwords retain automatic login', () {
expect(urlLinkToCmdArgs(Uri.parse('rustdesk://123456789')),
['--connect', '123456789']);
for (final query in ['password=', 'password=&without-password=false']) {
for (final query in ['password=', 'password=&request-approval=false']) {
expect(urlLinkToCmdArgs(Uri.parse('rustdesk://123456789?$query')),
['--connect', '123456789', '--password', '']);
}
});
test('without-password ignores an explicit password in either order', () {
test('request-approval ignores an explicit password in either order', () {
for (final query in [
'password=provided&without-password=true',
'without-password=true&password=provided',
'password=provided&request-approval=true',
'request-approval=true&password=provided',
]) {
expect(urlLinkToCmdArgs(Uri.parse('rustdesk://123456789?$query')),
['--connect', '123456789', '--without-password']);
['--connect', '123456789', '--request-approval']);
}
});
}