import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hbb/common/widgets/address_book.dart'; import 'package:flutter_hbb/consts.dart'; import 'package:get/get.dart'; import '../../common.dart'; import '../../common/formatter/id_formatter.dart'; import '../../models/model.dart'; 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'; typedef PopupMenuEntryBuilder = Future>> Function(BuildContext); enum PeerUiType { grid, list } final peerCardUiType = PeerUiType.grid.obs; class _PeerCard extends StatefulWidget { final Peer peer; final Function(BuildContext, String) connect; final PopupMenuEntryBuilder popupMenuEntryBuilder; const _PeerCard( {required this.peer, required this.connect, required this.popupMenuEntryBuilder, Key? key}) : super(key: key); @override _PeerCardState createState() => _PeerCardState(); } /// State for the connection page. class _PeerCardState extends State<_PeerCard> with AutomaticKeepAliveClientMixin { var _menuPos = RelativeRect.fill; final double _cardRadius = 16; final double _borderWidth = 2; @override Widget build(BuildContext context) { super.build(context); if (isDesktop) { return _buildDesktop(); } else { return _buildMobile(); } } Widget _buildMobile() { final peer = super.widget.peer; final name = '${peer.username}${peer.username.isNotEmpty && peer.hostname.isNotEmpty ? '@' : ''}${peer.hostname}'; return Card( margin: EdgeInsets.symmetric(horizontal: 2), child: GestureDetector( onTap: !isWebDesktop ? () => connect(context, peer.id) : null, onDoubleTap: isWebDesktop ? () => connect(context, peer.id) : null, onLongPressStart: (details) { final x = details.globalPosition.dx; final y = details.globalPosition.dy; _menuPos = RelativeRect.fromLTRB(x, y, x, y); _showPeerMenu(peer.id); }, child: Container( padding: EdgeInsets.only(left: 12, top: 8, bottom: 8), child: Row( children: [ Container( width: 50, height: 50, decoration: BoxDecoration( color: str2color('${peer.id}${peer.platform}', 0x7f), borderRadius: BorderRadius.circular(4), ), padding: const EdgeInsets.all(6), child: getPlatformImage(peer.platform)), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ getOnline(4, peer.online), Text(peer.alias.isEmpty ? formatID(peer.id) : peer.alias) ]), Text(name) ], ).paddingOnly(left: 8.0), ), InkWell( child: const Padding( padding: EdgeInsets.all(12), child: Icon(Icons.more_vert)), onTapDown: (e) { final x = e.globalPosition.dx; final y = e.globalPosition.dy; _menuPos = RelativeRect.fromLTRB(x, y, x, y); }, onTap: () { _showPeerMenu(peer.id); }) ], ), ))); } Widget _buildDesktop() { final peer = super.widget.peer; var deco = Rx(BoxDecoration( border: Border.all(color: Colors.transparent, width: _borderWidth), borderRadius: peerCardUiType.value == PeerUiType.grid ? BorderRadius.circular(_cardRadius) : null)); return MouseRegion( onEnter: (evt) { deco.value = BoxDecoration( border: Border.all( color: Theme.of(context).colorScheme.primary, width: _borderWidth), borderRadius: peerCardUiType.value == PeerUiType.grid ? BorderRadius.circular(_cardRadius) : null); }, onExit: (evt) { deco.value = BoxDecoration( border: Border.all(color: Colors.transparent, width: _borderWidth), borderRadius: peerCardUiType.value == PeerUiType.grid ? BorderRadius.circular(_cardRadius) : null); }, child: GestureDetector( onDoubleTap: () => widget.connect(context, peer.id), child: Obx(() => peerCardUiType.value == PeerUiType.grid ? _buildPeerCard(context, peer, deco) : _buildPeerTile(context, peer, deco))), ); } Widget _buildPeerTile( BuildContext context, Peer peer, Rx deco) { final name = '${peer.username}${peer.username.isNotEmpty && peer.hostname.isNotEmpty ? '@' : ''}${peer.hostname}'; final greyStyle = TextStyle( fontSize: 11, color: Theme.of(context).textTheme.titleLarge?.color?.withOpacity(0.6)); final alias = bind.mainGetPeerOptionSync(id: peer.id, key: 'alias'); return Obx( () => Container( foregroundDecoration: deco.value, child: Row( mainAxisSize: MainAxisSize.max, children: [ Container( decoration: BoxDecoration( color: str2color('${peer.id}${peer.platform}', 0x7f), ), alignment: Alignment.center, width: 42, child: getPlatformImage(peer.platform, size: 30).paddingAll(6), ), Expanded( child: Container( decoration: BoxDecoration(color: Theme.of(context).backgroundColor), child: Row( children: [ Expanded( child: Column( children: [ Row(children: [ getOnline(8, peer.online), Expanded( child: Text( alias.isEmpty ? formatID(peer.id) : alias, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.titleSmall, )), ]).marginOnly(bottom: 2), Align( alignment: Alignment.centerLeft, child: Text( name, style: greyStyle, textAlign: TextAlign.start, overflow: TextOverflow.ellipsis, ), ), ], ).marginOnly(top: 2), ), _actionMore(peer), ], ).paddingOnly(left: 10.0, top: 3.0), ), ) ], ), ), ); } Widget _buildPeerCard( BuildContext context, Peer peer, Rx deco) { final name = '${peer.username}${peer.username.isNotEmpty && peer.hostname.isNotEmpty ? '@' : ''}${peer.hostname}'; return Card( color: Colors.transparent, elevation: 0, margin: EdgeInsets.zero, child: Obx( () => Container( foregroundDecoration: deco.value, child: ClipRRect( borderRadius: BorderRadius.circular(_cardRadius - _borderWidth), child: Column( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ Expanded( child: Container( color: str2color('${peer.id}${peer.platform}', 0x7f), child: Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.center, children: [ Container( padding: const EdgeInsets.all(6), child: getPlatformImage(peer.platform, size: 60), ), Row( children: [ Expanded( child: Tooltip( message: name, waitDuration: const Duration(seconds: 1), child: Text( name, style: const TextStyle( color: Colors.white70, fontSize: 12), textAlign: TextAlign.center, overflow: TextOverflow.ellipsis, ), ), ), ], ), ], ).paddingAll(4.0), ), ], ), ), ), Container( color: Theme.of(context).backgroundColor, child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded( child: Row(children: [ getOnline(8, peer.online), Expanded( child: Text( peer.alias.isEmpty ? formatID(peer.id) : peer.alias, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.titleSmall, )), ]).paddingSymmetric(vertical: 8)), _actionMore(peer), ], ).paddingSymmetric(horizontal: 12.0), ) ], ), ), ), ), ); } Widget _actionMore(Peer peer) => Listener( onPointerDown: (e) { final x = e.position.dx; final y = e.position.dy; _menuPos = RelativeRect.fromLTRB(x, y, x, y); }, onPointerUp: (_) => _showPeerMenu(peer.id), child: ActionMore()); /// Show the peer menu and handle user's choice. /// User might remove the peer or send a file to the peer. void _showPeerMenu(String id) async { await mod_menu.showMenu( context: context, position: _menuPos, items: await super.widget.popupMenuEntryBuilder(context), elevation: 8, ); } @override bool get wantKeepAlive => true; } enum CardType { recent, fav, lan, ab, grp, } abstract class BasePeerCard extends StatelessWidget { final Peer peer; final EdgeInsets? menuPadding; final CardType cardType; BasePeerCard( {required this.peer, required this.cardType, this.menuPadding, Key? key}) : super(key: key); @override Widget build(BuildContext context) { return _PeerCard( peer: peer, connect: (BuildContext context, String id) => connect(context, id), popupMenuEntryBuilder: _buildPopupMenuEntry, ); } Future>> _buildPopupMenuEntry( BuildContext context) async => (await _buildMenuItems(context)) .map((e) => e.build( context, const MenuConfig( commonColor: CustomPopupMenuTheme.commonColor, height: CustomPopupMenuTheme.height, dividerHeight: CustomPopupMenuTheme.dividerHeight))) .expand((i) => i) .toList(); @protected Future>> _buildMenuItems(BuildContext context); MenuEntryBase _connectCommonAction( BuildContext context, String id, String title, {bool isFileTransfer = false, bool isTcpTunneling = false, bool isRDP = false}) { return MenuEntryButton( childBuilder: (TextStyle? style) => Text( title, style: style, ), proc: () { connect( context, peer.id, isFileTransfer: isFileTransfer, isTcpTunneling: isTcpTunneling, isRDP: isRDP, ); }, padding: menuPadding, dismissOnClicked: true, ); } @protected MenuEntryBase _connectAction(BuildContext context, Peer peer) { return _connectCommonAction( context, peer.id, peer.alias.isEmpty ? translate('Connect') : "${translate('Connect')} ${peer.id}"); } @protected MenuEntryBase _transferFileAction(BuildContext context, String id) { return _connectCommonAction( context, id, translate('Transfer File'), isFileTransfer: true, ); } @protected MenuEntryBase _tcpTunnelingAction(BuildContext context, String id) { return _connectCommonAction( context, id, translate('TCP Tunneling'), isTcpTunneling: true, ); } @protected MenuEntryBase _rdpAction(BuildContext context, String id) { return MenuEntryButton( childBuilder: (TextStyle? style) => Container( alignment: AlignmentDirectional.center, height: CustomPopupMenuTheme.height, child: Row( children: [ Text( translate('RDP'), style: style, ), Expanded( child: Align( alignment: Alignment.centerRight, child: Transform.scale( scale: 0.8, child: IconButton( icon: const Icon(Icons.edit), padding: EdgeInsets.zero, onPressed: () { if (Navigator.canPop(context)) { Navigator.pop(context); } _rdpDialog(id, cardType); }, )), )) ], )), proc: () { connect(context, id, isRDP: true); }, padding: menuPadding, dismissOnClicked: true, ); } @protected MenuEntryBase _wolAction(String id) { return MenuEntryButton( childBuilder: (TextStyle? style) => Text( translate('WOL'), style: style, ), proc: () { bind.mainWol(id: id); }, padding: menuPadding, dismissOnClicked: true, ); } /// Only available on Windows. @protected MenuEntryBase _createShortCutAction(String id) { return MenuEntryButton( childBuilder: (TextStyle? style) => Text( translate('Create Desktop Shortcut'), style: style, ), proc: () { bind.mainCreateShortcut(id: id); }, padding: menuPadding, dismissOnClicked: true, ); } @protected Future> _forceAlwaysRelayAction(String id) async { const option = 'force-always-relay'; return MenuEntrySwitch( switchType: SwitchType.scheckbox, text: translate('Always connect via relay'), getter: () async { if (cardType == CardType.ab) { return gFFI.abModel.find(id)?.forceAlwaysRelay ?? false; } else { return (await bind.mainGetPeerOption(id: id, key: option)).isNotEmpty; } }, setter: (bool v) async { gFFI.abModel.setPeerForceAlwaysRelay(id, v); await bind.mainSetPeerOption( id: id, key: option, value: bool2option('force-always-relay', v)); }, padding: menuPadding, dismissOnClicked: true, ); } @protected MenuEntryBase _renameAction(String id) { return MenuEntryButton( childBuilder: (TextStyle? style) => Text( translate('Rename'), style: style, ), proc: () { _rename(id); }, padding: menuPadding, dismissOnClicked: true, ); } @protected MenuEntryBase _removeAction( String id, Future Function() reloadFunc, {bool isLan = false}) { return MenuEntryButton( childBuilder: (TextStyle? style) => Text( translate('Remove'), style: style, ), proc: () { () async { if (isLan) { // TODO } else { await bind.mainRemovePeer(id: id); } removePreference(id); await reloadFunc(); }(); }, padding: menuPadding, dismissOnClicked: true, ); } @protected MenuEntryBase _unrememberPasswordAction(String id) { return MenuEntryButton( childBuilder: (TextStyle? style) => Text( translate('Unremember Password'), style: style, ), proc: () { bind.mainForgetPassword(id: id); }, padding: menuPadding, dismissOnClicked: true, ); } @protected MenuEntryBase _addFavAction(String id) { return MenuEntryButton( childBuilder: (TextStyle? style) => Text( translate('Add to Favorites'), style: style, ), proc: () { () async { final favs = (await bind.mainGetFav()).toList(); if (!favs.contains(id)) { favs.add(id); await bind.mainStoreFav(favs: favs); } }(); }, padding: menuPadding, dismissOnClicked: true, ); } @protected MenuEntryBase _rmFavAction( String id, Future Function() reloadFunc) { return MenuEntryButton( childBuilder: (TextStyle? style) => Text( translate('Remove from Favorites'), style: style, ), proc: () { () async { final favs = (await bind.mainGetFav()).toList(); if (favs.remove(id)) { await bind.mainStoreFav(favs: favs); await reloadFunc(); } }(); }, padding: menuPadding, dismissOnClicked: true, ); } @protected MenuEntryBase _addToAb(Peer peer) { return MenuEntryButton( childBuilder: (TextStyle? style) => Text( translate('Add to Address Book'), style: style, ), proc: () { () async { if (!gFFI.abModel.idContainBy(peer.id)) { gFFI.abModel.addPeer(peer); await gFFI.abModel.pushAb(); } }(); }, padding: menuPadding, dismissOnClicked: true, ); } void _rename(String id) async { RxBool isInProgress = false.obs; String name; if (cardType == CardType.ab) { name = gFFI.abModel.find(id)?.alias ?? ""; } else { name = await bind.mainGetPeerOption(id: id, key: 'alias'); } var controller = TextEditingController(text: name); gFFI.dialogManager.show((setState, close) { submit() async { isInProgress.value = true; String name = controller.text.trim(); await bind.mainSetPeerAlias(id: id, alias: name); gFFI.abModel.setPeerAlias(id, name); update(); close(); isInProgress.value = false; } return CustomAlertDialog( title: Text(translate('Rename')), content: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( child: Form( child: TextFormField( controller: controller, focusNode: FocusNode()..requestFocus(), decoration: const InputDecoration(border: OutlineInputBorder()), ), ), ), Obx(() => Offstage( offstage: isInProgress.isFalse, child: const LinearProgressIndicator())), ], ), actions: [ TextButton(onPressed: close, child: Text(translate("Cancel"))), TextButton(onPressed: submit, child: Text(translate("OK"))), ], onSubmit: submit, onCancel: close, ); }); } void update() { switch (cardType) { case CardType.recent: bind.mainLoadRecentPeers(); break; case CardType.fav: bind.mainLoadFavPeers(); break; case CardType.lan: bind.mainLoadLanPeers(); break; case CardType.ab: gFFI.abModel.pullAb(); break; case CardType.grp: gFFI.groupModel.pull(); break; } } } class RecentPeerCard extends BasePeerCard { RecentPeerCard({required Peer peer, EdgeInsets? menuPadding, Key? key}) : super( peer: peer, cardType: CardType.recent, menuPadding: menuPadding, key: key); @override Future>> _buildMenuItems( BuildContext context) async { final List> menuItems = [ _connectAction(context, peer), _transferFileAction(context, peer.id), ]; if (isDesktop && peer.platform != 'Android') { menuItems.add(_tcpTunnelingAction(context, peer.id)); } menuItems.add(await _forceAlwaysRelayAction(peer.id)); if (peer.platform == 'Windows') { menuItems.add(_rdpAction(context, peer.id)); } menuItems.add(_wolAction(peer.id)); if (Platform.isWindows) { menuItems.add(_createShortCutAction(peer.id)); } menuItems.add(MenuEntryDivider()); menuItems.add(_renameAction(peer.id)); menuItems.add(_removeAction(peer.id, () async { await bind.mainLoadRecentPeers(); })); if (await bind.mainPeerHasPassword(id: peer.id)) { menuItems.add(_unrememberPasswordAction(peer.id)); } menuItems.add(_addFavAction(peer.id)); if (!gFFI.abModel.idContainBy(peer.id)) { menuItems.add(_addToAb(peer)); } return menuItems; } } class FavoritePeerCard extends BasePeerCard { FavoritePeerCard({required Peer peer, EdgeInsets? menuPadding, Key? key}) : super( peer: peer, cardType: CardType.fav, menuPadding: menuPadding, key: key); @override Future>> _buildMenuItems( BuildContext context) async { final List> menuItems = [ _connectAction(context, peer), _transferFileAction(context, peer.id), ]; if (isDesktop && peer.platform != 'Android') { menuItems.add(_tcpTunnelingAction(context, peer.id)); } menuItems.add(await _forceAlwaysRelayAction(peer.id)); if (peer.platform == 'Windows') { menuItems.add(_rdpAction(context, peer.id)); } menuItems.add(_wolAction(peer.id)); if (Platform.isWindows) { menuItems.add(_createShortCutAction(peer.id)); } menuItems.add(MenuEntryDivider()); menuItems.add(_renameAction(peer.id)); menuItems.add(_removeAction(peer.id, () async { await bind.mainLoadFavPeers(); })); if (await bind.mainPeerHasPassword(id: peer.id)) { menuItems.add(_unrememberPasswordAction(peer.id)); } menuItems.add(_rmFavAction(peer.id, () async { await bind.mainLoadFavPeers(); })); if (!gFFI.abModel.idContainBy(peer.id)) { menuItems.add(_addToAb(peer)); } return menuItems; } } class DiscoveredPeerCard extends BasePeerCard { DiscoveredPeerCard({required Peer peer, EdgeInsets? menuPadding, Key? key}) : super( peer: peer, cardType: CardType.lan, menuPadding: menuPadding, key: key); @override Future>> _buildMenuItems( BuildContext context) async { final List> menuItems = [ _connectAction(context, peer), _transferFileAction(context, peer.id), ]; if (isDesktop && peer.platform != 'Android') { menuItems.add(_tcpTunnelingAction(context, peer.id)); } menuItems.add(await _forceAlwaysRelayAction(peer.id)); if (peer.platform == 'Windows') { menuItems.add(_rdpAction(context, peer.id)); } menuItems.add(_wolAction(peer.id)); if (Platform.isWindows) { menuItems.add(_createShortCutAction(peer.id)); } menuItems.add(MenuEntryDivider()); menuItems.add(_removeAction(peer.id, () async {})); if (!gFFI.abModel.idContainBy(peer.id)) { menuItems.add(_addToAb(peer)); } return menuItems; } } class AddressBookPeerCard extends BasePeerCard { AddressBookPeerCard({required Peer peer, EdgeInsets? menuPadding, Key? key}) : super( peer: peer, cardType: CardType.ab, menuPadding: menuPadding, key: key); @override Future>> _buildMenuItems( BuildContext context) async { final List> menuItems = [ _connectAction(context, peer), _transferFileAction(context, peer.id), ]; if (isDesktop && peer.platform != 'Android') { menuItems.add(_tcpTunnelingAction(context, peer.id)); } menuItems.add(await _forceAlwaysRelayAction(peer.id)); if (peer.platform == 'Windows') { menuItems.add(_rdpAction(context, peer.id)); } menuItems.add(_wolAction(peer.id)); if (Platform.isWindows) { menuItems.add(_createShortCutAction(peer.id)); } menuItems.add(MenuEntryDivider()); menuItems.add(_renameAction(peer.id)); menuItems.add(_removeAction(peer.id, () async {})); if (await bind.mainPeerHasPassword(id: peer.id)) { menuItems.add(_unrememberPasswordAction(peer.id)); } if (gFFI.abModel.tags.isNotEmpty) { menuItems.add(_editTagAction(peer.id)); } return menuItems; } @protected @override MenuEntryBase _removeAction( String id, Future Function() reloadFunc, {bool isLan = false}) { return MenuEntryButton( childBuilder: (TextStyle? style) => Text( translate('Remove'), style: style, ), proc: () { () async { gFFI.abModel.deletePeer(id); await gFFI.abModel.pushAb(); }(); }, padding: super.menuPadding, dismissOnClicked: true, ); } @protected MenuEntryBase _editTagAction(String id) { return MenuEntryButton( childBuilder: (TextStyle? style) => Text( translate('Edit Tag'), style: style, ), proc: () { _abEditTag(id); }, padding: super.menuPadding, dismissOnClicked: true, ); } void _abEditTag(String id) { var isInProgress = false; final tags = List.of(gFFI.abModel.tags); var selectedTag = gFFI.abModel.getPeerTags(id).obs; gFFI.dialogManager.show((setState, close) { submit() async { setState(() { isInProgress = true; }); gFFI.abModel.changeTagForPeer(id, selectedTag); await gFFI.abModel.pushAb(); close(); } return CustomAlertDialog( title: Text(translate("Edit Tag")), content: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( padding: const EdgeInsets.symmetric(vertical: 8.0), child: Wrap( children: tags .map((e) => AddressBookTag( name: e, tags: selectedTag, onTap: () { if (selectedTag.contains(e)) { selectedTag.remove(e); } else { selectedTag.add(e); } }, showActionMenu: false)) .toList(growable: false), ), ), Offstage( offstage: !isInProgress, child: const LinearProgressIndicator()) ], ), actions: [ TextButton(onPressed: close, child: Text(translate("Cancel"))), TextButton(onPressed: submit, child: Text(translate("OK"))), ], onSubmit: submit, onCancel: close, ); }); } } class MyGroupPeerCard extends BasePeerCard { MyGroupPeerCard({required Peer peer, EdgeInsets? menuPadding, Key? key}) : super( peer: peer, cardType: CardType.grp, menuPadding: menuPadding, key: key); @override Future>> _buildMenuItems( BuildContext context) async { final List> menuItems = [ _connectAction(context, peer), _transferFileAction(context, peer.id), ]; if (isDesktop && peer.platform != 'Android') { menuItems.add(_tcpTunnelingAction(context, peer.id)); } menuItems.add(await _forceAlwaysRelayAction(peer.id)); if (peer.platform == 'Windows') { menuItems.add(_rdpAction(context, peer.id)); } menuItems.add(_wolAction(peer.id)); if (Platform.isWindows) { menuItems.add(_createShortCutAction(peer.id)); } menuItems.add(MenuEntryDivider()); menuItems.add(_renameAction(peer.id)); if (await bind.mainPeerHasPassword(id: peer.id)) { menuItems.add(_unrememberPasswordAction(peer.id)); } return menuItems; } } void _rdpDialog(String id, CardType card) async { String port, username; if (card == CardType.ab) { port = gFFI.abModel.find(id)?.rdpPort ?? ''; username = gFFI.abModel.find(id)?.rdpUsername ?? ''; } else { port = await bind.mainGetPeerOption(id: id, key: 'rdp_port'); username = await bind.mainGetPeerOption(id: id, key: 'rdp_username'); } final portController = TextEditingController(text: port); final userController = TextEditingController(text: username); final passwordController = TextEditingController( text: await bind.mainGetPeerOption(id: id, key: 'rdp_password')); RxBool secure = true.obs; gFFI.dialogManager.show((setState, close) { submit() async { String port = portController.text.trim(); String username = userController.text; String password = passwordController.text; await bind.mainSetPeerOption(id: id, key: 'rdp_port', value: port); await bind.mainSetPeerOption( id: id, key: 'rdp_username', value: username); await bind.mainSetPeerOption( id: id, key: 'rdp_password', value: password); gFFI.abModel.setRdp(id, port, username); close(); } return CustomAlertDialog( title: Text('RDP ${translate('Settings')}'), content: ConstrainedBox( constraints: const BoxConstraints(minWidth: 500), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox( height: 8.0, ), Row( children: [ ConstrainedBox( constraints: const BoxConstraints(minWidth: 100), child: Text( "${translate('Port')}:", textAlign: TextAlign.start, ).marginOnly(bottom: 16.0)), const SizedBox( width: 24.0, ), Expanded( child: TextField( inputFormatters: [ FilteringTextInputFormatter.allow(RegExp( r'^([0-9]|[1-9]\d|[1-9]\d{2}|[1-9]\d{3}|[1-5]\d{4}|6[0-4]\d{3}|65[0-4]\d{2}|655[0-2]\d|6553[0-5])$')) ], decoration: const InputDecoration( border: OutlineInputBorder(), hintText: '3389'), controller: portController, focusNode: FocusNode()..requestFocus(), ), ), ], ), const SizedBox( height: 8.0, ), Row( children: [ ConstrainedBox( constraints: const BoxConstraints(minWidth: 100), child: Text( "${translate('Username')}:", textAlign: TextAlign.start, ).marginOnly(bottom: 16.0)), const SizedBox( width: 24.0, ), Expanded( child: TextField( decoration: const InputDecoration(border: OutlineInputBorder()), controller: userController, ), ), ], ), const SizedBox( height: 8.0, ), Row( children: [ ConstrainedBox( constraints: const BoxConstraints(minWidth: 100), child: Text("${translate('Password')}:") .marginOnly(bottom: 16.0)), const SizedBox( width: 24.0, ), Expanded( child: Obx(() => TextField( obscureText: secure.value, decoration: InputDecoration( border: const OutlineInputBorder(), suffixIcon: IconButton( onPressed: () => secure.value = !secure.value, icon: Icon(secure.value ? Icons.visibility_off : Icons.visibility))), controller: passwordController, )), ), ], ), ], ), ), actions: [ TextButton(onPressed: close, child: Text(translate("Cancel"))), TextButton(onPressed: submit, child: Text(translate("OK"))), ], onSubmit: submit, onCancel: close, ); }); } Widget getOnline(double rightPadding, bool online) { return Tooltip( message: translate(online ? 'Online' : 'Offline'), waitDuration: const Duration(seconds: 1), child: Padding( padding: EdgeInsets.fromLTRB(0, 4, rightPadding, 4), child: CircleAvatar( radius: 3, backgroundColor: online ? Colors.green : kColorWarn))); } class ActionMore extends StatelessWidget { final RxBool _iconMoreHover = false.obs; @override Widget build(BuildContext context) { return MouseRegion( onEnter: (_) => _iconMoreHover.value = true, onExit: (_) => _iconMoreHover.value = false, child: Obx(() => CircleAvatar( radius: 14, backgroundColor: _iconMoreHover.value ? Theme.of(context).scaffoldBackgroundColor : Theme.of(context).backgroundColor, child: Icon(Icons.more_vert, size: 18, color: _iconMoreHover.value ? Theme.of(context).textTheme.titleLarge?.color : Theme.of(context) .textTheme .titleLarge ?.color ?.withOpacity(0.5))))); } }