diff --git a/flutter/lib/mobile/pages/connection_page.dart b/flutter/lib/mobile/pages/connection_page.dart index c9bd15709..63743638a 100644 --- a/flutter/lib/mobile/pages/connection_page.dart +++ b/flutter/lib/mobile/pages/connection_page.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'package:auto_size_text_field/auto_size_text_field.dart'; import 'package:flutter/material.dart'; @@ -6,10 +7,12 @@ import 'package:flutter_hbb/common/formatter/id_formatter.dart'; import 'package:get/get.dart'; import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher.dart'; +import 'package:flutter_hbb/models/peer_model.dart'; import '../../common.dart'; import '../../common/widgets/login.dart'; import '../../common/widgets/peer_tab_page.dart'; +import '../../common/widgets/peer_card.dart'; import '../../consts.dart'; import '../../models/model.dart'; import '../../models/platform_model.dart'; @@ -42,6 +45,15 @@ class _ConnectionPageState extends State { /// Update url. If it's not null, means an update is available. var _updateUrl = ''; + 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() { @@ -116,6 +128,64 @@ class _ConnectionPageState extends State { color: Colors.white, fontWeight: FontWeight.bold)))); } + 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. Widget _buildRemoteIDTextField() { @@ -133,7 +203,61 @@ class _ConnectionPageState extends State { Expanded( child: Container( padding: const EdgeInsets.only(left: 16, right: 16), - child: AutoSizeTextField( + 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{ + _idEmpty.value = fieldTextEditingController.text.isEmpty; + 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 AutoSizeTextField( + controller: fieldTextEditingController, + focusNode: fieldFocusNode, minFontSize: 18, autocorrect: false, enableSuggestions: false, @@ -161,8 +285,36 @@ class _ConnectionPageState extends State { color: MyTheme.darkGray, ), ), - controller: _idController, inputFormatters: [IDTextInputFormatter()], + ); + }, + optionsViewBuilder: (BuildContext context, AutocompleteOnSelected onSelected, Iterable options) { + double maxHeight = options.length * 50; + maxHeight = maxHeight > 200 ? 200 : maxHeight; + return Align( + alignment: Alignment.topLeft, + child: ClipRRect( + borderRadius: BorderRadius.circular(5), + child: Material( + elevation: 4, + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: maxHeight, + maxWidth: 320, + ), + child: peers.isEmpty && isPeersLoading + ? Container( + height: 80, + child: Center( + child: CircularProgressIndicator( + strokeWidth: 2, + ))) + : ListView( + padding: EdgeInsets.only(top: 5), + children: options.map((peer) => _buildPeerTile(context, peer)).toList(), + )))) + ); + }, ), ), ), @@ -170,7 +322,9 @@ class _ConnectionPageState extends State { offstage: _idEmpty.value, child: IconButton( onPressed: () { - _idController.clear(); + setState(() { + _idController.clear(); + }); }, icon: Icon(Icons.clear, color: MyTheme.darkGray)), )), @@ -193,6 +347,115 @@ class _ConnectionPageState extends State { child: Container(constraints: kMobilePageConstraints, 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), + ), + ) + ]), + ); + } + @override void dispose() { _idController.dispose();