import 'dart:async'; import 'package:auto_size_text_field/auto_size_text_field.dart'; import 'package:flutter/material.dart'; 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'; import 'home_page.dart'; import 'scan_page.dart'; import 'settings_page.dart'; /// Connection page for connecting to a remote peer. class ConnectionPage extends StatefulWidget implements PageShape { ConnectionPage({Key? key}) : super(key: key); @override final icon = const Icon(Icons.connected_tv); @override final title = translate("Connection"); @override final appBarActions = isWeb ? [const WebMenu()] : []; @override State createState() => _ConnectionPageState(); } /// State for the connection page. class _ConnectionPageState extends State { /// Controller for the id input bar. final _idController = IDTextEditingController(); final RxBool _idEmpty = true.obs; /// Update url. If it's not null, means an update is available. var _updateUrl = ''; List peers = []; bool isPeersLoading = false; bool isPeersLoaded = false; StreamSubscription? _uniLinksSubscription; @override void initState() { super.initState(); if (!isWeb) _uniLinksSubscription = listenUniLinks(); if (_idController.text.isEmpty) { () async { final lastRemoteId = await bind.mainGetLastRemoteId(); if (lastRemoteId != _idController.id) { setState(() { _idController.id = lastRemoteId; }); } }(); } if (isAndroid) { Timer(const Duration(seconds: 1), () async { _updateUrl = await bind.mainGetSoftwareUpdateUrl(); if (_updateUrl.isNotEmpty) setState(() {}); }); } _idController.addListener(() { _idEmpty.value = _idController.text.isEmpty; }); Get.put(_idController); } @override Widget build(BuildContext context) { Provider.of(context); return CustomScrollView( slivers: [ SliverList( delegate: SliverChildListDelegate([ if (!bind.isCustomClient()) _buildUpdateUI(), _buildRemoteIDTextField(), ])), SliverFillRemaining( hasScrollBody: true, child: PeerTabPage(), ) ], ).marginOnly(top: 2, left: 10, right: 10); } /// Callback for the connect button. /// Connects to the selected peer. void onConnect() { var id = _idController.id; connect(context, id); } /// UI for software update. /// If [_updateUrl] is not empty, shows a button to update the software. Widget _buildUpdateUI() { return _updateUrl.isEmpty ? const SizedBox(height: 0) : InkWell( onTap: () async { final url = 'https://rustdesk.com/download'; if (await canLaunchUrl(Uri.parse(url))) { await launchUrl(Uri.parse(url)); } }, child: Container( alignment: AlignmentDirectional.center, width: double.infinity, color: Colors.pinkAccent, padding: const EdgeInsets.symmetric(vertical: 12), child: Text(translate('Download new version'), style: const TextStyle( 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() { final w = SizedBox( height: 84, child: Padding( padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 2), child: Ink( decoration: BoxDecoration( color: Theme.of(context).cardColor, borderRadius: BorderRadius.all(Radius.circular(13)), ), child: Row( children: [ Expanded( child: Container( padding: const EdgeInsets.only(left: 16, right: 16), 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: '', password: '', 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, fontSize: 30, color: MyTheme.idColor, ), decoration: InputDecoration( labelText: translate('Remote ID'), // hintText: 'Enter your remote ID', border: InputBorder.none, helperStyle: const TextStyle( fontWeight: FontWeight.bold, fontSize: 16, color: MyTheme.darkGray, ), labelStyle: const TextStyle( fontWeight: FontWeight.w600, fontSize: 16, letterSpacing: 0.2, color: MyTheme.darkGray, ), ), inputFormatters: [IDTextInputFormatter()], ); }, onSelected: (option) { setState(() { _idController.id = option.id; FocusScope.of(context).unfocus(); }); }, optionsViewBuilder: (BuildContext context, AutocompleteOnSelected onSelected, Iterable options) { double maxHeight = options.length * 50; if (options.length == 1) { maxHeight = 52; } else if (options.length == 3) { maxHeight = 146; } else if (options.length == 4) { maxHeight = 193; } maxHeight = maxHeight.clamp(0, 200); return Align( alignment: Alignment.topLeft, child: Container( decoration: BoxDecoration( boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.3), blurRadius: 5, spreadRadius: 1, ), ], ), 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( onSelect: () => onSelected( peer), peer: peer)) .toList(), )))))); }, ), ), ), Obx(() => Offstage( offstage: _idEmpty.value, child: IconButton( onPressed: () { setState(() { _idController.clear(); }); }, icon: Icon(Icons.clear, color: MyTheme.darkGray)), )), SizedBox( width: 60, height: 60, child: IconButton( icon: const Icon(Icons.arrow_forward, color: MyTheme.darkGray, size: 45), onPressed: onConnect, ), ), ], ), ), ), ); return Align( alignment: Alignment.topCenter, child: Container(constraints: kMobilePageConstraints, child: w)); } @override void dispose() { _uniLinksSubscription?.cancel(); _idController.dispose(); if (Get.isRegistered()) { Get.delete(); } super.dispose(); } } class WebMenu extends StatefulWidget { const WebMenu({Key? key}) : super(key: key); @override State createState() => _WebMenuState(); } class _WebMenuState extends State { @override Widget build(BuildContext context) { Provider.of(context); return PopupMenuButton( tooltip: "", icon: const Icon(Icons.more_vert), itemBuilder: (context) { return (isIOS ? [ const PopupMenuItem( value: "scan", child: Icon(Icons.qr_code_scanner, color: Colors.black), ) ] : >[]) + [ PopupMenuItem( value: "server", child: Text(translate('ID/Relay Server')), ) ] + [ PopupMenuItem( value: "login", child: Text(gFFI.userModel.userName.value.isEmpty ? translate("Login") : '${translate("Logout")} (${gFFI.userModel.userName.value})'), ) ] + [ PopupMenuItem( value: "about", child: Text('${translate('About')} RustDesk'), ) ]; }, onSelected: (value) { if (value == 'server') { showServerSettings(gFFI.dialogManager); } if (value == 'about') { showAbout(gFFI.dialogManager); } if (value == 'login') { if (gFFI.userModel.userName.value.isEmpty) { loginDialog(); } else { logOutConfirmDialog(); } } if (value == 'scan') { Navigator.push( context, MaterialPageRoute( builder: (BuildContext context) => ScanPage(), ), ); } }); } }