for merge

This commit is contained in:
rustdesk
2022-05-12 16:50:30 +08:00
parent 9a3e6c63cd
commit 6de0fa781c
149 changed files with 0 additions and 357 deletions

307
flutter/lib/common.dart Normal file
View File

@@ -0,0 +1,307 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'dart:async';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'models/model.dart';
final globalKey = GlobalKey<NavigatorState>();
final navigationBarKey = GlobalKey();
var isAndroid = false;
var isIOS = false;
var isWeb = false;
var isDesktop = false;
var version = "";
int androidVersion = 0;
typedef F = String Function(String);
typedef FMethod = String Function(String, dynamic);
class Translator {
static late F call;
}
class MyTheme {
MyTheme._();
static const Color grayBg = Color(0xFFEEEEEE);
static const Color white = Color(0xFFFFFFFF);
static const Color accent = Color(0xFF0071FF);
static const Color accent50 = Color(0x770071FF);
static const Color accent80 = Color(0xAA0071FF);
static const Color canvasColor = Color(0xFF212121);
static const Color border = Color(0xFFCCCCCC);
static const Color idColor = Color(0xFF00B6F0);
static const Color darkGray = Color(0xFFB9BABC);
}
final ButtonStyle flatButtonStyle = TextButton.styleFrom(
minimumSize: Size(88, 36),
padding: EdgeInsets.symmetric(horizontal: 16.0),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(2.0)),
),
);
void showToast(String text, {Duration? duration}) {
SmartDialog.showToast(text, displayTime: duration);
}
void showLoading(String text, {bool clickMaskDismiss = false}) {
SmartDialog.dismiss();
SmartDialog.showLoading(
clickMaskDismiss: false,
builder: (context) {
return Container(
color: MyTheme.white,
constraints: BoxConstraints(maxWidth: 240),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(height: 30),
Center(child: CircularProgressIndicator()),
SizedBox(height: 20),
Center(
child: Text(Translator.call(text),
style: TextStyle(fontSize: 15))),
SizedBox(height: 20),
Center(
child: TextButton(
style: flatButtonStyle,
onPressed: () {
SmartDialog.dismiss();
backToHome();
},
child: Text(Translator.call('Cancel'),
style: TextStyle(color: MyTheme.accent))))
]));
});
}
backToHome() {
Navigator.popUntil(globalKey.currentContext!, ModalRoute.withName("/"));
}
typedef DialogBuilder = CustomAlertDialog Function(
StateSetter setState, void Function([dynamic]) close);
class DialogManager {
static int _tag = 0;
static dismissByTag(String tag, [result]) {
SmartDialog.dismiss(tag: tag, result: result);
}
static Future<T?> show<T>(DialogBuilder builder,
{bool clickMaskDismiss = false,
bool backDismiss = false,
String? tag,
bool useAnimation = true}) async {
final t;
if (tag != null) {
t = tag;
} else {
_tag += 1;
t = _tag.toString();
}
SmartDialog.dismiss(status: SmartStatus.allToast);
SmartDialog.dismiss(status: SmartStatus.loading);
final close = ([res]) {
SmartDialog.dismiss(tag: t, result: res);
};
final res = await SmartDialog.show<T>(
tag: t,
clickMaskDismiss: clickMaskDismiss,
backDismiss: backDismiss,
useAnimation: useAnimation,
builder: (_) => StatefulBuilder(
builder: (_, setState) => builder(setState, close)));
return res;
}
}
class CustomAlertDialog extends StatelessWidget {
CustomAlertDialog(
{required this.title,
required this.content,
required this.actions,
this.contentPadding});
final Widget title;
final Widget content;
final List<Widget> actions;
final double? contentPadding;
@override
Widget build(BuildContext context) {
return AlertDialog(
scrollable: true,
title: title,
contentPadding:
EdgeInsets.symmetric(horizontal: contentPadding ?? 25, vertical: 10),
content: content,
actions: actions,
);
}
}
void msgBox(String type, String title, String text, {bool? hasCancel}) {
var wrap = (String text, void Function() onPressed) => ButtonTheme(
padding: EdgeInsets.symmetric(horizontal: 20, vertical: 10),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
//limits the touch area to the button area
minWidth: 0,
//wraps child's width
height: 0,
child: TextButton(
style: flatButtonStyle,
onPressed: onPressed,
child: Text(Translator.call(text),
style: TextStyle(color: MyTheme.accent))));
SmartDialog.dismiss();
final buttons = [
wrap(Translator.call('OK'), () {
SmartDialog.dismiss();
backToHome();
})
];
if (hasCancel == null) {
hasCancel = type != 'error';
}
if (hasCancel) {
buttons.insert(
0,
wrap(Translator.call('Cancel'), () {
SmartDialog.dismiss();
}));
}
DialogManager.show((setState, close) => CustomAlertDialog(
title: Text(translate(title), style: TextStyle(fontSize: 21)),
content: Text(Translator.call(text), style: TextStyle(fontSize: 15)),
actions: buttons));
}
Color str2color(String str, [alpha = 0xFF]) {
var hash = 160 << 16 + 114 << 8 + 91;
for (var i = 0; i < str.length; i += 1) {
hash = str.codeUnitAt(i) + ((hash << 5) - hash);
}
hash = hash % 16777216;
return Color((hash & 0xFF7FFF) | (alpha << 24));
}
const K = 1024;
const M = K * K;
const G = M * K;
String readableFileSize(double size) {
if (size < K) {
return size.toString() + " B";
} else if (size < M) {
return (size / K).toStringAsFixed(2) + " KB";
} else if (size < G) {
return (size / M).toStringAsFixed(2) + " MB";
} else {
return (size / G).toStringAsFixed(2) + " GB";
}
}
/// Flutter can't not catch PointerMoveEvent when size is 1
/// This will happen in Android AccessibilityService Input
/// android can't init dispatching size yet ,see: https://stackoverflow.com/questions/59960451/android-accessibility-dispatchgesture-is-it-possible-to-specify-pressure-for-a
/// use this temporary solution until flutter or android fixes the bug
class AccessibilityListener extends StatelessWidget {
final Widget? child;
static final offset = 100;
AccessibilityListener({this.child});
@override
Widget build(BuildContext context) {
return Listener(
onPointerDown: (evt) {
if (evt.size == 1 && GestureBinding.instance != null) {
GestureBinding.instance!.handlePointerEvent(PointerAddedEvent(
pointer: evt.pointer + offset, position: evt.position));
GestureBinding.instance!.handlePointerEvent(PointerDownEvent(
pointer: evt.pointer + offset,
size: 0.1,
position: evt.position));
}
},
onPointerUp: (evt) {
if (evt.size == 1 && GestureBinding.instance != null) {
GestureBinding.instance!.handlePointerEvent(PointerUpEvent(
pointer: evt.pointer + offset,
size: 0.1,
position: evt.position));
GestureBinding.instance!.handlePointerEvent(PointerRemovedEvent(
pointer: evt.pointer + offset, position: evt.position));
}
},
onPointerMove: (evt) {
if (evt.size == 1 && GestureBinding.instance != null) {
GestureBinding.instance!.handlePointerEvent(PointerMoveEvent(
pointer: evt.pointer + offset,
size: 0.1,
delta: evt.delta,
position: evt.position));
}
},
child: child);
}
}
class PermissionManager {
static Completer<bool>? _completer;
static Timer? _timer;
static var _current = "";
static final permissions = ["audio", "file"];
static bool isWaitingFile() {
if (_completer != null) {
return !_completer!.isCompleted && _current == "file";
}
return false;
}
static Future<bool> check(String type) {
if (!permissions.contains(type))
return Future.error("Wrong permission!$type");
return FFI.invokeMethod("check_permission", type);
}
static Future<bool> request(String type) {
if (!permissions.contains(type))
return Future.error("Wrong permission!$type");
_current = type;
_completer = Completer<bool>();
FFI.invokeMethod("request_permission", type);
// timeout
_timer?.cancel();
_timer = Timer(Duration(seconds: 60), () {
if (_completer == null) return;
if (!_completer!.isCompleted) {
_completer!.complete(false);
}
_completer = null;
_current = "";
});
return _completer!.future;
}
static complete(String type, bool res) {
if (type != _current) {
res = false;
}
_timer?.cancel();
_completer?.complete(res);
_current = "";
}
}

55
flutter/lib/main.dart Normal file
View File

@@ -0,0 +1,55 @@
import 'package:flutter/material.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:provider/provider.dart';
import 'package:firebase_analytics/firebase_analytics.dart';
import 'package:firebase_core/firebase_core.dart';
import 'common.dart';
import 'models/model.dart';
import 'pages/home_page.dart';
import 'pages/server_page.dart';
import 'pages/settings_page.dart';
Future<Null> main() async {
WidgetsFlutterBinding.ensureInitialized();
var a = FFI.ffiModel.init();
var b = Firebase.initializeApp();
await a;
await b;
refreshCurrentUser();
toAndroidChannelInit();
runApp(App());
}
class App extends StatelessWidget {
@override
Widget build(BuildContext context) {
final analytics = FirebaseAnalytics.instance;
return MultiProvider(
providers: [
ChangeNotifierProvider.value(value: FFI.ffiModel),
ChangeNotifierProvider.value(value: FFI.imageModel),
ChangeNotifierProvider.value(value: FFI.cursorModel),
ChangeNotifierProvider.value(value: FFI.canvasModel),
],
child: MaterialApp(
navigatorKey: globalKey,
debugShowCheckedModeBanner: false,
title: 'RustDesk',
theme: ThemeData(
primarySwatch: Colors.blue,
visualDensity: VisualDensity.adaptivePlatformDensity,
),
home: !isAndroid ? WebHomePage() : HomePage(),
navigatorObservers: [
FirebaseAnalyticsObserver(analytics: analytics),
FlutterSmartDialog.observer
],
builder: FlutterSmartDialog.init(
builder: isAndroid
? (_, child) => AccessibilityListener(
child: child,
)
: null)),
);
}
}

View File

@@ -0,0 +1,103 @@
import 'dart:convert';
import 'package:dash_chat/dash_chat.dart';
import 'package:flutter/material.dart';
import '../widgets/overlay.dart';
import 'model.dart';
class ChatModel with ChangeNotifier {
static final clientModeID = -1;
final Map<int, List<ChatMessage>> _messages = Map()..[clientModeID] = [];
final ChatUser me = ChatUser(
uid: "",
name: "Me",
);
final _scroller = ScrollController();
var _currentID = clientModeID;
ScrollController get scroller => _scroller;
Map<int, List<ChatMessage>> get messages => _messages;
int get currentID => _currentID;
ChatUser get currentUser =>
FFI.serverModel.clients[_currentID]?.chatUser ?? me;
changeCurrentID(int id) {
if (_messages.containsKey(id)) {
_currentID = id;
notifyListeners();
} else {
final chatUser = FFI.serverModel.clients[id]?.chatUser;
if (chatUser == null) {
return debugPrint(
"Failed to changeCurrentID,remote user doesn't exist");
}
_messages[id] = [];
_currentID = id;
}
}
receive(int id, String text) {
if (text.isEmpty) return;
// first message show overlay icon
if (chatIconOverlayEntry == null) {
showChatIconOverlay();
}
late final chatUser;
if (id == clientModeID) {
chatUser = ChatUser(
name: FFI.ffiModel.pi.username,
uid: FFI.getId(),
);
} else {
chatUser = FFI.serverModel.clients[id]?.chatUser;
}
if (chatUser == null) {
return debugPrint("Failed to receive msg,user doesn't exist");
}
if (!_messages.containsKey(id)) {
_messages[id] = [];
}
_messages[id]!.add(ChatMessage(text: text, user: chatUser));
_currentID = id;
notifyListeners();
scrollToBottom();
}
scrollToBottom() {
Future.delayed(Duration(milliseconds: 500), () {
_scroller.animateTo(_scroller.position.maxScrollExtent,
duration: Duration(milliseconds: 200),
curve: Curves.fastLinearToSlowEaseIn);
});
}
send(ChatMessage message) {
if (message.text != null && message.text!.isNotEmpty) {
_messages[_currentID]?.add(message);
if (_currentID == clientModeID) {
FFI.setByName("chat_client_mode", message.text!);
} else {
final msg = Map()
..["id"] = _currentID
..["text"] = message.text!;
FFI.setByName("chat_server_mode", jsonEncode(msg));
}
}
notifyListeners();
scrollToBottom();
}
close() {
hideChatIconOverlay();
hideChatWindowOverlay();
notifyListeners();
}
}

View File

@@ -0,0 +1,775 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/pages/file_manager_page.dart';
import 'package:flutter/material.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:path/path.dart' as Path;
import 'model.dart';
enum SortBy { Name, Type, Modified, Size }
class FileModel extends ChangeNotifier {
var _isLocal = false;
var _selectMode = false;
var _localOption = DirectoryOption();
var _remoteOption = DirectoryOption();
var _jobId = 0;
var _jobProgress = JobProgress(); // from rust update
bool get isLocal => _isLocal;
bool get selectMode => _selectMode;
JobProgress get jobProgress => _jobProgress;
JobState get jobState => _jobProgress.state;
SortBy _sortStyle = SortBy.Name;
SortBy get sortStyle => _sortStyle;
FileDirectory _currentLocalDir = FileDirectory();
FileDirectory get currentLocalDir => _currentLocalDir;
FileDirectory _currentRemoteDir = FileDirectory();
FileDirectory get currentRemoteDir => _currentRemoteDir;
FileDirectory get currentDir => _isLocal ? currentLocalDir : currentRemoteDir;
String get currentHome => _isLocal ? _localOption.home : _remoteOption.home;
String get currentShortPath {
if (currentDir.path.startsWith(currentHome)) {
var path = currentDir.path.replaceFirst(currentHome, "");
if (path.length == 0) return "";
if (path[0] == "/" || path[0] == "\\") {
// remove more '/' or '\'
path = path.replaceFirst(path[0], "");
}
return path;
} else {
return currentDir.path.replaceFirst(currentHome, "");
}
}
bool get currentShowHidden =>
_isLocal ? _localOption.showHidden : _remoteOption.showHidden;
bool get currentIsWindows =>
_isLocal ? _localOption.isWindows : _remoteOption.isWindows;
final _fileFetcher = FileFetcher();
final _jobResultListener = JobResultListener<Map<String, dynamic>>();
toggleSelectMode() {
if (jobState == JobState.inProgress) {
return;
}
_selectMode = !_selectMode;
notifyListeners();
}
togglePage() {
_isLocal = !_isLocal;
notifyListeners();
}
toggleShowHidden({bool? showHidden, bool? local}) {
final isLocal = local ?? _isLocal;
if (isLocal) {
_localOption.showHidden = showHidden ?? !_localOption.showHidden;
} else {
_remoteOption.showHidden = showHidden ?? !_remoteOption.showHidden;
}
refresh();
}
tryUpdateJobProgress(Map<String, dynamic> evt) {
try {
int id = int.parse(evt['id']);
_jobProgress.id = id;
_jobProgress.fileNum = int.parse(evt['file_num']);
_jobProgress.speed = double.parse(evt['speed']);
_jobProgress.finishedSize = int.parse(evt['finished_size']);
notifyListeners();
} catch (e) {
debugPrint("Failed to tryUpdateJobProgress,evt:${evt.toString()}");
}
}
receiveFileDir(Map<String, dynamic> evt) {
if (_remoteOption.home.isEmpty && evt['is_local'] == "false") {
// init remote home, the connection will automatic read remote home when established,
try {
final fd = FileDirectory.fromJson(jsonDecode(evt['value']));
fd.format(_remoteOption.isWindows, sort: _sortStyle);
_remoteOption.home = fd.path;
debugPrint("init remote home:${fd.path}");
_currentRemoteDir = fd;
notifyListeners();
return;
} finally {}
}
_fileFetcher.tryCompleteTask(evt['value'], evt['is_local']);
}
jobDone(Map<String, dynamic> evt) {
if (_jobResultListener.isListening) {
_jobResultListener.complete(evt);
return;
}
_selectMode = false;
_jobProgress.state = JobState.done;
refresh();
}
jobError(Map<String, dynamic> evt) {
if (_jobResultListener.isListening) {
_jobResultListener.complete(evt);
return;
}
debugPrint("jobError $evt");
_selectMode = false;
_jobProgress.clear();
_jobProgress.state = JobState.error;
notifyListeners();
}
jobReset() {
_jobProgress.clear();
notifyListeners();
}
onReady() async {
_localOption.home = FFI.getByName("get_home_dir");
_localOption.showHidden =
FFI.getByName("peer_option", "local_show_hidden").isNotEmpty;
_remoteOption.showHidden =
FFI.getByName("peer_option", "remote_show_hidden").isNotEmpty;
_remoteOption.isWindows = FFI.ffiModel.pi.platform == "Windows";
debugPrint("remote platform: ${FFI.ffiModel.pi.platform}");
await Future.delayed(Duration(milliseconds: 100));
final local = FFI.getByName("peer_option", "local_dir");
final remote = FFI.getByName("peer_option", "remote_dir");
openDirectory(local.isEmpty ? _localOption.home : local, isLocal: true);
openDirectory(remote.isEmpty ? _remoteOption.home : remote, isLocal: false);
await Future.delayed(Duration(seconds: 1));
if (_currentLocalDir.path.isEmpty) {
openDirectory(_localOption.home, isLocal: true);
}
if (_currentRemoteDir.path.isEmpty) {
openDirectory(_remoteOption.home, isLocal: false);
}
}
onClose() {
SmartDialog.dismiss();
// save config
Map<String, String> msg = Map();
msg["name"] = "local_dir";
msg["value"] = _currentLocalDir.path;
FFI.setByName('peer_option', jsonEncode(msg));
msg["name"] = "local_show_hidden";
msg["value"] = _localOption.showHidden ? "Y" : "";
FFI.setByName('peer_option', jsonEncode(msg));
msg["name"] = "remote_dir";
msg["value"] = _currentRemoteDir.path;
FFI.setByName('peer_option', jsonEncode(msg));
msg["name"] = "remote_show_hidden";
msg["value"] = _remoteOption.showHidden ? "Y" : "";
FFI.setByName('peer_option', jsonEncode(msg));
_currentLocalDir.clear();
_currentRemoteDir.clear();
_localOption.clear();
_remoteOption.clear();
}
refresh() {
openDirectory(currentDir.path);
}
openDirectory(String path, {bool? isLocal}) async {
isLocal = isLocal ?? _isLocal;
final showHidden =
isLocal ? _localOption.showHidden : _remoteOption.showHidden;
final isWindows =
isLocal ? _localOption.isWindows : _remoteOption.isWindows;
try {
final fd = await _fileFetcher.fetchDirectory(path, isLocal, showHidden);
fd.format(isWindows, sort: _sortStyle);
if (isLocal) {
_currentLocalDir = fd;
} else {
_currentRemoteDir = fd;
}
notifyListeners();
} catch (e) {
debugPrint("Failed to openDirectory :$e");
}
}
goHome() {
openDirectory(currentHome);
}
goToParentDirectory() {
final parent = PathUtil.dirname(currentDir.path, currentIsWindows);
openDirectory(parent);
}
sendFiles(SelectedItems items) {
if (items.isLocal == null) {
debugPrint("Failed to sendFiles ,wrong path state");
return;
}
_jobProgress.state = JobState.inProgress;
final toPath =
items.isLocal! ? currentRemoteDir.path : currentLocalDir.path;
final isWindows =
items.isLocal! ? _localOption.isWindows : _remoteOption.isWindows;
final showHidden =
items.isLocal! ? _localOption.showHidden : _remoteOption.showHidden;
items.items.forEach((from) {
_jobId++;
final msg = {
"id": _jobId.toString(),
"path": from.path,
"to": PathUtil.join(toPath, from.name, isWindows),
"show_hidden": showHidden.toString(),
"is_remote": (!(items.isLocal!)).toString()
};
FFI.setByName("send_files", jsonEncode(msg));
});
}
bool removeCheckboxRemember = false;
removeAction(SelectedItems items) async {
removeCheckboxRemember = false;
if (items.isLocal == null) {
debugPrint("Failed to removeFile, wrong path state");
return;
}
final isWindows =
items.isLocal! ? _localOption.isWindows : _remoteOption.isWindows;
await Future.forEach(items.items, (Entry item) async {
_jobId++;
var title = "";
var content = "";
late final List<Entry> entries;
if (item.isFile) {
title = translate("Are you sure you want to delete this file?");
content = "${item.name}";
entries = [item];
} else if (item.isDirectory) {
title = translate("Not an empty directory");
showLoading(translate("Waiting"));
final fd = await _fileFetcher.fetchDirectoryRecursive(
_jobId, item.path, items.isLocal!, true);
if (fd.path.isEmpty) {
fd.path = item.path;
}
fd.format(isWindows);
SmartDialog.dismiss();
if (fd.entries.isEmpty) {
final confirm = await showRemoveDialog(
translate(
"Are you sure you want to delete this empty directory?"),
item.name,
false);
if (confirm == true) {
sendRemoveEmptyDir(item.path, 0, items.isLocal!);
}
return;
}
entries = fd.entries;
} else {
entries = [];
}
for (var i = 0; i < entries.length; i++) {
final dirShow = item.isDirectory
? "${translate("Are you sure you want to delete the file of this directory?")}\n"
: "";
final count = entries.length > 1 ? "${i + 1}/${entries.length}" : "";
content = dirShow + "$count \n${entries[i].path}";
final confirm =
await showRemoveDialog(title, content, item.isDirectory);
try {
if (confirm == true) {
sendRemoveFile(entries[i].path, i, items.isLocal!);
final res = await _jobResultListener.start();
// handle remove res;
if (item.isDirectory &&
res['file_num'] == (entries.length - 1).toString()) {
sendRemoveEmptyDir(item.path, i, items.isLocal!);
}
}
if (removeCheckboxRemember) {
if (confirm == true) {
for (var j = i + 1; j < entries.length; j++) {
sendRemoveFile(entries[j].path, j, items.isLocal!);
final res = await _jobResultListener.start();
if (item.isDirectory &&
res['file_num'] == (entries.length - 1).toString()) {
sendRemoveEmptyDir(item.path, i, items.isLocal!);
}
}
}
break;
}
} catch (e) {}
}
});
_selectMode = false;
refresh();
}
Future<bool?> showRemoveDialog(
String title, String content, bool showCheckbox) async {
return await DialogManager.show<bool>(
(setState, Function(bool v) close) => CustomAlertDialog(
title: Row(
children: [
Icon(Icons.warning, color: Colors.red),
SizedBox(width: 20),
Text(title)
],
),
content: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(content),
SizedBox(height: 5),
Text(translate("This is irreversible!"),
style: TextStyle(fontWeight: FontWeight.bold)),
showCheckbox
? CheckboxListTile(
contentPadding: const EdgeInsets.all(0),
dense: true,
controlAffinity: ListTileControlAffinity.leading,
title: Text(
translate("Do this for all conflicts"),
),
value: removeCheckboxRemember,
onChanged: (v) {
if (v == null) return;
setState(() => removeCheckboxRemember = v);
},
)
: SizedBox.shrink()
]),
actions: [
TextButton(
style: flatButtonStyle,
onPressed: () => close(false),
child: Text(translate("Cancel"))),
TextButton(
style: flatButtonStyle,
onPressed: () => close(true),
child: Text(translate("OK"))),
]),
useAnimation: false);
}
sendRemoveFile(String path, int fileNum, bool isLocal) {
final msg = {
"id": _jobId.toString(),
"path": path,
"file_num": fileNum.toString(),
"is_remote": (!(isLocal)).toString()
};
FFI.setByName("remove_file", jsonEncode(msg));
}
sendRemoveEmptyDir(String path, int fileNum, bool isLocal) {
final msg = {
"id": _jobId.toString(),
"path": path,
"is_remote": (!isLocal).toString()
};
FFI.setByName("remove_all_empty_dirs", jsonEncode(msg));
}
createDir(String path) {
_jobId++;
final msg = {
"id": _jobId.toString(),
"path": path,
"is_remote": (!isLocal).toString()
};
FFI.setByName("create_dir", jsonEncode(msg));
}
cancelJob(int id) {
FFI.setByName("cancel_job", id.toString());
jobReset();
}
changeSortStyle(SortBy sort) {
_sortStyle = sort;
_currentLocalDir.changeSortStyle(sort);
_currentRemoteDir.changeSortStyle(sort);
notifyListeners();
}
}
class JobResultListener<T> {
Completer<T>? _completer;
Timer? _timer;
int _timeoutSecond = 5;
bool get isListening => _completer != null;
clear() {
if (_completer != null) {
_timer?.cancel();
_timer = null;
_completer!.completeError("Cancel manually");
_completer = null;
return;
}
}
Future<T> start() {
if (_completer != null) return Future.error("Already start listen");
_completer = Completer();
_timer = Timer(Duration(seconds: _timeoutSecond), () {
if (!_completer!.isCompleted) {
_completer!.completeError("Time out");
}
_completer = null;
});
return _completer!.future;
}
complete(T res) {
if (_completer != null) {
_timer?.cancel();
_timer = null;
_completer!.complete(res);
_completer = null;
return;
}
}
}
class FileFetcher {
// Map<String,Completer<FileDirectory>> localTasks = Map(); // now we only use read local dir sync
Map<String, Completer<FileDirectory>> remoteTasks = Map();
Map<int, Completer<FileDirectory>> readRecursiveTasks = Map();
Future<FileDirectory> registerReadTask(bool isLocal, String path) {
// final jobs = isLocal?localJobs:remoteJobs; // maybe we will use read local dir async later
final tasks = remoteTasks; // bypass now
if (tasks.containsKey(path)) {
throw "Failed to registerReadTask, already have same read job";
}
final c = Completer<FileDirectory>();
tasks[path] = c;
Timer(Duration(seconds: 2), () {
tasks.remove(path);
if (c.isCompleted) return;
c.completeError("Failed to read dir,timeout");
});
return c.future;
}
Future<FileDirectory> registerReadRecursiveTask(int id) {
final tasks = readRecursiveTasks;
if (tasks.containsKey(id)) {
throw "Failed to registerRemoveTask, already have same ReadRecursive job";
}
final c = Completer<FileDirectory>();
tasks[id] = c;
Timer(Duration(seconds: 2), () {
tasks.remove(id);
if (c.isCompleted) return;
c.completeError("Failed to read dir,timeout");
});
return c.future;
}
tryCompleteTask(String? msg, String? isLocalStr) {
if (msg == null || isLocalStr == null) return;
late final isLocal;
late final tasks;
if (isLocalStr == "true") {
isLocal = true;
} else if (isLocalStr == "false") {
isLocal = false;
} else {
return;
}
try {
final fd = FileDirectory.fromJson(jsonDecode(msg));
if (fd.id > 0) {
// fd.id > 0 is result for read recursive
// to-do later,will be better if every fetch use ID,so that there will only one task map for read and recursive read
tasks = readRecursiveTasks;
final completer = tasks.remove(fd.id);
completer?.complete(fd);
} else if (fd.path.isNotEmpty) {
// result for normal read dir
// final jobs = isLocal?localJobs:remoteJobs; // maybe we will use read local dir async later
tasks = remoteTasks; // bypass now
final completer = tasks.remove(fd.path);
completer?.complete(fd);
}
} catch (e) {
debugPrint("tryCompleteJob err :$e");
}
}
Future<FileDirectory> fetchDirectory(
String path, bool isLocal, bool showHidden) async {
try {
final msg = {"path": path, "show_hidden": showHidden.toString()};
if (isLocal) {
final res = FFI.getByName("read_local_dir_sync", jsonEncode(msg));
final fd = FileDirectory.fromJson(jsonDecode(res));
return fd;
} else {
FFI.setByName("read_remote_dir", jsonEncode(msg));
return registerReadTask(isLocal, path);
}
} catch (e) {
return Future.error(e);
}
}
Future<FileDirectory> fetchDirectoryRecursive(
int id, String path, bool isLocal, bool showHidden) async {
// TODO test Recursive is show hidden default?
try {
final msg = {
"id": id.toString(),
"path": path,
"show_hidden": showHidden.toString(),
"is_remote": (!isLocal).toString()
};
FFI.setByName("read_dir_recursive", jsonEncode(msg));
return registerReadRecursiveTask(id);
} catch (e) {
return Future.error(e);
}
}
}
class FileDirectory {
List<Entry> entries = [];
int id = 0;
String path = "";
FileDirectory();
FileDirectory.fromJson(Map<String, dynamic> json) {
id = json['id'];
path = json['path'];
json['entries'].forEach((v) {
entries.add(new Entry.fromJson(v));
});
}
// generate full path for every entry , init sort style if need.
format(bool isWindows, {SortBy? sort}) {
entries.forEach((entry) {
entry.path = PathUtil.join(path, entry.name, isWindows);
});
if (sort != null) {
changeSortStyle(sort);
}
}
changeSortStyle(SortBy sort) {
entries = _sortList(entries, sort);
}
clear() {
entries = [];
id = 0;
path = "";
}
}
class Entry {
int entryType = 4;
int modifiedTime = 0;
String name = "";
String path = "";
int size = 0;
Entry();
Entry.fromJson(Map<String, dynamic> json) {
entryType = json['entry_type'];
modifiedTime = json['modified_time'];
name = json['name'];
size = json['size'];
}
bool get isFile => entryType > 3;
bool get isDirectory => entryType <= 3;
DateTime lastModified() {
return DateTime.fromMillisecondsSinceEpoch(modifiedTime * 1000);
}
}
enum JobState { none, inProgress, done, error }
class JobProgress {
JobState state = JobState.none;
var id = 0;
var fileNum = 0;
var speed = 0.0;
var finishedSize = 0;
clear() {
state = JobState.none;
id = 0;
fileNum = 0;
speed = 0;
finishedSize = 0;
}
}
class _PathStat {
final String path;
final DateTime dateTime;
_PathStat(this.path, this.dateTime);
}
class PathUtil {
static final windowsContext = Path.Context(style: Path.Style.windows);
static final posixContext = Path.Context(style: Path.Style.posix);
static String join(String path1, String path2, bool isWindows) {
final pathUtil = isWindows ? windowsContext : posixContext;
return pathUtil.join(path1, path2);
}
static List<String> split(String path, bool isWindows) {
final pathUtil = isWindows ? windowsContext : posixContext;
return pathUtil.split(path);
}
static String dirname(String path, bool isWindows) {
final pathUtil = isWindows ? windowsContext : posixContext;
return pathUtil.dirname(path);
}
}
class DirectoryOption {
String home;
bool showHidden;
bool isWindows;
DirectoryOption(
{this.home = "", this.showHidden = false, this.isWindows = false});
clear() {
home = "";
showHidden = false;
isWindows = false;
}
}
// code from file_manager pkg after edit
List<Entry> _sortList(List<Entry> list, SortBy sortType) {
if (sortType == SortBy.Name) {
// making list of only folders.
final dirs = list.where((element) => element.isDirectory).toList();
// sorting folder list by name.
dirs.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
// making list of only flies.
final files = list.where((element) => element.isFile).toList();
// sorting files list by name.
files.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
// first folders will go to list (if available) then files will go to list.
return [...dirs, ...files];
} else if (sortType == SortBy.Modified) {
// making the list of Path & DateTime
List<_PathStat> _pathStat = [];
for (Entry e in list) {
_pathStat.add(_PathStat(e.name, e.lastModified()));
}
// sort _pathStat according to date
_pathStat.sort((b, a) => a.dateTime.compareTo(b.dateTime));
// sorting [list] according to [_pathStat]
list.sort((a, b) => _pathStat
.indexWhere((element) => element.path == a.name)
.compareTo(_pathStat.indexWhere((element) => element.path == b.name)));
return list;
} else if (sortType == SortBy.Type) {
// making list of only folders.
final dirs = list.where((element) => element.isDirectory).toList();
// sorting folders by name.
dirs.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
// making the list of files
final files = list.where((element) => element.isFile).toList();
// sorting files list by extension.
files.sort((a, b) => a.name
.toLowerCase()
.split('.')
.last
.compareTo(b.name.toLowerCase().split('.').last));
return [...dirs, ...files];
} else if (sortType == SortBy.Size) {
// create list of path and size
Map<String, int> _sizeMap = {};
for (Entry e in list) {
_sizeMap[e.name] = e.size;
}
// making list of only folders.
final dirs = list.where((element) => element.isDirectory).toList();
// sorting folder list by name.
dirs.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
// making list of only flies.
final files = list.where((element) => element.isFile).toList();
// creating sorted list of [_sizeMapList] by size.
final List<MapEntry<String, int>> _sizeMapList = _sizeMap.entries.toList();
_sizeMapList.sort((b, a) => a.value.compareTo(b.value));
// sort [list] according to [_sizeMapList]
files.sort((a, b) => _sizeMapList
.indexWhere((element) => element.key == a.name)
.compareTo(
_sizeMapList.indexWhere((element) => element.key == b.name)));
return [...dirs, ...files];
}
return [];
}

View File

@@ -0,0 +1,967 @@
import 'package:flutter/services.dart';
import 'package:flutter_hbb/models/chat_model.dart';
import 'package:flutter_hbb/models/file_model.dart';
import 'package:flutter_hbb/models/server_model.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'dart:math';
import 'dart:convert';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:tuple/tuple.dart';
import 'dart:async';
import '../common.dart';
import '../widgets/dialog.dart';
import '../widgets/overlay.dart';
import 'native_model.dart' if (dart.library.html) 'web_model.dart';
typedef HandleMsgBox = void Function(Map<String, dynamic> evt, String id);
class FfiModel with ChangeNotifier {
PeerInfo _pi = PeerInfo();
Display _display = Display();
var _decoding = false;
bool _waitForImage = false;
var _inputBlocked = false;
final _permissions = Map<String, bool>();
bool? _secure;
bool? _direct;
bool _touchMode = false;
Timer? _timer;
var _reconnects = 1;
Map<String, bool> get permissions => _permissions;
Display get display => _display;
bool? get secure => _secure;
bool? get direct => _direct;
PeerInfo get pi => _pi;
bool get inputBlocked => _inputBlocked;
bool get touchMode => _touchMode;
bool get isPeerAndroid => _pi.platform == "Android";
set inputBlocked(v) {
_inputBlocked = v;
}
FfiModel() {
Translator.call = translate;
clear();
}
Future<void> init() async {
await PlatformFFI.init();
}
void toggleTouchMode() {
if (!isPeerAndroid) {
_touchMode = !_touchMode;
notifyListeners();
}
}
void updatePermission(Map<String, dynamic> evt) {
evt.forEach((k, v) {
if (k == 'name') return;
_permissions[k] = v == 'true';
});
print('$_permissions');
notifyListeners();
}
void updateUser() {
notifyListeners();
}
bool keyboard() => _permissions['keyboard'] != false;
void clear() {
_pi = PeerInfo();
_display = Display();
_waitForImage = false;
_secure = null;
_direct = null;
_inputBlocked = false;
_timer?.cancel();
_timer = null;
clearPermissions();
}
void setConnectionType(bool secure, bool direct) {
_secure = secure;
_direct = direct;
}
Image? getConnectionImage() {
String? icon;
if (secure == true && direct == true) {
icon = 'secure';
} else if (secure == false && direct == true) {
icon = 'insecure';
} else if (secure == false && direct == false) {
icon = 'insecure_relay';
} else if (secure == true && direct == false) {
icon = 'secure_relay';
}
return icon == null
? null
: Image.asset('assets/$icon.png', width: 48, height: 48);
}
void clearPermissions() {
_inputBlocked = false;
_permissions.clear();
}
void update(String peerId) {
var pos;
for (;;) {
var evt = FFI.popEvent();
if (evt == null) break;
var name = evt['name'];
if (name == 'msgbox') {
handleMsgBox(evt, peerId);
} else if (name == 'peer_info') {
handlePeerInfo(evt);
} else if (name == 'connection_ready') {
FFI.ffiModel.setConnectionType(
evt['secure'] == 'true', evt['direct'] == 'true');
} else if (name == 'switch_display') {
handleSwitchDisplay(evt);
} else if (name == 'cursor_data') {
FFI.cursorModel.updateCursorData(evt);
} else if (name == 'cursor_id') {
FFI.cursorModel.updateCursorId(evt);
} else if (name == 'cursor_position') {
pos = evt;
} else if (name == 'clipboard') {
Clipboard.setData(ClipboardData(text: evt['content']));
} else if (name == 'permission') {
FFI.ffiModel.updatePermission(evt);
} else if (name == 'chat_client_mode') {
FFI.chatModel.receive(ChatModel.clientModeID, evt['text'] ?? "");
} else if (name == 'chat_server_mode') {
FFI.chatModel
.receive(int.parse(evt['id'] as String), evt['text'] ?? "");
} else if (name == 'file_dir') {
FFI.fileModel.receiveFileDir(evt);
} else if (name == 'job_progress') {
FFI.fileModel.tryUpdateJobProgress(evt);
} else if (name == 'job_done') {
FFI.fileModel.jobDone(evt);
} else if (name == 'job_error') {
FFI.fileModel.jobError(evt);
} else if (name == 'try_start_without_auth') {
FFI.serverModel.loginRequest(evt);
} else if (name == 'on_client_authorized') {
FFI.serverModel.onClientAuthorized(evt);
} else if (name == 'on_client_remove') {
FFI.serverModel.onClientRemove(evt);
}
}
if (pos != null) FFI.cursorModel.updateCursorPosition(pos);
if (!_decoding) {
var rgba = PlatformFFI.getRgba();
if (rgba != null) {
if (_waitForImage) {
_waitForImage = false;
SmartDialog.dismiss();
}
_decoding = true;
final pid = FFI.id;
ui.decodeImageFromPixels(rgba, _display.width, _display.height,
isWeb ? ui.PixelFormat.rgba8888 : ui.PixelFormat.bgra8888, (image) {
PlatformFFI.clearRgbaFrame();
_decoding = false;
if (FFI.id != pid) return;
try {
// my throw exception, because the listener maybe already dispose
FFI.imageModel.update(image);
} catch (e) {
print('update image: $e');
}
});
}
}
}
void handleSwitchDisplay(Map<String, dynamic> evt) {
var old = _pi.currentDisplay;
_pi.currentDisplay = int.parse(evt['display']);
_display.x = double.parse(evt['x']);
_display.y = double.parse(evt['y']);
_display.width = int.parse(evt['width']);
_display.height = int.parse(evt['height']);
if (old != _pi.currentDisplay)
FFI.cursorModel.updateDisplayOrigin(_display.x, _display.y);
notifyListeners();
}
void handleMsgBox(Map<String, dynamic> evt, String id) {
var type = evt['type'];
var title = evt['title'];
var text = evt['text'];
if (type == 're-input-password') {
wrongPasswordDialog(id);
} else if (type == 'input-password') {
enterPasswordDialog(id);
} else {
var hasRetry = evt['hasRetry'] == 'true';
print(evt);
showMsgBox(type, title, text, hasRetry);
}
}
void showMsgBox(String type, String title, String text, bool hasRetry) {
msgBox(type, title, text);
if (hasRetry) {
_timer?.cancel();
_timer = Timer(Duration(seconds: _reconnects), () {
FFI.reconnect();
showLoading(translate('Connecting...'));
});
_reconnects *= 2;
} else {
_reconnects = 1;
}
}
void handlePeerInfo(Map<String, dynamic> evt) {
SmartDialog.dismiss();
_pi.version = evt['version'];
_pi.username = evt['username'];
_pi.hostname = evt['hostname'];
_pi.platform = evt['platform'];
_pi.sasEnabled = evt['sas_enabled'] == "true";
_pi.currentDisplay = int.parse(evt['current_display']);
if (isPeerAndroid) {
_touchMode = true;
if (FFI.ffiModel.permissions['keyboard'] != false) {
showMobileActionsOverlay();
}
} else {
_touchMode = FFI.getByName('peer_option', "touch-mode") != '';
}
if (evt['is_file_transfer'] == "true") {
FFI.fileModel.onReady();
} else {
_pi.displays = [];
List<dynamic> displays = json.decode(evt['displays']);
for (int i = 0; i < displays.length; ++i) {
Map<String, dynamic> d0 = displays[i];
var d = Display();
d.x = d0['x'].toDouble();
d.y = d0['y'].toDouble();
d.width = d0['width'];
d.height = d0['height'];
_pi.displays.add(d);
}
if (_pi.currentDisplay < _pi.displays.length) {
_display = _pi.displays[_pi.currentDisplay];
}
if (displays.length > 0) {
showLoading(translate('Connected, waiting for image...'));
_waitForImage = true;
_reconnects = 1;
}
}
notifyListeners();
}
}
class ImageModel with ChangeNotifier {
ui.Image? _image;
ui.Image? get image => _image;
void update(ui.Image? image) {
if (_image == null && image != null) {
if (isDesktop) {
FFI.canvasModel.updateViewStyle();
} else {
final size = MediaQueryData.fromWindow(ui.window).size;
final xscale = size.width / image.width;
final yscale = size.height / image.height;
FFI.canvasModel.scale = max(xscale, yscale);
}
initializeCursorAndCanvas();
Future.delayed(Duration(milliseconds: 1), () {
if (FFI.ffiModel.isPeerAndroid) {
FFI.setByName(
'peer_option', '{"name": "view-style", "value": "shrink"}');
FFI.canvasModel.updateViewStyle();
}
});
}
_image = image;
if (image != null) notifyListeners();
}
double get maxScale {
if (_image == null) return 1.0;
final size = MediaQueryData.fromWindow(ui.window).size;
final xscale = size.width / _image!.width;
final yscale = size.height / _image!.height;
return max(1.0, max(xscale, yscale));
}
double get minScale {
if (_image == null) return 1.0;
final size = MediaQueryData.fromWindow(ui.window).size;
final xscale = size.width / _image!.width;
final yscale = size.height / _image!.height;
return min(xscale, yscale);
}
}
class CanvasModel with ChangeNotifier {
double _x = 0;
double _y = 0;
double _scale = 1.0;
CanvasModel();
double get x => _x;
double get y => _y;
double get scale => _scale;
void updateViewStyle() {
final s = FFI.getByName('peer_option', 'view-style');
final size = MediaQueryData.fromWindow(ui.window).size;
final s1 = size.width / FFI.ffiModel.display.width;
final s2 = size.height / FFI.ffiModel.display.height;
if (s == 'shrink') {
final s = s1 < s2 ? s1 : s2;
if (s < 1) {
_scale = s;
}
} else if (s == 'stretch') {
final s = s1 > s2 ? s1 : s2;
if (s > 1) {
_scale = s;
}
} else {
_scale = 1;
}
_x = (size.width - FFI.ffiModel.display.width * _scale) / 2;
_y = (size.height - FFI.ffiModel.display.height * _scale) / 2;
notifyListeners();
}
void update(double x, double y, double scale) {
_x = x;
_y = y;
_scale = scale;
notifyListeners();
}
void moveDesktopMouse(double x, double y) {
final size = MediaQueryData.fromWindow(ui.window).size;
final dw = FFI.ffiModel.display.width * _scale;
final dh = FFI.ffiModel.display.height * _scale;
var dxOffset = 0;
var dyOffset = 0;
if (dw > size.width) {
dxOffset = (x - dw * (x / size.width) - _x).toInt();
}
if (dh > size.height) {
dyOffset = (y - dh * (y / size.height) - _y).toInt();
}
_x += dxOffset;
_y += dyOffset;
if (dxOffset != 0 || dyOffset != 0) {
notifyListeners();
}
FFI.cursorModel.moveLocal(x, y);
}
set scale(v) {
_scale = v;
notifyListeners();
}
void panX(double dx) {
_x += dx;
notifyListeners();
}
void resetOffset() {
if (isDesktop) {
updateViewStyle();
} else {
_x = 0;
_y = 0;
}
notifyListeners();
}
void panY(double dy) {
_y += dy;
notifyListeners();
}
void updateScale(double v) {
if (FFI.imageModel.image == null) return;
final offset = FFI.cursorModel.offset;
var r = FFI.cursorModel.getVisibleRect();
final px0 = (offset.dx - r.left) * _scale;
final py0 = (offset.dy - r.top) * _scale;
_scale *= v;
final maxs = FFI.imageModel.maxScale;
final mins = FFI.imageModel.minScale;
if (_scale > maxs) _scale = maxs;
if (_scale < mins) _scale = mins;
r = FFI.cursorModel.getVisibleRect();
final px1 = (offset.dx - r.left) * _scale;
final py1 = (offset.dy - r.top) * _scale;
_x -= px1 - px0;
_y -= py1 - py0;
notifyListeners();
}
void clear([bool notify = false]) {
_x = 0;
_y = 0;
_scale = 1.0;
if (notify) notifyListeners();
}
}
class CursorModel with ChangeNotifier {
ui.Image? _image;
final _images = Map<int, Tuple3<ui.Image, double, double>>();
double _x = -10000;
double _y = -10000;
double _hotx = 0;
double _hoty = 0;
double _displayOriginX = 0;
double _displayOriginY = 0;
ui.Image? get image => _image;
double get x => _x - _displayOriginX;
double get y => _y - _displayOriginY;
Offset get offset => Offset(_x, _y);
double get hotx => _hotx;
double get hoty => _hoty;
// remote physical display coordinate
Rect getVisibleRect() {
final size = MediaQueryData.fromWindow(ui.window).size;
final xoffset = FFI.canvasModel.x;
final yoffset = FFI.canvasModel.y;
final scale = FFI.canvasModel.scale;
final x0 = _displayOriginX - xoffset / scale;
final y0 = _displayOriginY - yoffset / scale;
return Rect.fromLTWH(x0, y0, size.width / scale, size.height / scale);
}
double adjustForKeyboard() {
final m = MediaQueryData.fromWindow(ui.window);
var keyboardHeight = m.viewInsets.bottom;
final size = m.size;
if (keyboardHeight < 100) return 0;
final s = FFI.canvasModel.scale;
final thresh = (size.height - keyboardHeight) / 2;
var h = (_y - getVisibleRect().top) * s; // local physical display height
return h - thresh;
}
void touch(double x, double y, MouseButtons button) {
moveLocal(x, y);
FFI.moveMouse(_x, _y);
FFI.tap(button);
}
void move(double x, double y) {
moveLocal(x, y);
FFI.moveMouse(_x, _y);
}
void moveLocal(double x, double y) {
final scale = FFI.canvasModel.scale;
final xoffset = FFI.canvasModel.x;
final yoffset = FFI.canvasModel.y;
_x = (x - xoffset) / scale + _displayOriginX;
_y = (y - yoffset) / scale + _displayOriginY;
notifyListeners();
}
void reset() {
_x = _displayOriginX;
_y = _displayOriginY;
FFI.moveMouse(_x, _y);
FFI.canvasModel.clear(true);
notifyListeners();
}
void updatePan(double dx, double dy, bool touchMode) {
if (FFI.imageModel.image == null) return;
if (touchMode) {
final scale = FFI.canvasModel.scale;
_x += dx / scale;
_y += dy / scale;
FFI.moveMouse(_x, _y);
notifyListeners();
return;
}
final scale = FFI.canvasModel.scale;
dx /= scale;
dy /= scale;
final r = getVisibleRect();
var cx = r.center.dx;
var cy = r.center.dy;
var tryMoveCanvasX = false;
if (dx > 0) {
final maxCanvasCanMove = _displayOriginX +
FFI.imageModel.image!.width -
r.right.roundToDouble();
tryMoveCanvasX = _x + dx > cx && maxCanvasCanMove > 0;
if (tryMoveCanvasX) {
dx = min(dx, maxCanvasCanMove);
} else {
final maxCursorCanMove = r.right - _x;
dx = min(dx, maxCursorCanMove);
}
} else if (dx < 0) {
final maxCanvasCanMove = _displayOriginX - r.left.roundToDouble();
tryMoveCanvasX = _x + dx < cx && maxCanvasCanMove < 0;
if (tryMoveCanvasX) {
dx = max(dx, maxCanvasCanMove);
} else {
final maxCursorCanMove = r.left - _x;
dx = max(dx, maxCursorCanMove);
}
}
var tryMoveCanvasY = false;
if (dy > 0) {
final mayCanvasCanMove = _displayOriginY +
FFI.imageModel.image!.height -
r.bottom.roundToDouble();
tryMoveCanvasY = _y + dy > cy && mayCanvasCanMove > 0;
if (tryMoveCanvasY) {
dy = min(dy, mayCanvasCanMove);
} else {
final mayCursorCanMove = r.bottom - _y;
dy = min(dy, mayCursorCanMove);
}
} else if (dy < 0) {
final mayCanvasCanMove = _displayOriginY - r.top.roundToDouble();
tryMoveCanvasY = _y + dy < cy && mayCanvasCanMove < 0;
if (tryMoveCanvasY) {
dy = max(dy, mayCanvasCanMove);
} else {
final mayCursorCanMove = r.top - _y;
dy = max(dy, mayCursorCanMove);
}
}
if (dx == 0 && dy == 0) return;
_x += dx;
_y += dy;
if (tryMoveCanvasX && dx != 0) {
FFI.canvasModel.panX(-dx);
}
if (tryMoveCanvasY && dy != 0) {
FFI.canvasModel.panY(-dy);
}
FFI.moveMouse(_x, _y);
notifyListeners();
}
void updateCursorData(Map<String, dynamic> evt) {
var id = int.parse(evt['id']);
_hotx = double.parse(evt['hotx']);
_hoty = double.parse(evt['hoty']);
var width = int.parse(evt['width']);
var height = int.parse(evt['height']);
List<dynamic> colors = json.decode(evt['colors']);
final rgba = Uint8List.fromList(colors.map((s) => s as int).toList());
var pid = FFI.id;
ui.decodeImageFromPixels(rgba, width, height, ui.PixelFormat.rgba8888,
(image) {
if (FFI.id != pid) return;
_image = image;
_images[id] = Tuple3(image, _hotx, _hoty);
try {
// my throw exception, because the listener maybe already dispose
notifyListeners();
} catch (e) {
print('notify cursor: $e');
}
});
}
void updateCursorId(Map<String, dynamic> evt) {
final tmp = _images[int.parse(evt['id'])];
if (tmp != null) {
_image = tmp.item1;
_hotx = tmp.item2;
_hoty = tmp.item3;
notifyListeners();
}
}
void updateCursorPosition(Map<String, dynamic> evt) {
_x = double.parse(evt['x']);
_y = double.parse(evt['y']);
notifyListeners();
}
void updateDisplayOrigin(double x, double y) {
_displayOriginX = x;
_displayOriginY = y;
_x = x + 1;
_y = y + 1;
FFI.moveMouse(x, y);
FFI.canvasModel.resetOffset();
notifyListeners();
}
void updateDisplayOriginWithCursor(
double x, double y, double xCursor, double yCursor) {
_displayOriginX = x;
_displayOriginY = y;
_x = xCursor;
_y = yCursor;
FFI.moveMouse(x, y);
notifyListeners();
}
void clear() {
_x = -10000;
_x = -10000;
_image = null;
_images.clear();
}
}
enum MouseButtons { left, right, wheel }
extension ToString on MouseButtons {
String get value {
switch (this) {
case MouseButtons.left:
return "left";
case MouseButtons.right:
return "right";
case MouseButtons.wheel:
return "wheel";
}
}
}
class FFI {
static var id = "";
static var shift = false;
static var ctrl = false;
static var alt = false;
static var command = false;
static var version = "";
static final imageModel = ImageModel();
static final ffiModel = FfiModel();
static final cursorModel = CursorModel();
static final canvasModel = CanvasModel();
static final serverModel = ServerModel();
static final chatModel = ChatModel();
static final fileModel = FileModel();
static String getId() {
return getByName('remote_id');
}
static void tap(MouseButtons button) {
sendMouse('down', button);
sendMouse('up', button);
}
static void scroll(double y) {
var y2 = y.round();
if (y2 == 0) return;
setByName('send_mouse',
json.encode(modify({'type': 'wheel', 'y': y2.toString()})));
}
static void reconnect() {
setByName('reconnect');
FFI.ffiModel.clearPermissions();
}
static void resetModifiers() {
shift = ctrl = alt = command = false;
}
static Map<String, String> modify(Map<String, String> evt) {
if (ctrl) evt['ctrl'] = 'true';
if (shift) evt['shift'] = 'true';
if (alt) evt['alt'] = 'true';
if (command) evt['command'] = 'true';
return evt;
}
static void sendMouse(String type, MouseButtons button) {
if (!ffiModel.keyboard()) return;
setByName('send_mouse',
json.encode(modify({'type': type, 'buttons': button.value})));
}
static void inputKey(String name, {bool? down, bool? press}) {
if (!ffiModel.keyboard()) return;
setByName(
'input_key',
json.encode(modify({
'name': name,
'down': (down ?? false).toString(),
'press': (press ?? true).toString()
})));
}
static void moveMouse(double x, double y) {
if (!ffiModel.keyboard()) return;
var x2 = x.toInt();
var y2 = y.toInt();
setByName('send_mouse', json.encode(modify({'x': '$x2', 'y': '$y2'})));
}
static List<Peer> peers() {
try {
var str = getByName('peers');
if (str == "") return [];
List<dynamic> peers = json.decode(str);
return peers
.map((s) => s as List<dynamic>)
.map((s) =>
Peer.fromJson(s[0] as String, s[1] as Map<String, dynamic>))
.toList();
} catch (e) {
print('peers(): $e');
}
return [];
}
static void connect(String id, {bool isFileTransfer = false}) {
if (isFileTransfer) {
setByName('connect_file_transfer', id);
} else {
setByName('connect', id);
}
FFI.id = id;
}
static Map<String, dynamic>? popEvent() {
var s = getByName('event');
if (s == '') return null;
try {
Map<String, dynamic> event = json.decode(s);
return event;
} catch (e) {
print('popEvent(): $e');
}
return null;
}
static void login(String password, bool remember) {
setByName(
'login',
json.encode({
'password': password,
'remember': remember ? 'true' : 'false',
}));
}
static void close() {
chatModel.close();
if (FFI.imageModel.image != null && !isDesktop) {
savePreference(id, cursorModel.x, cursorModel.y, canvasModel.x,
canvasModel.y, canvasModel.scale, ffiModel.pi.currentDisplay);
}
id = "";
setByName('close', '');
imageModel.update(null);
cursorModel.clear();
ffiModel.clear();
canvasModel.clear();
resetModifiers();
}
static String getByName(String name, [String arg = '']) {
return PlatformFFI.getByName(name, arg);
}
static void setByName(String name, [String value = '']) {
PlatformFFI.setByName(name, value);
}
static handleMouse(Map<String, dynamic> evt) {
var type = '';
var isMove = false;
switch (evt['type']) {
case 'mousedown':
type = 'down';
break;
case 'mouseup':
type = 'up';
break;
case 'mousemove':
isMove = true;
break;
default:
return;
}
evt['type'] = type;
var x = evt['x'];
var y = evt['y'];
if (isMove) {
FFI.canvasModel.moveDesktopMouse(x, y);
}
final d = FFI.ffiModel.display;
x -= FFI.canvasModel.x;
y -= FFI.canvasModel.y;
if (!isMove && (x < 0 || x > d.width || y < 0 || y > d.height)) {
return;
}
x /= FFI.canvasModel.scale;
y /= FFI.canvasModel.scale;
x += d.x;
y += d.y;
if (type != '') {
x = 0;
y = 0;
}
evt['x'] = '${x.round()}';
evt['y'] = '${y.round()}';
var buttons = '';
switch (evt['buttons']) {
case 1:
buttons = 'left';
break;
case 2:
buttons = 'right';
break;
case 4:
buttons = 'wheel';
break;
}
evt['buttons'] = buttons;
setByName('send_mouse', json.encode(evt));
}
static listenToMouse(bool yesOrNo) {
if (yesOrNo) {
PlatformFFI.startDesktopWebListener();
} else {
PlatformFFI.stopDesktopWebListener();
}
}
static void setMethodCallHandler(FMethod callback) {
PlatformFFI.setMethodCallHandler(callback);
}
static Future<bool> invokeMethod(String method, [dynamic arguments]) async {
return await PlatformFFI.invokeMethod(method, arguments);
}
}
class Peer {
final String id;
final String username;
final String hostname;
final String platform;
Peer.fromJson(String id, Map<String, dynamic> json)
: id = id,
username = json['username'],
hostname = json['hostname'],
platform = json['platform'];
}
class Display {
double x = 0;
double y = 0;
int width = 0;
int height = 0;
}
class PeerInfo {
String version = "";
String username = "";
String hostname = "";
String platform = "";
bool sasEnabled = false;
int currentDisplay = 0;
List<Display> displays = [];
}
void savePreference(String id, double xCursor, double yCursor, double xCanvas,
double yCanvas, double scale, int currentDisplay) async {
SharedPreferences prefs = await SharedPreferences.getInstance();
final p = Map<String, dynamic>();
p['xCursor'] = xCursor;
p['yCursor'] = yCursor;
p['xCanvas'] = xCanvas;
p['yCanvas'] = yCanvas;
p['scale'] = scale;
p['currentDisplay'] = currentDisplay;
prefs.setString('peer' + id, json.encode(p));
}
Future<Map<String, dynamic>?> getPreference(String id) async {
if (!isDesktop) return null;
SharedPreferences prefs = await SharedPreferences.getInstance();
var p = prefs.getString('peer' + id);
if (p == null) return null;
Map<String, dynamic> m = json.decode(p);
return m;
}
void removePreference(String id) async {
SharedPreferences prefs = await SharedPreferences.getInstance();
prefs.remove('peer' + id);
}
void initializeCursorAndCanvas() async {
var p = await getPreference(FFI.id);
int currentDisplay = 0;
if (p != null) {
currentDisplay = p['currentDisplay'];
}
if (p == null || currentDisplay != FFI.ffiModel.pi.currentDisplay) {
FFI.cursorModel
.updateDisplayOrigin(FFI.ffiModel.display.x, FFI.ffiModel.display.y);
return;
}
double xCursor = p['xCursor'];
double yCursor = p['yCursor'];
double xCanvas = p['xCanvas'];
double yCanvas = p['yCanvas'];
double scale = p['scale'];
FFI.cursorModel.updateDisplayOriginWithCursor(
FFI.ffiModel.display.x, FFI.ffiModel.display.y, xCursor, yCursor);
FFI.canvasModel.update(xCanvas, yCanvas, scale);
}
String translate(String name) {
if (name.startsWith('Failed to') && name.contains(': ')) {
return name.split(': ').map((x) => translate(x)).join(': ');
}
var a = 'translate';
var b = '{"locale": "$localeName", "text": "$name"}';
return FFI.getByName(a, b);
}

View File

@@ -0,0 +1,136 @@
import 'dart:io';
import 'dart:typed_data';
import 'dart:ffi';
import 'package:ffi/ffi.dart';
import 'package:path_provider/path_provider.dart';
import 'package:device_info/device_info.dart';
import 'package:package_info/package_info.dart';
import 'package:external_path/external_path.dart';
import 'package:flutter/services.dart';
import '../common.dart';
class RgbaFrame extends Struct {
@Uint32()
external int len;
external Pointer<Uint8> data;
}
typedef F2 = Pointer<Utf8> Function(Pointer<Utf8>, Pointer<Utf8>);
typedef F3 = void Function(Pointer<Utf8>, Pointer<Utf8>);
typedef F4 = void Function(Pointer<RgbaFrame>);
typedef F5 = Pointer<RgbaFrame> Function();
class PlatformFFI {
static Pointer<RgbaFrame>? _lastRgbaFrame;
static String _dir = '';
static String _homeDir = '';
static F2? _getByName;
static F3? _setByName;
static F4? _freeRgba;
static F5? _getRgba;
static void clearRgbaFrame() {
if (_lastRgbaFrame != null &&
_lastRgbaFrame != nullptr &&
_freeRgba != null) _freeRgba!(_lastRgbaFrame!);
}
static Uint8List? getRgba() {
if (_getRgba == null) return null;
_lastRgbaFrame = _getRgba!();
if (_lastRgbaFrame == null || _lastRgbaFrame == nullptr) return null;
final ref = _lastRgbaFrame!.ref;
return Uint8List.sublistView(ref.data.asTypedList(ref.len));
}
static Future<String> getVersion() async {
PackageInfo packageInfo = await PackageInfo.fromPlatform();
return packageInfo.version;
}
static String getByName(String name, [String arg = '']) {
if (_getByName == null) return '';
var a = name.toNativeUtf8();
var b = arg.toNativeUtf8();
var p = _getByName!(a, b);
assert(p != nullptr);
var res = p.toDartString();
calloc.free(p);
calloc.free(a);
calloc.free(b);
return res;
}
static void setByName(String name, [String value = '']) {
if (_setByName == null) return;
var a = name.toNativeUtf8();
var b = value.toNativeUtf8();
_setByName!(a, b);
calloc.free(a);
calloc.free(b);
}
static Future<Null> init() async {
isIOS = Platform.isIOS;
isAndroid = Platform.isAndroid;
final dylib = Platform.isAndroid
? DynamicLibrary.open('librustdesk.so')
: DynamicLibrary.process();
print('initializing FFI');
try {
_getByName = dylib.lookupFunction<F2, F2>('get_by_name');
_setByName =
dylib.lookupFunction<Void Function(Pointer<Utf8>, Pointer<Utf8>), F3>(
'set_by_name');
_freeRgba = dylib
.lookupFunction<Void Function(Pointer<RgbaFrame>), F4>('free_rgba');
_getRgba = dylib.lookupFunction<F5, F5>('get_rgba');
_dir = (await getApplicationDocumentsDirectory()).path;
try {
_homeDir = (await ExternalPath.getExternalStorageDirectories())[0];
} catch (e) {
print(e);
}
String id = 'NA';
String name = 'Flutter';
DeviceInfoPlugin deviceInfo = DeviceInfoPlugin();
if (Platform.isAndroid) {
AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo;
name = '${androidInfo.brand}-${androidInfo.model}';
id = androidInfo.id.hashCode.toString();
androidVersion = androidInfo.version.sdkInt;
} else {
IosDeviceInfo iosInfo = await deviceInfo.iosInfo;
name = iosInfo.utsname.machine;
id = iosInfo.identifierForVendor.hashCode.toString();
}
print("info1-id:$id,info2-name:$name,dir:$_dir,homeDir:$_homeDir");
setByName('info1', id);
setByName('info2', name);
setByName('home_dir', _homeDir);
setByName('init', _dir);
} catch (e) {
print(e);
}
version = await getVersion();
}
static void startDesktopWebListener() {}
static void stopDesktopWebListener() {}
static void setMethodCallHandler(FMethod callback) {
toAndroidChannel.setMethodCallHandler((call) async {
callback(call.method, call.arguments);
return null;
});
}
static invokeMethod(String method, [dynamic arguments]) async {
if (!isAndroid) return Future<bool>(() => false);
return await toAndroidChannel.invokeMethod(method, arguments);
}
}
final localeName = Platform.localeName;
final toAndroidChannel = MethodChannel("mChannel");

View File

@@ -0,0 +1,510 @@
import 'dart:async';
import 'dart:convert';
import 'package:dash_chat/dash_chat.dart';
import 'package:flutter/material.dart';
import 'package:wakelock/wakelock.dart';
import '../common.dart';
import '../pages/server_page.dart';
import 'model.dart';
const loginDialogTag = "LOGIN";
final _emptyIdShow = translate("Generating ...");
class ServerModel with ChangeNotifier {
Timer? _interval;
bool _isStart = false; // Android MainService status
bool _mediaOk = false;
bool _inputOk = false;
bool _audioOk = false;
bool _fileOk = false;
int _connectStatus = 0; // Rendezvous Server status
final _serverId = TextEditingController(text: _emptyIdShow);
final _serverPasswd = TextEditingController(text: "");
Map<int, Client> _clients = {};
bool get isStart => _isStart;
bool get mediaOk => _mediaOk;
bool get inputOk => _inputOk;
bool get audioOk => _audioOk;
bool get fileOk => _fileOk;
int get connectStatus => _connectStatus;
TextEditingController get serverId => _serverId;
TextEditingController get serverPasswd => _serverPasswd;
Map<int, Client> get clients => _clients;
final controller = ScrollController();
ServerModel() {
() async {
/**
* 1. check android permission
* 2. check config
* audio true by default (if permission on) (false default < Android 10)
* file true by default (if permission on)
* input false by default (it need turning on manually everytime)
*/
await Future.delayed(Duration(seconds: 1));
// audio
if (androidVersion < 30 || !await PermissionManager.check("audio")) {
_audioOk = false;
FFI.setByName(
'option',
jsonEncode(Map()
..["name"] = "enable-audio"
..["value"] = "N"));
} else {
final audioOption = FFI.getByName('option', 'enable-audio');
_audioOk = audioOption.isEmpty;
}
// file
if (!await PermissionManager.check("file")) {
_fileOk = false;
FFI.setByName(
'option',
jsonEncode(Map()
..["name"] = "enable-file-transfer"
..["value"] = "N"));
} else {
final fileOption = FFI.getByName('option', 'enable-file-transfer');
_fileOk = fileOption.isEmpty;
}
// input (mouse control)
Map<String, String> res = Map()
..["name"] = "enable-keyboard"
..["value"] = 'N';
FFI.setByName('option', jsonEncode(res)); // input false by default
notifyListeners();
}();
Timer.periodic(Duration(seconds: 1), (timer) {
var status = int.tryParse(FFI.getByName('connect_statue')) ?? 0;
if (status > 0) {
status = 1;
}
if (status != _connectStatus) {
_connectStatus = status;
notifyListeners();
}
final res =
FFI.getByName('check_clients_length', _clients.length.toString());
if (res.isNotEmpty) {
debugPrint("clients not match!");
updateClientState(res);
}
});
}
toggleAudio() async {
if (!_audioOk && !await PermissionManager.check("audio")) {
final res = await PermissionManager.request("audio");
if (!res) {
// TODO handle fail
return;
}
}
_audioOk = !_audioOk;
Map<String, String> res = Map()
..["name"] = "enable-audio"
..["value"] = _audioOk ? '' : 'N';
FFI.setByName('option', jsonEncode(res));
notifyListeners();
}
toggleFile() async {
if (!_fileOk && !await PermissionManager.check("file")) {
final res = await PermissionManager.request("file");
if (!res) {
// TODO handle fail
return;
}
}
_fileOk = !_fileOk;
Map<String, String> res = Map()
..["name"] = "enable-file-transfer"
..["value"] = _fileOk ? '' : 'N';
FFI.setByName('option', jsonEncode(res));
notifyListeners();
}
toggleInput() {
if (_inputOk) {
FFI.invokeMethod("stop_input");
} else {
showInputWarnAlert();
}
}
toggleService() async {
if (_isStart) {
final res =
await DialogManager.show<bool>((setState, close) => CustomAlertDialog(
title: Row(children: [
Icon(Icons.warning_amber_sharp,
color: Colors.redAccent, size: 28),
SizedBox(width: 10),
Text(translate("Warning")),
]),
content: Text(translate("android_stop_service_tip")),
actions: [
TextButton(
onPressed: () => close(),
child: Text(translate("Cancel"))),
ElevatedButton(
onPressed: () => close(true),
child: Text(translate("OK"))),
],
));
if (res == true) {
stopService();
}
} else {
final res =
await DialogManager.show<bool>((setState, close) => CustomAlertDialog(
title: Row(children: [
Icon(Icons.warning_amber_sharp,
color: Colors.redAccent, size: 28),
SizedBox(width: 10),
Text(translate("Warning")),
]),
content: Text(translate("android_service_will_start_tip")),
actions: [
TextButton(
onPressed: () => close(),
child: Text(translate("Cancel"))),
ElevatedButton(
onPressed: () => close(true),
child: Text(translate("OK"))),
],
));
if (res == true) {
startService();
}
}
}
Future<Null> startService() async {
_isStart = true;
notifyListeners();
FFI.setByName("ensure_init_event_queue");
_interval?.cancel();
_interval = Timer.periodic(Duration(milliseconds: 30), (timer) {
FFI.ffiModel.update("");
});
await FFI.invokeMethod("init_service");
FFI.setByName("start_service");
getIDPasswd();
updateClientState();
Wakelock.enable();
}
Future<Null> stopService() async {
_isStart = false;
_interval?.cancel();
_interval = null;
FFI.serverModel.closeAll();
await FFI.invokeMethod("stop_service");
FFI.setByName("stop_service");
notifyListeners();
Wakelock.disable();
}
Future<Null> initInput() async {
await FFI.invokeMethod("init_input");
}
Future<bool> updatePassword(String pw) async {
final oldPasswd = _serverPasswd.text;
FFI.setByName("update_password", pw);
await Future.delayed(Duration(milliseconds: 500));
await getIDPasswd(force: true);
// check result
if (pw == "") {
if (_serverPasswd.text.isNotEmpty && _serverPasswd.text != oldPasswd) {
return true;
} else {
return false;
}
} else {
if (_serverPasswd.text == pw) {
return true;
} else {
return false;
}
}
}
getIDPasswd({bool force = false}) async {
if (!force && _serverId.text != _emptyIdShow && _serverPasswd.text != "") {
return;
}
var count = 0;
const maxCount = 10;
while (count < maxCount) {
await Future.delayed(Duration(seconds: 1));
final id = FFI.getByName("server_id");
final passwd = FFI.getByName("server_password");
if (id.isEmpty) {
continue;
} else {
_serverId.text = id;
}
if (passwd.isEmpty) {
continue;
} else {
_serverPasswd.text = passwd;
}
debugPrint(
"fetch id & passwd again at $count:id:${_serverId.text},passwd:${_serverPasswd.text}");
count++;
if (_serverId.text != _emptyIdShow && _serverPasswd.text.isNotEmpty) {
break;
}
}
notifyListeners();
}
changeStatue(String name, bool value) {
debugPrint("changeStatue value $value");
switch (name) {
case "media":
_mediaOk = value;
if (value && !_isStart) {
startService();
}
break;
case "input":
if (_inputOk != value) {
Map<String, String> res = Map()
..["name"] = "enable-keyboard"
..["value"] = value ? '' : 'N';
FFI.setByName('option', jsonEncode(res));
}
_inputOk = value;
break;
default:
return;
}
notifyListeners();
}
updateClientState([String? json]) {
var res = json ?? FFI.getByName("clients_state");
try {
final List clientsJson = jsonDecode(res);
for (var clientJson in clientsJson) {
final client = Client.fromJson(clientJson);
_clients[client.id] = client;
}
notifyListeners();
} catch (e) {
debugPrint("Failed to updateClientState:$e");
}
}
void loginRequest(Map<String, dynamic> evt) {
try {
final client = Client.fromJson(jsonDecode(evt["client"]));
if (_clients.containsKey(client.id)) {
return;
}
_clients[client.id] = client;
scrollToBottom();
notifyListeners();
showLoginDialog(client);
} catch (e) {
debugPrint("Failed to call loginRequest,error:$e");
}
}
void showLoginDialog(Client client) {
DialogManager.show(
(setState, close) => CustomAlertDialog(
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(translate(client.isFileTransfer
? "File Connection"
: "Screen Connection")),
IconButton(
onPressed: () {
close();
},
icon: Icon(Icons.close))
]),
content: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(translate("Do you accept?")),
clientInfo(client),
Text(
translate("android_new_connection_tip"),
style: TextStyle(color: Colors.black54),
),
],
),
actions: [
TextButton(
child: Text(translate("Dismiss")),
onPressed: () {
sendLoginResponse(client, false);
close();
}),
ElevatedButton(
child: Text(translate("Accept")),
onPressed: () {
sendLoginResponse(client, true);
close();
}),
],
),
tag: getLoginDialogTag(client.id));
}
scrollToBottom() {
Future.delayed(Duration(milliseconds: 200), () {
controller.animateTo(controller.position.maxScrollExtent,
duration: Duration(milliseconds: 200),
curve: Curves.fastLinearToSlowEaseIn);
});
}
void sendLoginResponse(Client client, bool res) {
final Map<String, dynamic> response = Map();
response["id"] = client.id;
response["res"] = res;
if (res) {
FFI.setByName("login_res", jsonEncode(response));
if (!client.isFileTransfer) {
FFI.invokeMethod("start_capture");
}
FFI.invokeMethod("cancel_notification", client.id);
_clients[client.id]?.authorized = true;
notifyListeners();
} else {
FFI.setByName("login_res", jsonEncode(response));
FFI.invokeMethod("cancel_notification", client.id);
_clients.remove(client.id);
}
}
void onClientAuthorized(Map<String, dynamic> evt) {
try {
final client = Client.fromJson(jsonDecode(evt['client']));
DialogManager.dismissByTag(getLoginDialogTag(client.id));
_clients[client.id] = client;
scrollToBottom();
notifyListeners();
} catch (e) {}
}
void onClientRemove(Map<String, dynamic> evt) {
try {
final id = int.parse(evt['id'] as String);
if (_clients.containsKey(id)) {
_clients.remove(id);
DialogManager.dismissByTag(getLoginDialogTag(id));
FFI.invokeMethod("cancel_notification", id);
}
notifyListeners();
} catch (e) {
debugPrint("onClientRemove failed,error:$e");
}
}
closeAll() {
_clients.forEach((id, client) {
FFI.setByName("close_conn", id.toString());
});
_clients.clear();
}
}
class Client {
int id = 0; // client connections inner count id
bool authorized = false;
bool isFileTransfer = false;
String name = "";
String peerId = ""; // peer user's id,show at app
bool keyboard = false;
bool clipboard = false;
bool audio = false;
late ChatUser chatUser;
Client(this.authorized, this.isFileTransfer, this.name, this.peerId,
this.keyboard, this.clipboard, this.audio);
Client.fromJson(Map<String, dynamic> json) {
id = json['id'];
authorized = json['authorized'];
isFileTransfer = json['is_file_transfer'];
name = json['name'];
peerId = json['peer_id'];
keyboard = json['keyboard'];
clipboard = json['clipboard'];
audio = json['audio'];
chatUser = ChatUser(
uid: peerId,
name: name,
);
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = new Map<String, dynamic>();
data['id'] = this.id;
data['is_start'] = this.authorized;
data['is_file_transfer'] = this.isFileTransfer;
data['name'] = this.name;
data['peer_id'] = this.peerId;
data['keyboard'] = this.keyboard;
data['clipboard'] = this.clipboard;
data['audio'] = this.audio;
return data;
}
}
String getLoginDialogTag(int id) {
return loginDialogTag + id.toString();
}
showInputWarnAlert() {
DialogManager.show((setState, close) => CustomAlertDialog(
title: Text(translate("How to get Android input permission?")),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(translate("android_input_permission_tip1")),
SizedBox(height: 10),
Text(translate("android_input_permission_tip2")),
],
),
actions: [
TextButton(child: Text(translate("Cancel")), onPressed: close),
ElevatedButton(
child: Text(translate("Open System Setting")),
onPressed: () {
FFI.serverModel.initInput();
close();
}),
],
));
}

View File

@@ -0,0 +1,60 @@
import 'dart:typed_data';
import 'dart:js' as js;
import '../common.dart';
import 'dart:html';
import 'dart:async';
final List<StreamSubscription<MouseEvent>> mouseListeners = [];
final List<StreamSubscription<KeyboardEvent>> keyListeners = [];
int lastMouseDownButtons = 0;
bool mouseIn = false;
class PlatformFFI {
static void clearRgbaFrame() {}
static Uint8List? getRgba() {
return js.context.callMethod('getRgba');
}
static String getByName(String name, [String arg = '']) {
return js.context.callMethod('getByName', [name, arg]);
}
static void setByName(String name, [String value = '']) {
js.context.callMethod('setByName', [name, value]);
}
static Future<Null> init() async {
isWeb = true;
isDesktop = !js.context.callMethod('isMobile');
js.context.callMethod('init');
version = getByName('version');
}
static void startDesktopWebListener() {
mouseIn = true;
mouseListeners.add(
window.document.onContextMenu.listen((evt) => evt.preventDefault()));
}
static void stopDesktopWebListener() {
mouseIn = true;
mouseListeners.forEach((l) {
l.cancel();
});
mouseListeners.clear();
keyListeners.forEach((l) {
l.cancel();
});
keyListeners.clear();
}
static void setMethodCallHandler(FMethod callback) {}
static Future<bool> invokeMethod(String method, [dynamic arguments]) async {
return true;
}
}
final localeName = window.navigator.language;

View File

@@ -0,0 +1,81 @@
import 'package:dash_chat/dash_chat.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/models/chat_model.dart';
import 'package:provider/provider.dart';
import '../models/model.dart';
import 'home_page.dart';
ChatPage chatPage = ChatPage();
class ChatPage extends StatelessWidget implements PageShape {
@override
final title = translate("Chat");
@override
final icon = Icon(Icons.chat);
@override
final appBarActions = [
PopupMenuButton<int>(
icon: Icon(Icons.group),
itemBuilder: (context) {
final chatModel = FFI.chatModel;
final serverModel = FFI.serverModel;
return chatModel.messages.entries.map((entry) {
final id = entry.key;
final user = serverModel.clients[id]?.chatUser ?? chatModel.me;
return PopupMenuItem<int>(
child: Text("${user.name} ${user.uid}"),
value: id,
);
}).toList();
},
onSelected: (id) {
FFI.chatModel.changeCurrentID(id);
})
];
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider.value(
value: FFI.chatModel,
child: Container(
color: MyTheme.grayBg,
child: Consumer<ChatModel>(builder: (context, chatModel, child) {
final currentUser = chatModel.currentUser;
return Stack(
children: [
DashChat(
inputContainerStyle: BoxDecoration(color: Colors.white70),
sendOnEnter: false,
// if true,reload keyboard everytime,need fix
onSend: (chatMsg) {
chatModel.send(chatMsg);
},
user: chatModel.me,
messages: chatModel.messages[chatModel.currentID] ?? [],
// default scrollToBottom has bug https://github.com/fayeed/dash_chat/issues/53
scrollToBottom: false,
scrollController: chatModel.scroller,
),
chatModel.currentID == ChatModel.clientModeID
? SizedBox.shrink()
: Padding(
padding: EdgeInsets.all(12),
child: Row(
children: [
Icon(Icons.account_circle,
color: MyTheme.accent80),
SizedBox(width: 5),
Text(
"${currentUser.name ?? ""} ${currentUser.uid ?? ""}",
style: TextStyle(color: MyTheme.accent50),
),
],
)),
],
);
})));
}
}

View File

@@ -0,0 +1,342 @@
import 'package:flutter/material.dart';
import 'package:flutter_hbb/pages/file_manager_page.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import 'dart:async';
import '../common.dart';
import '../models/model.dart';
import 'home_page.dart';
import 'remote_page.dart';
import 'settings_page.dart';
import 'scan_page.dart';
class ConnectionPage extends StatefulWidget implements PageShape {
ConnectionPage({Key? key}) : super(key: key);
@override
final icon = Icon(Icons.connected_tv);
@override
final title = translate("Connection");
@override
final appBarActions = !isAndroid ? <Widget>[WebMenu()] : <Widget>[];
@override
_ConnectionPageState createState() => _ConnectionPageState();
}
class _ConnectionPageState extends State<ConnectionPage> {
final _idController = TextEditingController();
var _updateUrl = '';
var _menuPos;
@override
void initState() {
super.initState();
if (isAndroid) {
Timer(Duration(seconds: 5), () {
_updateUrl = FFI.getByName('software_update_url');
if (_updateUrl.isNotEmpty) setState(() {});
});
}
}
@override
Widget build(BuildContext context) {
Provider.of<FfiModel>(context);
if (_idController.text.isEmpty) _idController.text = FFI.getId();
return SingleChildScrollView(
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
getUpdateUI(),
getSearchBarUI(),
Container(height: 12),
getPeers(),
]),
);
}
void onConnect() {
var id = _idController.text.trim();
connect(id);
}
void connect(String id, {bool isFileTransfer = false}) async {
if (id == '') return;
id = id.replaceAll(' ', '');
if (isFileTransfer) {
if (!await PermissionManager.check("file")) {
if (!await PermissionManager.request("file")) {
return;
}
}
Navigator.push(
context,
MaterialPageRoute(
builder: (BuildContext context) => FileManagerPage(id: id),
),
);
} else {
Navigator.push(
context,
MaterialPageRoute(
builder: (BuildContext context) => RemotePage(id: id),
),
);
}
FocusScopeNode currentFocus = FocusScope.of(context);
if (!currentFocus.hasPrimaryFocus) {
currentFocus.unfocus();
}
}
Widget getUpdateUI() {
return _updateUrl.isEmpty
? SizedBox(height: 0)
: InkWell(
onTap: () async {
final url = _updateUrl + '.apk';
if (await canLaunch(url)) {
await launch(url);
}
},
child: Container(
alignment: AlignmentDirectional.center,
width: double.infinity,
color: Colors.pinkAccent,
padding: EdgeInsets.symmetric(vertical: 12),
child: Text(translate('Download new version'),
style: TextStyle(
color: Colors.white, fontWeight: FontWeight.bold))));
}
Widget getSearchBarUI() {
var w = Padding(
padding: const EdgeInsets.fromLTRB(16.0, 8.0, 16.0, 0.0),
child: Container(
height: 84,
child: Padding(
padding: const EdgeInsets.only(top: 8, bottom: 8),
child: Ink(
decoration: BoxDecoration(
color: MyTheme.white,
borderRadius: const BorderRadius.all(Radius.circular(13)),
),
child: Row(
children: <Widget>[
Expanded(
child: Container(
padding: const EdgeInsets.only(left: 16, right: 16),
child: TextField(
autocorrect: false,
enableSuggestions: false,
keyboardType: TextInputType.visiblePassword,
// keyboardType: TextInputType.number,
style: 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: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: MyTheme.darkGray,
),
labelStyle: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 16,
letterSpacing: 0.2,
color: MyTheme.darkGray,
),
),
controller: _idController,
),
),
),
SizedBox(
width: 60,
height: 60,
child: IconButton(
icon: Icon(Icons.arrow_forward,
color: MyTheme.darkGray, size: 45),
onPressed: onConnect,
),
),
],
),
),
),
),
);
return Center(
child: Container(constraints: BoxConstraints(maxWidth: 600), child: w));
}
@override
void dispose() {
_idController.dispose();
super.dispose();
}
Widget getPlatformImage(String platform) {
platform = platform.toLowerCase();
if (platform == 'mac os')
platform = 'mac';
else if (platform != 'linux' && platform != 'android') platform = 'win';
return Image.asset('assets/$platform.png', width: 24, height: 24);
}
Widget getPeers() {
final size = MediaQuery.of(context).size;
final space = 8.0;
var width = size.width - 2 * space;
final minWidth = 320.0;
if (size.width > minWidth + 2 * space) {
final n = (size.width / (minWidth + 2 * space)).floor();
width = size.width / n - 2 * space;
}
final cards = <Widget>[];
var peers = FFI.peers();
peers.forEach((p) {
cards.add(Container(
width: width,
child: Card(
child: GestureDetector(
onTap: !isDesktop ? () => connect('${p.id}') : null,
onDoubleTap: isDesktop ? () => connect('${p.id}') : null,
onLongPressStart: (details) {
final x = details.globalPosition.dx;
final y = details.globalPosition.dy;
_menuPos = RelativeRect.fromLTRB(x, y, x, y);
showPeerMenu(context, p.id);
},
child: ListTile(
contentPadding: const EdgeInsets.only(left: 12),
subtitle: Text('${p.username}@${p.hostname}'),
title: Text('${p.id}'),
leading: Container(
padding: const EdgeInsets.all(6),
child: getPlatformImage('${p.platform}'),
color: str2color('${p.id}${p.platform}', 0x7f)),
trailing: InkWell(
child: Padding(
padding: const EdgeInsets.all(12),
child: Icon(Icons.more_vert)),
onTapDown: (e) {
final x = e.globalPosition.dx;
final y = e.globalPosition.dy;
_menuPos = RelativeRect.fromLTRB(x, y, x, y);
},
onTap: () {
showPeerMenu(context, p.id);
}),
)))));
});
return Wrap(children: cards, spacing: space, runSpacing: space);
}
void showPeerMenu(BuildContext context, String id) async {
var value = await showMenu(
context: context,
position: this._menuPos,
items: [
PopupMenuItem<String>(
child: Text(translate('Remove')), value: 'remove')
] +
(!isAndroid
? []
: [
PopupMenuItem<String>(
child: Text(translate('File transfer')), value: 'file')
]),
elevation: 8,
);
if (value == 'remove') {
setState(() => FFI.setByName('remove', '$id'));
() async {
removePreference(id);
}();
} else if (value == 'file') {
connect(id, isFileTransfer: true);
}
}
}
class WebMenu extends StatefulWidget {
@override
_WebMenuState createState() => _WebMenuState();
}
class _WebMenuState extends State<WebMenu> {
@override
Widget build(BuildContext context) {
Provider.of<FfiModel>(context);
final username = getUsername();
return PopupMenuButton<String>(
icon: Icon(Icons.more_vert),
itemBuilder: (context) {
return (isIOS
? [
PopupMenuItem(
child: Icon(Icons.qr_code_scanner, color: Colors.black),
value: "scan",
)
]
: <PopupMenuItem<String>>[]) +
[
PopupMenuItem(
child: Text(translate('ID/Relay Server')),
value: "server",
)
] +
(getUrl().contains('admin.rustdesk.com')
? <PopupMenuItem<String>>[]
: [
PopupMenuItem(
child: Text(username == null
? translate("Login")
: translate("Logout") + ' ($username)'),
value: "login",
)
]) +
[
PopupMenuItem(
child: Text(translate('About') + ' RustDesk'),
value: "about",
)
];
},
onSelected: (value) {
if (value == 'server') {
showServerSettings();
}
if (value == 'about') {
showAbout();
}
if (value == 'login') {
if (username == null) {
showLogin();
} else {
logout();
}
}
if (value == 'scan') {
Navigator.push(
context,
MaterialPageRoute(
builder: (BuildContext context) => ScanPage(),
),
);
}
});
}
}

View File

@@ -0,0 +1,602 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_hbb/models/file_model.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:provider/provider.dart';
import 'package:flutter_breadcrumb/flutter_breadcrumb.dart';
import 'package:wakelock/wakelock.dart';
import 'package:toggle_switch/toggle_switch.dart';
import '../common.dart';
import '../models/model.dart';
import '../widgets/dialog.dart';
class FileManagerPage extends StatefulWidget {
FileManagerPage({Key? key, required this.id}) : super(key: key);
final String id;
@override
State<StatefulWidget> createState() => _FileManagerPageState();
}
class _FileManagerPageState extends State<FileManagerPage> {
final model = FFI.fileModel;
final _selectedItems = SelectedItems();
Timer? _interval;
final _breadCrumbScroller = ScrollController();
@override
void initState() {
super.initState();
FFI.connect(widget.id, isFileTransfer: true);
WidgetsBinding.instance!.addPostFrameCallback((_) {
showLoading(translate('Connecting...'));
_interval = Timer.periodic(Duration(milliseconds: 30),
(timer) => FFI.ffiModel.update(widget.id));
});
Wakelock.enable();
}
@override
void dispose() {
model.onClose();
_interval?.cancel();
FFI.close();
SmartDialog.dismiss();
Wakelock.disable();
super.dispose();
}
@override
Widget build(BuildContext context) => ChangeNotifierProvider.value(
value: FFI.fileModel,
child: Consumer<FileModel>(builder: (_context, _model, _child) {
return WillPopScope(
onWillPop: () async {
if (model.selectMode) {
model.toggleSelectMode();
} else {
goBack();
}
return false;
},
child: Scaffold(
backgroundColor: MyTheme.grayBg,
appBar: AppBar(
leading: Row(children: [
IconButton(icon: Icon(Icons.close), onPressed: clientClose),
]),
centerTitle: true,
title: ToggleSwitch(
initialLabelIndex: model.isLocal ? 0 : 1,
activeBgColor: [MyTheme.idColor],
inactiveBgColor: MyTheme.grayBg,
inactiveFgColor: Colors.black54,
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 = model.isLocal ? 0 : 1;
if (index != current) {
model.togglePage();
}
},
),
actions: [
PopupMenuButton<String>(
icon: Icon(Icons.more_vert),
itemBuilder: (context) {
return [
PopupMenuItem(
child: Row(
children: [
Icon(Icons.refresh, color: Colors.black),
SizedBox(width: 5),
Text(translate("Refresh File"))
],
),
value: "refresh",
),
PopupMenuItem(
child: Row(
children: [
Icon(Icons.check, color: Colors.black),
SizedBox(width: 5),
Text(translate("Multi Select"))
],
),
value: "select",
),
PopupMenuItem(
child: Row(
children: [
Icon(Icons.folder_outlined,
color: Colors.black),
SizedBox(width: 5),
Text(translate("Create Folder"))
],
),
value: "folder",
),
PopupMenuItem(
child: Row(
children: [
Icon(
model.currentShowHidden
? Icons.check_box_outlined
: Icons.check_box_outline_blank,
color: Colors.black),
SizedBox(width: 5),
Text(translate("Show Hidden Files"))
],
),
value: "hidden",
)
];
},
onSelected: (v) {
if (v == "refresh") {
model.refresh();
} else if (v == "select") {
_selectedItems.clear();
model.toggleSelectMode();
} else if (v == "folder") {
final name = TextEditingController();
DialogManager.show(
(setState, close) => CustomAlertDialog(
title: Text(translate("Create Folder")),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
decoration: InputDecoration(
labelText: translate(
"Please enter the folder name"),
),
controller: name,
),
],
),
actions: [
TextButton(
style: flatButtonStyle,
onPressed: () => close(false),
child: Text(translate("Cancel"))),
ElevatedButton(
style: flatButtonStyle,
onPressed: () {
if (name.value.text.isNotEmpty) {
model.createDir(PathUtil.join(
model.currentDir.path,
name.value.text,
model.currentIsWindows));
close();
}
},
child: Text(translate("OK")))
]));
} else if (v == "hidden") {
model.toggleShowHidden();
}
}),
],
),
body: body(),
bottomSheet: bottomSheet(),
));
}));
bool needShowCheckBox() {
if (!model.selectMode) {
return false;
}
return !_selectedItems.isOtherPage(model.isLocal);
}
Widget body() {
final isLocal = model.isLocal;
final fd = model.currentDir;
final entries = fd.entries;
return Column(children: [
headTools(),
Expanded(
child: ListView.builder(
itemCount: entries.length + 1,
itemBuilder: (context, index) {
if (index >= entries.length) {
return listTail();
}
var selected = false;
if (model.selectMode) {
selected = _selectedItems.contains(entries[index]);
}
final sizeStr = entries[index].isFile
? readableFileSize(entries[index].size.toDouble())
: "";
return Card(
child: ListTile(
leading: Icon(
entries[index].isFile ? Icons.feed_outlined : Icons.folder,
size: 40),
title: Text(entries[index].name),
selected: selected,
subtitle: Text(
entries[index]
.lastModified()
.toString()
.replaceAll(".000", "") +
" " +
sizeStr,
style: TextStyle(fontSize: 12, color: MyTheme.darkGray),
),
trailing: needShowCheckBox()
? Checkbox(
value: selected,
onChanged: (v) {
if (v == null) return;
if (v && !selected) {
_selectedItems.add(isLocal, entries[index]);
} else if (!v && selected) {
_selectedItems.remove(entries[index]);
}
setState(() {});
})
: PopupMenuButton<String>(
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,
)
];
},
onSelected: (v) {
if (v == "delete") {
final items = SelectedItems();
items.add(isLocal, entries[index]);
model.removeAction(items);
} else if (v == "multi_select") {
_selectedItems.clear();
model.toggleSelectMode();
}
}),
onTap: () {
if (model.selectMode && !_selectedItems.isOtherPage(isLocal)) {
if (selected) {
_selectedItems.remove(entries[index]);
} else {
_selectedItems.add(isLocal, entries[index]);
}
setState(() {});
return;
}
if (entries[index].isDirectory) {
model.openDirectory(entries[index].path);
breadCrumbScrollToEnd();
} else {
// Perform file-related tasks.
}
},
onLongPress: () {
_selectedItems.clear();
model.toggleSelectMode();
if (model.selectMode) {
_selectedItems.add(isLocal, entries[index]);
}
setState(() {});
},
),
);
},
))
]);
}
goBack() {
model.goToParentDirectory();
}
breadCrumbScrollToEnd() {
Future.delayed(Duration(milliseconds: 200), () {
_breadCrumbScroller.animateTo(
_breadCrumbScroller.position.maxScrollExtent,
duration: Duration(milliseconds: 200),
curve: Curves.fastLinearToSlowEaseIn);
});
}
Widget headTools() => Container(
child: Row(
children: [
Expanded(
child: BreadCrumb(
items: getPathBreadCrumbItems(() => model.goHome(), (list) {
var path = "";
if (model.currentHome.startsWith(list[0])) {
// absolute path
for (var item in list) {
path = PathUtil.join(path, item, model.currentIsWindows);
}
} else {
path += model.currentHome;
for (var item in list) {
path = PathUtil.join(path, item, model.currentIsWindows);
}
}
model.openDirectory(path);
}),
divider: Icon(Icons.chevron_right),
overflow: ScrollableOverflow(controller: _breadCrumbScroller),
)),
Row(
children: [
IconButton(
icon: Icon(Icons.arrow_upward),
onPressed: goBack,
),
PopupMenuButton<SortBy>(
icon: Icon(Icons.sort),
itemBuilder: (context) {
return SortBy.values
.map((e) => PopupMenuItem(
child:
Text(translate(e.toString().split(".").last)),
value: e,
))
.toList();
},
onSelected: model.changeSortStyle),
],
)
],
));
Widget listTail() {
return Container(
height: 100,
child: Column(
children: [
Padding(
padding: EdgeInsets.fromLTRB(30, 5, 30, 0),
child: Text(
model.currentDir.path,
style: TextStyle(color: MyTheme.darkGray),
),
),
Padding(
padding: EdgeInsets.all(2),
child: Text(
"${translate("Total")}: ${model.currentDir.entries.length} ${translate("items")}",
style: TextStyle(color: MyTheme.darkGray),
),
)
],
),
);
}
Widget? bottomSheet() {
final state = model.jobState;
final isOtherPage = _selectedItems.isOtherPage(model.isLocal);
final selectedItemsLen = "${_selectedItems.length} ${translate("items")}";
final local = _selectedItems.isLocal == null
? ""
: " [${_selectedItems.isLocal! ? translate("Local") : translate("Remote")}]";
if (model.selectMode) {
if (_selectedItems.length == 0 || !isOtherPage) {
return BottomSheetBody(
leading: Icon(Icons.check),
title: translate("Selected"),
text: selectedItemsLen + local,
onCanceled: () => model.toggleSelectMode(),
actions: [
IconButton(
icon: Icon(Icons.compare_arrows),
onPressed: model.togglePage,
),
IconButton(
icon: Icon(Icons.delete_forever),
onPressed: () {
if (_selectedItems.length > 0) {
model.removeAction(_selectedItems);
}
},
)
]);
} else {
return BottomSheetBody(
leading: Icon(Icons.input),
title: translate("Paste here?"),
text: selectedItemsLen + local,
onCanceled: () => model.toggleSelectMode(),
actions: [
IconButton(
icon: Icon(Icons.compare_arrows),
onPressed: model.togglePage,
),
IconButton(
icon: Icon(Icons.paste),
onPressed: () {
model.toggleSelectMode();
model.sendFiles(_selectedItems);
},
)
]);
}
}
switch (state) {
case JobState.inProgress:
return BottomSheetBody(
leading: CircularProgressIndicator(),
title: translate("Waiting"),
text:
"${translate("Speed")}: ${readableFileSize(model.jobProgress.speed)}/s",
onCanceled: () => model.cancelJob(model.jobProgress.id),
);
case JobState.done:
return BottomSheetBody(
leading: Icon(Icons.check),
title: "${translate("Successful")}!",
text: "",
onCanceled: () => model.jobReset(),
);
case JobState.error:
return BottomSheetBody(
leading: Icon(Icons.error),
title: "${translate("Error")}!",
text: "",
onCanceled: () => model.jobReset(),
);
case JobState.none:
break;
}
return null;
}
List<BreadCrumbItem> getPathBreadCrumbItems(
void Function() onHome, void Function(List<String>) onPressed) {
final path = model.currentShortPath;
final list = PathUtil.split(path, model.currentIsWindows);
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) {
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, color: MyTheme.grayBg))
],
)
],
),
Row(children: () {
_actions.add(IconButton(
icon: Icon(Icons.cancel_outlined),
onPressed: onCanceled,
));
return _actions;
}())
],
),
));
},
onClosing: () {},
backgroundColor: MyTheme.grayBg,
enableDrag: false,
);
}
}
class SelectedItems {
bool? _isLocal;
final List<Entry> _items = [];
List<Entry> get items => _items;
int get length => _items.length;
bool? get isLocal => _isLocal;
add(bool isLocal, Entry e) {
if (_isLocal == null) {
_isLocal = isLocal;
}
if (_isLocal != null && _isLocal != isLocal) {
return;
}
if (!_items.contains(e)) {
_items.add(e);
}
}
bool contains(Entry e) {
return _items.contains(e);
}
remove(Entry e) {
_items.remove(e);
if (_items.length == 0) {
_isLocal = null;
}
}
bool isOtherPage(bool currentIsLocal) {
if (_isLocal == null) {
return false;
} else {
return _isLocal != currentIsLocal;
}
}
clear() {
_items.clear();
_isLocal = null;
}
}

View File

@@ -0,0 +1,95 @@
import 'package:flutter/material.dart';
import 'package:flutter_hbb/pages/chat_page.dart';
import 'package:flutter_hbb/pages/server_page.dart';
import 'package:flutter_hbb/pages/settings_page.dart';
import '../common.dart';
import '../widgets/overlay.dart';
import 'connection_page.dart';
abstract class PageShape extends Widget {
final String title = "";
final Icon icon = Icon(null);
final List<Widget> appBarActions = [];
}
class HomePage extends StatefulWidget {
HomePage({Key? key}) : super(key: key);
@override
_HomePageState createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
var _selectedIndex = 0;
final List<PageShape> _pages = [];
@override
void initState() {
super.initState();
_pages.add(ConnectionPage());
if (isAndroid) {
_pages.addAll([chatPage, 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: Text("RustDesk"),
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 (index == 1 && _selectedIndex != index) {
hideChatIconOverlay();
hideChatWindowOverlay();
}
_selectedIndex = index;
}),
),
body: _pages.elementAt(_selectedIndex),
));
}
}
class WebHomePage extends StatelessWidget {
final connectionPage = ConnectionPage();
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: MyTheme.grayBg,
appBar: AppBar(
centerTitle: true,
title: Text("RustDesk" + (isWeb ? " (Beta) " : "")),
actions: connectionPage.appBarActions,
),
body: connectionPage,
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,258 @@
import 'package:flutter/material.dart';
import 'package:qr_code_scanner/qr_code_scanner.dart';
import 'package:image_picker/image_picker.dart';
import 'package:image/image.dart' as img;
import 'package:zxing2/qrcode.dart';
import 'dart:io';
import 'dart:async';
import 'dart:convert';
import '../common.dart';
import '../models/model.dart';
class ScanPage extends StatefulWidget {
@override
_ScanPageState createState() => _ScanPageState();
}
class _ScanPageState extends State<ScanPage> {
QRViewController? controller;
final GlobalKey qrKey = GlobalKey(debugLabel: 'QR');
// In order to get hot reload to work we need to pause the camera if the platform
// is android, or resume the camera if the platform is iOS.
@override
void reassemble() {
super.reassemble();
if (isAndroid) {
controller!.pauseCamera();
}
controller!.resumeCamera();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Scan QR'),
actions: [
IconButton(
color: Colors.white,
icon: Icon(Icons.image_search),
iconSize: 32.0,
onPressed: () async {
final ImagePicker _picker = ImagePicker();
final XFile? file =
await _picker.pickImage(source: ImageSource.gallery);
if (file != null) {
var image = img.decodeNamedImage(
File(file.path).readAsBytesSync(), file.path)!;
LuminanceSource source = RGBLuminanceSource(
image.width,
image.height,
image
.getBytes(format: img.Format.abgr)
.buffer
.asInt32List());
var bitmap = BinaryBitmap(HybridBinarizer(source));
var reader = QRCodeReader();
try {
var result = reader.decode(bitmap);
showServerSettingFromQr(result.text);
} catch (e) {
showToast('No QR code found');
}
}
}),
IconButton(
color: Colors.yellow,
icon: Icon(Icons.flash_on),
iconSize: 32.0,
onPressed: () async {
await controller?.toggleFlash();
}),
IconButton(
color: Colors.white,
icon: Icon(Icons.switch_camera),
iconSize: 32.0,
onPressed: () async {
await controller?.flipCamera();
},
),
],
),
body: _buildQrView(context));
}
Widget _buildQrView(BuildContext context) {
// For this example we check how width or tall the device is and change the scanArea and overlay accordingly.
var scanArea = (MediaQuery.of(context).size.width < 400 ||
MediaQuery.of(context).size.height < 400)
? 150.0
: 300.0;
// To ensure the Scanner view is properly sizes after rotation
// we need to listen for Flutter SizeChanged notification and update controller
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;
});
controller.scannedDataStream.listen((scanData) {
if (scanData.code != null) {
showServerSettingFromQr(scanData.code!);
}
});
}
void _onPermissionSet(BuildContext context, QRViewController ctrl, bool p) {
if (!p) {
showToast('No permisssion');
}
}
@override
void dispose() {
controller?.dispose();
super.dispose();
}
void showServerSettingFromQr(String data) async {
backToHome();
await controller?.pauseCamera();
if (!data.startsWith('config=')) {
showToast('Invalid QR code');
return;
}
try {
Map<String, dynamic> values = json.decode(data.substring(7));
var host = values['host'] != null ? values['host'] as String : '';
var key = values['key'] != null ? values['key'] as String : '';
var api = values['api'] != null ? values['api'] as String : '';
Timer(Duration(milliseconds: 60), () {
showServerSettingsWithValue(host, '', key, api);
});
} catch (e) {
showToast('Invalid QR code');
}
}
}
void showServerSettingsWithValue(
String id, String relay, String key, String api) {
final formKey = GlobalKey<FormState>();
final id0 = FFI.getByName('option', 'custom-rendezvous-server');
final relay0 = FFI.getByName('option', 'relay-server');
final api0 = FFI.getByName('option', 'api-server');
final key0 = FFI.getByName('option', 'key');
DialogManager.show((setState, close) {
return CustomAlertDialog(
title: Text(translate('ID/Relay Server')),
content: Form(
key: formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
TextFormField(
initialValue: id,
decoration: InputDecoration(
labelText: translate('ID Server'),
),
validator: validate,
onSaved: (String? value) {
if (value != null) id = value.trim();
},
)
] +
(isAndroid
? [
TextFormField(
initialValue: relay,
decoration: InputDecoration(
labelText: translate('Relay Server'),
),
validator: validate,
onSaved: (String? value) {
if (value != null) relay = value.trim();
},
)
]
: []) +
[
TextFormField(
initialValue: api,
decoration: InputDecoration(
labelText: translate('API Server'),
),
validator: validate,
onSaved: (String? value) {
if (value != null) api = value.trim();
},
),
TextFormField(
initialValue: key,
decoration: InputDecoration(
labelText: 'Key',
),
validator: null,
onSaved: (String? value) {
if (value != null) key = value.trim();
},
),
])),
actions: [
TextButton(
style: flatButtonStyle,
onPressed: () {
close();
},
child: Text(translate('Cancel')),
),
TextButton(
style: flatButtonStyle,
onPressed: () {
if (formKey.currentState != null &&
formKey.currentState!.validate()) {
formKey.currentState!.save();
if (id != id0)
FFI.setByName('option',
'{"name": "custom-rendezvous-server", "value": "$id"}');
if (relay != relay0)
FFI.setByName(
'option', '{"name": "relay-server", "value": "$relay"}');
if (key != key0)
FFI.setByName('option', '{"name": "key", "value": "$key"}');
if (api != api0)
FFI.setByName(
'option', '{"name": "api-server", "value": "$api"}');
FFI.ffiModel.updateUser();
close();
}
},
child: Text(translate('OK')),
),
],
);
});
}
String? validate(value) {
value = value.trim();
if (value.isEmpty) {
return null;
}
final res = FFI.getByName('test_if_valid_server', value);
return res.isEmpty ? null : res;
}

View File

@@ -0,0 +1,500 @@
import 'package:flutter/material.dart';
import 'package:flutter_hbb/models/model.dart';
import 'package:flutter_hbb/widgets/dialog.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:provider/provider.dart';
import '../common.dart';
import '../models/server_model.dart';
import 'home_page.dart';
import '../models/model.dart';
class ServerPage extends StatelessWidget implements PageShape {
@override
final title = translate("Share Screen");
@override
final icon = Icon(Icons.mobile_screen_share);
@override
final appBarActions = [
PopupMenuButton<String>(
icon: Icon(Icons.more_vert),
itemBuilder: (context) {
return [
PopupMenuItem(
child: Text(translate("Change ID")),
value: "changeID",
enabled: false,
),
PopupMenuItem(
child: Text(translate("Set your own password")),
value: "changePW",
enabled: FFI.serverModel.isStart,
),
PopupMenuItem(
child: Text(translate("Refresh random password")),
value: "refreshPW",
enabled: FFI.serverModel.isStart,
)
];
},
onSelected: (value) {
if (value == "changeID") {
// TODO
} else if (value == "changePW") {
updatePasswordDialog();
} else if (value == "refreshPW") {
() async {
showLoading(translate("Waiting"));
if (await FFI.serverModel.updatePassword("")) {
showSuccess();
} else {
showError();
}
debugPrint("end updatePassword");
}();
}
})
];
@override
Widget build(BuildContext context) {
checkService();
return ChangeNotifierProvider.value(
value: FFI.serverModel,
child: Consumer<ServerModel>(
builder: (context, serverModel, child) => SingleChildScrollView(
controller: FFI.serverModel.controller,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [
ServerInfo(),
PermissionChecker(),
ConnectionManager(),
SizedBox.fromSize(size: Size(0, 15.0)),
],
),
),
)));
}
}
void checkService() async {
FFI.invokeMethod("check_service"); // jvm
// for Android 10/11,MANAGE_EXTERNAL_STORAGE permission from a system setting page
if (PermissionManager.isWaitingFile() && !FFI.serverModel.fileOk) {
PermissionManager.complete("file", await PermissionManager.check("file"));
debugPrint("file permission finished");
}
}
class ServerInfo extends StatefulWidget {
@override
_ServerInfoState createState() => _ServerInfoState();
}
class _ServerInfoState extends State<ServerInfo> {
final model = FFI.serverModel;
var _passwdShow = false;
@override
Widget build(BuildContext context) {
return model.isStart
? PaddingCard(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
readOnly: true,
style: TextStyle(
fontSize: 25.0,
fontWeight: FontWeight.bold,
color: MyTheme.accent),
controller: model.serverId,
decoration: InputDecoration(
icon: const Icon(Icons.perm_identity),
labelText: translate("ID"),
labelStyle: TextStyle(
fontWeight: FontWeight.bold, color: MyTheme.accent50),
),
onSaved: (String? value) {},
),
TextFormField(
readOnly: true,
obscureText: !_passwdShow,
style: TextStyle(
fontSize: 25.0,
fontWeight: FontWeight.bold,
color: MyTheme.accent),
controller: model.serverPasswd,
decoration: InputDecoration(
icon: const Icon(Icons.lock),
labelText: translate("Password"),
labelStyle: TextStyle(
fontWeight: FontWeight.bold, color: MyTheme.accent50),
suffix: IconButton(
icon: Icon(Icons.visibility),
onPressed: () {
setState(() {
_passwdShow = !_passwdShow;
});
})),
onSaved: (String? value) {},
),
],
))
: PaddingCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Center(
child: Row(
children: [
Icon(Icons.warning_amber_sharp,
color: Colors.redAccent, size: 24),
SizedBox(width: 10),
Text(
translate("Service is not running"),
style: TextStyle(
fontFamily: 'WorkSans',
fontWeight: FontWeight.bold,
fontSize: 18,
color: MyTheme.accent80,
),
)
],
)),
SizedBox(height: 5),
Center(
child: Text(
translate("android_start_service_tip"),
style: TextStyle(fontSize: 12, color: MyTheme.darkGray),
))
],
));
}
}
class PermissionChecker extends StatefulWidget {
@override
_PermissionCheckerState createState() => _PermissionCheckerState();
}
class _PermissionCheckerState extends State<PermissionChecker> {
@override
Widget build(BuildContext context) {
final serverModel = Provider.of<ServerModel>(context);
final hasAudioPermission = androidVersion >= 30;
final status;
if (serverModel.connectStatus == -1) {
status = 'not_ready_status';
} else if (serverModel.connectStatus == 0) {
status = 'connecting_status';
} else {
status = 'Ready';
}
return PaddingCard(
title: translate("Permissions"),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PermissionRow(translate("Screen Capture"), serverModel.mediaOk,
serverModel.toggleService),
PermissionRow(translate("Input Control"), serverModel.inputOk,
serverModel.toggleInput),
PermissionRow(translate("File Transfer"), serverModel.fileOk,
serverModel.toggleFile),
hasAudioPermission
? PermissionRow(translate("Audio Capture"), serverModel.audioOk,
serverModel.toggleAudio)
: Text(
"* ${translate("android_version_audio_tip")}",
style: TextStyle(color: MyTheme.darkGray),
),
SizedBox(height: 8),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
flex: 0,
child: serverModel.mediaOk
? ElevatedButton.icon(
style: ButtonStyle(
backgroundColor:
MaterialStateProperty.all(Colors.red)),
icon: Icon(Icons.stop),
onPressed: serverModel.toggleService,
label: Text(translate("Stop service")))
: ElevatedButton.icon(
icon: Icon(Icons.play_arrow),
onPressed: serverModel.toggleService,
label: Text(translate("Start Service")))),
Expanded(
child: serverModel.mediaOk
? Row(
children: [
Expanded(
flex: 0,
child: Padding(
padding:
EdgeInsets.only(left: 20, right: 5),
child: Icon(Icons.circle,
color: serverModel.connectStatus > 0
? Colors.greenAccent
: Colors.deepOrangeAccent,
size: 10))),
Expanded(
child: Text(translate(status),
softWrap: true,
style: TextStyle(
fontSize: 14.0,
color: MyTheme.accent50)))
],
)
: SizedBox.shrink())
],
),
],
));
}
}
class PermissionRow extends StatelessWidget {
PermissionRow(this.name, this.isOk, this.onPressed);
final String name;
final bool isOk;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
SizedBox(
width: 140,
child: Text(name,
style: TextStyle(fontSize: 16.0, color: MyTheme.accent50))),
SizedBox(
width: 50,
child: Text(isOk ? translate("ON") : translate("OFF"),
style: TextStyle(
fontSize: 16.0,
color: isOk ? Colors.green : Colors.grey)),
)
],
),
TextButton(
onPressed: onPressed,
child: Text(
translate(isOk ? "CLOSE" : "OPEN"),
style: TextStyle(fontWeight: FontWeight.bold),
)),
const Divider(height: 0)
],
);
}
}
class ConnectionManager extends StatelessWidget {
@override
Widget build(BuildContext context) {
final serverModel = Provider.of<ServerModel>(context);
return Column(
children: serverModel.clients.entries
.map((entry) => PaddingCard(
title: translate(entry.value.isFileTransfer
? "File Connection"
: "Screen Connection"),
titleIcon: entry.value.isFileTransfer
? Icons.folder_outlined
: Icons.mobile_screen_share,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(child: clientInfo(entry.value)),
Expanded(
flex: -1,
child: entry.value.isFileTransfer ||
!entry.value.authorized
? SizedBox.shrink()
: IconButton(
onPressed: () {
FFI.chatModel
.changeCurrentID(entry.value.id);
final bar =
navigationBarKey.currentWidget;
if (bar != null) {
bar as BottomNavigationBar;
bar.onTap!(1);
}
},
icon: Icon(
Icons.chat,
color: MyTheme.accent80,
)))
],
),
entry.value.authorized
? SizedBox.shrink()
: Text(
translate("android_new_connection_tip"),
style: TextStyle(color: Colors.black54),
),
entry.value.authorized
? ElevatedButton.icon(
style: ButtonStyle(
backgroundColor:
MaterialStateProperty.all(Colors.red)),
icon: Icon(Icons.close),
onPressed: () {
FFI.setByName("close_conn", entry.key.toString());
FFI.invokeMethod(
"cancel_notification", entry.key);
},
label: Text(translate("Close")))
: Row(children: [
TextButton(
child: Text(translate("Dismiss")),
onPressed: () {
serverModel.sendLoginResponse(
entry.value, false);
}),
SizedBox(width: 20),
ElevatedButton(
child: Text(translate("Accept")),
onPressed: () {
serverModel.sendLoginResponse(
entry.value, true);
}),
]),
],
)))
.toList());
}
}
class PaddingCard extends StatelessWidget {
PaddingCard({required this.child, this.title, this.titleIcon});
final String? title;
final IconData? titleIcon;
final Widget child;
@override
Widget build(BuildContext context) {
final children = [child];
if (title != null) {
children.insert(
0,
Padding(
padding: EdgeInsets.symmetric(vertical: 5.0),
child: Row(
children: [
titleIcon != null
? Padding(
padding: EdgeInsets.only(right: 10),
child: Icon(titleIcon,
color: MyTheme.accent80, size: 30))
: SizedBox.shrink(),
Text(
title!,
style: TextStyle(
fontFamily: 'WorkSans',
fontWeight: FontWeight.bold,
fontSize: 20,
color: MyTheme.accent80,
),
)
],
)));
}
return Container(
width: double.maxFinite,
child: Card(
margin: EdgeInsets.fromLTRB(15.0, 15.0, 15.0, 0),
child: Padding(
padding: EdgeInsets.symmetric(vertical: 15.0, horizontal: 30.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: children,
),
),
));
}
}
Widget clientInfo(Client client) {
return Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(
children: [
Expanded(
flex: -1,
child: Padding(
padding: EdgeInsets.only(right: 12),
child: CircleAvatar(
child: Text(client.name[0]),
backgroundColor: MyTheme.border))),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(client.name,
style: TextStyle(color: MyTheme.idColor, fontSize: 18)),
SizedBox(width: 8),
Text(client.peerId,
style: TextStyle(color: MyTheme.idColor, fontSize: 10))
]))
],
),
]));
}
void toAndroidChannelInit() {
FFI.setMethodCallHandler((method, arguments) {
debugPrint("flutter got android msg,$method,$arguments");
try {
switch (method) {
case "start_capture":
{
SmartDialog.dismiss();
FFI.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");
FFI.serverModel.changeStatue(name, value);
break;
}
case "on_android_permission_result":
{
var type = arguments["type"] as String;
var result = arguments["result"] as bool;
PermissionManager.complete(type, result);
break;
}
case "on_media_projection_canceled":
{
FFI.serverModel.stopService();
break;
}
}
} catch (e) {
debugPrint("MethodCallHandler err:$e");
}
return "";
});
}

View File

@@ -0,0 +1,357 @@
import 'package:settings_ui/settings_ui.dart';
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:provider/provider.dart';
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../common.dart';
import '../widgets/dialog.dart';
import '../models/model.dart';
import 'home_page.dart';
import 'scan_page.dart';
class SettingsPage extends StatefulWidget implements PageShape {
@override
final title = translate("Settings");
@override
final icon = Icon(Icons.settings);
@override
final appBarActions = [ScanButton()];
@override
_SettingsState createState() => _SettingsState();
}
class _SettingsState extends State<SettingsPage> {
static const url = 'https://rustdesk.com/';
@override
Widget build(BuildContext context) {
Provider.of<FfiModel>(context);
final username = getUsername();
return SettingsList(
sections: [
SettingsSection(
title: Text(translate("Account")),
tiles: [
SettingsTile.navigation(
title: Text(username == null
? translate("Login")
: translate("Logout") + ' ($username)'),
leading: Icon(Icons.person),
onPressed: (context) {
if (username == null) {
showLogin();
} else {
logout();
}
},
),
],
),
SettingsSection(
title: Text(translate("Settings")),
tiles: [
SettingsTile.navigation(
title: Text(translate('ID/Relay Server')),
leading: Icon(Icons.cloud),
onPressed: (context) {
showServerSettings();
},
),
],
),
SettingsSection(
title: Text(translate("About")),
tiles: [
SettingsTile.navigation(
onPressed: (context) async {
if (await canLaunch(url)) {
await launch(url);
}
},
title: Text(translate("Version: ") + version),
value: Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: Text('rustdesk.com',
style: TextStyle(
decoration: TextDecoration.underline,
)),
),
leading: Icon(Icons.info)),
],
),
],
);
}
}
void showServerSettings() {
final id = FFI.getByName('option', 'custom-rendezvous-server');
final relay = FFI.getByName('option', 'relay-server');
final api = FFI.getByName('option', 'api-server');
final key = FFI.getByName('option', 'key');
showServerSettingsWithValue(id, relay, key, api);
}
void showAbout() {
DialogManager.show((setState, close) {
return CustomAlertDialog(
title: Text(translate('About') + ' RustDesk'),
content: Wrap(direction: Axis.vertical, spacing: 12, children: [
Text('Version: $version'),
InkWell(
onTap: () async {
const url = 'https://rustdesk.com/';
if (await canLaunch(url)) {
await launch(url);
}
},
child: Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: Text('rustdesk.com',
style: TextStyle(
decoration: TextDecoration.underline,
)),
)),
]),
actions: [],
);
}, clickMaskDismiss: true, backDismiss: true);
}
Future<String> login(String name, String pass) async {
/* js test CORS
const data = { username: 'example', password: 'xx' };
fetch('http://localhost:21114/api/login', {
method: 'POST', // or 'PUT'
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
})
.then(response => response.json())
.then(data => {
console.log('Success:', data);
})
.catch((error) => {
console.error('Error:', error);
});
*/
final url = getUrl();
final body = {
'username': name,
'password': pass,
'id': FFI.getByName('server_id'),
'uuid': FFI.getByName('uuid')
};
try {
final response = await http.post(Uri.parse('${url}/api/login'),
headers: {"Content-Type": "application/json"}, body: json.encode(body));
return parseResp(response.body);
} catch (e) {
print(e);
return 'Failed to access $url';
}
}
String parseResp(String body) {
final data = json.decode(body);
final error = data['error'];
if (error != null) {
return error!;
}
final token = data['access_token'];
if (token != null) {
FFI.setByName('option', '{"name": "access_token", "value": "$token"}');
}
final info = data['user'];
if (info != null) {
final value = json.encode(info);
FFI.setByName('option', json.encode({"name": "user_info", "value": value}));
FFI.ffiModel.updateUser();
}
return '';
}
void refreshCurrentUser() async {
final token = FFI.getByName("option", "access_token");
if (token == '') return;
final url = getUrl();
final body = {
'id': FFI.getByName('server_id'),
'uuid': FFI.getByName('uuid')
};
try {
final response = await http.post(Uri.parse('${url}/api/currentUser'),
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer $token"
},
body: json.encode(body));
final status = response.statusCode;
if (status == 401 || status == 400) {
resetToken();
return;
}
parseResp(response.body);
} catch (e) {
print('$e');
}
}
void logout() async {
final token = FFI.getByName("option", "access_token");
if (token == '') return;
final url = getUrl();
final body = {
'id': FFI.getByName('server_id'),
'uuid': FFI.getByName('uuid')
};
try {
await http.post(Uri.parse('${url}/api/logout'),
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer $token"
},
body: json.encode(body));
} catch (e) {
showToast('Failed to access $url');
}
resetToken();
}
void resetToken() {
FFI.setByName('option', '{"name": "access_token", "value": ""}');
FFI.setByName('option', '{"name": "user_info", "value": ""}');
FFI.ffiModel.updateUser();
}
String getUrl() {
var url = FFI.getByName('option', 'api-server');
if (url == '') {
url = FFI.getByName('option', 'custom-rendezvous-server');
if (url != '') {
if (url.contains(':')) {
final tmp = url.split(':');
if (tmp.length == 2) {
var port = int.parse(tmp[1]) - 2;
url = 'http://${tmp[0]}:$port';
}
} else {
url = 'http://${url}:21114';
}
}
}
if (url == '') {
url = 'https://admin.rustdesk.com';
}
return url;
}
void showLogin() {
final passwordController = TextEditingController();
final nameController = TextEditingController();
var loading = false;
var error = '';
DialogManager.show((setState, close) {
return CustomAlertDialog(
title: Text(translate('Login')),
content: Column(mainAxisSize: MainAxisSize.min, children: [
TextField(
autofocus: true,
autocorrect: false,
enableSuggestions: false,
keyboardType: TextInputType.visiblePassword,
decoration: InputDecoration(
labelText: translate('Username'),
),
controller: nameController,
),
PasswordWidget(controller: passwordController),
]),
actions: (loading
? <Widget>[CircularProgressIndicator()]
: (error != ""
? <Widget>[
Text(translate(error),
style: TextStyle(color: Colors.red))
]
: <Widget>[])) +
<Widget>[
TextButton(
style: flatButtonStyle,
onPressed: loading
? null
: () {
close();
setState(() {
loading = false;
});
},
child: Text(translate('Cancel')),
),
TextButton(
style: flatButtonStyle,
onPressed: loading
? null
: () async {
final name = nameController.text.trim();
final pass = passwordController.text.trim();
if (name != "" && pass != "") {
setState(() {
loading = true;
});
final e = await login(name, pass);
setState(() {
loading = false;
error = e;
});
if (e == "") {
close();
}
}
},
child: Text(translate('OK')),
),
],
);
});
}
String? getUsername() {
final token = FFI.getByName("option", "access_token");
String? username;
if (token != "") {
final info = FFI.getByName("option", "user_info");
if (info != "") {
try {
Map<String, dynamic> tmp = json.decode(info);
username = tmp["name"];
} catch (e) {
print('$e');
}
}
}
return username;
}
class ScanButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return IconButton(
icon: Icon(Icons.qr_code_scanner),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (BuildContext context) => ScanPage(),
),
);
},
);
}
}

View File

@@ -0,0 +1,228 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import '../common.dart';
import '../models/model.dart';
void clientClose() {
msgBox('', 'Close', 'Are you sure to close the connection?');
}
const SEC1 = Duration(seconds: 1);
void showSuccess({Duration duration = SEC1}) {
SmartDialog.dismiss();
showToast(translate("Successful"), duration: SEC1);
}
void showError({Duration duration = SEC1}) {
SmartDialog.dismiss();
showToast(translate("Error"), duration: SEC1);
}
void updatePasswordDialog() {
final p0 = TextEditingController();
final p1 = TextEditingController();
var validateLength = false;
var validateSame = false;
DialogManager.show((setState, close) {
return CustomAlertDialog(
title: Text(translate('Set your own password')),
content: Form(
autovalidateMode: AutovalidateMode.onUserInteraction,
child: Column(mainAxisSize: MainAxisSize.min, children: [
TextFormField(
autofocus: true,
obscureText: true,
keyboardType: TextInputType.visiblePassword,
decoration: InputDecoration(
labelText: translate('Password'),
),
controller: p0,
validator: (v) {
if (v == null) return null;
final val = v.trim().length > 5;
if (validateLength != val) {
// use delay to make setState success
Future.delayed(Duration(microseconds: 1),
() => setState(() => validateLength = val));
}
return val
? null
: translate('Too short, at least 6 characters.');
},
),
TextFormField(
obscureText: true,
keyboardType: TextInputType.visiblePassword,
decoration: InputDecoration(
labelText: translate('Confirmation'),
),
controller: p1,
validator: (v) {
if (v == null) return null;
final val = p0.text == v;
if (validateSame != val) {
Future.delayed(Duration(microseconds: 1),
() => setState(() => validateSame = val));
}
return val
? null
: translate('The confirmation is not identical.');
},
),
])),
actions: [
TextButton(
style: flatButtonStyle,
onPressed: () {
close();
},
child: Text(translate('Cancel')),
),
TextButton(
style: flatButtonStyle,
onPressed: (validateLength && validateSame)
? () async {
close();
showLoading(translate("Waiting"));
if (await FFI.serverModel.updatePassword(p0.text)) {
showSuccess();
} else {
showError();
}
}
: null,
child: Text(translate('OK')),
),
],
);
});
}
void enterPasswordDialog(String id) {
final controller = TextEditingController();
var remember = FFI.getByName('remember', id) == 'true';
DialogManager.show((setState, close) {
return CustomAlertDialog(
title: Text(translate('Password Required')),
content: Column(mainAxisSize: MainAxisSize.min, children: [
PasswordWidget(controller: controller),
CheckboxListTile(
contentPadding: const EdgeInsets.all(0),
dense: true,
controlAffinity: ListTileControlAffinity.leading,
title: Text(
translate('Remember password'),
),
value: remember,
onChanged: (v) {
if (v != null) {
setState(() => remember = v);
}
},
),
]),
actions: [
TextButton(
style: flatButtonStyle,
onPressed: () {
close();
backToHome();
},
child: Text(translate('Cancel')),
),
TextButton(
style: flatButtonStyle,
onPressed: () {
var text = controller.text.trim();
if (text == '') return;
FFI.login(text, remember);
close();
showLoading(translate('Logging in...'));
},
child: Text(translate('OK')),
),
],
);
});
}
void wrongPasswordDialog(String id) {
DialogManager.show((setState, close) => CustomAlertDialog(
title: Text(translate('Wrong Password')),
content: Text(translate('Do you want to enter again?')),
actions: [
TextButton(
style: flatButtonStyle,
onPressed: () {
close();
backToHome();
},
child: Text(translate('Cancel')),
),
TextButton(
style: flatButtonStyle,
onPressed: () {
enterPasswordDialog(id);
},
child: Text(translate('Retry')),
),
]));
}
class PasswordWidget extends StatefulWidget {
PasswordWidget({Key? key, required this.controller}) : super(key: key);
final TextEditingController controller;
@override
_PasswordWidgetState createState() => _PasswordWidgetState();
}
class _PasswordWidgetState extends State<PasswordWidget> {
bool _passwordVisible = false;
final _focusNode = FocusNode();
@override
void initState() {
super.initState();
Timer(Duration(milliseconds: 50), () => _focusNode.requestFocus());
}
@override
void dispose() {
_focusNode.unfocus();
_focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return TextField(
focusNode: _focusNode,
controller: widget.controller,
obscureText: !_passwordVisible,
//This will obscure text dynamically
keyboardType: TextInputType.visiblePassword,
decoration: InputDecoration(
labelText: Translator.call('Password'),
hintText: Translator.call('Enter your password'),
// Here is key idea
suffixIcon: IconButton(
icon: Icon(
// Based on passwordVisible state choose the icon
_passwordVisible ? Icons.visibility : Icons.visibility_off,
color: Theme.of(context).primaryColorDark,
),
onPressed: () {
// Update the state i.e. toogle the state of passwordVisible variable
setState(() {
_passwordVisible = !_passwordVisible;
});
},
),
),
);
}
}

View File

@@ -0,0 +1,208 @@
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common.dart';
import 'package:toggle_switch/toggle_switch.dart';
import '../models/model.dart';
class GestureIcons {
static const String _family = 'gestureicons';
GestureIcons._();
static const IconData icon_mouse = IconData(0xe65c, fontFamily: _family);
static const IconData icon_Tablet_Touch =
IconData(0xe9ce, fontFamily: _family);
static const IconData icon_gesture_f_drag =
IconData(0xe686, fontFamily: _family);
static const IconData icon_Mobile_Touch =
IconData(0xe9cd, fontFamily: _family);
static const IconData icon_gesture_press =
IconData(0xe66c, fontFamily: _family);
static const IconData icon_gesture_tap =
IconData(0xe66f, fontFamily: _family);
static const IconData icon_gesture_pinch =
IconData(0xe66a, fontFamily: _family);
static const IconData icon_gesture_press_hold =
IconData(0xe66b, fontFamily: _family);
static const IconData icon_gesture_f_drag_up_down_ =
IconData(0xe685, fontFamily: _family);
static const IconData icon_gesture_f_tap_ =
IconData(0xe68e, fontFamily: _family);
static const IconData icon_gesture_f_swipe_right =
IconData(0xe68f, fontFamily: _family);
static const IconData icon_gesture_f_double_tap =
IconData(0xe691, fontFamily: _family);
}
typedef OnTouchModeChange = void Function(bool);
class GestureHelp extends StatefulWidget {
GestureHelp(
{Key? key, required this.touchMode, required this.onTouchModeChange})
: super(key: key);
final bool touchMode;
final OnTouchModeChange onTouchModeChange;
@override
State<StatefulWidget> createState() => _GestureHelpState();
}
class _GestureHelpState extends State<GestureHelp> {
var _selectedIndex;
var _touchMode;
@override
void initState() {
_touchMode = widget.touchMode;
_selectedIndex = _touchMode ? 1 : 0;
super.initState();
}
@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.center,
children: <Widget>[
ToggleSwitch(
initialLabelIndex: _selectedIndex,
inactiveBgColor: MyTheme.darkGray,
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);
}
});
},
),
const SizedBox(height: 30),
Container(
child: Wrap(
spacing: space,
runSpacing: 2 * space,
children: _touchMode
? [
GestureInfo(
width,
GestureIcons.icon_Mobile_Touch,
translate("One-Finger Tap"),
translate("Left Mouse")),
GestureInfo(
width,
GestureIcons.icon_gesture_press_hold,
translate("One-Long Tap"),
translate("Right Mouse")),
GestureInfo(
width,
GestureIcons.icon_gesture_f_swipe_right,
translate("One-Finger Move"),
translate("Mouse Drag")),
GestureInfo(
width,
GestureIcons.icon_gesture_f_drag_up_down_,
translate("Two-Finger vertically"),
translate("Mouse Wheel")),
GestureInfo(
width,
GestureIcons.icon_gesture_f_drag,
translate("Two-Finger Move"),
translate("Canvas Move")),
GestureInfo(
width,
GestureIcons.icon_gesture_pinch,
translate("Pinch to Zoom"),
translate("Canvas Zoom")),
]
: [
GestureInfo(
width,
GestureIcons.icon_Mobile_Touch,
translate("One-Finger Tap"),
translate("Left Mouse")),
GestureInfo(
width,
GestureIcons.icon_gesture_f_tap_,
translate("Two-Finger Tap"),
translate("Right Mouse")),
GestureInfo(
width,
GestureIcons.icon_gesture_f_swipe_right,
translate("Double Tap & Move"),
translate("Mouse Drag")),
GestureInfo(
width,
GestureIcons.icon_gesture_f_drag_up_down_,
translate("Two-Finger vertically"),
translate("Mouse Wheel")),
GestureInfo(
width,
GestureIcons.icon_gesture_f_drag,
translate("Two-Finger Move"),
translate("Canvas Move")),
GestureInfo(
width,
GestureIcons.icon_gesture_pinch,
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: this.width,
child: Column(
children: [
Icon(
icon,
size: iconSize,
color: iconColor,
),
SizedBox(height: 6),
Text(fromText,
textAlign: TextAlign.center,
style: TextStyle(fontSize: 9, color: Colors.grey)),
SizedBox(height: 3),
Text(toText,
textAlign: TextAlign.center,
style: TextStyle(fontSize: 12, color: Colors.black))
],
));
}
}

View File

@@ -0,0 +1,789 @@
import 'dart:async';
import 'package:flutter/gestures.dart';
import 'package:flutter/widgets.dart';
enum CustomTouchGestureState {
none,
oneFingerPan,
twoFingerScale,
twoFingerVerticalDrag,
twoFingerHorizontalDrag,
}
// Adjust Carefully! balance vertical and pan
const kScaleSlop = kPrecisePointerPanSlop / 28;
class CustomTouchGestureRecognizer extends ScaleGestureRecognizer {
CustomTouchGestureRecognizer({
Object? debugOwner,
Set<PointerDeviceKind>? supportedDevices,
}) : super(
debugOwner: debugOwner,
supportedDevices: supportedDevices,
) {
_init();
}
// oneFingerPan
GestureDragStartCallback? onOneFingerPanStart;
GestureDragUpdateCallback? onOneFingerPanUpdate;
GestureDragEndCallback? onOneFingerPanEnd;
// twoFingerScale : scale + pan event
GestureScaleStartCallback? onTwoFingerScaleStart;
GestureScaleUpdateCallback? onTwoFingerScaleUpdate;
GestureScaleEndCallback? onTwoFingerScaleEnd;
// twoFingerVerticalDrag
GestureDragStartCallback? onTwoFingerVerticalDragStart;
GestureDragUpdateCallback? onTwoFingerVerticalDragUpdate;
GestureDragEndCallback? onTwoFingerVerticalDragEnd;
// twoFingerHorizontalDrag
GestureDragStartCallback? onTwoFingerHorizontalDragStart;
GestureDragUpdateCallback? onTwoFingerHorizontalDragUpdate;
GestureDragEndCallback? onTwoFingerHorizontalDragEnd;
void _init() {
debugPrint("CustomTouchGestureRecognizer init");
onStart = (d) {
if (d.pointerCount == 1) {
_currentState = CustomTouchGestureState.oneFingerPan;
if (onOneFingerPanStart != null) {
onOneFingerPanStart!(DragStartDetails(
localPosition: d.localFocalPoint, globalPosition: d.focalPoint));
}
debugPrint("start pan");
} else if (d.pointerCount == 2) {
_currentState = CustomTouchGestureState.none;
startWatchTimer();
} else {
_currentState = CustomTouchGestureState.none;
_reset();
}
};
onUpdate = (d) {
if (_isWatch) {
_updateCompute(d);
return;
}
if (_currentState != CustomTouchGestureState.none) {
switch (_currentState) {
case CustomTouchGestureState.oneFingerPan:
if (onOneFingerPanUpdate != null) {
onOneFingerPanUpdate!(_getDragUpdateDetails(d));
}
break;
case CustomTouchGestureState.twoFingerScale:
if (onTwoFingerScaleUpdate != null) {
onTwoFingerScaleUpdate!(d);
}
break;
case CustomTouchGestureState.twoFingerHorizontalDrag:
if (onTwoFingerHorizontalDragUpdate != null) {
onTwoFingerHorizontalDragUpdate!(_getDragUpdateDetails(d));
}
break;
case CustomTouchGestureState.twoFingerVerticalDrag:
if (onTwoFingerVerticalDragUpdate != null) {
onTwoFingerVerticalDragUpdate!(_getDragUpdateDetails(d));
}
break;
default:
break;
}
return;
}
};
onEnd = (d) {
debugPrint("ScaleGestureRecognizer onEnd");
// end
switch (_currentState) {
case CustomTouchGestureState.oneFingerPan:
debugPrint("TwoFingerState.pan onEnd");
if (onOneFingerPanEnd != null) {
onOneFingerPanEnd!(_getDragEndDetails(d));
}
break;
case CustomTouchGestureState.twoFingerScale:
debugPrint("TwoFingerState.scale onEnd");
if (onTwoFingerScaleEnd != null) {
onTwoFingerScaleEnd!(d);
}
break;
case CustomTouchGestureState.twoFingerHorizontalDrag:
debugPrint("TwoFingerState.horizontal onEnd");
if (onTwoFingerHorizontalDragEnd != null) {
onTwoFingerHorizontalDragEnd!(_getDragEndDetails(d));
}
break;
case CustomTouchGestureState.twoFingerVerticalDrag:
debugPrint("TwoFingerState.vertical onEnd");
if (onTwoFingerVerticalDragEnd != null) {
onTwoFingerVerticalDragEnd!(_getDragEndDetails(d));
}
break;
default:
break;
}
_currentState = CustomTouchGestureState.none;
_reset();
};
}
var _currentState = CustomTouchGestureState.none;
var _isWatch = false;
Timer? _timer;
double _sumScale = 0;
double _sumVertical = 0;
double _sumHorizontal = 0;
void _clearSum() {
_sumScale = 0;
_sumVertical = 0;
_sumHorizontal = 0;
}
void _reset() {
_isWatch = false;
_clearSum();
if (_timer != null) _timer!.cancel();
}
void _updateCompute(ScaleUpdateDetails d) {
_sumScale += d.scale - 1;
_sumHorizontal += d.focalPointDelta.dx;
_sumVertical += d.focalPointDelta.dy;
// start , order is important
if (onTwoFingerScaleUpdate != null && _sumScale.abs() > kScaleSlop) {
debugPrint("start Scale");
_currentState = CustomTouchGestureState.twoFingerScale;
if (onTwoFingerScaleStart != null) {
onTwoFingerScaleStart!(ScaleStartDetails(
localFocalPoint: d.localFocalPoint, focalPoint: d.focalPoint));
}
_reset();
} else if (onTwoFingerVerticalDragUpdate != null &&
_sumVertical.abs() > kPrecisePointerPanSlop &&
_sumHorizontal.abs() < kPrecisePointerPanSlop) {
debugPrint("start Vertical");
if (onTwoFingerVerticalDragStart != null) {
onTwoFingerVerticalDragStart!(_getDragStartDetails(d));
}
_currentState = CustomTouchGestureState.twoFingerVerticalDrag;
_reset();
}
}
void startWatchTimer() {
debugPrint("startWatchTimer");
_isWatch = true;
_clearSum();
if (_timer != null) _timer!.cancel();
_timer = Timer(const Duration(milliseconds: 200), _reset);
}
DragStartDetails _getDragStartDetails(ScaleUpdateDetails d) =>
DragStartDetails(
globalPosition: d.focalPoint,
localPosition: d.localFocalPoint,
);
DragUpdateDetails _getDragUpdateDetails(ScaleUpdateDetails d) =>
DragUpdateDetails(
globalPosition: d.focalPoint,
localPosition: d.localFocalPoint,
delta: d.focalPointDelta);
DragEndDetails _getDragEndDetails(ScaleEndDetails d) =>
DragEndDetails(velocity: d.velocity);
}
class HoldTapMoveGestureRecognizer extends GestureRecognizer {
HoldTapMoveGestureRecognizer({
Object? debugOwner,
Set<PointerDeviceKind>? supportedDevices,
}) : super(
debugOwner: debugOwner,
supportedDevices: supportedDevices,
);
GestureDragStartCallback? onHoldDragStart;
GestureDragUpdateCallback? onHoldDragUpdate;
GestureDragDownCallback? onHoldDragDown;
GestureDragCancelCallback? onHoldDragCancel;
GestureDragEndCallback? onHoldDragEnd;
bool _isStart = false;
Timer? _firstTapUpTimer;
Timer? _secondTapDownTimer;
_TapTracker? _firstTap;
_TapTracker? _secondTap;
final Map<int, _TapTracker> _trackers = <int, _TapTracker>{};
@override
bool isPointerAllowed(PointerDownEvent event) {
if (_firstTap == null) {
switch (event.buttons) {
case kPrimaryButton:
if (onHoldDragStart == null &&
onHoldDragUpdate == null &&
onHoldDragCancel == null &&
onHoldDragEnd == null) {
return false;
}
break;
default:
return false;
}
}
return super.isPointerAllowed(event);
}
@override
void addAllowedPointer(PointerDownEvent event) {
if (_firstTap != null) {
if (!_firstTap!.isWithinGlobalTolerance(event, kDoubleTapSlop)) {
// Ignore out-of-bounds second taps.
return;
} else if (!_firstTap!.hasElapsedMinTime() ||
!_firstTap!.hasSameButton(event)) {
// Restart when the second tap is too close to the first (touch screens
// often detect touches intermittently), or when buttons mismatch.
_reset();
return _trackTap(event);
} else if (onHoldDragDown != null) {
invokeCallback<void>(
'onHoldDragDown',
() => onHoldDragDown!(DragDownDetails(
globalPosition: event.position,
localPosition: event.localPosition)));
}
}
_trackTap(event);
}
void _trackTap(PointerDownEvent event) {
_stopFirstTapUpTimer();
_stopSecondTapDownTimer();
final _TapTracker tracker = _TapTracker(
event: event,
entry: GestureBinding.instance!.gestureArena.add(event.pointer, this),
doubleTapMinTime: kDoubleTapMinTime,
gestureSettings: gestureSettings,
);
_trackers[event.pointer] = tracker;
tracker.startTrackingPointer(_handleEvent, event.transform);
}
void _handleEvent(PointerEvent event) {
final _TapTracker tracker = _trackers[event.pointer]!;
if (event is PointerUpEvent) {
if (_firstTap == null && _secondTap == null) {
_registerFirstTap(tracker);
} else if (_secondTap != null) {
if (event.pointer == _secondTap!.pointer) {
if (onHoldDragEnd != null) onHoldDragEnd!(DragEndDetails());
}
} else {
_reject(tracker);
}
} else if (event is PointerDownEvent) {
if (_firstTap != null && _secondTap == null) {
_registerSecondTap(tracker);
}
} else if (event is PointerMoveEvent) {
if (!tracker.isWithinGlobalTolerance(event, kDoubleTapTouchSlop)) {
if (_firstTap != null && _firstTap!.pointer == event.pointer) {
// first tap move
_reject(tracker);
} else if (_secondTap != null && _secondTap!.pointer == event.pointer) {
// debugPrint("_secondTap move");
// second tap move
if (!_isStart) {
_resolve();
}
if (onHoldDragUpdate != null)
onHoldDragUpdate!(DragUpdateDetails(
globalPosition: event.position,
localPosition: event.localPosition,
delta: event.delta));
}
}
} else if (event is PointerCancelEvent) {
_reject(tracker);
}
}
@override
void acceptGesture(int pointer) {}
@override
void rejectGesture(int pointer) {
_TapTracker? tracker = _trackers[pointer];
// If tracker isn't in the list, check if this is the first tap tracker
if (tracker == null && _firstTap != null && _firstTap!.pointer == pointer) {
tracker = _firstTap;
}
// If tracker is still null, we rejected ourselves already
if (tracker != null) {
_reject(tracker);
}
}
void _resolve() {
_stopSecondTapDownTimer();
_firstTap?.entry.resolve(GestureDisposition.accepted);
_secondTap?.entry.resolve(GestureDisposition.accepted);
_isStart = true;
// TODO start details
if (onHoldDragStart != null) onHoldDragStart!(DragStartDetails());
}
void _reject(_TapTracker tracker) {
_checkCancel();
_isStart = false;
_trackers.remove(tracker.pointer);
tracker.entry.resolve(GestureDisposition.rejected);
_freezeTracker(tracker);
_reset();
}
@override
void dispose() {
_reset();
super.dispose();
}
void _reset() {
_isStart = false;
// debugPrint("reset");
_stopFirstTapUpTimer();
_stopSecondTapDownTimer();
if (_firstTap != null) {
if (_trackers.isNotEmpty) {
_checkCancel();
}
// Note, order is important below in order for the resolve -> reject logic
// to work properly.
final _TapTracker tracker = _firstTap!;
_firstTap = null;
_reject(tracker);
GestureBinding.instance!.gestureArena.release(tracker.pointer);
if (_secondTap != null) {
final _TapTracker tracker = _secondTap!;
_secondTap = null;
_reject(tracker);
GestureBinding.instance!.gestureArena.release(tracker.pointer);
}
}
_firstTap = null;
_secondTap = null;
_clearTrackers();
}
void _registerFirstTap(_TapTracker tracker) {
_startFirstTapUpTimer();
GestureBinding.instance!.gestureArena.hold(tracker.pointer);
// Note, order is important below in order for the clear -> reject logic to
// work properly.
_freezeTracker(tracker);
_trackers.remove(tracker.pointer);
_firstTap = tracker;
}
void _registerSecondTap(_TapTracker tracker) {
if (_firstTap != null) {
_stopFirstTapUpTimer();
_freezeTracker(_firstTap!);
_firstTap = null;
}
_startSecondTapDownTimer();
GestureBinding.instance!.gestureArena.hold(tracker.pointer);
_secondTap = tracker;
// TODO
}
void _clearTrackers() {
_trackers.values.toList().forEach(_reject);
assert(_trackers.isEmpty);
}
void _freezeTracker(_TapTracker tracker) {
tracker.stopTrackingPointer(_handleEvent);
}
void _startFirstTapUpTimer() {
_firstTapUpTimer ??= Timer(kDoubleTapTimeout, _reset);
}
void _startSecondTapDownTimer() {
_secondTapDownTimer ??= Timer(kDoubleTapTimeout, _resolve);
}
void _stopFirstTapUpTimer() {
if (_firstTapUpTimer != null) {
_firstTapUpTimer!.cancel();
_firstTapUpTimer = null;
}
}
void _stopSecondTapDownTimer() {
if (_secondTapDownTimer != null) {
_secondTapDownTimer!.cancel();
_secondTapDownTimer = null;
}
}
void _checkCancel() {
if (onHoldDragCancel != null) {
invokeCallback<void>('onHoldDragCancel', onHoldDragCancel!);
}
}
@override
String get debugDescription => 'double tap';
}
class DoubleFinerTapGestureRecognizer extends GestureRecognizer {
DoubleFinerTapGestureRecognizer({
Object? debugOwner,
Set<PointerDeviceKind>? supportedDevices,
}) : super(
debugOwner: debugOwner,
supportedDevices: supportedDevices,
);
GestureTapDownCallback? onDoubleFinerTapDown;
GestureTapDownCallback? onDoubleFinerTap;
GestureTapCancelCallback? onDoubleFinerTapCancel;
Timer? _firstTapTimer;
_TapTracker? _firstTap;
var _isStart = false;
final Set<int> _upTap = {};
final Map<int, _TapTracker> _trackers = <int, _TapTracker>{};
@override
bool isPointerAllowed(PointerDownEvent event) {
if (_firstTap == null) {
switch (event.buttons) {
case kPrimaryButton:
if (onDoubleFinerTapDown == null &&
onDoubleFinerTap == null &&
onDoubleFinerTapCancel == null) {
return false;
}
break;
default:
return false;
}
}
return super.isPointerAllowed(event);
}
@override
void addAllowedPointer(PointerDownEvent event) {
debugPrint("addAllowedPointer");
if (_isStart) {
// second
if (onDoubleFinerTapDown != null) {
final TapDownDetails details = TapDownDetails(
globalPosition: event.position,
localPosition: event.localPosition,
kind: getKindForPointer(event.pointer),
);
invokeCallback<void>(
'onDoubleFinerTapDown', () => onDoubleFinerTapDown!(details));
}
} else {
// first tap
_isStart = true;
_startFirstTapDownTimer();
}
_trackTap(event);
}
void _trackTap(PointerDownEvent event) {
final _TapTracker tracker = _TapTracker(
event: event,
entry: GestureBinding.instance!.gestureArena.add(event.pointer, this),
doubleTapMinTime: kDoubleTapMinTime,
gestureSettings: gestureSettings,
);
_trackers[event.pointer] = tracker;
// debugPrint("_trackers:$_trackers");
tracker.startTrackingPointer(_handleEvent, event.transform);
_registerTap(tracker);
}
void _handleEvent(PointerEvent event) {
final _TapTracker tracker = _trackers[event.pointer]!;
if (event is PointerUpEvent) {
debugPrint("PointerUpEvent");
_upTap.add(tracker.pointer);
} else if (event is PointerMoveEvent) {
if (!tracker.isWithinGlobalTolerance(event, kDoubleTapTouchSlop))
_reject(tracker);
} else if (event is PointerCancelEvent) {
_reject(tracker);
}
}
@override
void acceptGesture(int pointer) {}
@override
void rejectGesture(int pointer) {
_TapTracker? tracker = _trackers[pointer];
// If tracker isn't in the list, check if this is the first tap tracker
if (tracker == null && _firstTap != null && _firstTap!.pointer == pointer) {
tracker = _firstTap;
}
// If tracker is still null, we rejected ourselves already
if (tracker != null) {
_reject(tracker);
}
}
void _reject(_TapTracker tracker) {
_trackers.remove(tracker.pointer);
tracker.entry.resolve(GestureDisposition.rejected);
_freezeTracker(tracker);
if (_firstTap != null) {
if (tracker == _firstTap) {
_reset();
} else {
_checkCancel();
if (_trackers.isEmpty) {
_reset();
}
}
}
}
@override
void dispose() {
_reset();
super.dispose();
}
void _reset() {
_stopFirstTapUpTimer();
_firstTap = null;
_clearTrackers();
}
void _registerTap(_TapTracker tracker) {
GestureBinding.instance!.gestureArena.hold(tracker.pointer);
// Note, order is important below in order for the clear -> reject logic to
// work properly.
}
void _clearTrackers() {
_trackers.values.toList().forEach(_reject);
assert(_trackers.isEmpty);
}
void _freezeTracker(_TapTracker tracker) {
tracker.stopTrackingPointer(_handleEvent);
}
void _startFirstTapDownTimer() {
_firstTapTimer ??= Timer(kDoubleTapTimeout, _timeoutCheck);
}
void _stopFirstTapUpTimer() {
if (_firstTapTimer != null) {
_firstTapTimer!.cancel();
_firstTapTimer = null;
}
}
void _timeoutCheck() {
_isStart = false;
if (_upTap.length == 2) {
_resolve();
} else {
_reset();
}
_upTap.clear();
}
void _resolve() {
// TODO tap down details
if (onDoubleFinerTap != null) onDoubleFinerTap!(TapDownDetails());
_trackers.forEach((key, value) {
value.entry.resolve(GestureDisposition.accepted);
});
_reset();
}
void _checkCancel() {
if (onDoubleFinerTapCancel != null) {
invokeCallback<void>('onHoldDragCancel', onDoubleFinerTapCancel!);
}
}
@override
String get debugDescription => 'double tap';
}
/// TapTracker helps track individual tap sequences as part of a
/// larger gesture.
class _TapTracker {
_TapTracker({
required PointerDownEvent event,
required this.entry,
required Duration doubleTapMinTime,
required this.gestureSettings,
}) : assert(doubleTapMinTime != null),
assert(event != null),
assert(event.buttons != null),
pointer = event.pointer,
_initialGlobalPosition = event.position,
initialButtons = event.buttons,
_doubleTapMinTimeCountdown =
_CountdownZoned(duration: doubleTapMinTime);
final DeviceGestureSettings? gestureSettings;
final int pointer;
final GestureArenaEntry entry;
final Offset _initialGlobalPosition;
final int initialButtons;
final _CountdownZoned _doubleTapMinTimeCountdown;
bool _isTrackingPointer = false;
void startTrackingPointer(PointerRoute route, Matrix4? transform) {
if (!_isTrackingPointer) {
_isTrackingPointer = true;
GestureBinding.instance!.pointerRouter
.addRoute(pointer, route, transform);
}
}
void stopTrackingPointer(PointerRoute route) {
if (_isTrackingPointer) {
_isTrackingPointer = false;
GestureBinding.instance!.pointerRouter.removeRoute(pointer, route);
}
}
bool isWithinGlobalTolerance(PointerEvent event, double tolerance) {
final Offset offset = event.position - _initialGlobalPosition;
return offset.distance <= tolerance;
}
bool hasElapsedMinTime() {
return _doubleTapMinTimeCountdown.timeout;
}
bool hasSameButton(PointerDownEvent event) {
return event.buttons == initialButtons;
}
}
/// CountdownZoned tracks whether the specified duration has elapsed since
/// creation, honoring [Zone].
class _CountdownZoned {
_CountdownZoned({required Duration duration}) : assert(duration != null) {
Timer(duration, _onTimeout);
}
bool _timeout = false;
bool get timeout => _timeout;
void _onTimeout() {
_timeout = true;
}
}
RawGestureDetector getMixinGestureDetector({
Widget? child,
GestureTapUpCallback? onTapUp,
GestureTapDownCallback? onDoubleTapDown,
GestureDoubleTapCallback? onDoubleTap,
GestureLongPressDownCallback? onLongPressDown,
GestureLongPressCallback? onLongPress,
GestureDragStartCallback? onHoldDragStart,
GestureDragUpdateCallback? onHoldDragUpdate,
GestureDragCancelCallback? onHoldDragCancel,
GestureDragEndCallback? onHoldDragEnd,
GestureTapDownCallback? onDoubleFinerTap,
GestureDragStartCallback? onOneFingerPanStart,
GestureDragUpdateCallback? onOneFingerPanUpdate,
GestureDragEndCallback? onOneFingerPanEnd,
GestureScaleUpdateCallback? onTwoFingerScaleUpdate,
GestureScaleEndCallback? onTwoFingerScaleEnd,
GestureDragUpdateCallback? onTwoFingerHorizontalDragUpdate,
GestureDragUpdateCallback? onTwoFingerVerticalDragUpdate,
GestureDragStartCallback? onTwoFingerPanStart,
GestureDragUpdateCallback? onTwoFingerPanUpdate,
}) {
return RawGestureDetector(
child: child,
gestures: <Type, GestureRecognizerFactory>{
// Official
TapGestureRecognizer:
GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
() => TapGestureRecognizer(), (instance) {
instance.onTapUp = onTapUp;
}),
DoubleTapGestureRecognizer:
GestureRecognizerFactoryWithHandlers<DoubleTapGestureRecognizer>(
() => DoubleTapGestureRecognizer(), (instance) {
instance
..onDoubleTapDown = onDoubleTapDown
..onDoubleTap = onDoubleTap;
}),
LongPressGestureRecognizer:
GestureRecognizerFactoryWithHandlers<LongPressGestureRecognizer>(
() => LongPressGestureRecognizer(), (instance) {
instance
..onLongPressDown = onLongPressDown
..onLongPress = onLongPress;
}),
// Customized
HoldTapMoveGestureRecognizer:
GestureRecognizerFactoryWithHandlers<HoldTapMoveGestureRecognizer>(
() => HoldTapMoveGestureRecognizer(),
(instance) => {
instance
..onHoldDragStart = onHoldDragStart
..onHoldDragUpdate = onHoldDragUpdate
..onHoldDragCancel = onHoldDragCancel
..onHoldDragEnd = onHoldDragEnd
}),
DoubleFinerTapGestureRecognizer: GestureRecognizerFactoryWithHandlers<
DoubleFinerTapGestureRecognizer>(
() => DoubleFinerTapGestureRecognizer(), (instance) {
instance.onDoubleFinerTap = onDoubleFinerTap;
}),
CustomTouchGestureRecognizer:
GestureRecognizerFactoryWithHandlers<CustomTouchGestureRecognizer>(
() => CustomTouchGestureRecognizer(), (instance) {
instance
..onOneFingerPanStart = onOneFingerPanStart
..onOneFingerPanUpdate = onOneFingerPanUpdate
..onOneFingerPanEnd = onOneFingerPanEnd
..onTwoFingerScaleUpdate = onTwoFingerScaleUpdate
..onTwoFingerScaleEnd = onTwoFingerScaleEnd
..onTwoFingerHorizontalDragUpdate = onTwoFingerHorizontalDragUpdate
..onTwoFingerVerticalDragUpdate = onTwoFingerVerticalDragUpdate;
})
});
}

View File

@@ -0,0 +1,378 @@
import 'package:draggable_float_widget/draggable_float_widget.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common.dart';
import '../models/model.dart';
import '../pages/chat_page.dart';
OverlayEntry? chatIconOverlayEntry;
OverlayEntry? chatWindowOverlayEntry;
OverlayEntry? mobileActionsOverlayEntry;
class DraggableChatWindow extends StatelessWidget {
DraggableChatWindow(
{this.position = Offset.zero, required this.width, required this.height});
final Offset position;
final double width;
final double height;
@override
Widget build(BuildContext context) {
return Draggable(
checkKeyboard: true,
position: position,
width: width,
height: height,
builder: (_, onPanUpdate) {
return isIOS
? chatPage
: Scaffold(
resizeToAvoidBottomInset: false,
appBar: CustomAppBar(
onPanUpdate: onPanUpdate,
appBar: Container(
color: MyTheme.accent50,
height: 50,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Padding(
padding: EdgeInsets.symmetric(horizontal: 15),
child: Text(
translate("Chat"),
style: TextStyle(
color: Colors.white,
fontFamily: 'WorkSans',
fontWeight: FontWeight.bold,
fontSize: 20),
)),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton(
onPressed: () {
hideChatWindowOverlay();
},
icon: Icon(Icons.keyboard_arrow_down)),
IconButton(
onPressed: () {
hideChatWindowOverlay();
hideChatIconOverlay();
},
icon: Icon(Icons.close))
],
)
],
),
),
),
body: chatPage,
);
});
}
}
class CustomAppBar extends StatelessWidget implements PreferredSizeWidget {
final GestureDragUpdateCallback onPanUpdate;
final Widget appBar;
const CustomAppBar(
{Key? key, required this.onPanUpdate, required this.appBar})
: super(key: key);
@override
Widget build(BuildContext context) {
return GestureDetector(onPanUpdate: onPanUpdate, child: appBar);
}
@override
Size get preferredSize => new Size.fromHeight(kToolbarHeight);
}
showChatIconOverlay({Offset offset = const Offset(200, 50)}) {
if (chatIconOverlayEntry != null) {
chatIconOverlayEntry!.remove();
}
if (globalKey.currentState == null || globalKey.currentState!.overlay == null)
return;
final globalOverlayState = globalKey.currentState!.overlay!;
final overlay = OverlayEntry(builder: (context) {
return DraggableFloatWidget(
config: DraggableFloatWidgetBaseConfig(
initPositionYInTop: false,
initPositionYMarginBorder: 100,
borderTopContainTopBar: true,
),
child: FloatingActionButton(
onPressed: () {
if (chatWindowOverlayEntry == null) {
showChatWindowOverlay();
} else {
hideChatWindowOverlay();
}
},
child: Icon(Icons.message)));
});
globalOverlayState.insert(overlay);
chatIconOverlayEntry = overlay;
debugPrint("created");
}
hideChatIconOverlay() {
if (chatIconOverlayEntry != null) {
chatIconOverlayEntry!.remove();
chatIconOverlayEntry = null;
}
}
showChatWindowOverlay() {
if (chatWindowOverlayEntry != null) return;
if (globalKey.currentState == null || globalKey.currentState!.overlay == null)
return;
final globalOverlayState = globalKey.currentState!.overlay!;
final overlay = OverlayEntry(builder: (context) {
return DraggableChatWindow(
position: Offset(20, 80), width: 250, height: 350);
});
globalOverlayState.insert(overlay);
chatWindowOverlayEntry = overlay;
debugPrint("chatEntry created");
}
hideChatWindowOverlay() {
if (chatWindowOverlayEntry != null) {
chatWindowOverlayEntry!.remove();
chatWindowOverlayEntry = null;
return;
}
}
toggleChatOverlay() {
if (chatIconOverlayEntry == null || chatWindowOverlayEntry == null) {
FFI.invokeMethod("enable_soft_keyboard", true);
showChatIconOverlay();
showChatWindowOverlay();
} else {
hideChatIconOverlay();
hideChatWindowOverlay();
}
}
/// floating buttons of back/home/recent actions for android
class DraggableMobileActions extends StatelessWidget {
DraggableMobileActions(
{this.position = Offset.zero,
this.onBackPressed,
this.onRecentPressed,
this.onHomePressed,
required this.width,
required this.height});
final Offset position;
final double width;
final double height;
final VoidCallback? onBackPressed;
final VoidCallback? onHomePressed;
final VoidCallback? onRecentPressed;
@override
Widget build(BuildContext context) {
return Draggable(
position: position,
width: width,
height: height,
builder: (_, onPanUpdate) {
return GestureDetector(
onPanUpdate: onPanUpdate,
child: Container(
decoration: BoxDecoration(
color: MyTheme.accent.withOpacity(0.4),
borderRadius: BorderRadius.all(Radius.circular(15))),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
IconButton(
color: MyTheme.white,
onPressed: onBackPressed,
icon: Icon(Icons.arrow_back)),
IconButton(
color: MyTheme.white,
onPressed: onHomePressed,
icon: Icon(Icons.home)),
IconButton(
color: MyTheme.white,
onPressed: onRecentPressed,
icon: Icon(Icons.more_horiz)),
VerticalDivider(
width: 0,
thickness: 2,
indent: 10,
endIndent: 10,
),
IconButton(
color: MyTheme.white,
onPressed: hideMobileActionsOverlay,
icon: Icon(Icons.keyboard_arrow_down)),
],
),
));
});
}
}
showMobileActionsOverlay() {
if (mobileActionsOverlayEntry != null) return;
if (globalKey.currentContext == null ||
globalKey.currentState == null ||
globalKey.currentState!.overlay == null) return;
final globalOverlayState = globalKey.currentState!.overlay!;
// compute overlay position
final screenW = MediaQuery.of(globalKey.currentContext!).size.width;
final screenH = MediaQuery.of(globalKey.currentContext!).size.height;
final double overlayW = 200;
final double overlayH = 45;
final left = (screenW - overlayW) / 2;
final top = screenH - overlayH - 60;
debugPrint("left top : $left,$top");
final overlay = OverlayEntry(builder: (context) {
return DraggableMobileActions(
position: Offset(left, top),
width: overlayW,
height: overlayH,
onBackPressed: () => FFI.tap(MouseButtons.right),
onHomePressed: () => FFI.tap(MouseButtons.wheel),
onRecentPressed: () async {
FFI.sendMouse('down', MouseButtons.wheel);
await Future.delayed(Duration(milliseconds: 500));
FFI.sendMouse('up', MouseButtons.wheel);
},
);
});
globalOverlayState.insert(overlay);
mobileActionsOverlayEntry = overlay;
debugPrint("mobileActionsOverlay created");
}
hideMobileActionsOverlay() {
if (mobileActionsOverlayEntry != null) {
mobileActionsOverlayEntry!.remove();
mobileActionsOverlayEntry = null;
return;
}
}
class Draggable extends StatefulWidget {
Draggable(
{this.checkKeyboard = false,
this.checkScreenSize = false,
this.position = Offset.zero,
required this.width,
required this.height,
required this.builder});
final bool checkKeyboard;
final bool checkScreenSize;
final Offset position;
final double width;
final double height;
final Widget Function(BuildContext, GestureDragUpdateCallback) builder;
@override
State<StatefulWidget> createState() => _DraggableState();
}
class _DraggableState extends State<Draggable> {
late Offset _position;
bool _keyboardVisible = false;
double _saveHeight = 0;
double _lastBottomHeight = 0;
@override
void initState() {
super.initState();
_position = widget.position;
}
void onPanUpdate(DragUpdateDetails d) {
final offset = d.delta;
final size = MediaQuery.of(context).size;
double x = 0;
double y = 0;
if (_position.dx + offset.dx + widget.width > size.width) {
x = size.width - widget.width;
} else if (_position.dx + offset.dx < 0) {
x = 0;
} else {
x = _position.dx + offset.dx;
}
if (_position.dy + offset.dy + widget.height > size.height) {
y = size.height - widget.height;
} else if (_position.dy + offset.dy < 0) {
y = 0;
} else {
y = _position.dy + offset.dy;
}
setState(() {
_position = Offset(x, y);
});
}
checkScreenSize() {}
checkKeyboard() {
final bottomHeight = MediaQuery.of(context).viewInsets.bottom;
final currentVisible = bottomHeight != 0;
debugPrint(bottomHeight.toString() + currentVisible.toString());
// save
if (!_keyboardVisible && currentVisible) {
_saveHeight = _position.dy;
}
// reset
if (_lastBottomHeight > 0 && bottomHeight == 0) {
setState(() {
_position = Offset(_position.dx, _saveHeight);
});
}
// onKeyboardVisible
if (_keyboardVisible && currentVisible) {
final sumHeight = bottomHeight + widget.height;
final contextHeight = MediaQuery.of(context).size.height;
if (sumHeight + _position.dy > contextHeight) {
final y = contextHeight - sumHeight;
setState(() {
_position = Offset(_position.dx, y);
});
}
}
_keyboardVisible = currentVisible;
_lastBottomHeight = bottomHeight;
}
@override
Widget build(BuildContext context) {
if (widget.checkKeyboard) {
checkKeyboard();
}
if (widget.checkKeyboard) {
checkScreenSize();
}
return Positioned(
top: _position.dy,
left: _position.dx,
width: widget.width,
height: widget.height,
child: widget.builder(context, onPanUpdate));
}
}