import 'package:contextmenu/contextmenu.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hbb/utils/multi_window_manager.dart'; import 'package:get/get.dart'; import '../../common.dart'; import '../../models/model.dart'; import '../../models/peer_model.dart'; import '../../models/platform_model.dart'; typedef PopupMenuItemsFunc = Future>> Function(); enum PeerType { recent, fav, discovered, ab } enum PeerUiType { grid, list } final peerCardUiType = PeerUiType.grid.obs; class _PeerCard extends StatefulWidget { final Peer peer; final PopupMenuItemsFunc popupMenuItemsFunc; final PeerType type; _PeerCard( {required this.peer, required this.popupMenuItemsFunc, Key? key, required this.type}) : super(key: key); @override _PeerCardState createState() => _PeerCardState(); } /// State for the connection page. class _PeerCardState extends State<_PeerCard> with AutomaticKeepAliveClientMixin { var _menuPos; final double _cardRadis = 20; final double _borderWidth = 2; @override Widget build(BuildContext context) { super.build(context); final peer = super.widget.peer; var deco = Rx(BoxDecoration( border: Border.all(color: Colors.transparent, width: _borderWidth), borderRadius: peerCardUiType.value == PeerUiType.grid ? BorderRadius.circular(_cardRadis) : null)); return MouseRegion( onEnter: (evt) { deco.value = BoxDecoration( border: Border.all(color: MyTheme.button, width: _borderWidth), borderRadius: peerCardUiType.value == PeerUiType.grid ? BorderRadius.circular(_cardRadis) : null); }, onExit: (evt) { deco.value = BoxDecoration( border: Border.all(color: Colors.transparent, width: _borderWidth), borderRadius: peerCardUiType.value == PeerUiType.grid ? BorderRadius.circular(_cardRadis) : null); }, child: GestureDetector( onDoubleTapDown: (_) => _connect(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 greyStyle = TextStyle(fontSize: 12, color: MyTheme.color(context).lighterText); RxBool iconHover = false.obs; 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, child: _getPlatformImage('${peer.platform}', 30).paddingAll(6), ), Expanded( child: Container( decoration: BoxDecoration(color: MyTheme.color(context).bg), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded( child: Column( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ Row(children: [ Padding( padding: EdgeInsets.fromLTRB(0, 4, 4, 4), child: CircleAvatar( radius: 5, backgroundColor: peer.online ? Colors.green : Colors.yellow)), Text( '${peer.id}', style: TextStyle(fontWeight: FontWeight.w400), ), ]), Align( alignment: Alignment.centerLeft, child: FutureBuilder( future: bind.mainGetPeerOption( id: peer.id, key: 'alias'), builder: (_, snapshot) { if (snapshot.hasData) { final name = snapshot.data!.isEmpty ? '${peer.username}@${peer.hostname}' : snapshot.data!; return Tooltip( message: name, waitDuration: Duration(seconds: 1), child: Text( name, style: greyStyle, textAlign: TextAlign.start, overflow: TextOverflow.ellipsis, ), ); } else { // alias has not arrived return Text( '${peer.username}@${peer.hostname}', style: greyStyle, textAlign: TextAlign.start, overflow: TextOverflow.ellipsis, ); } }, ), ), ], ), ), InkWell( child: CircleAvatar( radius: 12, backgroundColor: iconHover.value ? MyTheme.color(context).grayBg! : MyTheme.color(context).bg!, child: Icon( Icons.more_vert, size: 18, color: iconHover.value ? MyTheme.color(context).text : MyTheme.color(context).lightText, ), ), onTapDown: (e) { final x = e.globalPosition.dx; final y = e.globalPosition.dy; _menuPos = RelativeRect.fromLTRB(x, y, x, y); }, onTap: () { _showPeerMenu(context, peer.id); }, onHover: (value) => iconHover.value = value, ), ], ).paddingSymmetric(horizontal: 4.0), ), ) ], ), ), ); } Widget _buildPeerCard( BuildContext context, Peer peer, Rx deco) { RxBool iconHover = false.obs; return Card( color: Colors.transparent, elevation: 0, margin: EdgeInsets.zero, child: Obx( () => Container( foregroundDecoration: deco.value, child: ClipRRect( borderRadius: BorderRadius.circular(_cardRadis - _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}', 60), ), Row( children: [ Expanded( child: FutureBuilder( future: bind.mainGetPeerOption( id: peer.id, key: 'alias'), builder: (_, snapshot) { if (snapshot.hasData) { final name = snapshot.data!.isEmpty ? '${peer.username}@${peer.hostname}' : snapshot.data!; return Tooltip( message: name, waitDuration: Duration(seconds: 1), child: Text( name, style: TextStyle( color: Colors.white70, fontSize: 12), textAlign: TextAlign.center, overflow: TextOverflow.ellipsis, ), ); } else { // alias has not arrived return Center( child: Text( '${peer.username}@${peer.hostname}', style: TextStyle( color: Colors.white70, fontSize: 12), textAlign: TextAlign.center, overflow: TextOverflow.ellipsis, )); } }, ), ), ], ), ], ).paddingAll(4.0), ), ], ), ), ), Container( color: MyTheme.color(context).bg, child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Row(children: [ Padding( padding: EdgeInsets.fromLTRB(0, 4, 8, 4), child: CircleAvatar( radius: 5, backgroundColor: peer.online ? Colors.green : Colors.yellow)), Text('${peer.id}') ]), InkWell( child: CircleAvatar( radius: 12, backgroundColor: iconHover.value ? MyTheme.color(context).grayBg! : MyTheme.color(context).bg!, child: Icon(Icons.more_vert, size: 18, color: iconHover.value ? MyTheme.color(context).text : MyTheme.color(context).lightText)), onTapDown: (e) { final x = e.globalPosition.dx; final y = e.globalPosition.dy; _menuPos = RelativeRect.fromLTRB(x, y, x, y); }, onTap: () { _showPeerMenu(context, peer.id); }, onHover: (value) => iconHover.value = value), ], ).paddingSymmetric(vertical: 8.0, horizontal: 12.0), ) ], ), ), ), ), ); } /// Connect to a peer with [id]. /// If [isFileTransfer], starts a session only for file transfer. void _connect(String id, {bool isFileTransfer = false}) async { if (id == '') return; id = id.replaceAll(' ', ''); if (isFileTransfer) { await rustDeskWinManager.new_file_transfer(id); } else { await rustDeskWinManager.new_remote_desktop(id); } FocusScopeNode currentFocus = FocusScope.of(context); if (!currentFocus.hasPrimaryFocus) { currentFocus.unfocus(); } } /// Show the peer menu and handle user's choice. /// User might remove the peer or send a file to the peer. void _showPeerMenu(BuildContext context, String id) async { var value = await showMenu( context: context, position: this._menuPos, items: await super.widget.popupMenuItemsFunc(), elevation: 8, ); if (value == 'remove') { await bind.mainRemovePeer(id: id); removePreference(id); Get.forceAppUpdate(); // TODO use inner model / state } else if (value == 'file') { _connect(id, isFileTransfer: true); } else if (value == 'add-fav') { final favs = (await bind.mainGetFav()).toList(); if (favs.indexOf(id) < 0) { favs.add(id); bind.mainStoreFav(favs: favs); } } else if (value == 'remove-fav') { final favs = (await bind.mainGetFav()).toList(); if (favs.remove(id)) { bind.mainStoreFav(favs: favs); Get.forceAppUpdate(); // TODO use inner model / state } } else if (value == 'connect') { _connect(id, isFileTransfer: false); } else if (value == 'ab-delete') { gFFI.abModel.deletePeer(id); await gFFI.abModel.updateAb(); setState(() {}); } else if (value == 'ab-edit-tag') { _abEditTag(id); } else if (value == 'rename') { _rename(id); } else if (value == 'unremember-password') { await bind.mainForgetPassword(id: id); } else if (value == 'force-always-relay') { String value; String oldValue = await bind.mainGetPeerOption(id: id, key: 'force-always-relay'); if (oldValue.isEmpty) { value = 'Y'; } else { value = ''; } await bind.mainSetPeerOption( id: id, key: 'force-always-relay', value: value); } } Widget _buildTag(String tagName, RxList rxTags, {Function()? onTap}) { return ContextMenuArea( width: 100, builder: (context) => [ ListTile( title: Text(translate("Delete")), onTap: () { gFFI.abModel.deleteTag(tagName); gFFI.abModel.updateAb(); Future.delayed(Duration.zero, () => Get.back()); }, ) ], child: GestureDetector( onTap: onTap, child: Obx( () => Container( decoration: BoxDecoration( color: rxTags.contains(tagName) ? Colors.blue : null, border: Border.all(color: MyTheme.darkGray), borderRadius: BorderRadius.circular(10)), margin: EdgeInsets.symmetric(horizontal: 4.0, vertical: 8.0), padding: EdgeInsets.symmetric(vertical: 2.0, horizontal: 8.0), child: Text( tagName, style: TextStyle( color: rxTags.contains(tagName) ? MyTheme.white : null), ), ), ), ), ); } /// Get the image for the current [platform]. Widget _getPlatformImage(String platform, double size) { platform = platform.toLowerCase(); if (platform == 'mac os') platform = 'mac'; else if (platform != 'linux' && platform != 'android') platform = 'win'; return Image.asset('assets/$platform.png', height: size, width: size); } 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) { return CustomAlertDialog( title: Text(translate("Edit Tag")), content: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( padding: EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), child: Wrap( children: tags .map((e) => _buildTag(e, selectedTag, onTap: () { if (selectedTag.contains(e)) { selectedTag.remove(e); } else { selectedTag.add(e); } })) .toList(growable: false), ), ), Offstage(offstage: !isInProgress, child: LinearProgressIndicator()) ], ), actions: [ TextButton( onPressed: () { close(); }, child: Text(translate("Cancel"))), TextButton( onPressed: () async { setState(() { isInProgress = true; }); gFFI.abModel.changeTagForPeer(id, selectedTag); await gFFI.abModel.updateAb(); close(); }, child: Text(translate("OK"))), ], ); }); } void _rename(String id) async { var isInProgress = false; var name = await bind.mainGetPeerOption(id: id, key: 'alias'); if (widget.type == PeerType.ab) { final peer = gFFI.abModel.peers.firstWhere((p) => id == p['id']); if (peer == null) { // this should not happen } else { name = peer['alias'] ?? ""; } } final k = GlobalKey(); gFFI.dialogManager.show((setState, close) { return CustomAlertDialog( title: Text(translate("Rename")), content: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( padding: EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), child: Form( key: k, child: TextFormField( controller: TextEditingController(text: name), decoration: InputDecoration(border: OutlineInputBorder()), onChanged: (newStr) { name = newStr; }, validator: (s) { if (s == null || s.isEmpty) { return translate("Empty"); } return null; }, onSaved: (s) { name = s ?? "unnamed"; }, ), ), ), Offstage(offstage: !isInProgress, child: LinearProgressIndicator()) ], ), actions: [ TextButton( onPressed: () { close(); }, child: Text(translate("Cancel"))), TextButton( onPressed: () async { setState(() { isInProgress = true; }); if (k.currentState != null) { if (k.currentState!.validate()) { k.currentState!.save(); await bind.mainSetPeerOption( id: id, key: 'alias', value: name); if (widget.type == PeerType.ab) { gFFI.abModel.setPeerOption(id, 'alias', name); await gFFI.abModel.updateAb(); } else { Future.delayed(Duration.zero, () { this.setState(() {}); }); } close(); } } setState(() { isInProgress = false; }); }, child: Text(translate("OK"))), ], ); }); } @override bool get wantKeepAlive => true; } abstract class BasePeerCard extends StatelessWidget { final Peer peer; final PeerType type; BasePeerCard({required this.peer, required this.type, Key? key}) : super(key: key); @override Widget build(BuildContext context) { return _PeerCard( peer: peer, popupMenuItemsFunc: _getPopupMenuItems, type: type, ); } @protected Future>> _getPopupMenuItems(); } class RecentPeerCard extends BasePeerCard { RecentPeerCard({required Peer peer, Key? key}) : super(peer: peer, key: key, type: PeerType.recent); Future>> _getPopupMenuItems() async { return [ PopupMenuItem( child: Text(translate('Connect')), value: 'connect'), PopupMenuItem( child: Text(translate('Transfer File')), value: 'file'), PopupMenuItem( child: Text(translate('TCP Tunneling')), value: 'tcp-tunnel'), await _forceAlwaysRelayMenuItem(peer.id), PopupMenuItem(child: Text(translate('Rename')), value: 'rename'), PopupMenuItem(child: Text(translate('Remove')), value: 'remove'), PopupMenuItem( child: Text(translate('Unremember Password')), value: 'unremember-password'), PopupMenuItem( child: Text(translate('Add to Favorites')), value: 'add-fav'), ]; } } class FavoritePeerCard extends BasePeerCard { FavoritePeerCard({required Peer peer, Key? key}) : super(peer: peer, key: key, type: PeerType.fav); Future>> _getPopupMenuItems() async { return [ PopupMenuItem( child: Text(translate('Connect')), value: 'connect'), PopupMenuItem( child: Text(translate('Transfer File')), value: 'file'), PopupMenuItem( child: Text(translate('TCP Tunneling')), value: 'tcp-tunnel'), await _forceAlwaysRelayMenuItem(peer.id), PopupMenuItem(child: Text(translate('Rename')), value: 'rename'), PopupMenuItem(child: Text(translate('Remove')), value: 'remove'), PopupMenuItem( child: Text(translate('Unremember Password')), value: 'unremember-password'), PopupMenuItem( child: Text(translate('Remove from Favorites')), value: 'remove-fav'), ]; } } class DiscoveredPeerCard extends BasePeerCard { DiscoveredPeerCard({required Peer peer, Key? key}) : super(peer: peer, key: key, type: PeerType.discovered); Future>> _getPopupMenuItems() async { return [ PopupMenuItem( child: Text(translate('Connect')), value: 'connect'), PopupMenuItem( child: Text(translate('Transfer File')), value: 'file'), PopupMenuItem( child: Text(translate('TCP Tunneling')), value: 'tcp-tunnel'), await _forceAlwaysRelayMenuItem(peer.id), PopupMenuItem(child: Text(translate('Rename')), value: 'rename'), PopupMenuItem(child: Text(translate('Remove')), value: 'remove'), PopupMenuItem( child: Text(translate('Unremember Password')), value: 'unremember-password'), PopupMenuItem( child: Text(translate('Add to Favorites')), value: 'add-fav'), ]; } } class AddressBookPeerCard extends BasePeerCard { AddressBookPeerCard({required Peer peer, Key? key}) : super(peer: peer, key: key, type: PeerType.ab); Future>> _getPopupMenuItems() async { return [ PopupMenuItem( child: Text(translate('Connect')), value: 'connect'), PopupMenuItem( child: Text(translate('Transfer File')), value: 'file'), PopupMenuItem( child: Text(translate('TCP Tunneling')), value: 'tcp-tunnel'), await _forceAlwaysRelayMenuItem(peer.id), PopupMenuItem(child: Text(translate('Rename')), value: 'rename'), PopupMenuItem( child: Text(translate('Remove')), value: 'ab-delete'), PopupMenuItem( child: Text(translate('Unremember Password')), value: 'unremember-password'), PopupMenuItem( child: Text(translate('Add to Favorites')), value: 'add-fav'), PopupMenuItem( child: Text(translate('Edit Tag')), value: 'ab-edit-tag'), ]; } } Future> _forceAlwaysRelayMenuItem(String id) async { bool force_always_relay = (await bind.mainGetPeerOption(id: id, key: 'force-always-relay')) .isNotEmpty; return PopupMenuItem( child: Row( children: [ Offstage( offstage: !force_always_relay, child: Icon(Icons.check), ), Text(translate('Always connect via relay')), ], ), value: 'force-always-relay'); }