Initial commit: RustDesk 1.4.7 macOS desktop port

- Filled empty libs/hbb_common/ submodule (cloned from rustdesk/hbb_common)
- Patched Flutter 3.44 / Dart 3.12 compatibility:
  * flutter/lib/generated_bridge.dart: asTypedList with cast<>, DartPort=Int64
  * flutter/lib/common.dart: DialogTheme->DialogThemeData, TabBarTheme->TabBarThemeData
  * flutter/pubspec.yaml: extended_text 14.0.0->15.0.2, google_fonts override 5.0.0
  * flutter/macos/Runner/Configs/Release.xcconfig: EXCLUDED_ARCHS=x86_64
- Build verified: cargo check + cargo build --features flutter + cargo build --release --features flutter
- Verified flutter build macos --debug and --release both produce working .app
- Verified .dmg installer (27MB arm64) created via hdiutil
- Build deps: Xcode 26.5, CocoaPods 1.16.2, Flutter 3.44, VCPKG arm64-osx
This commit is contained in:
xuwenwei
2026-06-08 21:42:20 +08:00
parent 3d8db09123
commit d3b6026bfd
840 changed files with 291780 additions and 1 deletions
@@ -0,0 +1,377 @@
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:flutter_hbb/common/widgets/connection_page_title.dart';
import 'package:flutter_hbb/models/state_model.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/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';
/// Connection page for connecting to a remote peer.
class ConnectionPage extends StatefulWidget implements PageShape {
ConnectionPage({Key? key, required this.appBarActions}) : super(key: key);
@override
final icon = const Icon(Icons.connected_tv);
@override
final title = translate("Connection");
@override
final List<Widget> appBarActions;
@override
State<ConnectionPage> createState() => _ConnectionPageState();
}
/// State for the connection page.
class _ConnectionPageState extends State<ConnectionPage> {
/// Controller for the id input bar.
final _idController = IDTextEditingController();
final RxBool _idEmpty = true.obs;
final FocusNode _idFocusNode = FocusNode();
final TextEditingController _idEditingController = TextEditingController();
final AllPeersLoader _allPeersLoader = AllPeersLoader();
StreamSubscription? _uniLinksSubscription;
// https://github.com/flutter/flutter/issues/157244
Iterable<Peer> _autocompleteOpts = [];
_ConnectionPageState() {
if (!isWeb) _uniLinksSubscription = listenUniLinks();
_idController.addListener(() {
_idEmpty.value = _idController.text.isEmpty;
});
Get.put<IDTextEditingController>(_idController);
}
@override
void initState() {
super.initState();
_allPeersLoader.init(setState);
_idFocusNode.addListener(onFocusChanged);
if (_idController.text.isEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) async {
final lastRemoteId = await bind.mainGetLastRemoteId();
if (lastRemoteId != _idController.id) {
setState(() {
_idController.id = lastRemoteId;
});
}
});
}
Get.put<TextEditingController>(_idEditingController);
}
@override
Widget build(BuildContext context) {
Provider.of<FfiModel>(context);
return CustomScrollView(
slivers: [
SliverList(
delegate: SliverChildListDelegate([
if (!bind.isCustomClient() && !isIOS)
Obx(() => _buildUpdateUI(stateGlobal.updateUrl.value)),
_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);
}
void onFocusChanged() {
_idEmpty.value = _idEditingController.text.isEmpty;
if (_idFocusNode.hasFocus) {
if (_allPeersLoader.needLoad) {
_allPeersLoader.getAllPeers();
}
final textLength = _idEditingController.value.text.length;
// Select all to facilitate removing text, just following the behavior of address input of chrome.
_idEditingController.selection =
TextSelection(baseOffset: 0, extentOffset: textLength);
}
}
/// UI for software update.
/// If _updateUrl] is not empty, shows a button to update the software.
Widget _buildUpdateUI(String updateUrl) {
return updateUrl.isEmpty
? const SizedBox(height: 0)
: InkWell(
onTap: () async {
final url = 'https://rustdesk.com/download';
// https://pub.dev/packages/url_launcher#configuration
// https://developer.android.com/training/package-visibility/use-cases#open-urls-custom-tabs
//
// `await launchUrl(Uri.parse(url))` can also run if skip
// 1. The following check
// 2. `<action android:name="android.support.customtabs.action.CustomTabsService" />` in AndroidManifest.xml
//
// But it is better to add the check.
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))));
}
/// 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: <Widget>[
Expanded(
child: Container(
padding: const EdgeInsets.only(left: 16, right: 16),
child: RawAutocomplete<Peer>(
optionsBuilder: (TextEditingValue textEditingValue) {
if (textEditingValue.text == '') {
_autocompleteOpts = const Iterable<Peer>.empty();
} else if (_allPeersLoader.peers.isEmpty &&
!_allPeersLoader.isPeersLoaded) {
Peer emptyPeer = Peer(
id: '',
username: '',
hostname: '',
alias: '',
platform: '',
tags: [],
hash: '',
password: '',
forceAlwaysRelay: false,
rdpPort: '',
rdpUsername: '',
loginName: '',
device_group_name: '',
note: '',
);
_autocompleteOpts = [emptyPeer];
} else {
String textWithoutSpaces =
textEditingValue.text.replaceAll(" ", "");
if (int.tryParse(textWithoutSpaces) != null) {
textEditingValue = TextEditingValue(
text: textWithoutSpaces,
selection: textEditingValue.selection,
);
}
String textToFind = textEditingValue.text.toLowerCase();
_autocompleteOpts = _allPeersLoader.peers
.where((peer) =>
peer.id.toLowerCase().contains(textToFind) ||
peer.username
.toLowerCase()
.contains(textToFind) ||
peer.hostname
.toLowerCase()
.contains(textToFind) ||
peer.alias.toLowerCase().contains(textToFind))
.toList();
}
return _autocompleteOpts;
},
focusNode: _idFocusNode,
textEditingController: _idEditingController,
fieldViewBuilder: (BuildContext context,
TextEditingController fieldTextEditingController,
FocusNode fieldFocusNode,
VoidCallback onFieldSubmitted) {
updateTextAndPreserveSelection(
fieldTextEditingController, _idController.text);
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()],
onSubmitted: (_) {
onConnect();
},
);
},
onSelected: (option) {
setState(() {
_idController.id = option.id;
FocusScope.of(context).unfocus();
});
},
optionsViewBuilder: (BuildContext context,
AutocompleteOnSelected<Peer> onSelected,
Iterable<Peer> options) {
options = _autocompleteOpts;
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: _allPeersLoader
.peers.isEmpty &&
!_allPeersLoader.isPeersLoaded
? 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,
),
),
],
),
),
),
);
final child = Column(children: [
if (isWebDesktop)
getConnectionPageTitle(context, true)
.marginOnly(bottom: 10, top: 15, left: 12),
w
]);
return Align(
alignment: Alignment.topCenter,
child: Container(constraints: kMobilePageConstraints, child: child));
}
@override
void dispose() {
_uniLinksSubscription?.cancel();
_idController.dispose();
_idFocusNode.removeListener(onFocusChanged);
_allPeersLoader.clear();
_idFocusNode.dispose();
_idEditingController.dispose();
if (Get.isRegistered<IDTextEditingController>()) {
Get.delete<IDTextEditingController>();
}
if (Get.isRegistered<TextEditingController>()) {
Get.delete<TextEditingController>();
}
super.dispose();
}
}
@@ -0,0 +1,769 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_breadcrumb/flutter_breadcrumb.dart';
import 'package:flutter_hbb/models/file_model.dart';
import 'package:get/get.dart';
import 'package:toggle_switch/toggle_switch.dart';
import '../../common.dart';
import '../../common/widgets/dialog.dart';
class FileManagerPage extends StatefulWidget {
FileManagerPage(
{Key? key,
required this.id,
this.password,
this.isSharedPassword,
this.forceRelay})
: super(key: key);
final String id;
final String? password;
final bool? isSharedPassword;
final bool? forceRelay;
@override
State<StatefulWidget> createState() => _FileManagerPageState();
}
enum SelectMode { local, remote, none }
extension SelectModeEq on SelectMode {
bool eq(bool? currentIsLocal) {
if (currentIsLocal == null) {
return false;
}
if (currentIsLocal) {
return this == SelectMode.local;
} else {
return this == SelectMode.remote;
}
}
}
extension SelectModeExt on Rx<SelectMode> {
void toggle(bool currentIsLocal) {
switch (value) {
case SelectMode.local:
value = SelectMode.none;
break;
case SelectMode.remote:
value = SelectMode.none;
break;
case SelectMode.none:
if (currentIsLocal) {
value = SelectMode.local;
} else {
value = SelectMode.remote;
}
break;
}
}
}
class _FileManagerPageState extends State<FileManagerPage> {
final model = gFFI.fileModel;
final selectMode = SelectMode.none.obs;
var showLocal = true;
FileController get currentFileController =>
showLocal ? model.localController : model.remoteController;
FileDirectory get currentDir => currentFileController.directory.value;
DirectoryOptions get currentOptions => currentFileController.options.value;
final _uniqueKey = UniqueKey();
@override
void initState() {
super.initState();
gFFI.start(widget.id,
isFileTransfer: true,
password: widget.password,
isSharedPassword: widget.isSharedPassword,
forceRelay: widget.forceRelay);
WidgetsBinding.instance.addPostFrameCallback((_) {
gFFI.dialogManager
.showLoading(translate('Connecting...'), onCancel: closeConnection);
});
gFFI.ffiModel.updateEventListener(gFFI.sessionId, widget.id);
WakelockManager.enable(_uniqueKey);
}
@override
void dispose() {
model.close().whenComplete(() {
gFFI.close();
gFFI.dialogManager.dismissAll();
WakelockManager.disable(_uniqueKey);
});
model.jobController.clear();
super.dispose();
}
@override
Widget build(BuildContext context) => WillPopScope(
onWillPop: () async {
if (selectMode.value != SelectMode.none) {
selectMode.value = SelectMode.none;
setState(() {});
} else {
currentFileController.goBack();
}
return false;
},
child: Scaffold(
// backgroundColor: MyTheme.grayBg,
appBar: AppBar(
leading: Row(children: [
IconButton(
icon: Icon(Icons.close),
onPressed: () => clientClose(gFFI.sessionId, gFFI)),
]),
centerTitle: true,
title: ToggleSwitch(
initialLabelIndex: showLocal ? 0 : 1,
activeBgColor: [MyTheme.idColor],
inactiveBgColor: Theme.of(context).brightness == Brightness.light
? MyTheme.grayBg
: null,
inactiveFgColor: Theme.of(context).brightness == Brightness.light
? Colors.black54
: null,
totalSwitches: 2,
minWidth: 100,
fontSize: 15,
iconSize: 18,
labels: [translate("Local"), translate("Remote")],
icons: [Icons.phone_android_sharp, Icons.screen_share],
onToggle: (index) {
final current = showLocal ? 0 : 1;
if (index != current) {
setState(() => showLocal = !showLocal);
}
},
),
actions: [
PopupMenuButton<String>(
tooltip: "",
icon: Icon(Icons.more_vert),
itemBuilder: (context) {
return [
PopupMenuItem(
child: Row(
children: [
Icon(Icons.refresh,
color: Theme.of(context).iconTheme.color),
SizedBox(width: 5),
Text(translate("Refresh File"))
],
),
value: "refresh",
),
PopupMenuItem(
enabled: currentDir.path != "/",
child: Row(
children: [
Icon(Icons.check,
color: Theme.of(context).iconTheme.color),
SizedBox(width: 5),
Text(translate("Multi Select"))
],
),
value: "select",
),
PopupMenuItem(
enabled: currentDir.path != "/",
child: Row(
children: [
Icon(Icons.folder_outlined,
color: Theme.of(context).iconTheme.color),
SizedBox(width: 5),
Text(translate("Create Folder"))
],
),
value: "folder",
),
PopupMenuItem(
enabled: currentDir.path != "/",
child: Row(
children: [
Icon(
currentOptions.showHidden
? Icons.check_box_outlined
: Icons.check_box_outline_blank,
color: Theme.of(context).iconTheme.color),
SizedBox(width: 5),
Text(translate("Show Hidden Files"))
],
),
value: "hidden",
)
];
},
onSelected: (v) {
if (v == "refresh") {
currentFileController.refresh();
} else if (v == "select") {
model.localController.selectedItems.clear();
model.remoteController.selectedItems.clear();
selectMode.toggle(showLocal);
setState(() {});
} else if (v == "folder") {
final name = TextEditingController();
String? errorText;
gFFI.dialogManager.show((setState, close, context) {
name.addListener(() {
if (errorText != null) {
setState(() {
errorText = null;
});
}
});
return CustomAlertDialog(
title: Text(translate("Create Folder")),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
decoration: InputDecoration(
labelText:
translate("Please enter the folder name"),
errorText: errorText,
),
controller: name,
).workaroundFreezeLinuxMint(),
],
),
actions: [
dialogButton("Cancel",
onPressed: () => close(false), isOutline: true),
dialogButton("OK", onPressed: () {
if (name.value.text.isNotEmpty) {
if (!PathUtil.validName(
name.value.text,
currentFileController
.options.value.isWindows)) {
setState(() {
errorText =
translate("Invalid folder name");
});
return;
}
currentFileController.createDir(PathUtil.join(
currentDir.path,
name.value.text,
currentOptions.isWindows));
close();
}
})
]);
});
} else if (v == "hidden") {
currentFileController.toggleShowHidden();
}
}),
],
),
body: showLocal
? FileManagerView(
controller: model.localController,
selectMode: selectMode,
)
: FileManagerView(
controller: model.remoteController,
selectMode: selectMode,
),
bottomSheet: bottomSheet(),
));
Widget? bottomSheet() {
return Obx(() {
final selectedItems = getActiveSelectedItems();
final jobTable = model.jobController.jobTable;
final localLabel = selectedItems?.isLocal == null
? ""
: " [${selectedItems!.isLocal ? translate("Local") : translate("Remote")}]";
if (!(selectMode.value == SelectMode.none)) {
final selectedItemsLen =
"${selectedItems?.items.length ?? 0} ${translate("items")}";
if (selectedItems == null ||
selectedItems.items.isEmpty ||
selectMode.value.eq(showLocal)) {
return BottomSheetBody(
leading: Icon(Icons.check),
title: translate("Selected"),
text: selectedItemsLen + localLabel,
onCanceled: () {
selectedItems?.items.clear();
selectMode.value = SelectMode.none;
setState(() {});
},
actions: [
IconButton(
icon: Icon(Icons.compare_arrows),
onPressed: () => setState(() => showLocal = !showLocal),
),
IconButton(
icon: Icon(Icons.delete_forever),
onPressed: selectedItems != null
? () async {
if (selectedItems.items.isNotEmpty) {
await currentFileController
.removeAction(selectedItems);
selectedItems.items.clear();
selectMode.value = SelectMode.none;
}
}
: null,
)
]);
} else {
return BottomSheetBody(
leading: Icon(Icons.input),
title: translate("Paste here?"),
text: selectedItemsLen + localLabel,
onCanceled: () {
selectedItems.items.clear();
selectMode.value = SelectMode.none;
setState(() {});
},
actions: [
IconButton(
icon: Icon(Icons.compare_arrows),
onPressed: () => setState(() => showLocal = !showLocal),
),
IconButton(
icon: Icon(Icons.paste),
onPressed: () {
selectMode.value = SelectMode.none;
final otherSide = showLocal
? model.remoteController
: model.localController;
final thisSideData =
DirectoryData(currentDir, currentOptions);
otherSide.sendFiles(selectedItems, thisSideData);
selectedItems.items.clear();
selectMode.value = SelectMode.none;
},
)
]);
}
}
if (jobTable.isEmpty) {
return Offstage();
}
// Find the first job that is in progress (the one actually transferring data)
// Rust backend processes jobs sequentially, so the first inProgress job is the active one
final activeJob = jobTable
.firstWhereOrNull((job) => job.state == JobState.inProgress) ??
jobTable.last;
switch (activeJob.state) {
case JobState.inProgress:
return BottomSheetBody(
leading: CircularProgressIndicator(),
title: translate("Waiting"),
text:
"${translate("Speed")}: ${readableFileSize(activeJob.speed)}/s",
onCanceled: () {
model.jobController.cancelJob(activeJob.id);
jobTable.clear();
},
);
case JobState.done:
return BottomSheetBody(
leading: Icon(Icons.check),
title: "${translate("Successful")}!",
text: activeJob.display(),
onCanceled: () => jobTable.clear(),
);
case JobState.error:
return BottomSheetBody(
leading: Icon(Icons.error),
title: "${translate("Error")}!",
text: "",
onCanceled: () => jobTable.clear(),
);
case JobState.none:
break;
case JobState.paused:
// TODO: Handle this case.
break;
}
return Offstage();
});
}
SelectedItems? getActiveSelectedItems() {
final localSelectedItems = model.localController.selectedItems;
final remoteSelectedItems = model.remoteController.selectedItems;
if (localSelectedItems.items.isNotEmpty &&
remoteSelectedItems.items.isNotEmpty) {
// assert unreachable
debugPrint("Wrong SelectedItems state, reset");
localSelectedItems.clear();
remoteSelectedItems.clear();
}
if (localSelectedItems.items.isEmpty && remoteSelectedItems.items.isEmpty) {
return null;
}
if (localSelectedItems.items.length > remoteSelectedItems.items.length) {
return localSelectedItems;
} else {
return remoteSelectedItems;
}
}
}
class FileManagerView extends StatefulWidget {
final FileController controller;
final Rx<SelectMode> selectMode;
FileManagerView({required this.controller, required this.selectMode});
@override
State<StatefulWidget> createState() => _FileManagerViewState();
}
class _FileManagerViewState extends State<FileManagerView> {
final _listScrollController = ScrollController();
final _breadCrumbScroller = ScrollController();
late final ascending = Rx<bool>(controller.sortAscending);
bool get isLocal => widget.controller.isLocal;
FileController get controller => widget.controller;
SelectedItems get _selectedItems => widget.controller.selectedItems;
@override
void initState() {
super.initState();
controller.directory.listen((e) => breadCrumbScrollToEnd());
}
@override
Widget build(BuildContext context) {
return Column(children: [
headTools(),
Expanded(child: Obx(() {
final entries = controller.directory.value.entries;
return ListView.builder(
controller: _listScrollController,
itemCount: entries.length + 1,
itemBuilder: (context, index) {
if (index >= entries.length) {
return listTail();
}
var selected = false;
if (widget.selectMode.value != SelectMode.none) {
selected = _selectedItems.items.contains(entries[index]);
}
final sizeStr = entries[index].isFile
? readableFileSize(entries[index].size.toDouble())
: "";
final showCheckBox = () {
return widget.selectMode.value != SelectMode.none &&
widget.selectMode.value.eq(controller.selectedItems.isLocal);
}();
return Card(
child: ListTile(
leading: entries[index].isDrive
? Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: Image(
image: iconHardDrive,
fit: BoxFit.scaleDown,
color: Theme.of(context)
.iconTheme
.color
?.withOpacity(0.7)))
: Icon(
entries[index].isFile
? Icons.feed_outlined
: Icons.folder,
size: 40),
title: Text(entries[index].name),
selected: selected,
subtitle: entries[index].isDrive
? null
: Text(
"${entries[index].lastModified().toString().replaceAll(".000", "")} $sizeStr",
style: TextStyle(fontSize: 12, color: MyTheme.darkGray),
),
trailing: entries[index].isDrive
? null
: showCheckBox
? Checkbox(
value: selected,
onChanged: (v) {
if (v == null) return;
if (v && !selected) {
_selectedItems.add(entries[index]);
} else if (!v && selected) {
_selectedItems.remove(entries[index]);
}
setState(() {});
})
: PopupMenuButton<String>(
tooltip: "",
icon: Icon(Icons.more_vert),
itemBuilder: (context) {
return [
PopupMenuItem(
child: Text(translate("Delete")),
value: "delete",
),
PopupMenuItem(
child: Text(translate("Multi Select")),
value: "multi_select",
),
PopupMenuItem(
child: Text(translate("Properties")),
value: "properties",
enabled: false,
),
if (!entries[index].isDrive &&
versionCmp(gFFI.ffiModel.pi.version,
"1.3.0") >=
0)
PopupMenuItem(
child: Text(translate("Rename")),
value: "rename",
)
];
},
onSelected: (v) {
if (v == "delete") {
final items = SelectedItems(isLocal: isLocal);
items.add(entries[index]);
controller.removeAction(items);
} else if (v == "multi_select") {
_selectedItems.clear();
widget.selectMode.toggle(isLocal);
setState(() {});
} else if (v == "rename") {
controller.renameAction(
entries[index], isLocal);
}
}),
onTap: () {
if (showCheckBox) {
if (selected) {
_selectedItems.remove(entries[index]);
} else {
_selectedItems.add(entries[index]);
}
setState(() {});
return;
}
if (entries[index].isDirectory || entries[index].isDrive) {
controller.openDirectory(entries[index].path);
} else {
// Perform file-related tasks.
}
},
onLongPress: entries[index].isDrive
? null
: () {
_selectedItems.clear();
widget.selectMode.toggle(isLocal);
if (widget.selectMode.value != SelectMode.none) {
_selectedItems.add(entries[index]);
}
setState(() {});
},
),
);
},
);
}))
]);
}
void breadCrumbScrollToEnd() {
Future.delayed(Duration(milliseconds: 200), () {
if (_breadCrumbScroller.hasClients) {
_breadCrumbScroller.animateTo(
_breadCrumbScroller.position.maxScrollExtent,
duration: Duration(milliseconds: 200),
curve: Curves.fastLinearToSlowEaseIn);
}
});
}
Widget headTools() => Container(
child: Row(
children: [
Expanded(child: Obx(() {
final home = controller.options.value.home;
final isWindows = controller.options.value.isWindows;
return BreadCrumb(
items: getPathBreadCrumbItems(controller.shortPath, isWindows,
() => controller.goToHomeDirectory(), (list) {
var path = "";
if (home.startsWith(list[0])) {
// absolute path
for (var item in list) {
path = PathUtil.join(path, item, isWindows);
}
} else {
path += home;
for (var item in list) {
path = PathUtil.join(path, item, isWindows);
}
}
controller.openDirectory(path);
}),
divider: Icon(Icons.chevron_right),
overflow: ScrollableOverflow(controller: _breadCrumbScroller),
);
})),
Row(
children: [
IconButton(
icon: Icon(Icons.arrow_back),
onPressed: controller.goBack,
),
IconButton(
icon: Icon(Icons.arrow_upward),
onPressed: controller.goToParentDirectory,
),
PopupMenuButton<SortBy>(
tooltip: "",
icon: Icon(Icons.sort),
itemBuilder: (context) {
return SortBy.values
.map((e) => PopupMenuItem(
child: Text(translate(e.toString())),
value: e,
))
.toList();
},
onSelected: (sortBy) {
// If selecting the same sort option, flip the order
// If selecting a different sort option, use ascending order
if (controller.sortBy.value == sortBy) {
ascending.value = !controller.sortAscending;
} else {
ascending.value = true;
}
controller.changeSortStyle(sortBy,
ascending: ascending.value);
}),
],
)
],
));
Widget listTail() => Obx(() => Container(
height: 100,
child: Column(
children: [
Padding(
padding: EdgeInsets.fromLTRB(30, 5, 30, 0),
child: Text(
controller.directory.value.path,
style: TextStyle(color: MyTheme.darkGray),
),
),
Padding(
padding: EdgeInsets.all(2),
child: Text(
"${translate("Total")}: ${controller.directory.value.entries.length} ${translate("items")}",
style: TextStyle(color: MyTheme.darkGray),
),
)
],
),
));
List<BreadCrumbItem> getPathBreadCrumbItems(String shortPath, bool isWindows,
void Function() onHome, void Function(List<String>) onPressed) {
final list = PathUtil.split(shortPath, isWindows);
final breadCrumbList = [
BreadCrumbItem(
content: IconButton(
icon: Icon(Icons.home_filled),
onPressed: onHome,
))
];
breadCrumbList.addAll(list.asMap().entries.map((e) => BreadCrumbItem(
content: TextButton(
child: Text(e.value),
style:
ButtonStyle(minimumSize: MaterialStateProperty.all(Size(0, 0))),
onPressed: () => onPressed(list.sublist(0, e.key + 1))))));
return breadCrumbList;
}
}
class BottomSheetBody extends StatelessWidget {
BottomSheetBody(
{required this.leading,
required this.title,
required this.text,
this.onCanceled,
this.actions});
final Widget leading;
final String title;
final String text;
final VoidCallback? onCanceled;
final List<IconButton>? actions;
@override
BottomSheet build(BuildContext context) {
// ignore: no_leading_underscores_for_local_identifiers
final _actions = actions ?? [];
return BottomSheet(
builder: (BuildContext context) {
return Container(
height: 65,
alignment: Alignment.centerLeft,
decoration: BoxDecoration(
color: MyTheme.accent50,
borderRadius: BorderRadius.vertical(top: Radius.circular(10))),
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 15),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
leading,
SizedBox(width: 16),
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: TextStyle(fontSize: 18)),
Text(text,
style: TextStyle(fontSize: 14)) // TODO color
],
)
],
),
Row(children: () {
_actions.add(IconButton(
icon: Icon(Icons.cancel_outlined),
onPressed: onCanceled,
));
return _actions;
}())
],
),
));
},
onClosing: () {},
// backgroundColor: MyTheme.grayBg,
enableDrag: false,
);
}
}
+255
View File
@@ -0,0 +1,255 @@
import 'package:flutter/material.dart';
import 'package:flutter_hbb/mobile/pages/server_page.dart';
import 'package:flutter_hbb/mobile/pages/settings_page.dart';
import 'package:flutter_hbb/web/settings_page.dart';
import 'package:get/get.dart';
import '../../common.dart';
import '../../common/widgets/chat_page.dart';
import '../../models/platform_model.dart';
import '../../models/state_model.dart';
import 'connection_page.dart';
abstract class PageShape extends Widget {
final String title = "";
final Widget icon = Icon(null);
final List<Widget> appBarActions = [];
}
class HomePage extends StatefulWidget {
static final homeKey = GlobalKey<HomePageState>();
HomePage() : super(key: homeKey);
@override
HomePageState createState() => HomePageState();
}
class HomePageState extends State<HomePage> {
var _selectedIndex = 0;
int get selectedIndex => _selectedIndex;
final List<PageShape> _pages = [];
int _chatPageTabIndex = -1;
bool get isChatPageCurrentTab => isAndroid
? _selectedIndex == _chatPageTabIndex
: false; // change this when ios have chat page
void refreshPages() {
setState(() {
initPages();
});
}
@override
void initState() {
super.initState();
initPages();
}
void initPages() {
_pages.clear();
if (!bind.isIncomingOnly()) {
_pages.add(ConnectionPage(
appBarActions: [],
));
}
if (isAndroid && !bind.isOutgoingOnly()) {
_chatPageTabIndex = _pages.length;
_pages.addAll([ChatPage(type: ChatPageType.mobileMain), ServerPage()]);
}
_pages.add(SettingsPage());
}
@override
Widget build(BuildContext context) {
return WillPopScope(
onWillPop: () async {
if (_selectedIndex != 0) {
setState(() {
_selectedIndex = 0;
});
} else {
return true;
}
return false;
},
child: Scaffold(
// backgroundColor: MyTheme.grayBg,
appBar: AppBar(
centerTitle: true,
title: appTitle(),
actions: _pages.elementAt(_selectedIndex).appBarActions,
),
bottomNavigationBar: BottomNavigationBar(
key: navigationBarKey,
items: _pages
.map((page) =>
BottomNavigationBarItem(icon: page.icon, label: page.title))
.toList(),
currentIndex: _selectedIndex,
type: BottomNavigationBarType.fixed,
selectedItemColor: MyTheme.accent, //
unselectedItemColor: MyTheme.darkGray,
onTap: (index) => setState(() {
// close chat overlay when go chat page
if (_selectedIndex != index) {
_selectedIndex = index;
if (isChatPageCurrentTab) {
gFFI.chatModel.hideChatIconOverlay();
gFFI.chatModel.hideChatWindowOverlay();
gFFI.chatModel.mobileClearClientUnread(
gFFI.chatModel.currentKey.connId);
}
}
}),
),
body: _pages.elementAt(_selectedIndex),
));
}
Widget appTitle() {
final currentUser = gFFI.chatModel.currentUser;
final currentKey = gFFI.chatModel.currentKey;
if (isChatPageCurrentTab &&
currentUser != null &&
currentKey.peerId.isNotEmpty) {
final connected =
gFFI.serverModel.clients.any((e) => e.id == currentKey.connId);
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Tooltip(
message: currentKey.isOut
? translate('Outgoing connection')
: translate('Incoming connection'),
child: Icon(
currentKey.isOut
? Icons.call_made_rounded
: Icons.call_received_rounded,
),
),
Expanded(
child: Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
"${currentUser.firstName} ${currentUser.id}",
),
if (connected)
Container(
width: 10,
height: 10,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Color.fromARGB(255, 133, 246, 199)),
).marginSymmetric(horizontal: 2),
],
),
),
),
],
);
}
return Text(bind.mainGetAppNameSync());
}
}
class WebHomePage extends StatelessWidget {
final connectionPage =
ConnectionPage(appBarActions: <Widget>[const WebSettingsPage()]);
@override
Widget build(BuildContext context) {
stateGlobal.isInMainPage = true;
handleUnilink(context);
return Scaffold(
// backgroundColor: MyTheme.grayBg,
appBar: AppBar(
centerTitle: true,
title: Text("${bind.mainGetAppNameSync()} (Preview)"),
actions: connectionPage.appBarActions,
),
body: connectionPage,
);
}
handleUnilink(BuildContext context) {
if (webInitialLink.isEmpty) {
return;
}
final link = webInitialLink;
webInitialLink = '';
final splitter = ["/#/", "/#", "#/", "#"];
var fakelink = '';
for (var s in splitter) {
if (link.contains(s)) {
var list = link.split(s);
if (list.length < 2 || list[1].isEmpty) {
return;
}
list.removeAt(0);
fakelink = "rustdesk://${list.join(s)}";
break;
}
}
if (fakelink.isEmpty) {
return;
}
final uri = Uri.tryParse(fakelink);
if (uri == null) {
return;
}
final args = urlLinkToCmdArgs(uri);
if (args == null || args.isEmpty) {
return;
}
bool isFileTransfer = false;
bool isViewCamera = false;
bool isTerminal = false;
String? id;
String? password;
for (int i = 0; i < args.length; i++) {
switch (args[i]) {
case '--connect':
case '--play':
id = args[i + 1];
i++;
break;
case '--file-transfer':
isFileTransfer = true;
id = args[i + 1];
i++;
break;
case '--view-camera':
isViewCamera = true;
id = args[i + 1];
i++;
break;
case '--terminal':
isTerminal = true;
id = args[i + 1];
i++;
break;
case '--terminal-admin':
setEnvTerminalAdmin();
isTerminal = true;
id = args[i + 1];
i++;
break;
case '--password':
password = args[i + 1];
i++;
break;
default:
break;
}
}
if (id != null) {
connect(context, id,
isFileTransfer: isFileTransfer,
isViewCamera: isViewCamera,
isTerminal: isTerminal,
password: password);
}
}
}
File diff suppressed because it is too large Load Diff
+165
View File
@@ -0,0 +1,165 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image/image.dart' as img;
import 'package:image_picker/image_picker.dart';
import 'package:qr_code_scanner/qr_code_scanner.dart';
import 'package:zxing2/qrcode.dart';
import '../../common.dart';
import '../../models/platform_model.dart';
import '../widgets/dialog.dart';
class ScanPage extends StatefulWidget {
@override
State<ScanPage> createState() => _ScanPageState();
}
class _ScanPageState extends State<ScanPage> {
QRViewController? controller;
final GlobalKey qrKey = GlobalKey(debugLabel: 'QR');
StreamSubscription? scanSubscription;
@override
void reassemble() {
super.reassemble();
if (isAndroid && controller != null) {
controller!.pauseCamera();
} else if (controller != null) {
controller!.resumeCamera();
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Scan QR'),
actions: [
_buildImagePickerButton(),
_buildFlashToggleButton(),
_buildCameraSwitchButton(),
],
),
body: _buildQrView(context),
);
}
Widget _buildQrView(BuildContext context) {
var scanArea = MediaQuery.of(context).size.width < 400 ||
MediaQuery.of(context).size.height < 400
? 150.0
: 300.0;
return QRView(
key: qrKey,
onQRViewCreated: _onQRViewCreated,
overlay: QrScannerOverlayShape(
borderColor: Colors.red,
borderRadius: 10,
borderLength: 30,
borderWidth: 10,
cutOutSize: scanArea,
),
onPermissionSet: (ctrl, p) => _onPermissionSet(context, ctrl, p),
);
}
void _onQRViewCreated(QRViewController controller) {
setState(() {
this.controller = controller;
});
scanSubscription = controller.scannedDataStream.listen((scanData) {
if (scanData.code != null) {
showServerSettingFromQr(scanData.code!);
}
});
}
void _onPermissionSet(BuildContext context, QRViewController ctrl, bool p) {
if (!p) {
showToast('No permission');
}
}
Future<void> _pickImage() async {
final ImagePicker picker = ImagePicker();
final XFile? file = await picker.pickImage(source: ImageSource.gallery);
if (file != null) {
try {
var image = img.decodeImage(await File(file.path).readAsBytes())!;
LuminanceSource source = RGBLuminanceSource(
image.width,
image.height,
image.getBytes(order: img.ChannelOrder.abgr).buffer.asInt32List(),
);
var bitmap = BinaryBitmap(HybridBinarizer(source));
var reader = QRCodeReader();
var result = reader.decode(bitmap);
if (result.text.startsWith(bind.mainUriPrefixSync())) {
handleUriLink(uriString: result.text);
} else {
showServerSettingFromQr(result.text);
}
} catch (e) {
showToast('No QR code found');
}
}
}
Widget _buildImagePickerButton() {
return IconButton(
color: Colors.white,
icon: Icon(Icons.image_search),
iconSize: 32.0,
onPressed: _pickImage,
);
}
Widget _buildFlashToggleButton() {
return IconButton(
color: Colors.yellow,
icon: Icon(Icons.flash_on),
iconSize: 32.0,
onPressed: () async {
await controller?.toggleFlash();
},
);
}
Widget _buildCameraSwitchButton() {
return IconButton(
color: Colors.white,
icon: Icon(Icons.switch_camera),
iconSize: 32.0,
onPressed: () async {
await controller?.flipCamera();
},
);
}
@override
void dispose() {
scanSubscription?.cancel();
controller?.dispose();
super.dispose();
}
void showServerSettingFromQr(String data) async {
closeConnection();
await controller?.pauseCamera();
if (!data.startsWith('config=')) {
showToast('Invalid QR code');
return;
}
try {
final sc = ServerConfig.decode(data.substring(7));
Timer(Duration(milliseconds: 60), () {
showServerSettingsWithValue(sc, gFFI.dialogManager, null);
});
} catch (e) {
showToast('Invalid QR code');
}
}
}
+965
View File
@@ -0,0 +1,965 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hbb/desktop/pages/desktop_home_page.dart';
import 'package:flutter_hbb/mobile/widgets/dialog.dart';
import 'package:flutter_hbb/models/chat_model.dart';
import 'package:get/get.dart';
import 'package:provider/provider.dart';
import '../../common.dart';
import '../../common/widgets/dialog.dart';
import '../../consts.dart';
import '../../models/platform_model.dart';
import '../../models/server_model.dart';
import 'home_page.dart';
class ServerPage extends StatefulWidget implements PageShape {
@override
final title = translate("Share screen");
@override
final icon = const Icon(Icons.mobile_screen_share);
@override
final appBarActions = (!bind.isDisableSettings() &&
bind.mainGetBuildinOption(key: kOptionHideSecuritySetting) != 'Y')
? [_DropDownAction()]
: [];
ServerPage({Key? key}) : super(key: key);
@override
State<StatefulWidget> createState() => _ServerPageState();
}
class _DropDownAction extends StatelessWidget {
_DropDownAction();
// should only have one action
final actions = [
PopupMenuButton<String>(
tooltip: "",
icon: const Icon(Icons.more_vert),
itemBuilder: (context) {
listTile(String text, bool checked) {
return ListTile(
title: Text(translate(text)),
trailing: Icon(
Icons.check,
color: checked ? null : Colors.transparent,
));
}
final approveMode = gFFI.serverModel.approveMode;
final verificationMethod = gFFI.serverModel.verificationMethod;
final showPasswordOption = approveMode != 'click';
final isApproveModeFixed = isOptionFixed(kOptionApproveMode);
final isNumericOneTimePasswordFixed =
isOptionFixed(kOptionAllowNumericOneTimePassword);
final isAllowNumericOneTimePassword =
gFFI.serverModel.allowNumericOneTimePassword;
return [
if (!isChangeIdDisabled())
PopupMenuItem(
enabled: gFFI.serverModel.connectStatus > 0,
value: "changeID",
child: Text(translate("Change ID")),
),
if (!isChangeIdDisabled()) const PopupMenuDivider(),
PopupMenuItem(
value: 'AcceptSessionsViaPassword',
child: listTile(
'Accept sessions via password', approveMode == 'password'),
enabled: !isApproveModeFixed,
),
PopupMenuItem(
value: 'AcceptSessionsViaClick',
child:
listTile('Accept sessions via click', approveMode == 'click'),
enabled: !isApproveModeFixed,
),
PopupMenuItem(
value: "AcceptSessionsViaBoth",
child: listTile("Accept sessions via both",
approveMode != 'password' && approveMode != 'click'),
enabled: !isApproveModeFixed,
),
if (showPasswordOption) const PopupMenuDivider(),
if (showPasswordOption &&
verificationMethod != kUseTemporaryPassword &&
!isChangePermanentPasswordDisabled())
PopupMenuItem(
value: "setPermanentPassword",
child: Text(translate("Set permanent password")),
),
if (showPasswordOption &&
verificationMethod != kUsePermanentPassword)
PopupMenuItem(
value: "setTemporaryPasswordLength",
child: Text(translate("One-time password length")),
),
if (showPasswordOption &&
verificationMethod != kUsePermanentPassword)
PopupMenuItem(
value: "allowNumericOneTimePassword",
child: listTile(translate("Numeric one-time password"),
isAllowNumericOneTimePassword),
enabled: !isNumericOneTimePasswordFixed,
),
if (showPasswordOption) const PopupMenuDivider(),
if (showPasswordOption)
PopupMenuItem(
value: kUseTemporaryPassword,
child: listTile('Use one-time password',
verificationMethod == kUseTemporaryPassword),
),
if (showPasswordOption)
PopupMenuItem(
value: kUsePermanentPassword,
child: listTile('Use permanent password',
verificationMethod == kUsePermanentPassword),
),
if (showPasswordOption)
PopupMenuItem(
value: kUseBothPasswords,
child: listTile(
'Use both passwords',
verificationMethod != kUseTemporaryPassword &&
verificationMethod != kUsePermanentPassword),
),
];
},
onSelected: (value) async {
if (value == "changeID") {
changeIdDialog();
} else if (value == "setPermanentPassword") {
setPasswordDialog();
} else if (value == "setTemporaryPasswordLength") {
setTemporaryPasswordLengthDialog(gFFI.dialogManager);
} else if (value == "allowNumericOneTimePassword") {
gFFI.serverModel.switchAllowNumericOneTimePassword();
gFFI.serverModel.updatePasswordModel();
} else if (value == kUsePermanentPassword ||
value == kUseTemporaryPassword ||
value == kUseBothPasswords) {
callback() {
bind.mainSetOption(key: kOptionVerificationMethod, value: value);
gFFI.serverModel.updatePasswordModel();
}
if (value == kUsePermanentPassword &&
(await bind.mainGetCommon(key: "permanent-password-set")) !=
"true") {
if (isChangePermanentPasswordDisabled()) {
callback();
return;
}
setPasswordDialog(notEmptyCallback: callback);
} else {
callback();
}
} else if (value.startsWith("AcceptSessionsVia")) {
value = value.substring("AcceptSessionsVia".length);
if (value == "Password") {
gFFI.serverModel.setApproveMode('password');
} else if (value == "Click") {
gFFI.serverModel.setApproveMode('click');
} else {
gFFI.serverModel.setApproveMode(defaultOptionApproveMode);
}
}
})
];
@override
Widget build(BuildContext context) {
return actions[0];
}
}
class _ServerPageState extends State<ServerPage> {
Timer? _updateTimer;
@override
void initState() {
super.initState();
_updateTimer = periodic_immediate(const Duration(seconds: 3), () async {
await gFFI.serverModel.fetchID();
});
gFFI.serverModel.checkAndroidPermission();
}
@override
void dispose() {
_updateTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
checkService();
return ChangeNotifierProvider.value(
value: gFFI.serverModel,
child: Consumer<ServerModel>(
builder: (context, serverModel, child) => SingleChildScrollView(
controller: gFFI.serverModel.controller,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [
buildPresetPasswordWarningMobile(),
gFFI.serverModel.isStart
? ServerInfo()
: ServiceNotRunningNotification(),
const ConnectionManager(),
const PermissionChecker(),
SizedBox.fromSize(size: const Size(0, 15.0)),
],
),
),
)));
}
}
void checkService() async {
gFFI.invokeMethod("check_service");
// for Android 10/11, request MANAGE_EXTERNAL_STORAGE permission from system setting page
if (AndroidPermissionManager.isWaitingFile() && !gFFI.serverModel.fileOk) {
AndroidPermissionManager.complete(kManageExternalStorage,
await AndroidPermissionManager.check(kManageExternalStorage));
debugPrint("file permission finished");
}
}
class ServiceNotRunningNotification extends StatelessWidget {
ServiceNotRunningNotification({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final serverModel = Provider.of<ServerModel>(context);
return PaddingCard(
title: translate("Service is not running"),
titleIcon:
const Icon(Icons.warning_amber_sharp, color: Colors.redAccent),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(translate("android_start_service_tip"),
style:
const TextStyle(fontSize: 12, color: MyTheme.darkGray))
.marginOnly(bottom: 8),
ElevatedButton.icon(
icon: const Icon(Icons.play_arrow),
onPressed: () {
if (gFFI.userModel.userName.value.isEmpty &&
bind.mainGetLocalOption(key: "show-scam-warning") !=
"N") {
showScamWarning(context, serverModel);
} else {
serverModel.toggleService();
}
},
label: Text(translate("Start service")))
],
));
}
}
class ScamWarningDialog extends StatefulWidget {
final ServerModel serverModel;
ScamWarningDialog({required this.serverModel});
@override
ScamWarningDialogState createState() => ScamWarningDialogState();
}
class ScamWarningDialogState extends State<ScamWarningDialog> {
int _countdown = bind.isCustomClient() ? 0 : 12;
bool show_warning = false;
late Timer _timer;
late ServerModel _serverModel;
@override
void initState() {
super.initState();
_serverModel = widget.serverModel;
startCountdown();
}
void startCountdown() {
const oneSecond = Duration(seconds: 1);
_timer = Timer.periodic(oneSecond, (timer) {
setState(() {
_countdown--;
if (_countdown <= 0) {
timer.cancel();
}
});
});
}
@override
void dispose() {
_timer.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final isButtonLocked = _countdown > 0;
return AlertDialog(
content: ClipRRect(
borderRadius: BorderRadius.circular(20.0),
child: SingleChildScrollView(
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topRight,
end: Alignment.bottomLeft,
colors: [
Color(0xffe242bc),
Color(0xfff4727c),
],
),
),
padding: EdgeInsets.all(25.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.warning_amber_sharp,
color: Colors.white,
),
SizedBox(width: 10),
Text(
translate("Warning"),
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 20.0,
),
),
],
),
SizedBox(height: 20),
Center(
child: Image.asset(
'assets/scam.png',
width: 180,
),
),
SizedBox(height: 18),
Text(
translate("scam_title"),
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 22.0,
),
),
SizedBox(height: 18),
Text(
"${translate("scam_text1")}\n\n${translate("scam_text2")}\n",
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 16.0,
),
),
Row(
children: <Widget>[
Checkbox(
value: show_warning,
onChanged: (value) {
setState(() {
show_warning = value!;
});
},
),
Text(
translate("Don't show again"),
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 15.0,
),
),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Container(
constraints: BoxConstraints(maxWidth: 150),
child: ElevatedButton(
onPressed: isButtonLocked
? null
: () {
Navigator.of(context).pop();
_serverModel.toggleService();
if (show_warning) {
bind.mainSetLocalOption(
key: "show-scam-warning", value: "N");
}
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blueAccent,
),
child: Text(
isButtonLocked
? "${translate("I Agree")} (${_countdown}s)"
: translate("I Agree"),
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 13.0,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
),
SizedBox(width: 15),
Container(
constraints: BoxConstraints(maxWidth: 150),
child: ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blueAccent,
),
child: Text(
translate("Decline"),
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 13.0,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
),
],
),
],
),
),
),
),
contentPadding: EdgeInsets.all(0.0),
);
}
}
class ServerInfo extends StatelessWidget {
final model = gFFI.serverModel;
final emptyController = TextEditingController(text: "-");
ServerInfo({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final serverModel = Provider.of<ServerModel>(context);
const Color colorPositive = Colors.green;
const Color colorNegative = Colors.red;
const double iconMarginRight = 15;
const double iconSize = 24;
const TextStyle textStyleHeading = TextStyle(
fontSize: 16.0, fontWeight: FontWeight.bold, color: Colors.grey);
const TextStyle textStyleValue =
TextStyle(fontSize: 25.0, fontWeight: FontWeight.bold);
void copyToClipboard(String value) {
Clipboard.setData(ClipboardData(text: value));
showToast(translate('Copied'));
}
Widget ConnectionStateNotification() {
if (serverModel.connectStatus == -1) {
return Row(children: [
const Icon(Icons.warning_amber_sharp,
color: colorNegative, size: iconSize)
.marginOnly(right: iconMarginRight),
Expanded(child: Text(translate('not_ready_status')))
]);
} else if (serverModel.connectStatus == 0) {
return Row(children: [
SizedBox(width: 20, height: 20, child: CircularProgressIndicator())
.marginOnly(left: 4, right: iconMarginRight),
Expanded(child: Text(translate('connecting_status')))
]);
} else {
return Row(children: [
const Icon(Icons.check, color: colorPositive, size: iconSize)
.marginOnly(right: iconMarginRight),
Expanded(child: Text(translate('Ready')))
]);
}
}
final showOneTime = serverModel.approveMode != 'click' &&
serverModel.verificationMethod != kUsePermanentPassword;
return PaddingCard(
title: translate('Your Device'),
child: Column(
// ID
children: [
Row(children: [
const Icon(Icons.perm_identity,
color: Colors.grey, size: iconSize)
.marginOnly(right: iconMarginRight),
Text(
translate('ID'),
style: textStyleHeading,
)
]),
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
Text(
model.serverId.value.text,
style: textStyleValue,
),
IconButton(
visualDensity: VisualDensity.compact,
icon: Icon(Icons.copy_outlined),
onPressed: () {
copyToClipboard(model.serverId.value.text.trim());
})
]).marginOnly(left: 39, bottom: 10),
// Password
Row(children: [
const Icon(Icons.lock_outline, color: Colors.grey, size: iconSize)
.marginOnly(right: iconMarginRight),
Text(
translate('One-time Password'),
style: textStyleHeading,
)
]),
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
Text(
!showOneTime ? '-' : model.serverPasswd.value.text,
style: textStyleValue,
),
!showOneTime
? SizedBox.shrink()
: Row(children: [
IconButton(
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.refresh),
onPressed: () => bind.mainUpdateTemporaryPassword()),
IconButton(
visualDensity: VisualDensity.compact,
icon: Icon(Icons.copy_outlined),
onPressed: () {
copyToClipboard(
model.serverPasswd.value.text.trim());
})
])
]).marginOnly(left: 40, bottom: 15),
ConnectionStateNotification()
],
));
}
}
class PermissionChecker extends StatefulWidget {
const PermissionChecker({Key? key}) : super(key: key);
@override
State<PermissionChecker> createState() => _PermissionCheckerState();
}
class _PermissionCheckerState extends State<PermissionChecker> {
@override
Widget build(BuildContext context) {
final serverModel = Provider.of<ServerModel>(context);
final hasAudioPermission = androidVersion >= 30;
final hideStopService = isAndroid &&
bind.mainGetBuildinOption(key: kOptionHideStopService) == 'Y';
final allowPermChangeInAcceptWindow = option2bool(
kOptionEnablePermChangeInAcceptWindow,
bind.mainGetBuildinOption(
key: kOptionEnablePermChangeInAcceptWindow,
));
final permissionChangeLocked = isAndroid &&
serverModel.clients.any((c) => !c.disconnected) &&
!allowPermChangeInAcceptWindow;
return PaddingCard(
title: translate("Permissions"),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
serverModel.mediaOk && !hideStopService
? ElevatedButton.icon(
style: ButtonStyle(
backgroundColor:
MaterialStateProperty.all(Colors.red)),
icon: const Icon(Icons.stop),
onPressed: serverModel.toggleService,
label: Text(translate("Stop service")))
.marginOnly(bottom: 8)
: SizedBox.shrink(),
if (!hideStopService || !serverModel.mediaOk)
PermissionRow(
translate("Screen Capture"),
serverModel.mediaOk,
!serverModel.mediaOk &&
gFFI.userModel.userName.value.isEmpty &&
bind.mainGetLocalOption(key: "show-scam-warning") != "N"
? () => showScamWarning(context, serverModel)
: serverModel.toggleService),
PermissionRow(
translate("Input Control"),
serverModel.inputOk,
serverModel.toggleInput,
),
PermissionRow(
translate("Transfer file"),
serverModel.fileOk,
serverModel.toggleFile,
enabled: !permissionChangeLocked,
),
hasAudioPermission
? PermissionRow(translate("Audio Capture"), serverModel.audioOk,
serverModel.toggleAudio,
enabled: !permissionChangeLocked)
: Row(children: [
Icon(Icons.info_outline).marginOnly(right: 15),
Expanded(
child: Text(
translate("android_version_audio_tip"),
style: const TextStyle(color: MyTheme.darkGray),
))
]),
PermissionRow(
translate("Enable clipboard"),
serverModel.clipboardOk,
serverModel.toggleClipboard,
enabled: !permissionChangeLocked,
),
]));
}
}
class PermissionRow extends StatelessWidget {
const PermissionRow(this.name, this.isOk, this.onPressed,
{Key? key, this.enabled = true})
: super(key: key);
final String name;
final bool isOk;
final VoidCallback onPressed;
final bool enabled;
@override
Widget build(BuildContext context) {
return SwitchListTile(
visualDensity: VisualDensity.compact,
contentPadding: EdgeInsets.all(0),
title: Text(name),
value: isOk,
onChanged: enabled
? (bool value) {
onPressed();
}
: null);
}
}
class ConnectionManager extends StatelessWidget {
const ConnectionManager({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final serverModel = Provider.of<ServerModel>(context);
return Column(
children: serverModel.clients
.map((client) => PaddingCard(
title: translate(
client.isFileTransfer ? "Transfer file" : "Share screen"),
titleIcon: client.isFileTransfer
? Icon(Icons.folder_outlined)
: Icon(Icons.mobile_screen_share),
child: Column(children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(child: ClientInfo(client)),
Expanded(
flex: -1,
child: client.isFileTransfer || !client.authorized
? const SizedBox.shrink()
: IconButton(
onPressed: () {
gFFI.chatModel.changeCurrentKey(
MessageKey(client.peerId, client.id));
final bar = navigationBarKey.currentWidget;
if (bar != null) {
bar as BottomNavigationBar;
bar.onTap!(1);
}
},
icon: unreadTopRightBuilder(
client.unreadChatMessageCount)))
],
),
client.authorized
? const SizedBox.shrink()
: Text(
translate("android_new_connection_tip"),
style: Theme.of(context).textTheme.bodyMedium,
).marginOnly(bottom: 5),
client.authorized
? _buildDisconnectButton(client)
: _buildNewConnectionHint(serverModel, client),
if (client.incomingVoiceCall && !client.inVoiceCall)
..._buildNewVoiceCallHint(context, serverModel, client),
])))
.toList());
}
Widget _buildDisconnectButton(Client client) {
final disconnectButton = ElevatedButton.icon(
style: ButtonStyle(backgroundColor: MaterialStatePropertyAll(Colors.red)),
icon: const Icon(Icons.close),
onPressed: () {
bind.cmCloseConnection(connId: client.id);
gFFI.invokeMethod("cancel_notification", client.id);
},
label: Text(translate("Disconnect")),
);
final buttons = [disconnectButton];
if (client.inVoiceCall) {
buttons.insert(
0,
ElevatedButton.icon(
style: ButtonStyle(
backgroundColor: MaterialStatePropertyAll(Colors.red)),
icon: const Icon(Icons.phone),
label: Text(translate("Stop")),
onPressed: () {
bind.cmCloseVoiceCall(id: client.id);
gFFI.invokeMethod("cancel_notification", client.id);
},
),
);
}
if (buttons.length == 1) {
return Container(
alignment: Alignment.centerRight,
child: disconnectButton,
);
} else {
return Row(
children: buttons,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
);
}
}
Widget _buildNewConnectionHint(ServerModel serverModel, Client client) {
return Row(mainAxisAlignment: MainAxisAlignment.end, children: [
TextButton(
child: Text(translate("Dismiss")),
onPressed: () {
serverModel.sendLoginResponse(client, false);
}).marginOnly(right: 15),
if (serverModel.approveMode != 'password')
ElevatedButton.icon(
icon: const Icon(Icons.check),
label: Text(translate("Accept")),
onPressed: () {
serverModel.sendLoginResponse(client, true);
}),
]);
}
List<Widget> _buildNewVoiceCallHint(
BuildContext context, ServerModel serverModel, Client client) {
return [
Text(
translate("android_new_voice_call_tip"),
style: Theme.of(context).textTheme.bodyMedium,
).marginOnly(bottom: 5),
Row(mainAxisAlignment: MainAxisAlignment.end, children: [
TextButton(
child: Text(translate("Dismiss")),
onPressed: () {
serverModel.handleVoiceCall(client, false);
}).marginOnly(right: 15),
if (serverModel.approveMode != 'password')
ElevatedButton.icon(
icon: const Icon(Icons.check),
label: Text(translate("Accept")),
onPressed: () {
serverModel.handleVoiceCall(client, true);
}),
])
];
}
}
class PaddingCard extends StatelessWidget {
const PaddingCard({Key? key, required this.child, this.title, this.titleIcon})
: super(key: key);
final String? title;
final Icon? titleIcon;
final Widget child;
@override
Widget build(BuildContext context) {
final children = [child];
if (title != null) {
children.insert(
0,
Padding(
padding: const EdgeInsets.fromLTRB(0, 5, 0, 8),
child: Row(
children: [
titleIcon?.marginOnly(right: 10) ?? const SizedBox.shrink(),
Expanded(
child: Text(title!,
style: Theme.of(context)
.textTheme
.titleLarge
?.merge(TextStyle(fontWeight: FontWeight.bold))),
)
],
)));
}
return SizedBox(
width: double.maxFinite,
child: Card(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(13),
),
margin: const EdgeInsets.fromLTRB(12.0, 10.0, 12.0, 0),
child: Padding(
padding:
const EdgeInsets.symmetric(vertical: 15.0, horizontal: 20.0),
child: Column(
children: children,
),
),
));
}
}
class ClientInfo extends StatelessWidget {
final Client client;
ClientInfo(this.client);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(children: [
Row(
children: [
Expanded(
flex: -1,
child: Padding(
padding: const EdgeInsets.only(right: 12),
child: _buildAvatar(context))),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(client.name, style: const TextStyle(fontSize: 18)),
const SizedBox(width: 8),
Text(client.peerId, style: const TextStyle(fontSize: 10))
]))
],
),
]));
}
Widget _buildAvatar(BuildContext context) {
final fallback = CircleAvatar(
backgroundColor: str2color(client.name,
Theme.of(context).brightness == Brightness.light ? 255 : 150),
child: Text(client.name.isNotEmpty ? client.name[0] : '?'),
);
return buildAvatarWidget(
avatar: client.avatar,
size: 40,
fallback: fallback,
) ??
fallback;
}
}
void androidChannelInit() {
gFFI.setMethodCallHandler((method, arguments) {
debugPrint("flutter got android msg,$method,$arguments");
try {
switch (method) {
case "start_capture":
{
gFFI.dialogManager.dismissAll();
gFFI.serverModel.updateClientState();
break;
}
case "on_state_changed":
{
var name = arguments["name"] as String;
var value = arguments["value"] as String == "true";
debugPrint("from jvm:on_state_changed,$name:$value");
gFFI.serverModel.changeStatue(name, value);
break;
}
case "on_android_permission_result":
{
var type = arguments["type"] as String;
var result = arguments["result"] as bool;
AndroidPermissionManager.complete(type, result);
break;
}
case "on_media_projection_canceled":
{
gFFI.serverModel.stopService();
break;
}
case "msgbox":
{
var type = arguments["type"] as String;
var title = arguments["title"] as String;
var text = arguments["text"] as String;
var link = (arguments["link"] ?? '') as String;
msgBox(gFFI.sessionId, type, title, text, link, gFFI.dialogManager);
break;
}
case "stop_service":
{
print(
"stop_service by kotlin, isStart:${gFFI.serverModel.isStart}");
if (gFFI.serverModel.isStart) {
gFFI.serverModel.stopService();
}
break;
}
}
} catch (e) {
debugPrintStack(label: "MethodCallHandler err:$e");
}
return "";
});
}
void showScamWarning(BuildContext context, ServerModel serverModel) {
showDialog(
context: context,
builder: (BuildContext context) {
return ScamWarningDialog(serverModel: serverModel);
},
);
}
File diff suppressed because it is too large Load Diff
+441
View File
@@ -0,0 +1,441 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/common/widgets/dialog.dart';
import 'package:flutter_hbb/models/model.dart';
import 'package:flutter_hbb/models/terminal_model.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:xterm/xterm.dart';
import '../../desktop/pages/terminal_connection_manager.dart';
import '../../consts.dart';
class TerminalPage extends StatefulWidget {
const TerminalPage({
Key? key,
required this.id,
required this.password,
required this.isSharedPassword,
this.forceRelay,
this.connToken,
}) : super(key: key);
final String id;
final String? password;
final bool? forceRelay;
final bool? isSharedPassword;
final String? connToken;
final terminalId = 0;
@override
State<TerminalPage> createState() => _TerminalPageState();
}
class _TerminalPageState extends State<TerminalPage>
with AutomaticKeepAliveClientMixin, WidgetsBindingObserver {
late FFI _ffi;
late TerminalModel _terminalModel;
double? _cellHeight;
double _sysKeyboardHeight = 0;
Timer? _keyboardDebounce;
final GlobalKey _keyboardKey = GlobalKey();
double _keyboardHeight = 0;
late bool _showTerminalExtraKeys;
// For iOS edge swipe gesture
double _swipeStartX = 0;
double _swipeCurrentX = 0;
// For web only.
// 'monospace' does not work on web, use Google Fonts, `??` is only for null safety.
final String _robotoMonoFontFamily = isWeb
? (GoogleFonts.robotoMono().fontFamily ?? 'monospace')
: 'monospace';
SessionID get sessionId => _ffi.sessionId;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
debugPrint(
'[TerminalPage] Initializing terminal ${widget.terminalId} for peer ${widget.id}');
// Use shared FFI instance from connection manager
_ffi = TerminalConnectionManager.getConnection(
peerId: widget.id,
password: widget.password,
isSharedPassword: widget.isSharedPassword,
forceRelay: widget.forceRelay,
connToken: widget.connToken,
);
// Create terminal model with specific terminal ID
_terminalModel = TerminalModel(_ffi, widget.terminalId);
debugPrint(
'[TerminalPage] Terminal model created for terminal ${widget.terminalId}');
_terminalModel.onResizeExternal = (w, h, pw, ph) {
_cellHeight = ph * 1.0;
};
// Register this terminal model with FFI for event routing
_ffi.registerTerminalModel(widget.terminalId, _terminalModel);
// Web desktop users have full hardware keyboard access, so the on-screen
// terminal extra keys bar is unnecessary and disabled.
_showTerminalExtraKeys = !isWebDesktop &&
mainGetLocalBoolOptionSync(kOptionEnableShowTerminalExtraKeys);
// Initialize terminal connection
WidgetsBinding.instance.addPostFrameCallback((_) {
_ffi.dialogManager
.showLoading(translate('Connecting...'), onCancel: closeConnection);
if (_showTerminalExtraKeys) {
_updateKeyboardHeight();
}
});
_ffi.ffiModel.updateEventListener(_ffi.sessionId, widget.id);
}
@override
void dispose() {
// Unregister terminal model from FFI
_ffi.unregisterTerminalModel(widget.terminalId);
_terminalModel.dispose();
_keyboardDebounce?.cancel();
WidgetsBinding.instance.removeObserver(this);
super.dispose();
TerminalConnectionManager.releaseConnection(widget.id);
}
@override
void didChangeMetrics() {
super.didChangeMetrics();
_keyboardDebounce?.cancel();
_keyboardDebounce = Timer(const Duration(milliseconds: 20), () {
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
setState(() {
_sysKeyboardHeight = bottomInset;
});
});
}
void _updateKeyboardHeight() {
if (_keyboardKey.currentContext != null) {
final renderBox = _keyboardKey.currentContext!.findRenderObject() as RenderBox;
_keyboardHeight = renderBox.size.height;
}
}
EdgeInsets _calculatePadding(double heightPx) {
if (_cellHeight == null) {
return const EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0);
}
final realHeight = heightPx - _sysKeyboardHeight - _keyboardHeight;
final rows = (realHeight / _cellHeight!).floor();
final extraSpace = realHeight - rows * _cellHeight!;
final topBottom = max(0.0, extraSpace / 2.0);
return EdgeInsets.only(left: 5.0, right: 5.0, top: topBottom, bottom: topBottom + _sysKeyboardHeight + _keyboardHeight);
}
@override
Widget build(BuildContext context) {
super.build(context);
return WillPopScope(
onWillPop: () async {
clientClose(sessionId, _ffi);
return false; // Prevent default back behavior
},
child: buildBody(),
);
}
Widget buildBody() {
final scaffold = Scaffold(
resizeToAvoidBottomInset: false, // Disable automatic layout adjustment; manually control UI updates to prevent flickering when the keyboard shows/hides
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
body: Stack(
children: [
Positioned.fill(
child: SafeArea(
top: true,
child: LayoutBuilder(
builder: (context, constraints) {
final heightPx = constraints.maxHeight;
return TerminalView(
_terminalModel.terminal,
controller: _terminalModel.terminalController,
autofocus: true,
textStyle: _getTerminalStyle(),
backgroundOpacity: 0.7,
// The following comment is from xterm.dart source code:
// Workaround to detect delete key for platforms and IMEs that do not
// emit a hardware delete event. Preferred on mobile platforms. [false] by
// default.
//
// Android works fine without this workaround.
deleteDetection: isIOS,
padding: _calculatePadding(heightPx),
onSecondaryTapDown: (details, offset) async {
final selection = _terminalModel.terminalController.selection;
if (selection != null) {
final text = _terminalModel.terminal.buffer.getText(selection);
_terminalModel.terminalController.clearSelection();
await Clipboard.setData(ClipboardData(text: text));
} else {
final data = await Clipboard.getData('text/plain');
final text = data?.text;
if (text != null) {
_terminalModel.terminal.paste(text);
}
}
},
);
},
),
),
),
if (_showTerminalExtraKeys) _buildFloatingKeyboard(),
// iOS-style circular close button in top-right corner
if (isIOS) _buildCloseButton(),
],
),
);
// Add iOS edge swipe gesture to exit (similar to Android back button)
if (isIOS) {
return LayoutBuilder(
builder: (context, constraints) {
final screenWidth = constraints.maxWidth;
// Base thresholds on screen width but clamp to reasonable logical pixel ranges
// Edge detection region: ~10% of width, clamped between 20 and 80 logical pixels
final edgeThreshold = (screenWidth * 0.1).clamp(20.0, 80.0);
// Required horizontal movement: ~25% of width, clamped between 80 and 300 logical pixels
final swipeThreshold = (screenWidth * 0.25).clamp(80.0, 300.0);
return RawGestureDetector(
behavior: HitTestBehavior.translucent,
gestures: <Type, GestureRecognizerFactory>{
HorizontalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers<HorizontalDragGestureRecognizer>(
() => HorizontalDragGestureRecognizer(
debugOwner: this,
// Only respond to touch input, exclude mouse/trackpad
supportedDevices: kTouchBasedDeviceKinds,
),
(HorizontalDragGestureRecognizer instance) {
instance
// Capture initial touch-down position (before touch slop)
..onDown = (details) {
_swipeStartX = details.localPosition.dx;
_swipeCurrentX = details.localPosition.dx;
}
..onUpdate = (details) {
_swipeCurrentX = details.localPosition.dx;
}
..onEnd = (details) {
// Check if swipe started from left edge and moved right
if (_swipeStartX < edgeThreshold && (_swipeCurrentX - _swipeStartX) > swipeThreshold) {
clientClose(sessionId, _ffi);
}
_swipeStartX = 0;
_swipeCurrentX = 0;
}
..onCancel = () {
_swipeStartX = 0;
_swipeCurrentX = 0;
};
},
),
},
child: scaffold,
);
},
);
}
return scaffold;
}
Widget _buildCloseButton() {
return Positioned(
top: 0,
right: 0,
child: SafeArea(
minimum: const EdgeInsets.only(
top: 16, // iOS standard margin
right: 16, // iOS standard margin
),
child: Semantics(
button: true,
label: translate('Close'),
child: Container(
width: 44, // iOS standard tap target size
height: 44,
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.5), // Half transparency
shape: BoxShape.circle,
),
child: Material(
color: Colors.transparent,
shape: const CircleBorder(),
clipBehavior: Clip.antiAlias,
child: InkWell(
customBorder: const CircleBorder(),
onTap: () {
clientClose(sessionId, _ffi);
},
child: Tooltip(
message: translate('Close'),
child: const Icon(
Icons.chevron_left, // iOS-style back arrow
color: Colors.white,
size: 28,
),
),
),
),
),
),
),
);
}
Widget _buildFloatingKeyboard() {
return AnimatedPositioned(
duration: const Duration(milliseconds: 200),
left: 0,
right: 0,
bottom: _sysKeyboardHeight,
child: Container(
key: _keyboardKey,
color: Theme.of(context).scaffoldBackgroundColor,
padding: EdgeInsets.zero,
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildKeyButton('Esc'),
const SizedBox(width: 2),
_buildKeyButton('/'),
const SizedBox(width: 2),
_buildKeyButton('|'),
const SizedBox(width: 2),
_buildKeyButton('Home'),
const SizedBox(width: 2),
_buildKeyButton(''),
const SizedBox(width: 2),
_buildKeyButton('End'),
const SizedBox(width: 2),
_buildKeyButton('PgUp'),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildKeyButton('Tab'),
const SizedBox(width: 2),
_buildKeyButton('Ctrl+C'),
const SizedBox(width: 2),
_buildKeyButton('~'),
const SizedBox(width: 2),
_buildKeyButton(''),
const SizedBox(width: 2),
_buildKeyButton(''),
const SizedBox(width: 2),
_buildKeyButton(''),
const SizedBox(width: 2),
_buildKeyButton('PgDn'),
],
),
],
),
),
);
}
Widget _buildKeyButton(String label) {
return ElevatedButton(
onPressed: () {
_sendKeyToTerminal(label);
},
child: Text(label),
style: ElevatedButton.styleFrom(
minimumSize: const Size(48, 32),
padding: EdgeInsets.zero,
textStyle: const TextStyle(fontSize: 12),
backgroundColor: Theme.of(context).colorScheme.surfaceVariant,
foregroundColor: Theme.of(context).colorScheme.onSurfaceVariant,
),
);
}
void _sendKeyToTerminal(String key) {
String? send;
switch (key) {
case 'Esc':
send = '\x1B';
break;
case 'Tab':
send = '\t';
break;
case 'Ctrl+C':
send = '\x03';
break;
case '':
send = '\x1B[A';
break;
case '':
send = '\x1B[B';
break;
case '':
send = '\x1B[C';
break;
case '':
send = '\x1B[D';
break;
case 'Home':
send = '\x1B[H';
break;
case 'End':
send = '\x1B[F';
break;
case 'PgUp':
send = '\x1B[5~';
break;
case 'PgDn':
send = '\x1B[6~';
break;
default:
send = key;
break;
}
if (send != null) {
_terminalModel.sendVirtualKey(send);
}
}
// https://github.com/TerminalStudio/xterm.dart/issues/42#issuecomment-877495472
// https://github.com/TerminalStudio/xterm.dart/issues/198#issuecomment-2526548458
TerminalStyle _getTerminalStyle() {
return isWeb
? TerminalStyle(
fontFamily: _robotoMonoFontFamily,
fontSize: 14,
)
: const TerminalStyle();
}
@override
bool get wantKeepAlive => true;
}
@@ -0,0 +1,727 @@
import 'dart:async';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hbb/common/shared_state.dart';
import 'package:flutter_hbb/common/widgets/toolbar.dart';
import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/models/chat_model.dart';
import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
import 'package:flutter_svg/svg.dart';
import 'package:get/get.dart';
import 'package:provider/provider.dart';
import '../../common.dart';
import '../../common/widgets/overlay.dart';
import '../../common/widgets/dialog.dart';
import '../../common/widgets/remote_input.dart';
import '../../models/input_model.dart';
import '../../models/model.dart';
import '../../models/platform_model.dart';
import '../../utils/image.dart';
final initText = '1' * 1024;
// Workaround for Android (default input method, Microsoft SwiftKey keyboard) when using physical keyboard.
// When connecting a physical keyboard, `KeyEvent.physicalKey.usbHidUsage` are wrong is using Microsoft SwiftKey keyboard.
// https://github.com/flutter/flutter/issues/159384
// https://github.com/flutter/flutter/issues/159383
void _disableAndroidSoftKeyboard({bool? isKeyboardVisible}) {
if (isAndroid) {
if (isKeyboardVisible != true) {
// `enable_soft_keyboard` will be set to `true` when clicking the keyboard icon, in `openKeyboard()`.
gFFI.invokeMethod("enable_soft_keyboard", false);
}
}
}
class ViewCameraPage extends StatefulWidget {
ViewCameraPage(
{Key? key,
required this.id,
this.password,
this.isSharedPassword,
this.forceRelay})
: super(key: key);
final String id;
final String? password;
final bool? isSharedPassword;
final bool? forceRelay;
@override
State<ViewCameraPage> createState() => _ViewCameraPageState(id);
}
class _ViewCameraPageState extends State<ViewCameraPage>
with WidgetsBindingObserver {
Timer? _timer;
bool _showBar = !isWebDesktop;
bool _showGestureHelp = false;
Orientation? _currentOrientation;
double _viewInsetsBottom = 0;
final _uniqueKey = UniqueKey();
Timer? _timerDidChangeMetrics;
final _blockableOverlayState = BlockableOverlayState();
final keyboardVisibilityController = KeyboardVisibilityController();
final FocusNode _mobileFocusNode = FocusNode();
final FocusNode _physicalFocusNode = FocusNode();
var _showEdit = false; // use soft keyboard
InputModel get inputModel => gFFI.inputModel;
SessionID get sessionId => gFFI.sessionId;
final TextEditingController _textController =
TextEditingController(text: initText);
_ViewCameraPageState(String id) {
initSharedStates(id);
gFFI.chatModel.voiceCallStatus.value = VoiceCallStatus.notStarted;
gFFI.dialogManager.loadMobileActionsOverlayVisible();
}
@override
void initState() {
super.initState();
gFFI.ffiModel.updateEventListener(sessionId, widget.id);
gFFI.start(
widget.id,
isViewCamera: true,
password: widget.password,
isSharedPassword: widget.isSharedPassword,
forceRelay: widget.forceRelay,
);
WidgetsBinding.instance.addPostFrameCallback((_) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: []);
gFFI.dialogManager
.showLoading(translate('Connecting...'), onCancel: closeConnection);
});
WakelockManager.enable(_uniqueKey);
_physicalFocusNode.requestFocus();
gFFI.inputModel.listenToMouse(true);
gFFI.qualityMonitorModel.checkShowQualityMonitor(sessionId);
gFFI.chatModel
.changeCurrentKey(MessageKey(widget.id, ChatModel.clientModeID));
_blockableOverlayState.applyFfi(gFFI);
gFFI.imageModel.addCallbackOnFirstImage((String peerId) {
gFFI.recordingModel
.updateStatus(bind.sessionGetIsRecording(sessionId: gFFI.sessionId));
if (gFFI.recordingModel.start) {
showToast(translate('Automatically record outgoing sessions'));
}
_disableAndroidSoftKeyboard(
isKeyboardVisible: keyboardVisibilityController.isVisible);
});
WidgetsBinding.instance.addObserver(this);
}
@override
Future<void> dispose() async {
WidgetsBinding.instance.removeObserver(this);
// https://github.com/flutter/flutter/issues/64935
super.dispose();
gFFI.dialogManager.hideMobileActionsOverlay(store: false);
gFFI.inputModel.listenToMouse(false);
gFFI.imageModel.disposeImage();
gFFI.cursorModel.disposeImages();
await gFFI.invokeMethod("enable_soft_keyboard", true);
_mobileFocusNode.dispose();
_physicalFocusNode.dispose();
await gFFI.close();
_timer?.cancel();
_timerDidChangeMetrics?.cancel();
gFFI.dialogManager.dismissAll();
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
overlays: SystemUiOverlay.values);
WakelockManager.disable(_uniqueKey);
removeSharedStates(widget.id);
// `on_voice_call_closed` should be called when the connection is ended.
// The inner logic of `on_voice_call_closed` will check if the voice call is active.
// Only one client is considered here for now.
gFFI.chatModel.onVoiceCallClosed("End connetion");
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {}
@override
void didChangeMetrics() {
// If the soft keyboard is visible and the canvas has been changed(panned or scaled)
// Don't try reset the view style and focus the cursor.
if (gFFI.cursorModel.lastKeyboardIsVisible &&
gFFI.canvasModel.isMobileCanvasChanged) {
return;
}
final newBottom = MediaQueryData.fromView(ui.window).viewInsets.bottom;
_timerDidChangeMetrics?.cancel();
_timerDidChangeMetrics = Timer(Duration(milliseconds: 100), () async {
// We need this comparation because poping up the floating action will also trigger `didChangeMetrics()`.
if (newBottom != _viewInsetsBottom) {
gFFI.canvasModel.mobileFocusCanvasCursor();
_viewInsetsBottom = newBottom;
}
});
}
// to-do: It should be better to use transparent color instead of the bgColor.
// But for now, the transparent color will cause the canvas to be white.
// I'm sure that the white color is caused by the Overlay widget in BlockableOverlay.
// But I don't know why and how to fix it.
Widget emptyOverlay(Color bgColor) => BlockableOverlay(
/// the Overlay key will be set with _blockableOverlayState in BlockableOverlay
/// see override build() in [BlockableOverlay]
state: _blockableOverlayState,
underlying: Container(
color: bgColor,
),
);
Widget _bottomWidget() => (_showBar && gFFI.ffiModel.pi.displays.isNotEmpty
? getBottomAppBar()
: Offstage());
@override
Widget build(BuildContext context) {
final keyboardIsVisible =
keyboardVisibilityController.isVisible && _showEdit;
final showActionButton = !_showBar || keyboardIsVisible || _showGestureHelp;
return WillPopScope(
onWillPop: () async {
clientClose(sessionId, gFFI);
return false;
},
child: Scaffold(
// workaround for https://github.com/rustdesk/rustdesk/issues/3131
floatingActionButtonLocation: keyboardIsVisible
? FABLocation(FloatingActionButtonLocation.endFloat, 0, -35)
: null,
floatingActionButton: !showActionButton
? null
: FloatingActionButton(
mini: !keyboardIsVisible,
child: Icon(
(keyboardIsVisible || _showGestureHelp)
? Icons.expand_more
: Icons.expand_less,
color: Colors.white,
),
backgroundColor: MyTheme.accent,
onPressed: () {
setState(() {
if (keyboardIsVisible) {
_showEdit = false;
gFFI.invokeMethod("enable_soft_keyboard", false);
_mobileFocusNode.unfocus();
_physicalFocusNode.requestFocus();
} else if (_showGestureHelp) {
_showGestureHelp = false;
} else {
_showBar = !_showBar;
}
});
}),
bottomNavigationBar: Obx(() => Stack(
alignment: Alignment.bottomCenter,
children: [
gFFI.ffiModel.pi.isSet.isTrue &&
gFFI.ffiModel.waitForFirstImage.isTrue
? emptyOverlay(MyTheme.canvasColor)
: () {
gFFI.ffiModel.tryShowAndroidActionsOverlay();
return Offstage();
}(),
_bottomWidget(),
gFFI.ffiModel.pi.isSet.isFalse
? emptyOverlay(MyTheme.canvasColor)
: Offstage(),
],
)),
body: Obx(
() => getRawPointerAndKeyBody(Overlay(
initialEntries: [
OverlayEntry(builder: (context) {
return Container(
color: kColorCanvas,
child: SafeArea(
child: OrientationBuilder(builder: (ctx, orientation) {
if (_currentOrientation != orientation) {
Timer(const Duration(milliseconds: 200), () {
gFFI.dialogManager
.resetMobileActionsOverlay(ffi: gFFI);
_currentOrientation = orientation;
gFFI.canvasModel.updateViewStyle();
});
}
return Container(
color: MyTheme.canvasColor,
child: RawTouchGestureDetectorRegion(
child: getBodyForMobile(),
ffi: gFFI,
isCamera: true,
),
);
}),
),
);
})
],
)),
)),
);
}
Widget getRawPointerAndKeyBody(Widget child) {
return CameraRawPointerMouseRegion(
inputModel: inputModel,
// Disable RawKeyFocusScope before the connecting is established.
// The "Delete" key on the soft keyboard may be grabbed when inputting the password dialog.
child: gFFI.ffiModel.pi.isSet.isTrue
? RawKeyFocusScope(
focusNode: _physicalFocusNode,
inputModel: inputModel,
child: child)
: child,
);
}
Widget getBottomAppBar() {
return BottomAppBar(
elevation: 10,
color: MyTheme.accent,
child: Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Row(
children: <Widget>[
IconButton(
color: Colors.white,
icon: Icon(Icons.clear),
onPressed: () {
clientClose(sessionId, gFFI);
},
),
IconButton(
color: Colors.white,
icon: Icon(Icons.tv),
onPressed: () {
setState(() => _showEdit = false);
showOptions(context, widget.id, gFFI.dialogManager);
},
)
] +
(isWeb
? []
: <Widget>[
futureBuilder(
future: gFFI.invokeMethod(
"get_value", "KEY_IS_SUPPORT_VOICE_CALL"),
hasData: (isSupportVoiceCall) => IconButton(
color: Colors.white,
icon: isAndroid && isSupportVoiceCall
? SvgPicture.asset('assets/chat.svg',
colorFilter: ColorFilter.mode(
Colors.white, BlendMode.srcIn))
: Icon(Icons.message),
onPressed: () =>
isAndroid && isSupportVoiceCall
? showChatOptions(widget.id)
: onPressedTextChat(widget.id),
))
]) +
[
IconButton(
color: Colors.white,
icon: Icon(Icons.more_vert),
onPressed: () {
setState(() => _showEdit = false);
showActions(widget.id);
},
),
]),
Obx(() => IconButton(
color: Colors.white,
icon: Icon(Icons.expand_more),
onPressed: gFFI.ffiModel.waitForFirstImage.isTrue
? null
: () {
setState(() => _showBar = !_showBar);
},
)),
],
),
);
}
Widget getBodyForMobile() {
return Container(
color: MyTheme.canvasColor,
child: Stack(children: () {
final paints = [
ImagePaint(),
Positioned(
top: 10,
right: 10,
child: QualityMonitor(gFFI.qualityMonitorModel),
),
SizedBox(
width: 0,
height: 0,
child: !_showEdit
? Container()
: TextFormField(
textInputAction: TextInputAction.newline,
autocorrect: false,
// Flutter 3.16.9 Android.
// `enableSuggestions` causes secure keyboard to be shown.
// https://github.com/flutter/flutter/issues/139143
// https://github.com/flutter/flutter/issues/146540
// enableSuggestions: false,
autofocus: true,
focusNode: _mobileFocusNode,
maxLines: null,
controller: _textController,
// trick way to make backspace work always
keyboardType: TextInputType.multiline,
// `onChanged` may be called depending on the input method if this widget is wrapped in
// `Focus(onKeyEvent: ..., child: ...)`
// For `Backspace` button in the soft keyboard:
// en/fr input method:
// 1. The button will not trigger `onKeyEvent` if the text field is not empty.
// 2. The button will trigger `onKeyEvent` if the text field is empty.
// ko/zh/ja input method: the button will trigger `onKeyEvent`
// and the event will not popup if `KeyEventResult.handled` is returned.
onChanged: null,
).workaroundFreezeLinuxMint(),
),
];
return paints;
}()));
}
Widget getBodyForDesktopWithListener() {
var paints = <Widget>[ImagePaint()];
return Container(
color: MyTheme.canvasColor, child: Stack(children: paints));
}
List<TTextMenu> _getMobileActionMenus() {
if (gFFI.ffiModel.pi.platform != kPeerPlatformAndroid ||
!gFFI.ffiModel.keyboard) {
return [];
}
final enabled = versionCmp(gFFI.ffiModel.pi.version, '1.2.7') >= 0;
if (!enabled) return [];
return [
TTextMenu(
child: Text(translate('Back')),
onPressed: () => gFFI.inputModel.onMobileBack(),
),
TTextMenu(
child: Text(translate('Home')),
onPressed: () => gFFI.inputModel.onMobileHome(),
),
TTextMenu(
child: Text(translate('Apps')),
onPressed: () => gFFI.inputModel.onMobileApps(),
),
TTextMenu(
child: Text(translate('Volume up')),
onPressed: () => gFFI.inputModel.onMobileVolumeUp(),
),
TTextMenu(
child: Text(translate('Volume down')),
onPressed: () => gFFI.inputModel.onMobileVolumeDown(),
),
TTextMenu(
child: Text(translate('Power')),
onPressed: () => gFFI.inputModel.onMobilePower(),
),
];
}
void showActions(String id) async {
final size = MediaQuery.of(context).size;
final x = 120.0;
final y = size.height;
final mobileActionMenus = _getMobileActionMenus();
final menus = toolbarControls(context, id, gFFI);
final List<PopupMenuEntry<int>> more = [
...mobileActionMenus
.asMap()
.entries
.map((e) =>
PopupMenuItem<int>(child: e.value.getChild(), value: e.key))
.toList(),
if (mobileActionMenus.isNotEmpty) PopupMenuDivider(),
...menus
.asMap()
.entries
.map((e) => PopupMenuItem<int>(
child: e.value.getChild(),
value: e.key + mobileActionMenus.length))
.toList(),
];
() async {
var index = await showMenu(
context: context,
position: RelativeRect.fromLTRB(x, y, x, y),
items: more,
elevation: 8,
);
if (index != null) {
if (index < mobileActionMenus.length) {
mobileActionMenus[index].onPressed?.call();
} else if (index < mobileActionMenus.length + more.length) {
menus[index - mobileActionMenus.length].onPressed?.call();
}
}
}();
}
onPressedTextChat(String id) {
gFFI.chatModel.changeCurrentKey(MessageKey(id, ChatModel.clientModeID));
gFFI.chatModel.toggleChatOverlay();
}
showChatOptions(String id) async {
onPressVoiceCall() => bind.sessionRequestVoiceCall(sessionId: sessionId);
onPressEndVoiceCall() => bind.sessionCloseVoiceCall(sessionId: sessionId);
makeTextMenu(String label, Widget icon, VoidCallback onPressed,
{TextStyle? labelStyle}) =>
TTextMenu(
child: Text(translate(label), style: labelStyle),
trailingIcon: Transform.scale(
scale: (isDesktop || isWebDesktop) ? 0.8 : 1,
child: IgnorePointer(
child: IconButton(
onPressed: null,
icon: icon,
),
),
),
onPressed: onPressed,
);
final isInVoice = [
VoiceCallStatus.waitingForResponse,
VoiceCallStatus.connected
].contains(gFFI.chatModel.voiceCallStatus.value);
final menus = [
makeTextMenu('Text chat', Icon(Icons.message, color: MyTheme.accent),
() => onPressedTextChat(widget.id)),
isInVoice
? makeTextMenu(
'End voice call',
SvgPicture.asset(
'assets/call_wait.svg',
colorFilter:
ColorFilter.mode(Colors.redAccent, BlendMode.srcIn),
),
onPressEndVoiceCall,
labelStyle: TextStyle(color: Colors.redAccent))
: makeTextMenu(
'Voice call',
SvgPicture.asset(
'assets/call_wait.svg',
colorFilter: ColorFilter.mode(MyTheme.accent, BlendMode.srcIn),
),
onPressVoiceCall),
];
final menuItems = menus
.asMap()
.entries
.map((e) => PopupMenuItem<int>(child: e.value.getChild(), value: e.key))
.toList();
Future.delayed(Duration.zero, () async {
final size = MediaQuery.of(context).size;
final x = 120.0;
final y = size.height;
var index = await showMenu(
context: context,
position: RelativeRect.fromLTRB(x, y, x, y),
items: menuItems,
elevation: 8,
);
if (index != null && index < menus.length) {
menus[index].onPressed?.call();
}
});
}
}
class ImagePaint extends StatelessWidget {
@override
Widget build(BuildContext context) {
final m = Provider.of<ImageModel>(context);
final c = Provider.of<CanvasModel>(context);
var s = c.scale;
final adjust = c.getAdjustY();
return CustomPaint(
painter: ImagePainter(
image: m.image, x: c.x / s, y: (c.y + adjust) / s, scale: s),
);
}
}
void showOptions(
BuildContext context, String id, OverlayDialogManager dialogManager) async {
var displays = <Widget>[];
final pi = gFFI.ffiModel.pi;
final image = gFFI.ffiModel.getConnectionImageText();
if (image != null) {
displays.add(Padding(padding: const EdgeInsets.only(top: 8), child: image));
}
if (pi.displays.length > 1 && pi.currentDisplay != kAllDisplayValue) {
final cur = pi.currentDisplay;
final children = <Widget>[];
final isDarkTheme = MyTheme.currentThemeMode() == ThemeMode.dark;
final numColorSelected = Colors.white;
final numColorUnselected = isDarkTheme ? Colors.grey : Colors.black87;
// We can't use `Theme.of(context).primaryColor` here, the color is:
// - light theme: 0xff2196f3 (Colors.blue)
// - dark theme: 0xff212121 (the canvas color?)
final numBgSelected =
Theme.of(context).colorScheme.primary.withOpacity(0.6);
for (var i = 0; i < pi.displays.length; ++i) {
children.add(InkWell(
onTap: () {
if (i == cur) return;
openMonitorInTheSameTab(i, gFFI, pi);
gFFI.dialogManager.dismissAll();
},
child: Ink(
width: 40,
height: 40,
decoration: BoxDecoration(
border: Border.all(color: Theme.of(context).hintColor),
borderRadius: BorderRadius.circular(2),
color: i == cur ? numBgSelected : null),
child: Center(
child: Text((i + 1).toString(),
style: TextStyle(
color:
i == cur ? numColorSelected : numColorUnselected,
fontWeight: FontWeight.bold))))));
}
displays.add(Padding(
padding: const EdgeInsets.only(top: 8),
child: Wrap(
alignment: WrapAlignment.center,
spacing: 8,
children: children,
)));
}
if (displays.isNotEmpty) {
displays.add(const Divider(color: MyTheme.border));
}
List<TRadioMenu<String>> viewStyleRadios =
await toolbarViewStyle(context, id, gFFI);
List<TRadioMenu<String>> imageQualityRadios =
await toolbarImageQuality(context, id, gFFI);
List<TRadioMenu<String>> codecRadios = await toolbarCodec(context, id, gFFI);
List<TToggleMenu> displayToggles =
await toolbarDisplayToggle(context, id, gFFI);
dialogManager.show((setState, close, context) {
var viewStyle =
(viewStyleRadios.isNotEmpty ? viewStyleRadios[0].groupValue : '').obs;
var imageQuality =
(imageQualityRadios.isNotEmpty ? imageQualityRadios[0].groupValue : '')
.obs;
var codec = (codecRadios.isNotEmpty ? codecRadios[0].groupValue : '').obs;
final radios = [
for (var e in viewStyleRadios)
Obx(() => getRadio<String>(
e.child,
e.value,
viewStyle.value,
e.onChanged != null
? (v) {
e.onChanged?.call(v);
if (v != null) viewStyle.value = v;
}
: null)),
const Divider(color: MyTheme.border),
for (var e in imageQualityRadios)
Obx(() => getRadio<String>(
e.child,
e.value,
imageQuality.value,
e.onChanged != null
? (v) {
e.onChanged?.call(v);
if (v != null) imageQuality.value = v;
}
: null)),
const Divider(color: MyTheme.border),
for (var e in codecRadios)
Obx(() => getRadio<String>(
e.child,
e.value,
codec.value,
e.onChanged != null
? (v) {
e.onChanged?.call(v);
if (v != null) codec.value = v;
}
: null)),
if (codecRadios.isNotEmpty) const Divider(color: MyTheme.border),
];
final rxToggleValues = displayToggles.map((e) => e.value.obs).toList();
final displayTogglesList = displayToggles
.asMap()
.entries
.map((e) => Obx(() => CheckboxListTile(
contentPadding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
value: rxToggleValues[e.key].value,
onChanged: e.value.onChanged != null
? (v) {
e.value.onChanged?.call(v);
if (v != null) rxToggleValues[e.key].value = v;
}
: null,
title: e.value.child)))
.toList();
final toggles = [
...displayTogglesList,
];
var popupDialogMenus = List<Widget>.empty(growable: true);
if (popupDialogMenus.isNotEmpty) {
popupDialogMenus.add(const Divider(color: MyTheme.border));
}
return CustomAlertDialog(
content: Column(
mainAxisSize: MainAxisSize.min,
children: displays + radios + popupDialogMenus + toggles),
);
}, clickMaskDismiss: true, backDismiss: true).then((value) {
_disableAndroidSoftKeyboard();
});
}
class FABLocation extends FloatingActionButtonLocation {
FloatingActionButtonLocation location;
double offsetX;
double offsetY;
FABLocation(this.location, this.offsetX, this.offsetY);
@override
Offset getOffset(ScaffoldPrelayoutGeometry scaffoldGeometry) {
final offset = location.getOffset(scaffoldGeometry);
return Offset(offset.dx + offsetX, offset.dy + offsetY);
}
}
@@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import 'package:flutter_hbb/models/model.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/common/widgets/custom_scale_base.dart';
class MobileCustomScaleControls extends StatefulWidget {
final FFI ffi;
final ValueChanged<int>? onChanged;
const MobileCustomScaleControls({super.key, required this.ffi, this.onChanged});
@override
State<MobileCustomScaleControls> createState() => _MobileCustomScaleControlsState();
}
class _MobileCustomScaleControlsState extends CustomScaleControls<MobileCustomScaleControls> {
@override
FFI get ffi => widget.ffi;
@override
ValueChanged<int>? get onScaleChanged => widget.onChanged;
@override
Widget build(BuildContext context) {
// Smaller button size for mobile
const smallBtnConstraints = BoxConstraints(minWidth: 32, minHeight: 32);
final sliderControl = Slider(
value: scalePos,
min: 0.0,
max: 1.0,
divisions: (CustomScaleControls.maxPercent - CustomScaleControls.minPercent).round(),
label: '$scaleValue%',
onChanged: onSliderChanged,
);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 8.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${translate("Scale custom")}: $scaleValue%',
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
),
const SizedBox(height: 8),
Row(
children: [
IconButton(
iconSize: 20,
padding: const EdgeInsets.all(4),
constraints: smallBtnConstraints,
icon: const Icon(Icons.remove),
tooltip: translate('Decrease'),
onPressed: () => nudgeScale(-1),
),
Expanded(child: sliderControl),
IconButton(
iconSize: 20,
padding: const EdgeInsets.all(4),
constraints: smallBtnConstraints,
icon: const Icon(Icons.add),
tooltip: translate('Increase'),
onPressed: () => nudgeScale(1),
),
],
),
],
),
);
}
}
@@ -0,0 +1,114 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../common.dart';
import '../../models/platform_model.dart';
const _deployDialogTag = 'android-deploy-device';
void showDeployPromptDialog() {
gFFI.dialogManager.dismissByTag(_deployDialogTag);
gFFI.dialogManager.show<bool>((setState, close, context) {
submit() => close(true);
return CustomAlertDialog(
title: Text(translate("Deploy")),
content: Text(translate("server_requires_deployment_tip")),
actions: [
dialogButton("Cancel", onPressed: close, isOutline: true),
dialogButton("OK", onPressed: submit),
],
onSubmit: submit,
onCancel: close,
);
}, tag: _deployDialogTag).then((deploy) {
if (deploy == true) {
showDeployDialog();
}
});
}
void showDeployDialog() {
gFFI.dialogManager.dismissByTag(_deployDialogTag);
final tokenController = TextEditingController();
final idController = TextEditingController();
var errorText = "";
var isInProgress = false;
gFFI.dialogManager.show((setState, close, context) {
submit() async {
if (isInProgress) return;
final token = tokenController.text.trim();
if (token.isEmpty) {
setState(() {
errorText = translate("token is required!");
});
return;
}
setState(() {
errorText = "";
isInProgress = true;
});
String res;
try {
res = await bind.mainDeployDevice(
token: token, id: idController.text.trim());
} catch (e) {
setState(() {
errorText = translate(e.toString());
isInProgress = false;
});
return;
}
if (res.isEmpty) {
close();
await gFFI.serverModel.fetchID();
showToast(translate("Successful"));
} else {
setState(() {
errorText = translate(res.toString());
isInProgress = false;
});
}
}
return CustomAlertDialog(
title: Text(translate("Deploy")),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: tokenController,
decoration: InputDecoration(labelText: translate("API Token")),
obscureText: true,
enableSuggestions: false,
autocorrect: false,
autofocus: true,
).workaroundFreezeLinuxMint(),
TextField(
controller: idController,
decoration:
InputDecoration(labelText: translate("Custom ID (optional)")),
).workaroundFreezeLinuxMint(),
if (errorText.isNotEmpty)
Align(
alignment: Alignment.centerLeft,
child: SelectableText(
errorText,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontSize: 12,
),
).paddingOnly(top: 8),
),
if (isInProgress) const LinearProgressIndicator().paddingOnly(top: 8),
],
),
actions: [
dialogButton("Cancel",
onPressed: isInProgress ? null : close, isOutline: true),
dialogButton("OK", onPressed: isInProgress ? null : submit),
],
onSubmit: submit,
onCancel: isInProgress ? null : close,
);
}, tag: _deployDialogTag);
}
+234
View File
@@ -0,0 +1,234 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common/widgets/setting_widgets.dart';
import 'package:flutter_hbb/common/widgets/toolbar.dart';
import 'package:get/get.dart';
import '../../common.dart';
import '../../models/platform_model.dart';
void _showSuccess() {
showToast(translate("Successful"));
}
void setTemporaryPasswordLengthDialog(
OverlayDialogManager dialogManager) async {
List<String> lengths = ['6', '8', '10'];
String length = await bind.mainGetOption(key: "temporary-password-length");
var index = lengths.indexOf(length);
if (index < 0) index = 0;
length = lengths[index];
dialogManager.show((setState, close, context) {
setLength(newValue) {
final oldValue = length;
if (oldValue == newValue) return;
setState(() {
length = newValue;
});
bind.mainSetOption(key: "temporary-password-length", value: newValue);
bind.mainUpdateTemporaryPassword();
Future.delayed(Duration(milliseconds: 200), () {
close();
_showSuccess();
});
}
return CustomAlertDialog(
title: Text(translate("Set one-time password length")),
content: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: lengths
.map(
(value) => Row(
children: [
Text(value),
Radio(
value: value, groupValue: length, onChanged: setLength),
],
),
)
.toList()),
);
}, backDismiss: true, clickMaskDismiss: true);
}
void showServerSettings(OverlayDialogManager dialogManager,
void Function(VoidCallback) setState) async {
Map<String, dynamic> options = {};
try {
options = jsonDecode(await bind.mainGetOptions());
} catch (e) {
print("Invalid server config: $e");
}
showServerSettingsWithValue(
ServerConfig.fromOptions(options), dialogManager, setState);
}
void showServerSettingsWithValue(
ServerConfig serverConfig,
OverlayDialogManager dialogManager,
void Function(VoidCallback)? upSetState) async {
var isInProgress = false;
final idCtrl = TextEditingController(text: serverConfig.idServer);
final relayCtrl = TextEditingController(text: serverConfig.relayServer);
final apiCtrl = TextEditingController(text: serverConfig.apiServer);
final keyCtrl = TextEditingController(text: serverConfig.key);
RxString idServerMsg = ''.obs;
RxString relayServerMsg = ''.obs;
RxString apiServerMsg = ''.obs;
final controllers = [idCtrl, relayCtrl, apiCtrl, keyCtrl];
final errMsgs = [
idServerMsg,
relayServerMsg,
apiServerMsg,
];
dialogManager.show((setState, close, context) {
Future<bool> submit() async {
setState(() {
isInProgress = true;
});
bool ret = await setServerConfig(
null,
errMsgs,
ServerConfig(
idServer: idCtrl.text.trim(),
relayServer: relayCtrl.text.trim(),
apiServer: apiCtrl.text.trim(),
key: keyCtrl.text.trim()));
setState(() {
isInProgress = false;
});
return ret;
}
Widget buildField(
String label, TextEditingController controller, String errorMsg,
{String? Function(String?)? validator, bool autofocus = false}) {
if (isDesktop || isWeb) {
return Row(
children: [
SizedBox(
width: 120,
child: Text(label),
),
SizedBox(width: 8),
Expanded(
child: TextFormField(
controller: controller,
decoration: InputDecoration(
errorText: errorMsg.isEmpty ? null : errorMsg,
contentPadding:
EdgeInsets.symmetric(horizontal: 8, vertical: 12),
),
validator: validator,
autofocus: autofocus,
).workaroundFreezeLinuxMint(),
),
],
);
}
return TextFormField(
controller: controller,
decoration: InputDecoration(
labelText: label,
errorText: errorMsg.isEmpty ? null : errorMsg,
),
validator: validator,
).workaroundFreezeLinuxMint();
}
return CustomAlertDialog(
title: Row(
children: [
Expanded(child: Text(translate('ID/Relay Server'))),
...ServerConfigImportExportWidgets(controllers, errMsgs),
],
),
content: ConstrainedBox(
constraints: const BoxConstraints(minWidth: 500),
child: Form(
child: Obx(() => Column(
mainAxisSize: MainAxisSize.min,
children: [
buildField(translate('ID Server'), idCtrl, idServerMsg.value,
autofocus: true),
SizedBox(height: 8),
if (!isIOS && !isWeb) ...[
buildField(translate('Relay Server'), relayCtrl,
relayServerMsg.value),
SizedBox(height: 8),
],
buildField(
translate('API Server'),
apiCtrl,
apiServerMsg.value,
validator: (v) {
if (v != null && v.isNotEmpty) {
if (!(v.startsWith('http://') ||
v.startsWith("https://"))) {
return translate("invalid_http");
}
}
return null;
},
),
SizedBox(height: 8),
buildField('Key', keyCtrl, ''),
if (isInProgress)
Padding(
padding: EdgeInsets.only(top: 8),
child: LinearProgressIndicator(),
),
],
)),
),
),
actions: [
dialogButton('Cancel', onPressed: () {
close();
}, isOutline: true),
dialogButton(
'OK',
onPressed: () async {
if (await submit()) {
close();
showToast(translate('Successful'));
upSetState?.call(() {});
} else {
showToast(translate('Failed'));
}
},
),
],
);
});
}
void setPrivacyModeDialog(
OverlayDialogManager dialogManager,
List<TToggleMenu> privacyModeList,
RxString privacyModeState,
) async {
dialogManager.dismissAll();
dialogManager.show((setState, close, context) {
return CustomAlertDialog(
title: Text(translate('Privacy mode')),
content: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: privacyModeList
.map((value) => CheckboxListTile(
contentPadding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
title: value.child,
value: value.value,
onChanged: value.onChanged,
))
.toList()),
);
}, backDismiss: true, clickMaskDismiss: true);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,905 @@
// These floating mouse widgets are used to simulate a physical mouse
// when "mobile" -> "desktop" in mouse mode.
// This file does not contain whole mouse widgets, it only contains
// parts that help to control, such as wheel scroll and wheel button.
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/common/widgets/remote_input.dart';
import 'package:flutter_hbb/models/input_model.dart';
import 'package:flutter_hbb/models/model.dart';
import 'package:flutter_hbb/models/platform_model.dart';
// Used for the wheel button and wheel scroll widgets
const double _kSpaceToHorizontalEdge = 25;
const double _wheelWidth = 50;
const double _wheelHeight = 162;
// Used for the left/right button widgets
const double _kSpaceToVerticalEdge = 15;
const double _kSpaceBetweenLeftRightButtons = 40;
const double _kLeftRightButtonWidth = 55;
const double _kLeftRightButtonHeight = 40;
const double _kBorderWidth = 1;
final Color _kDefaultBorderColor = Colors.white.withOpacity(0.7);
final Color _kDefaultColor = Colors.black.withOpacity(0.4);
final Color _kTapDownColor = Colors.blue.withOpacity(0.7);
final Color _kWidgetHighlightColor = Colors.white.withOpacity(0.9);
const int _kInputTimerIntervalMillis = 100;
class FloatingMouseWidgets extends StatefulWidget {
final FFI ffi;
const FloatingMouseWidgets({
super.key,
required this.ffi,
});
@override
State<FloatingMouseWidgets> createState() => _FloatingMouseWidgetsState();
}
class _FloatingMouseWidgetsState extends State<FloatingMouseWidgets> {
InputModel get _inputModel => widget.ffi.inputModel;
CursorModel get _cursorModel => widget.ffi.cursorModel;
late final VirtualMouseMode _virtualMouseMode;
@override
void initState() {
super.initState();
_virtualMouseMode = widget.ffi.ffiModel.virtualMouseMode;
_virtualMouseMode.addListener(_onVirtualMouseModeChanged);
_cursorModel.blockEvents = false;
isSpecialHoldDragActive = false;
}
void _onVirtualMouseModeChanged() {
if (mounted) {
setState(() {});
}
}
@override
void dispose() {
_virtualMouseMode.removeListener(_onVirtualMouseModeChanged);
super.dispose();
_cursorModel.blockEvents = false;
isSpecialHoldDragActive = false;
}
@override
Widget build(BuildContext context) {
final virtualMouseMode = _virtualMouseMode;
if (!virtualMouseMode.showVirtualMouse) {
return const Offstage();
}
return Stack(
children: [
FloatingWheel(
inputModel: _inputModel,
cursorModel: _cursorModel,
),
if (virtualMouseMode.showVirtualJoystick)
VirtualJoystick(
cursorModel: _cursorModel,
inputModel: _inputModel,
),
FloatingLeftRightButton(
isLeft: true,
inputModel: _inputModel,
cursorModel: _cursorModel,
),
FloatingLeftRightButton(
isLeft: false,
inputModel: _inputModel,
cursorModel: _cursorModel,
),
],
);
}
}
class FloatingWheel extends StatefulWidget {
final InputModel inputModel;
final CursorModel cursorModel;
const FloatingWheel(
{super.key, required this.inputModel, required this.cursorModel});
@override
State<FloatingWheel> createState() => _FloatingWheelState();
}
class _FloatingWheelState extends State<FloatingWheel> {
Offset _position = Offset.zero;
bool _isInitialized = false;
Rect? _lastBlockedRect;
bool _isUpDown = false;
bool _isMidDown = false;
bool _isDownDown = false;
Orientation? _previousOrientation;
Timer? _scrollTimer;
InputModel get _inputModel => widget.inputModel;
CursorModel get _cursorModel => widget.cursorModel;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_resetPosition();
});
}
void _resetPosition() {
final size = MediaQuery.of(context).size;
setState(() {
_position = Offset(
size.width - _wheelWidth - _kSpaceToHorizontalEdge,
(size.height - _wheelHeight) / 2,
);
_isInitialized = true;
});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _updateBlockedRect();
});
}
void _updateBlockedRect() {
if (_lastBlockedRect != null) {
_cursorModel.removeBlockedRect(_lastBlockedRect!);
}
final newRect =
Rect.fromLTWH(_position.dx, _position.dy, _wheelWidth, _wheelHeight);
_cursorModel.addBlockedRect(newRect);
_lastBlockedRect = newRect;
}
@override
void dispose() {
_scrollTimer?.cancel();
if (_lastBlockedRect != null) {
_cursorModel.removeBlockedRect(_lastBlockedRect!);
}
super.dispose();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final currentOrientation = MediaQuery.of(context).orientation;
if (_previousOrientation != null &&
_previousOrientation != currentOrientation) {
_resetPosition();
}
_previousOrientation = currentOrientation;
}
Widget _buildUpDownButton(
void Function(PointerDownEvent) onPointerDown,
void Function(PointerUpEvent) onPointerUp,
void Function(PointerCancelEvent) onPointerCancel,
bool Function() flagGetter,
BorderRadiusGeometry borderRadius,
IconData iconData) {
return Listener(
onPointerDown: onPointerDown,
onPointerUp: onPointerUp,
onPointerCancel: onPointerCancel,
child: Container(
width: _wheelWidth,
height: 55,
alignment: Alignment.center,
decoration: BoxDecoration(
color: _kDefaultColor,
border: Border.all(
color: flagGetter() ? _kTapDownColor : _kDefaultBorderColor,
width: 1),
borderRadius: borderRadius,
),
child: Icon(iconData, color: _kDefaultBorderColor, size: 32),
),
);
}
@override
Widget build(BuildContext context) {
if (!_isInitialized) {
return Positioned(child: Offstage());
}
return Positioned(
left: _position.dx,
top: _position.dy,
child: _buildWidget(context),
);
}
Widget _buildWidget(BuildContext context) {
return Container(
width: _wheelWidth,
height: _wheelHeight,
child: Column(
children: [
_buildUpDownButton(
(event) {
setState(() {
_isUpDown = true;
});
_startScrollTimer(1);
},
(event) {
setState(() {
_isUpDown = false;
});
_stopScrollTimer();
},
(event) {
setState(() {
_isUpDown = false;
});
_stopScrollTimer();
},
() => _isUpDown,
BorderRadius.vertical(top: Radius.circular(_wheelWidth * 0.5)),
Icons.keyboard_arrow_up,
),
Listener(
onPointerDown: (event) {
setState(() {
_isMidDown = true;
});
_inputModel.tapDown(MouseButtons.wheel);
},
onPointerUp: (event) {
setState(() {
_isMidDown = false;
});
_inputModel.tapUp(MouseButtons.wheel);
},
onPointerCancel: (event) {
setState(() {
_isMidDown = false;
});
_inputModel.tapUp(MouseButtons.wheel);
},
child: Container(
width: _wheelWidth,
height: 52,
decoration: BoxDecoration(
color: _kDefaultColor,
border: Border.symmetric(
vertical: BorderSide(
color:
_isMidDown ? _kTapDownColor : _kDefaultBorderColor,
width: _kBorderWidth)),
),
child: Center(
child: Container(
width: _wheelWidth - 10,
height: _wheelWidth - 10,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 18,
height: 2,
color: _kDefaultBorderColor,
),
SizedBox(height: 6),
Container(
width: 24,
height: 2,
color: _kDefaultBorderColor,
),
SizedBox(height: 6),
Container(
width: 18,
height: 2,
color: _kDefaultBorderColor,
),
],
),
),
),
),
),
),
_buildUpDownButton(
(event) {
setState(() {
_isDownDown = true;
});
_startScrollTimer(-1);
},
(event) {
setState(() {
_isDownDown = false;
});
_stopScrollTimer();
},
(event) {
setState(() {
_isDownDown = false;
});
_stopScrollTimer();
},
() => _isDownDown,
BorderRadius.vertical(bottom: Radius.circular(_wheelWidth * 0.5)),
Icons.keyboard_arrow_down,
),
],
),
);
}
void _startScrollTimer(int direction) {
_scrollTimer?.cancel();
_inputModel.scroll(direction);
_scrollTimer = Timer.periodic(
Duration(milliseconds: _kInputTimerIntervalMillis), (timer) {
_inputModel.scroll(direction);
});
}
void _stopScrollTimer() {
_scrollTimer?.cancel();
_scrollTimer = null;
}
}
class FloatingLeftRightButton extends StatefulWidget {
final bool isLeft;
final InputModel inputModel;
final CursorModel cursorModel;
const FloatingLeftRightButton(
{super.key,
required this.isLeft,
required this.inputModel,
required this.cursorModel});
@override
State<FloatingLeftRightButton> createState() =>
_FloatingLeftRightButtonState();
}
class _FloatingLeftRightButtonState extends State<FloatingLeftRightButton> {
Offset _position = Offset.zero;
bool _isInitialized = false;
bool _isDown = false;
Rect? _lastBlockedRect;
Orientation? _previousOrientation;
Offset _preSavedPos = Offset.zero;
// Gesture ambiguity resolution
Timer? _tapDownTimer;
final Duration _pressTimeout = const Duration(milliseconds: 200);
bool _isDragging = false;
bool get _isLeft => widget.isLeft;
InputModel get _inputModel => widget.inputModel;
CursorModel get _cursorModel => widget.cursorModel;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
final currentOrientation = MediaQuery.of(context).orientation;
_previousOrientation = currentOrientation;
_resetPosition(currentOrientation);
});
}
@override
void dispose() {
if (_lastBlockedRect != null) {
_cursorModel.removeBlockedRect(_lastBlockedRect!);
}
_tapDownTimer?.cancel();
_trySavePosition();
super.dispose();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final currentOrientation = MediaQuery.of(context).orientation;
if (_previousOrientation == null ||
_previousOrientation != currentOrientation) {
_resetPosition(currentOrientation);
}
_previousOrientation = currentOrientation;
}
double _getOffsetX(double w) {
if (_isLeft) {
return (w - _kLeftRightButtonWidth * 2 - _kSpaceBetweenLeftRightButtons) *
0.5;
} else {
return (w + _kSpaceBetweenLeftRightButtons) * 0.5;
}
}
String _getPositionKey(Orientation ori) {
final strLeftRight = _isLeft ? 'l' : 'r';
final strOri = ori == Orientation.landscape ? 'l' : 'p';
return '$strLeftRight$strOri-mouse-btn-pos';
}
static Offset? _loadPositionFromString(String s) {
if (s.isEmpty) {
return null;
}
try {
final m = jsonDecode(s);
return Offset(m['x'], m['y']);
} catch (e) {
debugPrintStack(label: 'Failed to load position "$s" $e');
return null;
}
}
void _trySavePosition() {
if (_previousOrientation == null) return;
if (((_position - _preSavedPos)).distanceSquared < 0.1) return;
final pos = jsonEncode({
'x': _position.dx,
'y': _position.dy,
});
bind.setLocalFlutterOption(
k: _getPositionKey(_previousOrientation!), v: pos);
_preSavedPos = _position;
}
void _restorePosition(Orientation ori) {
final ps = bind.getLocalFlutterOption(k: _getPositionKey(ori));
final pos = _loadPositionFromString(ps);
if (pos == null) {
final size = MediaQuery.of(context).size;
_position = Offset(_getOffsetX(size.width),
size.height - _kSpaceToVerticalEdge - _kLeftRightButtonHeight);
} else {
_position = pos;
_preSavedPos = pos;
}
}
void _resetPosition(Orientation ori) {
setState(() {
_restorePosition(ori);
_isInitialized = true;
});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _updateBlockedRect();
});
}
void _updateBlockedRect() {
if (_lastBlockedRect != null) {
_cursorModel.removeBlockedRect(_lastBlockedRect!);
}
final newRect = Rect.fromLTWH(_position.dx, _position.dy,
_kLeftRightButtonWidth, _kLeftRightButtonHeight);
_cursorModel.addBlockedRect(newRect);
_lastBlockedRect = newRect;
}
void _onMoveUpdateDelta(Offset delta) {
final context = this.context;
final size = MediaQuery.of(context).size;
Offset newPosition = _position + delta;
double minX = _kSpaceToHorizontalEdge;
double minY = _kSpaceToVerticalEdge;
double maxX = size.width - _kLeftRightButtonWidth - _kSpaceToHorizontalEdge;
double maxY = size.height - _kLeftRightButtonHeight - _kSpaceToVerticalEdge;
newPosition = Offset(
newPosition.dx.clamp(minX, maxX),
newPosition.dy.clamp(minY, maxY),
);
final isPositionChanged = !(isDoubleEqual(newPosition.dx, _position.dx) &&
isDoubleEqual(newPosition.dy, _position.dy));
setState(() {
_position = newPosition;
});
if (isPositionChanged) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _updateBlockedRect();
});
}
}
void _onBodyPointerMoveUpdate(PointerMoveEvent event) {
_cursorModel.blockEvents = true;
// If move, it's a drag, not a tap.
_isDragging = true;
// Cancel the timer to prevent it from being recognized as a tap/hold.
_tapDownTimer?.cancel();
_tapDownTimer = null;
_onMoveUpdateDelta(event.delta);
}
Widget _buildButtonIcon() {
final double w = _kLeftRightButtonWidth * 0.45;
final double h = _kLeftRightButtonHeight * 0.75;
final double borderRadius = w * 0.5;
final double quarterCircleRadius = borderRadius * 0.9;
return Stack(
children: [
Container(
width: w,
height: h,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(_kLeftRightButtonWidth * 0.225),
color: Colors.white,
),
),
Positioned(
left: _isLeft ? quarterCircleRadius * 0.25 : null,
right: _isLeft ? null : quarterCircleRadius * 0.25,
top: quarterCircleRadius * 0.25,
child: CustomPaint(
size: Size(quarterCircleRadius * 2, quarterCircleRadius * 2),
painter: _QuarterCirclePainter(
color: _kDefaultColor,
isLeft: _isLeft,
radius: quarterCircleRadius,
),
),
),
],
);
}
@override
Widget build(BuildContext context) {
if (!_isInitialized) {
return Positioned(child: Offstage());
}
return Positioned(
left: _position.dx,
top: _position.dy,
// We can't use the GestureDetector here, because `onTapDown` may be
// triggered sometimes when dragging.
child: Listener(
onPointerMove: _onBodyPointerMoveUpdate,
onPointerDown: (event) async {
_isDragging = false;
setState(() {
_isDown = true;
});
// Start a timer. If it fires, it's a hold.
_tapDownTimer?.cancel();
_tapDownTimer = Timer(_pressTimeout, () {
isSpecialHoldDragActive = true;
() async {
await _cursorModel.syncCursorPosition();
await _inputModel
.tapDown(_isLeft ? MouseButtons.left : MouseButtons.right);
}();
_tapDownTimer = null;
});
},
onPointerUp: (event) {
_cursorModel.blockEvents = false;
setState(() {
_isDown = false;
});
// If timer is active, it's a quick tap.
if (_tapDownTimer != null) {
_tapDownTimer!.cancel();
_tapDownTimer = null;
// Fire tap down and up quickly.
_inputModel
.tapDown(_isLeft ? MouseButtons.left : MouseButtons.right)
.then(
(_) => Future.delayed(const Duration(milliseconds: 50), () {
_inputModel.tapUp(
_isLeft ? MouseButtons.left : MouseButtons.right);
}));
} else {
// If it's not a quick tap, it could be a hold or drag.
// If it was a hold, isSpecialHoldDragActive is true.
if (isSpecialHoldDragActive) {
_inputModel
.tapUp(_isLeft ? MouseButtons.left : MouseButtons.right);
}
}
if (_isDragging) {
_trySavePosition();
}
isSpecialHoldDragActive = false;
},
onPointerCancel: (event) {
_cursorModel.blockEvents = false;
setState(() {
_isDown = false;
});
_tapDownTimer?.cancel();
_tapDownTimer = null;
if (isSpecialHoldDragActive) {
_inputModel.tapUp(_isLeft ? MouseButtons.left : MouseButtons.right);
}
isSpecialHoldDragActive = false;
if (_isDragging) {
_trySavePosition();
}
},
child: Container(
width: _kLeftRightButtonWidth,
height: _kLeftRightButtonHeight,
alignment: Alignment.center,
decoration: BoxDecoration(
color: _kDefaultColor,
border: Border.all(
color: _isDown ? _kTapDownColor : _kDefaultBorderColor,
width: _kBorderWidth),
borderRadius: _isLeft
? BorderRadius.horizontal(
left: Radius.circular(_kLeftRightButtonHeight * 0.5))
: BorderRadius.horizontal(
right: Radius.circular(_kLeftRightButtonHeight * 0.5)),
),
child: _buildButtonIcon(),
),
),
);
}
}
class _QuarterCirclePainter extends CustomPainter {
final Color color;
final bool isLeft;
final double radius;
_QuarterCirclePainter(
{required this.color, required this.isLeft, required this.radius});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..style = PaintingStyle.fill;
final rect = Rect.fromLTWH(0, 0, radius * 2, radius * 2);
if (isLeft) {
canvas.drawArc(rect, -pi, pi / 2, true, paint);
} else {
canvas.drawArc(rect, -pi / 2, pi / 2, true, paint);
}
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => false;
}
// Virtual joystick can send either absolute movement (via updatePan)
// or relative movement (via sendMobileRelativeMouseMove) depending on the
// InputModel.relativeMouseMode setting.
class VirtualJoystick extends StatefulWidget {
final CursorModel cursorModel;
final InputModel inputModel;
const VirtualJoystick({
super.key,
required this.cursorModel,
required this.inputModel,
});
@override
State<VirtualJoystick> createState() => _VirtualJoystickState();
}
class _VirtualJoystickState extends State<VirtualJoystick> {
Offset _position = Offset.zero;
bool _isInitialized = false;
Offset _offset = Offset.zero;
final double _joystickRadius = 50.0;
final double _thumbRadius = 20.0;
final double _moveStep = 3.0;
final double _speed = 1.0;
/// Scale factor for relative mouse movement sensitivity.
/// Higher values result in faster cursor movement on the remote machine.
static const double _kRelativeMouseScale = 3.0;
// One-shot timer to detect a drag gesture
Timer? _dragStartTimer;
// Periodic timer for continuous movement
Timer? _continuousMoveTimer;
Size? _lastScreenSize;
bool _isPressed = false;
/// Check if relative mouse mode is enabled.
bool get _useRelativeMouse => widget.inputModel.relativeMouseMode.value;
@override
void initState() {
super.initState();
widget.cursorModel.blockEvents = false;
WidgetsBinding.instance.addPostFrameCallback((_) {
_lastScreenSize = MediaQuery.of(context).size;
_resetPosition();
});
}
@override
void dispose() {
_stopSendEventTimer();
widget.cursorModel.blockEvents = false;
super.dispose();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final currentScreenSize = MediaQuery.of(context).size;
if (_lastScreenSize != null && _lastScreenSize != currentScreenSize) {
_resetPosition();
}
_lastScreenSize = currentScreenSize;
}
void _resetPosition() {
final size = MediaQuery.of(context).size;
setState(() {
_position = Offset(
_kSpaceToHorizontalEdge + _joystickRadius,
size.height * 0.5 + _joystickRadius * 1.5,
);
_isInitialized = true;
});
}
Offset _offsetToPanDelta(Offset offset) {
return Offset(
offset.dx / _joystickRadius,
offset.dy / _joystickRadius,
);
}
/// Send movement delta to remote machine.
/// Uses relative mouse mode if enabled, otherwise uses absolute updatePan.
void _sendMovement(Offset delta) {
if (_useRelativeMouse) {
widget.inputModel.sendMobileRelativeMouseMove(
delta.dx * _kRelativeMouseScale, delta.dy * _kRelativeMouseScale);
} else {
// In absolute mode, use cursorModel.updatePan which tracks position.
widget.cursorModel.updatePan(delta, Offset.zero, false);
}
}
void _stopSendEventTimer() {
_dragStartTimer?.cancel();
_continuousMoveTimer?.cancel();
_dragStartTimer = null;
_continuousMoveTimer = null;
}
@override
Widget build(BuildContext context) {
if (!_isInitialized) {
return Positioned(child: Offstage());
}
return Positioned(
left: _position.dx - _joystickRadius,
top: _position.dy - _joystickRadius,
child: GestureDetector(
onPanStart: (details) {
setState(() {
_isPressed = true;
});
widget.cursorModel.blockEvents = true;
_updateOffset(details.localPosition);
// 1. Send a single, small pan event immediately for responsiveness.
// The movement is small for a gentle start.
final initialDelta = _offsetToPanDelta(_offset);
if (initialDelta.distance > 0) {
_sendMovement(initialDelta);
}
// 2. Start a one-shot timer to check if the user is holding for a drag.
_dragStartTimer?.cancel();
_dragStartTimer = Timer(const Duration(milliseconds: 120), () {
// 3. If the timer fires, it's a drag. Start the continuous movement timer.
_continuousMoveTimer?.cancel();
_continuousMoveTimer =
periodic_immediate(const Duration(milliseconds: 20), () async {
if (_offset != Offset.zero) {
_sendMovement(_offsetToPanDelta(_offset) * _moveStep * _speed);
}
});
});
},
onPanUpdate: (details) {
_updateOffset(details.localPosition);
},
onPanEnd: (details) {
setState(() {
_offset = Offset.zero;
_isPressed = false;
});
widget.cursorModel.blockEvents = false;
// 4. Critical step: On pan end, cancel all timers.
// If it was a flick, this cancels the drag detection before it fires.
// If it was a drag, this stops the continuous movement.
_stopSendEventTimer();
},
child: CustomPaint(
size: Size(_joystickRadius * 2, _joystickRadius * 2),
painter: _JoystickPainter(
_offset, _joystickRadius, _thumbRadius, _isPressed),
),
),
);
}
void _updateOffset(Offset localPosition) {
final center = Offset(_joystickRadius, _joystickRadius);
final offset = localPosition - center;
final distance = offset.distance;
if (distance <= _joystickRadius) {
setState(() {
_offset = offset;
});
} else {
final clampedOffset = offset / distance * _joystickRadius;
setState(() {
_offset = clampedOffset;
});
}
}
}
class _JoystickPainter extends CustomPainter {
final Offset _offset;
final double _joystickRadius;
final double _thumbRadius;
final bool _isPressed;
_JoystickPainter(
this._offset, this._joystickRadius, this._thumbRadius, this._isPressed);
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final joystickColor = _kDefaultColor;
final borderColor = _isPressed ? _kTapDownColor : _kDefaultBorderColor;
final thumbColor = _kWidgetHighlightColor;
final joystickPaint = Paint()
..color = joystickColor
..style = PaintingStyle.fill;
final borderPaint = Paint()
..color = borderColor
..style = PaintingStyle.stroke
..strokeWidth = 1.5;
final thumbPaint = Paint()
..color = thumbColor
..style = PaintingStyle.fill;
// Draw joystick base and border
canvas.drawCircle(center, _joystickRadius, joystickPaint);
canvas.drawCircle(center, _joystickRadius, borderPaint);
// Draw thumb
final thumbCenter = center + _offset;
canvas.drawCircle(thumbCenter, _thumbRadius, thumbPaint);
}
@override
bool shouldRepaint(covariant _JoystickPainter oldDelegate) {
return oldDelegate._offset != _offset ||
oldDelegate._isPressed != _isPressed;
}
}
@@ -0,0 +1,391 @@
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/models/input_model.dart';
import 'package:flutter_hbb/models/model.dart';
import 'package:get/get.dart';
import 'package:toggle_switch/toggle_switch.dart';
class GestureIcons {
static const String _family = 'gestureicons';
GestureIcons._();
static const IconData iconMouse = IconData(0xe65c, fontFamily: _family);
static const IconData iconTabletTouch = IconData(0xe9ce, fontFamily: _family);
static const IconData iconGestureFDrag =
IconData(0xe686, fontFamily: _family);
static const IconData iconMobileTouch = IconData(0xe9cd, fontFamily: _family);
static const IconData iconGesturePress =
IconData(0xe66c, fontFamily: _family);
static const IconData iconGestureTap = IconData(0xe66f, fontFamily: _family);
static const IconData iconGesturePinch =
IconData(0xe66a, fontFamily: _family);
static const IconData iconGesturePressHold =
IconData(0xe66b, fontFamily: _family);
static const IconData iconGestureFDragUpDown_ =
IconData(0xe685, fontFamily: _family);
static const IconData iconGestureFTap_ =
IconData(0xe68e, fontFamily: _family);
static const IconData iconGestureFSwipeRight =
IconData(0xe68f, fontFamily: _family);
static const IconData iconGestureFdoubleTap =
IconData(0xe691, fontFamily: _family);
static const IconData iconGestureFThreeFingers =
IconData(0xe687, fontFamily: _family);
}
typedef OnTouchModeChange = void Function(bool);
class GestureHelp extends StatefulWidget {
GestureHelp(
{Key? key,
required this.touchMode,
required this.onTouchModeChange,
required this.virtualMouseMode,
this.inputModel})
: super(key: key);
final bool touchMode;
final OnTouchModeChange onTouchModeChange;
final VirtualMouseMode virtualMouseMode;
final InputModel? inputModel;
@override
State<StatefulWidget> createState() =>
_GestureHelpState(touchMode, virtualMouseMode);
}
class _GestureHelpState extends State<GestureHelp> {
late int _selectedIndex;
late bool _touchMode;
final VirtualMouseMode _virtualMouseMode;
_GestureHelpState(bool touchMode, VirtualMouseMode virtualMouseMode)
: _virtualMouseMode = virtualMouseMode {
_touchMode = touchMode;
_selectedIndex = _touchMode ? 1 : 0;
}
/// Helper to exit relative mouse mode when certain conditions are met.
/// This reduces code duplication across multiple UI callbacks.
void _exitRelativeMouseModeIf(bool condition) {
if (condition) {
widget.inputModel?.setRelativeMouseMode(false);
}
}
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
final space = 12.0;
var width = size.width - 2 * space;
final minWidth = 90;
if (size.width > minWidth + 2 * space) {
final n = (size.width / (minWidth + 2 * space)).floor();
width = size.width / n - 2 * space;
}
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Center(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ToggleSwitch(
initialLabelIndex: _selectedIndex,
activeFgColor: Colors.white,
inactiveFgColor: Colors.white60,
activeBgColor: [MyTheme.accent],
inactiveBgColor: Theme.of(context).hintColor,
totalSwitches: 2,
minWidth: 150,
fontSize: 15,
iconSize: 18,
labels: [
translate("Mouse mode"),
translate("Touch mode")
],
icons: [Icons.mouse, Icons.touch_app],
onToggle: (index) {
setState(() {
if (_selectedIndex != index) {
_selectedIndex = index ?? 0;
_touchMode = index == 0 ? false : true;
widget.onTouchModeChange(_touchMode);
// Exit relative mouse mode when switching to touch mode
_exitRelativeMouseModeIf(_touchMode);
}
});
},
),
Transform.translate(
offset: const Offset(-10.0, 0.0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Checkbox(
value: _virtualMouseMode.showVirtualMouse,
onChanged: (value) async {
if (value == null) return;
await _virtualMouseMode.toggleVirtualMouse();
// Exit relative mouse mode when virtual mouse is hidden
_exitRelativeMouseModeIf(
!_virtualMouseMode.showVirtualMouse);
setState(() {});
},
),
InkWell(
onTap: () async {
await _virtualMouseMode.toggleVirtualMouse();
// Exit relative mouse mode when virtual mouse is hidden
_exitRelativeMouseModeIf(
!_virtualMouseMode.showVirtualMouse);
setState(() {});
},
child: Text(translate('Show virtual mouse')),
),
],
),
),
if (_touchMode && _virtualMouseMode.showVirtualMouse)
Padding(
// Indent "Virtual mouse size"
padding: const EdgeInsets.only(left: 24.0),
child: SizedBox(
width: 260,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.only(
top: 0.0, bottom: 0),
child: Text(translate('Virtual mouse size')),
),
Transform.translate(
offset: Offset(-0.0, -6.0),
child: Row(
children: [
Padding(
padding:
const EdgeInsets.only(left: 0.0),
child: Text(translate('Small')),
),
Expanded(
child: Slider(
value: _virtualMouseMode
.virtualMouseScale,
min: 0.8,
max: 1.8,
divisions: 10,
onChanged: (value) {
_virtualMouseMode
.setVirtualMouseScale(value);
setState(() {});
},
),
),
Padding(
padding:
const EdgeInsets.only(right: 16.0),
child: Text(translate('Large')),
),
],
),
),
],
),
),
),
if (!_touchMode && _virtualMouseMode.showVirtualMouse)
Transform.translate(
offset: const Offset(-10.0, -12.0),
child: Padding(
// Indent "Show virtual joystick"
padding: const EdgeInsets.only(left: 24.0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Checkbox(
value:
_virtualMouseMode.showVirtualJoystick,
onChanged: (value) async {
if (value == null) return;
await _virtualMouseMode
.toggleVirtualJoystick();
// Exit relative mouse mode when joystick is hidden
_exitRelativeMouseModeIf(
!_virtualMouseMode
.showVirtualJoystick);
setState(() {});
},
),
InkWell(
onTap: () async {
await _virtualMouseMode
.toggleVirtualJoystick();
// Exit relative mouse mode when joystick is hidden
_exitRelativeMouseModeIf(
!_virtualMouseMode
.showVirtualJoystick);
setState(() {});
},
child: Text(
translate("Show virtual joystick")),
),
],
)),
),
// Relative mouse mode option - only visible when joystick is shown
if (!_touchMode &&
_virtualMouseMode.showVirtualMouse &&
_virtualMouseMode.showVirtualJoystick &&
widget.inputModel != null)
Obx(() => Transform.translate(
offset: const Offset(-10.0, -24.0),
child: Padding(
// Indent further for 'Relative mouse mode'
padding: const EdgeInsets.only(left: 48.0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Checkbox(
value: widget.inputModel!
.relativeMouseMode.value,
onChanged: (value) {
if (value == null) return;
widget.inputModel!
.setRelativeMouseMode(value);
},
),
InkWell(
onTap: () {
widget.inputModel!
.toggleRelativeMouseMode();
},
child: Text(
translate('Relative mouse mode')),
),
],
)),
)),
],
),
),
Container(
child: Wrap(
spacing: space,
runSpacing: 2 * space,
children: _touchMode
? [
GestureInfo(
width,
GestureIcons.iconMobileTouch,
translate("One-Finger Tap"),
translate("Left Mouse")),
GestureInfo(
width,
GestureIcons.iconGesturePressHold,
translate("One-Long Tap"),
translate("Right Mouse")),
GestureInfo(
width,
GestureIcons.iconGestureFSwipeRight,
translate("One-Finger Move"),
translate("Mouse Drag")),
GestureInfo(
width,
GestureIcons.iconGestureFThreeFingers,
translate("Three-Finger vertically"),
translate("Mouse Wheel")),
GestureInfo(
width,
GestureIcons.iconGestureFDrag,
translate("Two-Finger Move"),
translate("Canvas Move")),
GestureInfo(
width,
GestureIcons.iconGesturePinch,
translate("Pinch to Zoom"),
translate("Canvas Zoom")),
]
: [
GestureInfo(
width,
GestureIcons.iconMobileTouch,
translate("One-Finger Tap"),
translate("Left Mouse")),
GestureInfo(
width,
GestureIcons.iconGesturePressHold,
translate("One-Long Tap"),
translate("Right Mouse")),
GestureInfo(
width,
GestureIcons.iconGestureFSwipeRight,
translate("Double Tap & Move"),
translate("Mouse Drag")),
GestureInfo(
width,
GestureIcons.iconGestureFThreeFingers,
translate("Three-Finger vertically"),
translate("Mouse Wheel")),
GestureInfo(
width,
GestureIcons.iconGestureFDrag,
translate("Two-Finger Move"),
translate("Canvas Move")),
GestureInfo(
width,
GestureIcons.iconGesturePinch,
translate("Pinch to Zoom"),
translate("Canvas Zoom")),
],
)),
],
)));
}
}
class GestureInfo extends StatelessWidget {
const GestureInfo(this.width, this.icon, this.fromText, this.toText,
{Key? key})
: super(key: key);
final String fromText;
final String toText;
final IconData icon;
final double width;
final iconSize = 35.0;
final iconColor = MyTheme.accent;
@override
Widget build(BuildContext context) {
return Container(
width: width,
child: Column(
children: [
Icon(
icon,
size: iconSize,
color: iconColor,
),
SizedBox(height: 6),
Text(fromText,
textAlign: TextAlign.center,
style:
TextStyle(fontSize: 9, color: Theme.of(context).hintColor)),
SizedBox(height: 3),
Text(toText,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12,
color: Theme.of(context).textTheme.bodySmall?.color))
],
));
}
}