diff --git a/flutter/lib/common/widgets/autocomplete.dart b/flutter/lib/common/widgets/autocomplete.dart new file mode 100644 index 000000000..9c14eab7f --- /dev/null +++ b/flutter/lib/common/widgets/autocomplete.dart @@ -0,0 +1,196 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:flutter_hbb/common/formatter/id_formatter.dart'; +import '../../../models/platform_model.dart'; +import 'package:flutter_hbb/models/peer_model.dart'; +import 'package:flutter_hbb/common.dart'; +import 'package:flutter_hbb/common/widgets/peer_card.dart'; + + + 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) { + print("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)); + } + return parsedPeers; + } + + class AutocompletePeerTile extends StatefulWidget { + final IDTextEditingController idController; + final Peer peer; + + const AutocompletePeerTile({ + Key? key, + required this.idController, + required this.peer, + }) : super(key: key); + + @override + _AutocompletePeerTileState createState() => _AutocompletePeerTileState(); +} + +class _AutocompletePeerTileState extends State{ + List _frontN(List list, int n) { + if (list.length <= n) { + return list; + } else { + return list.sublist(0, n); + } + } + @override + Widget build(BuildContext context){ + final double _tileRadius = 5; + final name = + '${widget.peer.username}${widget.peer.username.isNotEmpty && widget.peer.hostname.isNotEmpty ? '@' : ''}${widget.peer.hostname}'; + final greyStyle = TextStyle( + fontSize: 11, + color: Theme.of(context).textTheme.titleLarge?.color?.withOpacity(0.6)); + final child = GestureDetector( + onTap: () { + setState(() { + widget.idController.id = widget.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('${widget.peer.id}${widget.peer.platform}', 0x7f), + borderRadius: BorderRadius.only( + topLeft: Radius.circular(_tileRadius), + bottomLeft: Radius.circular(_tileRadius), + ), + ), + alignment: Alignment.center, + width: 42, + height: null, + child: Padding( + padding: EdgeInsets.all(6), + child: getPlatformImage(widget.peer.platform, size: 30) + ) + ), + Expanded( + child: Container( + padding: EdgeInsets.only(left: 10), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.background, + borderRadius: BorderRadius.only( + topRight: Radius.circular(_tileRadius), + bottomRight: Radius.circular(_tileRadius), + ), + ), + child: Row( + children: [ + Expanded( + child: Container( + margin: EdgeInsets.only(top: 2), + child: Container( + margin: EdgeInsets.only(top: 2), + child: Column( + children: [ + Container( + margin: EdgeInsets.only(top: 2), + child: Row(children: [ + getOnline(8, widget.peer.online), + Expanded( + child: Text( + widget.peer.alias.isEmpty ? formatID(widget.peer.id) : widget.peer.alias, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleSmall, + )), + !widget.peer.alias.isEmpty? + Padding( + padding: const EdgeInsets.only(left: 5, right: 5), + child: Text( + "(${widget.peer.id})", + style: greyStyle, + overflow: TextOverflow.ellipsis, + ) + ) + : Container(), + ])), + Align( + alignment: Alignment.centerLeft, + child: Text( + name, + style: greyStyle, + textAlign: TextAlign.start, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ) + ))), + ], + ) + ), + ) + ], + ))); + final colors = + _frontN(widget.peer.tags, 25).map((e) => gFFI.abModel.getTagColor(e)).toList(); + return Tooltip( + message: isMobile + ? '' + : widget.peer.tags.isNotEmpty + ? '${translate('Tags')}: ${widget.peer.tags.join(', ')}' + : '', + child: Stack(children: [ + child, + if (colors.isNotEmpty) + Positioned( + top: 5, + right: 10, + child: CustomPaint( + painter: TagPainter(radius: 3, colors: colors), + ), + ) + ]), + ); + } + } \ No newline at end of file diff --git a/flutter/lib/desktop/pages/connection_page.dart b/flutter/lib/desktop/pages/connection_page.dart index 272fd7c7b..670f330bb 100644 --- a/flutter/lib/desktop/pages/connection_page.dart +++ b/flutter/lib/desktop/pages/connection_page.dart @@ -16,7 +16,7 @@ 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 '../../common/widgets/autocomplete.dart'; import '../../models/platform_model.dart'; import '../widgets/button.dart'; @@ -51,6 +51,7 @@ class _ConnectionPageState extends State } } bool isPeersLoading = false; + bool isPeersLoaded = false; @override void initState() { @@ -151,59 +152,13 @@ class _ConnectionPageState extends State isPeersLoading = true; }); await Future.delayed(Duration(milliseconds: 100)); - await _getAllPeers(); + peers = await getAllPeers(); setState(() { isPeersLoading = false; + isPeersLoaded = true; }); } - 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. Widget _buildRemoteIDTextField(BuildContext context) { @@ -239,7 +194,7 @@ class _ConnectionPageState extends State if (textEditingValue.text == '') { return const Iterable.empty(); } - else if (peers.isEmpty) { + else if (peers.isEmpty && !isPeersLoaded) { Peer emptyPeer = Peer( id: '', username: '', @@ -331,12 +286,9 @@ class _ConnectionPageState extends State )); }, optionsViewBuilder: (BuildContext context, AutocompleteOnSelected onSelected, Iterable options) { - double maxHeight = 0; - for (var peer in options) { - if (maxHeight < 200) { - maxHeight += 50; - } - } + double maxHeight = options.length * 50; + maxHeight = maxHeight > 200 ? 200 : maxHeight; + return Align( alignment: Alignment.topLeft, child: ClipRRect( @@ -360,9 +312,7 @@ class _ConnectionPageState extends State : Padding( padding: const EdgeInsets.only(top: 5), child: ListView( - children: options - .map((peer) => _buildPeerTile(context, peer)) - .toList() + children: options.map((peer) => AutocompletePeerTile(idController: _idController, peer: peer)).toList(), ), ), ), @@ -398,115 +348,6 @@ 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/flutter/lib/mobile/pages/connection_page.dart b/flutter/lib/mobile/pages/connection_page.dart index c9bd15709..1c1dec8fc 100644 --- a/flutter/lib/mobile/pages/connection_page.dart +++ b/flutter/lib/mobile/pages/connection_page.dart @@ -6,10 +6,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/autocomplete.dart'; import '../../consts.dart'; import '../../models/model.dart'; import '../../models/platform_model.dart'; @@ -42,6 +44,16 @@ 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; + bool isPeersLoaded = false; @override void initState() { @@ -116,6 +128,18 @@ class _ConnectionPageState extends State { color: Colors.white, fontWeight: FontWeight.bold)))); } + Future _fetchPeers() async { + setState(() { + isPeersLoading = true; + }); + await Future.delayed(Duration(milliseconds: 100)); + peers = await getAllPeers(); + setState(() { + isPeersLoading = false; + isPeersLoaded = true; + }); + } + /// UI for the remote ID TextField. /// Search for a peer and connect to it if the id exists. Widget _buildRemoteIDTextField() { @@ -133,12 +157,69 @@ 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 && !isPeersLoaded) { + 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, keyboardType: TextInputType.visiblePassword, // keyboardType: TextInputType.number, + onChanged: (String text) { + _idController.id = text; + }, style: const TextStyle( fontFamily: 'WorkSans', fontWeight: FontWeight.bold, @@ -161,8 +242,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) => AutocompletePeerTile(idController: _idController, peer: peer)).toList(), + )))) + ); + }, ), ), ), @@ -170,7 +279,9 @@ class _ConnectionPageState extends State { offstage: _idEmpty.value, child: IconButton( onPressed: () { - _idController.clear(); + setState(() { + _idController.clear(); + }); }, icon: Icon(Icons.clear, color: MyTheme.darkGray)), )),