diff --git a/flutter/lib/desktop/pages/connection_page.dart b/flutter/lib/desktop/pages/connection_page.dart index 359746f4c..859fd0d70 100644 --- a/flutter/lib/desktop/pages/connection_page.dart +++ b/flutter/lib/desktop/pages/connection_page.dart @@ -11,10 +11,12 @@ import 'package:flutter_hbb/models/state_model.dart'; import 'package:get/get.dart'; import 'package:url_launcher/url_launcher_string.dart'; import 'package:window_manager/window_manager.dart'; +import 'package:flutter_hbb/models/peer_model.dart'; import '../../common.dart'; import '../../common/formatter/id_formatter.dart'; import '../../common/widgets/peer_tab_page.dart'; +import '../../common/widgets/peer_card.dart'; import '../../models/platform_model.dart'; import '../widgets/button.dart'; @@ -41,6 +43,15 @@ class _ConnectionPageState extends State var svcIsUsingPublicServer = true.obs; bool isWindowMinimized = false; + List peers = []; + List _frontN(List list, int n) { + if (list.length <= n) { + return list; + } else { + return list.sublist(0, n); + } + } + bool isPeersLoading = false; @override void initState() { @@ -58,12 +69,6 @@ class _ConnectionPageState extends State _updateTimer = periodic_immediate(Duration(seconds: 1), () async { updateStatus(); }); - _idFocusNode.addListener(() { - _idInputFocused.value = _idFocusNode.hasFocus; - // select all to faciliate removing text, just following the behavior of address input of chrome - _idController.selection = TextSelection( - baseOffset: 0, extentOffset: _idController.value.text.length); - }); Get.put(_idController); windowManager.addListener(this); } @@ -142,8 +147,66 @@ class _ConnectionPageState extends State connect(context, id, isFileTransfer: isFileTransfer); } + Future _fetchPeers() async { + setState(() { + isPeersLoading = true; + }); + await Future.delayed(Duration(milliseconds: 100)); + await _getAllPeers(); + setState(() { + isPeersLoading = false; + }); + } + + Future _getAllPeers() async { + Map recentPeers = jsonDecode(await bind.mainLoadRecentPeersSync()); + Map lanPeers = jsonDecode(await bind.mainLoadLanPeersSync()); + Map abPeers = jsonDecode(await bind.mainLoadAbSync()); + Map groupPeers = jsonDecode(await bind.mainLoadGroupSync()); + + Map combinedPeers = {}; + + void mergePeers(Map peers) { + if (peers.containsKey("peers")) { + dynamic peerData = peers["peers"]; + + if (peerData is String) { + try { + peerData = jsonDecode(peerData); + } catch (e) { + debugPrint("Error decoding peers: $e"); + return; + } + } + + if (peerData is List) { + for (var peer in peerData) { + if (peer is Map && peer.containsKey("id")) { + String id = peer["id"]; + if (id != null && !combinedPeers.containsKey(id)) { + combinedPeers[id] = peer; + } + } + } + } + } + } + + mergePeers(recentPeers); + mergePeers(lanPeers); + mergePeers(abPeers); + mergePeers(groupPeers); + + List parsedPeers = []; + + for (var peer in combinedPeers.values) { + parsedPeers.add(Peer.fromJson(peer)); + } + peers = parsedPeers; + } + /// UI for the remote ID TextField. - /// Search for a peer and connect to it if the id exists. + /// Search for a peer. Widget _buildRemoteIDTextField(BuildContext context) { var w = Container( width: 320 + 20 * 2, @@ -171,36 +234,141 @@ class _ConnectionPageState extends State Row( children: [ Expanded( - child: Obx( - () => TextField( - maxLength: 90, - autocorrect: false, - enableSuggestions: false, - keyboardType: TextInputType.visiblePassword, - focusNode: _idFocusNode, - style: const TextStyle( - fontFamily: 'WorkSans', - fontSize: 22, - height: 1.4, - ), - maxLines: 1, - cursorColor: - Theme.of(context).textTheme.titleLarge?.color, - decoration: InputDecoration( - filled: false, - counterText: '', - hintText: _idInputFocused.value - ? null - : translate('Enter Remote ID'), - contentPadding: const EdgeInsets.symmetric( - horizontal: 15, vertical: 13)), - controller: _idController, - inputFormatters: [IDTextInputFormatter()], - onSubmitted: (s) { - onConnect(); - }, - ), - ), + child: + Autocomplete( + optionsBuilder: (TextEditingValue textEditingValue) { + if (textEditingValue.text == '') { + return const Iterable.empty(); + } + else if (peers.isEmpty) { + Peer emptyPeer = Peer( + id: '', + username: '', + hostname: '', + alias: '', + platform: '', + tags: [], + hash: '', + forceAlwaysRelay: false, + rdpPort: '', + rdpUsername: '', + loginName: '', + ); + return [emptyPeer]; + } + else { + String textWithoutSpaces = textEditingValue.text.replaceAll(" ", ""); + if (int.tryParse(textWithoutSpaces) != null) { + textEditingValue = TextEditingValue( + text: textWithoutSpaces, + selection: textEditingValue.selection, + ); + } + String textToFind = textEditingValue.text.toLowerCase(); + + return peers.where((peer) => + peer.id.toLowerCase().contains(textToFind) || + peer.username.toLowerCase().contains(textToFind) || + peer.hostname.toLowerCase().contains(textToFind) || + peer.alias.toLowerCase().contains(textToFind)) + .toList(); + } + }, + + fieldViewBuilder: (BuildContext context, + TextEditingController fieldTextEditingController, + FocusNode fieldFocusNode , + VoidCallback onFieldSubmitted, + ) { + fieldTextEditingController.text = _idController.text; + fieldFocusNode.addListener(() async { + _idInputFocused.value = fieldFocusNode.hasFocus; + if (fieldFocusNode.hasFocus && !isPeersLoading){ + _fetchPeers(); + } + }); + final textLength = fieldTextEditingController.value.text.length; + // select all to facilitate removing text, just following the behavior of address input of chrome + fieldTextEditingController.selection = TextSelection(baseOffset: 0, extentOffset: textLength); + return Obx(() => + TextField( + maxLength: 90, + autocorrect: false, + enableSuggestions: false, + keyboardType: TextInputType.visiblePassword, + focusNode: fieldFocusNode, + style: const TextStyle( + fontFamily: 'WorkSans', + fontSize: 22, + height: 1.4, + ), + maxLines: 1, + cursorColor: Theme.of(context).textTheme.titleLarge?.color, + decoration: InputDecoration( + filled: false, + counterText: '', + hintText: _idInputFocused.value + ? null + : translate('Enter Remote ID'), + contentPadding: const EdgeInsets.symmetric( + horizontal: 15, vertical: 13)), + controller: fieldTextEditingController, + inputFormatters: [IDTextInputFormatter()], + onChanged: (v) { + _idController.id = v; + }, + onSubmitted: (s) { + if (s == '') { + return; + } + try { + final id = int.parse(s); + _idController.id = s; + onConnect(); + } catch (_) { + return; + } + }, + )); + }, + optionsViewBuilder: (BuildContext context, AutocompleteOnSelected onSelected, Iterable options) { + double maxHeight = 0; + for (var peer in options) { + if (maxHeight < 200) + maxHeight += 50; }; + return Align( + alignment: Alignment.topLeft, + child: ClipRRect( + borderRadius: BorderRadius.circular(5), + child: Material( + elevation: 4, + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: maxHeight, + maxWidth: 319, + ), + child: peers.isEmpty && isPeersLoading + ? Container( + height: 80, + child: Center( + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + ) + : Padding( + padding: const EdgeInsets.only(top: 5), + child: ListView( + children: options + .map((peer) => _buildPeerTile(context, peer)) + .toList() + ), + ), + ), + )), + ); + }, + ) ), ], ), @@ -229,6 +397,115 @@ class _ConnectionPageState extends State constraints: const BoxConstraints(maxWidth: 600), child: w); } + Widget _buildPeerTile( + BuildContext context, Peer peer) { + final double _tileRadius = 5; + 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 child = GestureDetector( + onTap: () { + setState(() { + _idController.id = peer.id; + FocusScope.of(context).unfocus(); + }); + }, + child: + Container( + height: 42, + margin: EdgeInsets.only(bottom: 5), + child: Row( + mainAxisSize: MainAxisSize.max, + children: [ + Container( + decoration: BoxDecoration( + color: str2color('${peer.id}${peer.platform}', 0x7f), + borderRadius: BorderRadius.only( + topLeft: Radius.circular(_tileRadius), + bottomLeft: Radius.circular(_tileRadius), + ), + ), + alignment: Alignment.center, + width: 42, + height: null, + child: getPlatformImage(peer.platform, size: 30) + .paddingAll(6), + ), + Expanded( + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.background, + borderRadius: BorderRadius.only( + topRight: Radius.circular(_tileRadius), + bottomRight: Radius.circular(_tileRadius), + ), + ), + child: Row( + children: [ + Expanded( + child: Column( + children: [ + 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, + )), + !peer.alias.isEmpty? + Padding( + padding: const EdgeInsets.only(left: 5, right: 5), + child: Text( + "(${peer.id})", + style: greyStyle, + overflow: TextOverflow.ellipsis, + ) + ) + : Container(), + ]).marginOnly(top: 2), + Align( + alignment: Alignment.centerLeft, + child: Text( + name, + style: greyStyle, + textAlign: TextAlign.start, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ).marginOnly(top: 2), + ), + ], + ).paddingOnly(left: 10.0, top: 3.0), + ), + ) + ], + ))); + final colors = + _frontN(peer.tags, 25).map((e) => gFFI.abModel.getTagColor(e)).toList(); + return Tooltip( + message: isMobile + ? '' + : peer.tags.isNotEmpty + ? '${translate('Tags')}: ${peer.tags.join(', ')}' + : '', + child: Stack(children: [ + child, + if (colors.isNotEmpty) + Positioned( + top: 5, + right: 10, + child: CustomPaint( + painter: TagPainter(radius: 3, colors: colors), + ), + ) + ]), + ); + } + Widget buildStatus() { final em = 14.0; return Container( diff --git a/src/flutter_ffi.rs b/src/flutter_ffi.rs index 14cc32978..f1d7e5923 100644 --- a/src/flutter_ffi.rs +++ b/src/flutter_ffi.rs @@ -955,6 +955,25 @@ pub fn main_load_recent_peers_sync() -> SyncReturn { SyncReturn("".to_string()) } +pub fn main_load_lan_peers_sync() -> SyncReturn { + let data = HashMap::from([ + ("name", "load_lan_peers".to_owned()), + ( + "peers", + serde_json::to_string(&get_lan_peers()).unwrap_or_default(), + ), + ]); + return SyncReturn(serde_json::ser::to_string(&data).unwrap_or("".to_owned())); +} + +pub fn main_load_ab_sync() -> SyncReturn { + return SyncReturn(serde_json::to_string(&config::Ab::load()).unwrap_or_default()); +} + +pub fn main_load_group_sync() -> SyncReturn { + return SyncReturn(serde_json::to_string(&config::Group::load()).unwrap_or_default()); +} + pub fn main_load_recent_peers_for_ab(filter: String) -> String { let id_filters = serde_json::from_str::>(&filter).unwrap_or_default(); let id_filters = if id_filters.is_empty() {