Initial commit: RustDesk 1.4.7 macOS desktop port
- Filled empty libs/hbb_common/ submodule (cloned from rustdesk/hbb_common) - Patched Flutter 3.44 / Dart 3.12 compatibility: * flutter/lib/generated_bridge.dart: asTypedList with cast<>, DartPort=Int64 * flutter/lib/common.dart: DialogTheme->DialogThemeData, TabBarTheme->TabBarThemeData * flutter/pubspec.yaml: extended_text 14.0.0->15.0.2, google_fonts override 5.0.0 * flutter/macos/Runner/Configs/Release.xcconfig: EXCLUDED_ARCHS=x86_64 - Build verified: cargo check + cargo build --features flutter + cargo build --release --features flutter - Verified flutter build macos --debug and --release both produce working .app - Verified .dmg installer (27MB arm64) created via hdiutil - Build deps: Xcode 26.5, CocoaPods 1.16.2, Flutter 3.44, VCPKG arm64-osx
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
class IDTextEditingController extends TextEditingController {
|
||||
IDTextEditingController({String? text}) : super(text: text);
|
||||
|
||||
String get id => trimID(value.text);
|
||||
|
||||
set id(String newID) => text = formatID(newID);
|
||||
}
|
||||
|
||||
class IDTextInputFormatter extends TextInputFormatter {
|
||||
@override
|
||||
TextEditingValue formatEditUpdate(
|
||||
TextEditingValue oldValue, TextEditingValue newValue) {
|
||||
if (newValue.text.isEmpty) {
|
||||
return newValue.copyWith(text: '');
|
||||
} else if (newValue.text.compareTo(oldValue.text) == 0) {
|
||||
return newValue;
|
||||
} else {
|
||||
int selectionIndexFromTheRight =
|
||||
newValue.text.length - newValue.selection.extentOffset;
|
||||
String newID = formatID(newValue.text);
|
||||
return TextEditingValue(
|
||||
text: newID,
|
||||
selection: TextSelection.collapsed(
|
||||
offset: newID.length - selectionIndexFromTheRight,
|
||||
),
|
||||
// https://github.com/flutter/flutter/issues/78066#issuecomment-797869906
|
||||
composing: newValue.composing,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String formatID(String id) {
|
||||
String id2 = id.replaceAll(' ', '');
|
||||
String suffix = '';
|
||||
if (id2.endsWith(r'\r') || id2.endsWith(r'/r')) {
|
||||
suffix = id2.substring(id2.length - 2, id2.length);
|
||||
id2 = id2.substring(0, id2.length - 2);
|
||||
}
|
||||
if (int.tryParse(id2) == null) return id;
|
||||
String newID = '';
|
||||
if (id2.length <= 3) {
|
||||
newID = id2;
|
||||
} else {
|
||||
var n = id2.length;
|
||||
var a = n % 3 != 0 ? n % 3 : 3;
|
||||
newID = id2.substring(0, a);
|
||||
for (var i = a; i < n; i += 3) {
|
||||
newID += " ${id2.substring(i, i + 3)}";
|
||||
}
|
||||
}
|
||||
return newID + suffix;
|
||||
}
|
||||
|
||||
String trimID(String id) {
|
||||
return id.replaceAll(' ', '');
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
|
||||
import 'package:flutter_hbb/models/peer_model.dart';
|
||||
|
||||
import '../../models/platform_model.dart';
|
||||
|
||||
class HttpType {
|
||||
static const kAuthReqTypeAccount = "account";
|
||||
static const kAuthReqTypeMobile = "mobile";
|
||||
static const kAuthReqTypeSMSCode = "sms_code";
|
||||
static const kAuthReqTypeEmailCode = "email_code";
|
||||
static const kAuthReqTypeTfaCode = "tfa_code";
|
||||
|
||||
static const kAuthResTypeToken = "access_token";
|
||||
static const kAuthResTypeEmailCheck = "email_check";
|
||||
static const kAuthResTypeTfaCheck = "tfa_check";
|
||||
}
|
||||
|
||||
enum UserStatus { kDisabled, kNormal, kUnverified }
|
||||
|
||||
// to-do: The UserPayload does not contain all the fields of the user.
|
||||
// Is all the fields of the user needed?
|
||||
class UserPayload {
|
||||
String name = '';
|
||||
String displayName = '';
|
||||
String avatar = '';
|
||||
String email = '';
|
||||
String note = '';
|
||||
String? verifier;
|
||||
UserStatus status;
|
||||
bool isAdmin = false;
|
||||
|
||||
UserPayload.fromJson(Map<String, dynamic> json)
|
||||
: name = json['name'] ?? '',
|
||||
displayName = json['display_name'] ?? '',
|
||||
avatar = json['avatar'] ?? '',
|
||||
email = json['email'] ?? '',
|
||||
note = json['note'] ?? '',
|
||||
verifier = json['verifier'],
|
||||
status = json['status'] == 0
|
||||
? UserStatus.kDisabled
|
||||
: json['status'] == -1
|
||||
? UserStatus.kUnverified
|
||||
: UserStatus.kNormal,
|
||||
isAdmin = json['is_admin'] == true;
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final Map<String, dynamic> map = {
|
||||
'name': name,
|
||||
'display_name': displayName,
|
||||
'avatar': avatar,
|
||||
'status': status == UserStatus.kDisabled
|
||||
? 0
|
||||
: status == UserStatus.kUnverified
|
||||
? -1
|
||||
: 1,
|
||||
};
|
||||
return map;
|
||||
}
|
||||
|
||||
Map<String, dynamic> toGroupCacheJson() {
|
||||
final Map<String, dynamic> map = {
|
||||
'name': name,
|
||||
'display_name': displayName,
|
||||
};
|
||||
return map;
|
||||
}
|
||||
|
||||
String get displayNameOrName {
|
||||
return displayName.trim().isEmpty ? name : displayName;
|
||||
}
|
||||
}
|
||||
|
||||
class PeerPayload {
|
||||
String id = '';
|
||||
Map<String, dynamic> info = {};
|
||||
int? status;
|
||||
String user = '';
|
||||
String user_name = '';
|
||||
String? device_group_name;
|
||||
String note = '';
|
||||
|
||||
PeerPayload.fromJson(Map<String, dynamic> json)
|
||||
: id = json['id'] ?? '',
|
||||
info = (json['info'] is Map<String, dynamic>) ? json['info'] : {},
|
||||
status = json['status'],
|
||||
user = json['user'] ?? '',
|
||||
user_name = json['user_name'] ?? '',
|
||||
device_group_name = json['device_group_name'] ?? '',
|
||||
note = json['note'] ?? '';
|
||||
|
||||
static Peer toPeer(PeerPayload p) {
|
||||
return Peer.fromJson({
|
||||
"id": p.id,
|
||||
'loginName': p.user_name,
|
||||
"username": p.info['username'] ?? '',
|
||||
"platform": _platform(p.info['os']),
|
||||
"hostname": p.info['device_name'],
|
||||
"device_group_name": p.device_group_name,
|
||||
"note": p.note,
|
||||
});
|
||||
}
|
||||
|
||||
static String? _platform(dynamic field) {
|
||||
if (field == null) {
|
||||
return null;
|
||||
}
|
||||
final fieldStr = field.toString();
|
||||
List<String> list = fieldStr.split(' / ');
|
||||
if (list.isEmpty) return null;
|
||||
final os = list[0];
|
||||
switch (os.toLowerCase()) {
|
||||
case 'windows':
|
||||
return kPeerPlatformWindows;
|
||||
case 'linux':
|
||||
return kPeerPlatformLinux;
|
||||
case 'macos':
|
||||
return kPeerPlatformMacOS;
|
||||
case 'android':
|
||||
return kPeerPlatformAndroid;
|
||||
default:
|
||||
if (fieldStr.toLowerCase().contains('linux')) {
|
||||
return kPeerPlatformLinux;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class LoginRequest {
|
||||
String? username;
|
||||
String? password;
|
||||
String? id;
|
||||
String? uuid;
|
||||
bool? autoLogin;
|
||||
String? type;
|
||||
String? verificationCode;
|
||||
String? tfaCode;
|
||||
String? secret;
|
||||
|
||||
LoginRequest(
|
||||
{this.username,
|
||||
this.password,
|
||||
this.id,
|
||||
this.uuid,
|
||||
this.autoLogin,
|
||||
this.type,
|
||||
this.verificationCode,
|
||||
this.tfaCode,
|
||||
this.secret});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final Map<String, dynamic> data = <String, dynamic>{};
|
||||
if (username != null) data['username'] = username;
|
||||
if (password != null) data['password'] = password;
|
||||
if (id != null) data['id'] = id;
|
||||
if (uuid != null) data['uuid'] = uuid;
|
||||
if (autoLogin != null) data['autoLogin'] = autoLogin;
|
||||
if (type != null) data['type'] = type;
|
||||
if (verificationCode != null) {
|
||||
data['verificationCode'] = verificationCode;
|
||||
}
|
||||
if (tfaCode != null) data['tfaCode'] = tfaCode;
|
||||
if (secret != null) data['secret'] = secret;
|
||||
|
||||
Map<String, dynamic> deviceInfo = {};
|
||||
try {
|
||||
deviceInfo = jsonDecode(bind.mainGetLoginDeviceInfo());
|
||||
} catch (e) {
|
||||
debugPrint('Failed to decode get device info: $e');
|
||||
}
|
||||
data['deviceInfo'] = deviceInfo;
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
class LoginResponse {
|
||||
String? access_token;
|
||||
String? type;
|
||||
String? tfa_type;
|
||||
String? secret;
|
||||
UserPayload? user;
|
||||
|
||||
LoginResponse(
|
||||
{this.access_token, this.type, this.tfa_type, this.secret, this.user});
|
||||
|
||||
LoginResponse.fromJson(Map<String, dynamic> json) {
|
||||
access_token = json['access_token'];
|
||||
type = json['type'];
|
||||
tfa_type = json['tfa_type'];
|
||||
secret = json['secret'];
|
||||
user = json['user'] != null ? UserPayload.fromJson(json['user']) : null;
|
||||
}
|
||||
}
|
||||
|
||||
class RequestException implements Exception {
|
||||
int statusCode;
|
||||
String cause;
|
||||
RequestException(this.statusCode, this.cause);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return "RequestException, statusCode: $statusCode, error: $cause";
|
||||
}
|
||||
}
|
||||
|
||||
enum ShareRule {
|
||||
read(1),
|
||||
readWrite(2),
|
||||
fullControl(3);
|
||||
|
||||
const ShareRule(this.value);
|
||||
final int value;
|
||||
|
||||
static String desc(int v) {
|
||||
if (v == ShareRule.read.value) {
|
||||
return translate('Read-only');
|
||||
}
|
||||
if (v == ShareRule.readWrite.value) {
|
||||
return translate('Read/Write');
|
||||
}
|
||||
if (v == ShareRule.fullControl.value) {
|
||||
return translate('Full Control');
|
||||
}
|
||||
return v.toString();
|
||||
}
|
||||
|
||||
static String shortDesc(int v) {
|
||||
if (v == ShareRule.read.value) {
|
||||
return 'R';
|
||||
}
|
||||
if (v == ShareRule.readWrite.value) {
|
||||
return 'RW';
|
||||
}
|
||||
if (v == ShareRule.fullControl.value) {
|
||||
return 'F';
|
||||
}
|
||||
return v.toString();
|
||||
}
|
||||
|
||||
static ShareRule? fromValue(int v) {
|
||||
if (v == ShareRule.read.value) {
|
||||
return ShareRule.read;
|
||||
}
|
||||
if (v == ShareRule.readWrite.value) {
|
||||
return ShareRule.readWrite;
|
||||
}
|
||||
if (v == ShareRule.fullControl.value) {
|
||||
return ShareRule.fullControl;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class AbProfile {
|
||||
String guid;
|
||||
String name;
|
||||
String owner;
|
||||
String? note;
|
||||
dynamic info;
|
||||
int rule;
|
||||
|
||||
AbProfile(this.guid, this.name, this.owner, this.note, this.rule, this.info);
|
||||
|
||||
AbProfile.fromJson(Map<String, dynamic> json)
|
||||
: guid = json['guid'] ?? '',
|
||||
name = json['name'] ?? '',
|
||||
owner = json['owner'] ?? '',
|
||||
note = json['note'] ?? '',
|
||||
info = json['info'],
|
||||
rule = json['rule'] ?? 0;
|
||||
}
|
||||
|
||||
class AbTag {
|
||||
String name;
|
||||
int color;
|
||||
|
||||
AbTag(this.name, this.color);
|
||||
|
||||
AbTag.fromJson(Map<String, dynamic> json)
|
||||
: name = json['name'] ?? '',
|
||||
color = json['color'] ?? '';
|
||||
}
|
||||
|
||||
class DeviceGroupPayload {
|
||||
String name;
|
||||
|
||||
DeviceGroupPayload(this.name);
|
||||
|
||||
DeviceGroupPayload.fromJson(Map<String, dynamic> json)
|
||||
: name = json['name'] ?? '';
|
||||
|
||||
Map<String, dynamic> toGroupCacheJson() {
|
||||
final Map<String, dynamic> map = {
|
||||
'name': name,
|
||||
};
|
||||
return map;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import '../consts.dart';
|
||||
|
||||
// TODO: A lot of dup code.
|
||||
|
||||
class PrivacyModeState {
|
||||
static String tag(String id) => 'privacy_mode_$id';
|
||||
|
||||
static void init(String id) {
|
||||
final key = tag(id);
|
||||
if (!Get.isRegistered<RxString>(tag: key)) {
|
||||
final RxString state = ''.obs;
|
||||
Get.put<RxString>(state, tag: key);
|
||||
}
|
||||
}
|
||||
|
||||
static void delete(String id) {
|
||||
final key = tag(id);
|
||||
if (Get.isRegistered<RxString>(tag: key)) {
|
||||
Get.delete<RxString>(tag: key);
|
||||
} else {
|
||||
Get.find<RxString>(tag: key).value = '';
|
||||
}
|
||||
}
|
||||
|
||||
static RxString find(String id) => Get.find<RxString>(tag: tag(id));
|
||||
}
|
||||
|
||||
class BlockInputState {
|
||||
static String tag(String id) => 'block_input_$id';
|
||||
|
||||
static void init(String id) {
|
||||
final key = tag(id);
|
||||
if (!Get.isRegistered<RxBool>(tag: key)) {
|
||||
final RxBool state = false.obs;
|
||||
Get.put<RxBool>(state, tag: key);
|
||||
} else {
|
||||
Get.find<RxBool>(tag: key).value = false;
|
||||
}
|
||||
}
|
||||
|
||||
static void delete(String id) {
|
||||
final key = tag(id);
|
||||
if (Get.isRegistered<RxBool>(tag: key)) {
|
||||
Get.delete<RxBool>(tag: key);
|
||||
}
|
||||
}
|
||||
|
||||
static RxBool find(String id) => Get.find<RxBool>(tag: tag(id));
|
||||
}
|
||||
|
||||
class CurrentDisplayState {
|
||||
static String tag(String id) => 'current_display_$id';
|
||||
|
||||
static void init(String id) {
|
||||
final key = tag(id);
|
||||
if (!Get.isRegistered<RxInt>(tag: key)) {
|
||||
final RxInt state = RxInt(0);
|
||||
Get.put<RxInt>(state, tag: key);
|
||||
} else {
|
||||
Get.find<RxInt>(tag: key).value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
static void delete(String id) {
|
||||
final key = tag(id);
|
||||
if (Get.isRegistered<RxInt>(tag: key)) {
|
||||
Get.delete<RxInt>(tag: key);
|
||||
}
|
||||
}
|
||||
|
||||
static RxInt find(String id) => Get.find<RxInt>(tag: tag(id));
|
||||
}
|
||||
|
||||
class ConnectionType {
|
||||
final Rx<String> _secure = kInvalidValueStr.obs;
|
||||
final Rx<String> _direct = kInvalidValueStr.obs;
|
||||
final Rx<String> _stream_type = kInvalidValueStr.obs;
|
||||
|
||||
Rx<String> get secure => _secure;
|
||||
Rx<String> get direct => _direct;
|
||||
Rx<String> get stream_type => _stream_type;
|
||||
|
||||
static String get strSecure => 'secure';
|
||||
static String get strInsecure => 'insecure';
|
||||
static String get strDirect => '';
|
||||
static String get strIndirect => '_relay';
|
||||
|
||||
void setSecure(bool v) {
|
||||
_secure.value = v ? strSecure : strInsecure;
|
||||
}
|
||||
|
||||
void setDirect(bool v) {
|
||||
_direct.value = v ? strDirect : strIndirect;
|
||||
}
|
||||
|
||||
void setStreamType(String v) {
|
||||
_stream_type.value = v;
|
||||
}
|
||||
|
||||
bool isValid() {
|
||||
return _secure.value != kInvalidValueStr &&
|
||||
_direct.value != kInvalidValueStr &&
|
||||
_stream_type.value != kInvalidValueStr;
|
||||
}
|
||||
}
|
||||
|
||||
class ConnectionTypeState {
|
||||
static String tag(String id) => 'connection_type_$id';
|
||||
|
||||
static void init(String id) {
|
||||
final key = tag(id);
|
||||
if (!Get.isRegistered<ConnectionType>(tag: key)) {
|
||||
final ConnectionType collectionType = ConnectionType();
|
||||
Get.put<ConnectionType>(collectionType, tag: key);
|
||||
}
|
||||
}
|
||||
|
||||
static void delete(String id) {
|
||||
final key = tag(id);
|
||||
if (Get.isRegistered<ConnectionType>(tag: key)) {
|
||||
Get.delete<ConnectionType>(tag: key);
|
||||
}
|
||||
}
|
||||
|
||||
static ConnectionType find(String id) =>
|
||||
Get.find<ConnectionType>(tag: tag(id));
|
||||
}
|
||||
|
||||
class FingerprintState {
|
||||
static String tag(String id) => 'fingerprint_$id';
|
||||
|
||||
static void init(String id) {
|
||||
final key = tag(id);
|
||||
if (!Get.isRegistered<RxString>(tag: key)) {
|
||||
final RxString state = ''.obs;
|
||||
Get.put<RxString>(state, tag: key);
|
||||
} else {
|
||||
Get.find<RxString>(tag: key).value = '';
|
||||
}
|
||||
}
|
||||
|
||||
static void delete(String id) {
|
||||
final key = tag(id);
|
||||
if (Get.isRegistered<RxString>(tag: key)) {
|
||||
Get.delete<RxString>(tag: key);
|
||||
}
|
||||
}
|
||||
|
||||
static RxString find(String id) => Get.find<RxString>(tag: tag(id));
|
||||
}
|
||||
|
||||
class ShowRemoteCursorState {
|
||||
static String tag(String id) => 'show_remote_cursor_$id';
|
||||
|
||||
static void init(String id) {
|
||||
final key = tag(id);
|
||||
if (!Get.isRegistered<RxBool>(tag: key)) {
|
||||
final RxBool state = false.obs;
|
||||
Get.put<RxBool>(state, tag: key);
|
||||
} else {
|
||||
Get.find<RxBool>(tag: key).value = false;
|
||||
}
|
||||
}
|
||||
|
||||
static void delete(String id) {
|
||||
final key = tag(id);
|
||||
if (Get.isRegistered<RxBool>(tag: key)) {
|
||||
Get.delete<RxBool>(tag: key);
|
||||
}
|
||||
}
|
||||
|
||||
static RxBool find(String id) => Get.find<RxBool>(tag: tag(id));
|
||||
}
|
||||
|
||||
class ShowRemoteCursorLockState {
|
||||
static String tag(String id) => 'show_remote_cursor_lock_$id';
|
||||
|
||||
static void init(String id) {
|
||||
final key = tag(id);
|
||||
if (!Get.isRegistered<RxBool>(tag: key)) {
|
||||
final RxBool state = false.obs;
|
||||
Get.put<RxBool>(state, tag: key);
|
||||
} else {
|
||||
Get.find<RxBool>(tag: key).value = false;
|
||||
}
|
||||
}
|
||||
|
||||
static void delete(String id) {
|
||||
final key = tag(id);
|
||||
if (Get.isRegistered<RxBool>(tag: key)) {
|
||||
Get.delete<RxBool>(tag: key);
|
||||
}
|
||||
}
|
||||
|
||||
static RxBool find(String id) => Get.find<RxBool>(tag: tag(id));
|
||||
}
|
||||
|
||||
class KeyboardEnabledState {
|
||||
static String tag(String id) => 'keyboard_enabled_$id';
|
||||
|
||||
static void init(String id) {
|
||||
final key = tag(id);
|
||||
if (!Get.isRegistered<RxBool>(tag: key)) {
|
||||
// Server side, default true
|
||||
final RxBool state = true.obs;
|
||||
Get.put<RxBool>(state, tag: key);
|
||||
} else {
|
||||
Get.find<RxBool>(tag: key).value = true;
|
||||
}
|
||||
}
|
||||
|
||||
static void delete(String id) {
|
||||
final key = tag(id);
|
||||
if (Get.isRegistered<RxBool>(tag: key)) {
|
||||
Get.delete<RxBool>(tag: key);
|
||||
}
|
||||
}
|
||||
|
||||
static RxBool find(String id) => Get.find<RxBool>(tag: tag(id));
|
||||
}
|
||||
|
||||
class RemoteCursorMovedState {
|
||||
static String tag(String id) => 'remote_cursor_moved_$id';
|
||||
|
||||
static void init(String id) {
|
||||
final key = tag(id);
|
||||
if (!Get.isRegistered<RxBool>(tag: key)) {
|
||||
final RxBool state = false.obs;
|
||||
Get.put<RxBool>(state, tag: key);
|
||||
} else {
|
||||
Get.find<RxBool>(tag: key).value = false;
|
||||
}
|
||||
}
|
||||
|
||||
static void delete(String id) {
|
||||
final key = tag(id);
|
||||
if (Get.isRegistered<RxBool>(tag: key)) {
|
||||
Get.delete<RxBool>(tag: key);
|
||||
}
|
||||
}
|
||||
|
||||
static RxBool find(String id) => Get.find<RxBool>(tag: tag(id));
|
||||
}
|
||||
|
||||
class RemoteCountState {
|
||||
static String tag() => 'remote_count_';
|
||||
|
||||
static void init() {
|
||||
final key = tag();
|
||||
if (!Get.isRegistered<RxInt>(tag: key)) {
|
||||
final RxInt state = 1.obs;
|
||||
Get.put<RxInt>(state, tag: key);
|
||||
} else {
|
||||
Get.find<RxInt>(tag: key).value = 1;
|
||||
}
|
||||
}
|
||||
|
||||
static void delete() {
|
||||
final key = tag();
|
||||
if (Get.isRegistered<RxInt>(tag: key)) {
|
||||
Get.delete<RxInt>(tag: key);
|
||||
}
|
||||
}
|
||||
|
||||
static RxInt find() => Get.find<RxInt>(tag: tag());
|
||||
}
|
||||
|
||||
class PeerBoolOption {
|
||||
static String tag(String id, String opt) => 'peer_{$opt}_$id';
|
||||
|
||||
static void init(String id, String opt, bool Function() init_getter) {
|
||||
final key = tag(id, opt);
|
||||
if (!Get.isRegistered<RxBool>(tag: key)) {
|
||||
final RxBool value = RxBool(init_getter());
|
||||
Get.put<RxBool>(value, tag: key);
|
||||
} else {
|
||||
Get.find<RxBool>(tag: key).value = init_getter();
|
||||
}
|
||||
}
|
||||
|
||||
static void delete(String id, String opt) {
|
||||
final key = tag(id, opt);
|
||||
if (Get.isRegistered<RxBool>(tag: key)) {
|
||||
Get.delete<RxBool>(tag: key);
|
||||
}
|
||||
}
|
||||
|
||||
static RxBool find(String id, String opt) =>
|
||||
Get.find<RxBool>(tag: tag(id, opt));
|
||||
}
|
||||
|
||||
class PeerStringOption {
|
||||
static String tag(String id, String opt) => 'peer_{$opt}_$id';
|
||||
|
||||
static void init(String id, String opt, String Function() init_getter) {
|
||||
final key = tag(id, opt);
|
||||
if (!Get.isRegistered<RxString>(tag: key)) {
|
||||
final RxString value = RxString(init_getter());
|
||||
Get.put<RxString>(value, tag: key);
|
||||
} else {
|
||||
Get.find<RxString>(tag: key).value = init_getter();
|
||||
}
|
||||
}
|
||||
|
||||
static void delete(String id, String opt) {
|
||||
final key = tag(id, opt);
|
||||
if (Get.isRegistered<RxString>(tag: key)) {
|
||||
Get.delete<RxString>(tag: key);
|
||||
}
|
||||
}
|
||||
|
||||
static RxString find(String id, String opt) =>
|
||||
Get.find<RxString>(tag: tag(id, opt));
|
||||
}
|
||||
|
||||
class UnreadChatCountState {
|
||||
static String tag(id) => 'unread_chat_count_$id';
|
||||
|
||||
static void init(String id) {
|
||||
final key = tag(id);
|
||||
if (!Get.isRegistered<RxInt>(tag: key)) {
|
||||
final RxInt state = RxInt(0);
|
||||
Get.put<RxInt>(state, tag: key);
|
||||
} else {
|
||||
Get.find<RxInt>(tag: key).value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
static void delete(String id) {
|
||||
final key = tag(id);
|
||||
if (Get.isRegistered<RxInt>(tag: key)) {
|
||||
Get.delete<RxInt>(tag: key);
|
||||
}
|
||||
}
|
||||
|
||||
static RxInt find(String id) => Get.find<RxInt>(tag: tag(id));
|
||||
}
|
||||
|
||||
initSharedStates(String id) {
|
||||
PrivacyModeState.init(id);
|
||||
BlockInputState.init(id);
|
||||
CurrentDisplayState.init(id);
|
||||
KeyboardEnabledState.init(id);
|
||||
ShowRemoteCursorState.init(id);
|
||||
ShowRemoteCursorLockState.init(id);
|
||||
RemoteCursorMovedState.init(id);
|
||||
FingerprintState.init(id);
|
||||
PeerBoolOption.init(id, kOptionZoomCursor, () => false);
|
||||
UnreadChatCountState.init(id);
|
||||
if (isMobile) ConnectionTypeState.init(id); // desktop in other places
|
||||
}
|
||||
|
||||
removeSharedStates(String id) {
|
||||
PrivacyModeState.delete(id);
|
||||
BlockInputState.delete(id);
|
||||
CurrentDisplayState.delete(id);
|
||||
ShowRemoteCursorState.delete(id);
|
||||
ShowRemoteCursorLockState.delete(id);
|
||||
KeyboardEnabledState.delete(id);
|
||||
RemoteCursorMovedState.delete(id);
|
||||
FingerprintState.delete(id);
|
||||
PeerBoolOption.delete(id, kOptionZoomCursor);
|
||||
UnreadChatCountState.delete(id);
|
||||
if (isMobile) ConnectionTypeState.delete(id);
|
||||
}
|
||||
@@ -0,0 +1,899 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:bot_toast/bot_toast.dart';
|
||||
import 'package:dropdown_button2/dropdown_button2.dart';
|
||||
import 'package:dynamic_layouts/dynamic_layouts.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hbb/common/formatter/id_formatter.dart';
|
||||
import 'package:flutter_hbb/common/hbbs/hbbs.dart';
|
||||
import 'package:flutter_hbb/common/widgets/peer_card.dart';
|
||||
import 'package:flutter_hbb/common/widgets/peers_view.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/desktop/widgets/popup_menu.dart';
|
||||
import 'package:flutter_hbb/models/ab_model.dart';
|
||||
import 'package:flutter_hbb/models/platform_model.dart';
|
||||
import 'package:flutter_hbb/models/state_model.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
import '../../desktop/widgets/material_mod_popup_menu.dart' as mod_menu;
|
||||
import 'package:get/get.dart';
|
||||
import 'package:flex_color_picker/flex_color_picker.dart';
|
||||
|
||||
import '../../common.dart';
|
||||
import 'dialog.dart';
|
||||
import 'login.dart';
|
||||
|
||||
final hideAbTagsPanel = false.obs;
|
||||
|
||||
class AddressBook extends StatefulWidget {
|
||||
final EdgeInsets? menuPadding;
|
||||
const AddressBook({Key? key, this.menuPadding}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() {
|
||||
return _AddressBookState();
|
||||
}
|
||||
}
|
||||
|
||||
class _AddressBookState extends State<AddressBook> {
|
||||
var menuPos = RelativeRect.fill;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Obx(() {
|
||||
if (!gFFI.userModel.isLogin) {
|
||||
return Center(
|
||||
child: ElevatedButton(
|
||||
onPressed: loginDialog, child: Text(translate("Login"))));
|
||||
} else if (gFFI.userModel.networkError.isNotEmpty) {
|
||||
return netWorkErrorWidget();
|
||||
} else {
|
||||
return Column(
|
||||
children: [
|
||||
// NOT use Offstage to wrap LinearProgressIndicator
|
||||
if (gFFI.abModel.currentAbLoading.value &&
|
||||
gFFI.abModel.currentAbEmpty)
|
||||
const LinearProgressIndicator(),
|
||||
buildErrorBanner(context,
|
||||
loading: gFFI.abModel.currentAbLoading,
|
||||
err: gFFI.abModel.abPullError,
|
||||
retry: null,
|
||||
close: gFFI.abModel.clearPullErrors),
|
||||
buildErrorBanner(context,
|
||||
loading: gFFI.abModel.currentAbLoading,
|
||||
err: gFFI.abModel.currentAbPushError,
|
||||
retry: null, // remove retry
|
||||
close: () => gFFI.abModel.currentAbPushError.value = ''),
|
||||
Expanded(
|
||||
child: Obx(() => stateGlobal.isPortrait.isTrue
|
||||
? _buildAddressBookPortrait()
|
||||
: _buildAddressBookLandscape()),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Widget _buildAddressBookLandscape() {
|
||||
return Row(
|
||||
children: [
|
||||
Offstage(
|
||||
offstage: hideAbTagsPanel.value,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.background)),
|
||||
child: Container(
|
||||
width: 200,
|
||||
height: double.infinity,
|
||||
child: Column(
|
||||
children: [
|
||||
_buildAbDropdown(),
|
||||
_buildTagHeader().marginOnly(
|
||||
left: 8.0,
|
||||
right: gFFI.abModel.legacyMode.value ? 8.0 : 0,
|
||||
top: gFFI.abModel.legacyMode.value ? 8.0 : 0),
|
||||
Expanded(
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
child: _buildTags(),
|
||||
),
|
||||
),
|
||||
_buildAbPermission(),
|
||||
],
|
||||
),
|
||||
),
|
||||
).marginOnly(right: 12.0)),
|
||||
_buildPeersViews()
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAddressBookPortrait() {
|
||||
const padding = 8.0;
|
||||
return Column(
|
||||
children: [
|
||||
Offstage(
|
||||
offstage: hideAbTagsPanel.value,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.background)),
|
||||
child: Container(
|
||||
padding:
|
||||
const EdgeInsets.fromLTRB(padding, 0, padding, padding),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildAbDropdown(),
|
||||
_buildTagHeader().marginOnly(left: 8.0, right: 0),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
child: _buildTags(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
).marginOnly(bottom: 12.0)),
|
||||
_buildPeersViews()
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAbPermission() {
|
||||
icon(IconData data, String tooltip) {
|
||||
return Tooltip(
|
||||
message: translate(tooltip),
|
||||
waitDuration: Duration.zero,
|
||||
child: Icon(data, size: 12.0).marginSymmetric(horizontal: 2.0));
|
||||
}
|
||||
|
||||
return Obx(() {
|
||||
if (gFFI.abModel.legacyMode.value) return Offstage();
|
||||
if (gFFI.abModel.current.isPersonal()) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
icon(Icons.cloud_off, "Personal"),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
List<Widget> children = [];
|
||||
final rule = gFFI.abModel.current.sharedProfile()?.rule;
|
||||
if (rule == ShareRule.read.value) {
|
||||
children.add(
|
||||
icon(Icons.visibility, ShareRule.desc(ShareRule.read.value)));
|
||||
} else if (rule == ShareRule.readWrite.value) {
|
||||
children
|
||||
.add(icon(Icons.edit, ShareRule.desc(ShareRule.readWrite.value)));
|
||||
} else if (rule == ShareRule.fullControl.value) {
|
||||
children.add(icon(
|
||||
Icons.security, ShareRule.desc(ShareRule.fullControl.value)));
|
||||
}
|
||||
final owner = gFFI.abModel.current.sharedProfile()?.owner;
|
||||
if (owner != null) {
|
||||
children.add(icon(Icons.person, "${translate("Owner")}: $owner"));
|
||||
}
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: children,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildAbDropdown() {
|
||||
if (gFFI.abModel.legacyMode.value) {
|
||||
return Offstage();
|
||||
}
|
||||
final names = gFFI.abModel.addressBookNames();
|
||||
if (!names.contains(gFFI.abModel.currentName.value)) {
|
||||
return Offstage();
|
||||
}
|
||||
// order: personal, divider, character order
|
||||
// https://pub.dev/packages/dropdown_button2#3-dropdownbutton2-with-items-of-different-heights-like-dividers
|
||||
final personalAddressBookName = gFFI.abModel.personalAddressBookName();
|
||||
bool contains = names.remove(personalAddressBookName);
|
||||
names.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase()));
|
||||
if (contains) {
|
||||
names.insert(0, personalAddressBookName);
|
||||
}
|
||||
|
||||
Row buildItem(String e, {bool button = false}) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Tooltip(
|
||||
waitDuration: Duration(milliseconds: 500),
|
||||
message: gFFI.abModel.translatedName(e),
|
||||
child: Text(
|
||||
gFFI.abModel.translatedName(e),
|
||||
style: button ? null : TextStyle(fontSize: 14.0),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: button ? TextAlign.center : null,
|
||||
)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
final items = names
|
||||
.map((e) => DropdownMenuItem(value: e, child: buildItem(e)))
|
||||
.toList();
|
||||
var menuItemStyleData = MenuItemStyleData(height: 36);
|
||||
if (contains && items.length > 1) {
|
||||
items.insert(1, DropdownMenuItem(enabled: false, child: Divider()));
|
||||
List<double> customHeights = List.filled(items.length, 36);
|
||||
customHeights[1] = 4;
|
||||
menuItemStyleData = MenuItemStyleData(customHeights: customHeights);
|
||||
}
|
||||
final TextEditingController textEditingController = TextEditingController();
|
||||
|
||||
final isOptFixed = isOptionFixed(kOptionCurrentAbName);
|
||||
return DropdownButton2<String>(
|
||||
value: gFFI.abModel.currentName.value,
|
||||
onChanged: isOptFixed
|
||||
? null
|
||||
: (value) {
|
||||
if (value != null) {
|
||||
gFFI.abModel.setCurrentName(value);
|
||||
bind.setLocalFlutterOption(k: kOptionCurrentAbName, v: value);
|
||||
}
|
||||
},
|
||||
customButton: Obx(() => Container(
|
||||
height: stateGlobal.isPortrait.isFalse ? 48 : 40,
|
||||
child: Row(children: [
|
||||
Expanded(
|
||||
child:
|
||||
buildItem(gFFI.abModel.currentName.value, button: true)),
|
||||
Icon(Icons.arrow_drop_down),
|
||||
]),
|
||||
)),
|
||||
underline: Container(
|
||||
height: 0.7,
|
||||
color: Theme.of(context).dividerColor.withOpacity(0.1),
|
||||
),
|
||||
menuItemStyleData: menuItemStyleData,
|
||||
items: items,
|
||||
isExpanded: true,
|
||||
isDense: true,
|
||||
dropdownSearchData: DropdownSearchData(
|
||||
searchController: textEditingController,
|
||||
searchInnerWidgetHeight: 50,
|
||||
searchInnerWidget: Container(
|
||||
height: 50,
|
||||
padding: const EdgeInsets.only(
|
||||
top: 8,
|
||||
bottom: 4,
|
||||
right: 8,
|
||||
left: 8,
|
||||
),
|
||||
child: TextFormField(
|
||||
expands: true,
|
||||
maxLines: null,
|
||||
controller: textEditingController,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 8,
|
||||
),
|
||||
hintText: translate('Search'),
|
||||
hintStyle: const TextStyle(fontSize: 12),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
).workaroundFreezeLinuxMint(),
|
||||
),
|
||||
searchMatchFn: (item, searchValue) {
|
||||
return item.value
|
||||
.toString()
|
||||
.toLowerCase()
|
||||
.contains(searchValue.toLowerCase());
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTagHeader() {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(translate('Tags')),
|
||||
Listener(
|
||||
onPointerDown: (e) {
|
||||
final x = e.position.dx;
|
||||
final y = e.position.dy;
|
||||
menuPos = RelativeRect.fromLTRB(x, y, x, y);
|
||||
},
|
||||
onPointerUp: (_) => _showMenu(menuPos),
|
||||
child: build_more(context, invert: true)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTags() {
|
||||
return Obx(() {
|
||||
List tags;
|
||||
if (gFFI.abModel.sortTags.value) {
|
||||
tags = gFFI.abModel.currentAbTags.toList();
|
||||
tags.sort();
|
||||
} else {
|
||||
tags = gFFI.abModel.currentAbTags.toList();
|
||||
}
|
||||
tags = [kUntagged, ...tags].toList();
|
||||
final editPermission = gFFI.abModel.current.canWrite();
|
||||
tagBuilder(String e) {
|
||||
return AddressBookTag(
|
||||
name: e,
|
||||
tags: gFFI.abModel.selectedTags,
|
||||
onTap: () {
|
||||
if (gFFI.abModel.selectedTags.contains(e)) {
|
||||
gFFI.abModel.selectedTags.remove(e);
|
||||
} else {
|
||||
gFFI.abModel.selectedTags.add(e);
|
||||
}
|
||||
},
|
||||
showActionMenu: editPermission);
|
||||
}
|
||||
|
||||
gridView(bool isPortrait) => DynamicGridView.builder(
|
||||
shrinkWrap: isPortrait,
|
||||
gridDelegate: SliverGridDelegateWithWrapping(),
|
||||
itemCount: tags.length,
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
final e = tags[index];
|
||||
return tagBuilder(e);
|
||||
});
|
||||
final maxHeight = max(MediaQuery.of(context).size.height / 6, 100.0);
|
||||
return Obx(() => stateGlobal.isPortrait.isFalse
|
||||
? gridView(false)
|
||||
: LimitedBox(maxHeight: maxHeight, child: gridView(true)));
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildPeersViews() {
|
||||
return Expanded(
|
||||
child: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: AddressBookPeersView(
|
||||
menuPadding: widget.menuPadding,
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
@protected
|
||||
MenuEntryBase<String> syncMenuItem() {
|
||||
final isOptFixed = isOptionFixed(syncAbOption);
|
||||
return MenuEntrySwitch<String>(
|
||||
switchType: SwitchType.scheckbox,
|
||||
text: translate('Sync with recent sessions'),
|
||||
getter: () async {
|
||||
return shouldSyncAb();
|
||||
},
|
||||
setter: (bool v) async {
|
||||
gFFI.abModel.setShouldAsync(v);
|
||||
},
|
||||
dismissOnClicked: true,
|
||||
enabled: (!isOptFixed).obs,
|
||||
);
|
||||
}
|
||||
|
||||
@protected
|
||||
MenuEntryBase<String> sortMenuItem() {
|
||||
final isOptFixed = isOptionFixed(sortAbTagsOption);
|
||||
return MenuEntrySwitch<String>(
|
||||
switchType: SwitchType.scheckbox,
|
||||
text: translate('Sort tags'),
|
||||
getter: () async {
|
||||
return shouldSortTags();
|
||||
},
|
||||
setter: (bool v) async {
|
||||
bind.mainSetLocalOption(
|
||||
key: sortAbTagsOption, value: v ? 'Y' : defaultOptionNo);
|
||||
gFFI.abModel.sortTags.value = v;
|
||||
},
|
||||
dismissOnClicked: true,
|
||||
enabled: (!isOptFixed).obs,
|
||||
);
|
||||
}
|
||||
|
||||
@protected
|
||||
MenuEntryBase<String> filterMenuItem() {
|
||||
final isOptFixed = isOptionFixed(filterAbTagOption);
|
||||
return MenuEntrySwitch<String>(
|
||||
switchType: SwitchType.scheckbox,
|
||||
text: translate('Filter by intersection'),
|
||||
getter: () async {
|
||||
return filterAbTagByIntersection();
|
||||
},
|
||||
setter: (bool v) async {
|
||||
bind.mainSetLocalOption(
|
||||
key: filterAbTagOption, value: v ? 'Y' : defaultOptionNo);
|
||||
gFFI.abModel.filterByIntersection.value = v;
|
||||
},
|
||||
dismissOnClicked: true,
|
||||
enabled: (!isOptFixed).obs,
|
||||
);
|
||||
}
|
||||
|
||||
void _showMenu(RelativeRect pos) {
|
||||
final canWrite = gFFI.abModel.current.canWrite();
|
||||
final items = [
|
||||
if (canWrite) getEntry(translate("Add ID"), addIdToCurrentAb),
|
||||
if (canWrite) getEntry(translate("Add Tag"), abAddTag),
|
||||
getEntry(translate("Unselect all tags"), gFFI.abModel.unsetSelectedTags),
|
||||
if (gFFI.abModel.legacyMode.value)
|
||||
sortMenuItem(), // It's already sorted after pulling down
|
||||
if (canWrite) syncMenuItem(),
|
||||
filterMenuItem(),
|
||||
if (!gFFI.abModel.legacyMode.value && canWrite)
|
||||
MenuEntryDivider<String>(),
|
||||
if (!gFFI.abModel.legacyMode.value && canWrite)
|
||||
getEntry(translate("ab_web_console_tip"), () async {
|
||||
final url = await bind.mainGetApiServer();
|
||||
if (await canLaunchUrlString(url)) {
|
||||
launchUrlString(url);
|
||||
}
|
||||
}),
|
||||
];
|
||||
|
||||
mod_menu.showMenu(
|
||||
context: context,
|
||||
position: pos,
|
||||
items: items
|
||||
.map((e) => e.build(
|
||||
context,
|
||||
MenuConfig(
|
||||
commonColor: CustomPopupMenuTheme.commonColor,
|
||||
height: CustomPopupMenuTheme.height,
|
||||
dividerHeight: CustomPopupMenuTheme.dividerHeight)))
|
||||
.expand((i) => i)
|
||||
.toList(),
|
||||
elevation: 8,
|
||||
);
|
||||
}
|
||||
|
||||
void addIdToCurrentAb() async {
|
||||
if (gFFI.abModel.isCurrentAbFull(true)) {
|
||||
return;
|
||||
}
|
||||
var isInProgress = false;
|
||||
var passwordVisible = false;
|
||||
IDTextEditingController idController = IDTextEditingController(text: '');
|
||||
TextEditingController aliasController = TextEditingController(text: '');
|
||||
TextEditingController passwordController = TextEditingController(text: '');
|
||||
TextEditingController noteController = TextEditingController(text: '');
|
||||
final tags = List.of(gFFI.abModel.currentAbTags);
|
||||
var selectedTag = List<dynamic>.empty(growable: true).obs;
|
||||
final style = TextStyle(fontSize: 14.0);
|
||||
String? errorMsg;
|
||||
final isCurrentAbShared = !gFFI.abModel.current.isPersonal();
|
||||
|
||||
gFFI.dialogManager.show((setState, close, context) {
|
||||
submit() async {
|
||||
setState(() {
|
||||
isInProgress = true;
|
||||
errorMsg = null;
|
||||
});
|
||||
String id = idController.id;
|
||||
if (id.isEmpty) {
|
||||
// pass
|
||||
} else {
|
||||
if (gFFI.abModel.idContainByCurrent(id)) {
|
||||
setState(() {
|
||||
isInProgress = false;
|
||||
errorMsg = translate('ID already exists');
|
||||
});
|
||||
return;
|
||||
}
|
||||
var password = '';
|
||||
if (isCurrentAbShared) {
|
||||
password = passwordController.text;
|
||||
}
|
||||
String? errMsg2 = await gFFI.abModel.addIdToCurrent(
|
||||
id,
|
||||
aliasController.text.trim(),
|
||||
password,
|
||||
selectedTag,
|
||||
noteController.text);
|
||||
if (errMsg2 != null) {
|
||||
setState(() {
|
||||
isInProgress = false;
|
||||
errorMsg = errMsg2;
|
||||
});
|
||||
return;
|
||||
}
|
||||
// final currentPeers
|
||||
}
|
||||
close();
|
||||
}
|
||||
|
||||
double marginBottom = 4;
|
||||
|
||||
row({required Widget label, required Widget input}) {
|
||||
makeChild(bool isPortrait) => Row(
|
||||
children: [
|
||||
!isPortrait
|
||||
? ConstrainedBox(
|
||||
constraints: const BoxConstraints(minWidth: 100),
|
||||
child: label.marginOnly(right: 10))
|
||||
: SizedBox.shrink(),
|
||||
Expanded(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minWidth: 200),
|
||||
child: input),
|
||||
),
|
||||
],
|
||||
).marginOnly(bottom: !isPortrait ? 8 : 0);
|
||||
return Obx(() => makeChild(stateGlobal.isPortrait.isTrue));
|
||||
}
|
||||
|
||||
return CustomAlertDialog(
|
||||
title: Text(translate("Add ID")),
|
||||
content: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
row(
|
||||
label: Row(
|
||||
children: [
|
||||
Text(
|
||||
'*',
|
||||
style: TextStyle(color: Colors.red, fontSize: 14),
|
||||
),
|
||||
Text(
|
||||
'ID',
|
||||
style: style,
|
||||
),
|
||||
],
|
||||
),
|
||||
input: Obx(() => TextField(
|
||||
controller: idController,
|
||||
inputFormatters: [IDTextInputFormatter()],
|
||||
decoration: InputDecoration(
|
||||
labelText: stateGlobal.isPortrait.isFalse
|
||||
? null
|
||||
: translate('ID'),
|
||||
errorText: errorMsg,
|
||||
errorMaxLines: 5),
|
||||
).workaroundFreezeLinuxMint())),
|
||||
row(
|
||||
label: Text(
|
||||
translate('Alias'),
|
||||
style: style,
|
||||
),
|
||||
input: Obx(() => TextField(
|
||||
controller: aliasController,
|
||||
decoration: InputDecoration(
|
||||
labelText: stateGlobal.isPortrait.isFalse
|
||||
? null
|
||||
: translate('Alias'),
|
||||
),
|
||||
).workaroundFreezeLinuxMint()),
|
||||
),
|
||||
if (isCurrentAbShared)
|
||||
row(
|
||||
label: Text(
|
||||
translate('Password'),
|
||||
style: style,
|
||||
),
|
||||
input: Obx(
|
||||
() => TextField(
|
||||
controller: passwordController,
|
||||
obscureText: !passwordVisible,
|
||||
decoration: InputDecoration(
|
||||
labelText: stateGlobal.isPortrait.isFalse
|
||||
? null
|
||||
: translate('Password'),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
passwordVisible
|
||||
? Icons.visibility
|
||||
: Icons.visibility_off,
|
||||
color: MyTheme.lightTheme.primaryColor),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
passwordVisible = !passwordVisible;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
).workaroundFreezeLinuxMint(),
|
||||
)),
|
||||
row(
|
||||
label: Text(
|
||||
translate('Note'),
|
||||
style: style,
|
||||
),
|
||||
input: Obx(
|
||||
() => TextField(
|
||||
controller: noteController,
|
||||
maxLines: 3,
|
||||
minLines: 1,
|
||||
maxLength: 300,
|
||||
decoration: InputDecoration(
|
||||
labelText: stateGlobal.isPortrait.isFalse
|
||||
? null
|
||||
: translate('Note'),
|
||||
),
|
||||
).workaroundFreezeLinuxMint(),
|
||||
)),
|
||||
if (gFFI.abModel.currentAbTags.isNotEmpty)
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
translate('Tags'),
|
||||
style: style,
|
||||
),
|
||||
).marginOnly(top: 8, bottom: marginBottom),
|
||||
if (gFFI.abModel.currentAbTags.isNotEmpty)
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Wrap(
|
||||
children: tags
|
||||
.map((e) => AddressBookTag(
|
||||
name: e,
|
||||
tags: selectedTag,
|
||||
onTap: () {
|
||||
if (selectedTag.contains(e)) {
|
||||
selectedTag.remove(e);
|
||||
} else {
|
||||
selectedTag.add(e);
|
||||
}
|
||||
},
|
||||
showActionMenu: false))
|
||||
.toList(growable: false),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(
|
||||
height: 4.0,
|
||||
),
|
||||
if (!gFFI.abModel.current.isPersonal())
|
||||
Row(children: [
|
||||
Icon(Icons.info, color: Colors.amber).marginOnly(right: 4),
|
||||
Text(
|
||||
translate('share_warning_tip'),
|
||||
style: TextStyle(fontSize: 12),
|
||||
)
|
||||
]).marginSymmetric(vertical: 10),
|
||||
// NOT use Offstage to wrap LinearProgressIndicator
|
||||
if (isInProgress) const LinearProgressIndicator(),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
dialogButton("Cancel", onPressed: close, isOutline: true),
|
||||
dialogButton("OK", onPressed: submit),
|
||||
],
|
||||
onSubmit: submit,
|
||||
onCancel: close,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void abAddTag() async {
|
||||
var field = "";
|
||||
var msg = "";
|
||||
var isInProgress = false;
|
||||
TextEditingController controller = TextEditingController(text: field);
|
||||
gFFI.dialogManager.show((setState, close, context) {
|
||||
submit() async {
|
||||
setState(() {
|
||||
msg = "";
|
||||
isInProgress = true;
|
||||
});
|
||||
field = controller.text.trim();
|
||||
if (field.isEmpty) {
|
||||
// pass
|
||||
} else {
|
||||
final tags = field.trim().split(RegExp(r"[\s,;\n]+"));
|
||||
field = tags.join(',');
|
||||
for (var t in [kUntagged, translate(kUntagged)]) {
|
||||
if (tags.contains(t)) {
|
||||
BotToast.showText(
|
||||
contentColor: Colors.red, text: 'Tag name cannot be "$t"');
|
||||
isInProgress = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
gFFI.abModel.addTags(tags);
|
||||
// final currentPeers
|
||||
}
|
||||
close();
|
||||
}
|
||||
|
||||
return CustomAlertDialog(
|
||||
title: Text(translate("Add Tag")),
|
||||
content: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(translate("whitelist_sep")),
|
||||
const SizedBox(
|
||||
height: 8.0,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
maxLines: null,
|
||||
decoration: InputDecoration(
|
||||
errorText: msg.isEmpty ? null : translate(msg),
|
||||
),
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
).workaroundFreezeLinuxMint(),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(
|
||||
height: 4.0,
|
||||
),
|
||||
// NOT use Offstage to wrap LinearProgressIndicator
|
||||
if (isInProgress) const LinearProgressIndicator(),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
dialogButton("Cancel", onPressed: close, isOutline: true),
|
||||
dialogButton("OK", onPressed: submit),
|
||||
],
|
||||
onSubmit: submit,
|
||||
onCancel: close,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class AddressBookTag extends StatelessWidget {
|
||||
final String name;
|
||||
final RxList<dynamic> tags;
|
||||
final Function()? onTap;
|
||||
final bool showActionMenu;
|
||||
|
||||
const AddressBookTag(
|
||||
{Key? key,
|
||||
required this.name,
|
||||
required this.tags,
|
||||
this.onTap,
|
||||
this.showActionMenu = true})
|
||||
: super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var pos = RelativeRect.fill;
|
||||
|
||||
void setPosition(TapDownDetails e) {
|
||||
final x = e.globalPosition.dx;
|
||||
final y = e.globalPosition.dy;
|
||||
pos = RelativeRect.fromLTRB(x, y, x, y);
|
||||
}
|
||||
|
||||
const double radius = 8;
|
||||
final isUnTagged = name == kUntagged;
|
||||
final showAction = showActionMenu && !isUnTagged;
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
onTapDown: showAction ? setPosition : null,
|
||||
onSecondaryTapDown: showAction ? setPosition : null,
|
||||
onSecondaryTap: showAction ? () => _showMenu(context, pos) : null,
|
||||
onLongPress: showAction ? () => _showMenu(context, pos) : null,
|
||||
child: Obx(() => Container(
|
||||
decoration: BoxDecoration(
|
||||
color: tags.contains(name)
|
||||
? gFFI.abModel.getCurrentAbTagColor(name)
|
||||
: Theme.of(context).colorScheme.background,
|
||||
borderRadius: BorderRadius.circular(4)),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4.0, vertical: 4.0),
|
||||
padding: const EdgeInsets.symmetric(vertical: 2.0, horizontal: 6.0),
|
||||
child: IntrinsicWidth(
|
||||
child: Row(
|
||||
children: [
|
||||
if (!isUnTagged)
|
||||
Container(
|
||||
width: radius,
|
||||
height: radius,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: tags.contains(name)
|
||||
? Colors.white
|
||||
: gFFI.abModel.getCurrentAbTagColor(name)),
|
||||
).marginOnly(right: radius / 2),
|
||||
Expanded(
|
||||
child: Text(isUnTagged ? translate(name) : name,
|
||||
style: TextStyle(
|
||||
overflow: TextOverflow.ellipsis,
|
||||
color: tags.contains(name) ? Colors.white : null)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
void _showMenu(BuildContext context, RelativeRect pos) {
|
||||
final items = [
|
||||
getEntry(translate("Rename"), () {
|
||||
renameDialog(
|
||||
oldName: name,
|
||||
validator: (String? newName) {
|
||||
if (newName == null || newName.isEmpty) {
|
||||
return translate('Can not be empty');
|
||||
}
|
||||
if (newName != name &&
|
||||
gFFI.abModel.currentAbTags.contains(newName)) {
|
||||
return translate('Already exists');
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onSubmit: (String newName) {
|
||||
if (name != newName) {
|
||||
gFFI.abModel.renameTag(name, newName);
|
||||
}
|
||||
Future.delayed(Duration.zero, () => Get.back());
|
||||
},
|
||||
onCancel: () {
|
||||
Future.delayed(Duration.zero, () => Get.back());
|
||||
});
|
||||
}),
|
||||
getEntry(translate(translate('Change Color')), () async {
|
||||
final model = gFFI.abModel;
|
||||
Color oldColor = model.getCurrentAbTagColor(name);
|
||||
Color newColor = await showColorPickerDialog(
|
||||
context,
|
||||
oldColor,
|
||||
pickersEnabled: {
|
||||
ColorPickerType.accent: false,
|
||||
ColorPickerType.wheel: true,
|
||||
},
|
||||
pickerTypeLabels: {
|
||||
ColorPickerType.primary: translate("Primary Color"),
|
||||
ColorPickerType.wheel: translate("HSV Color"),
|
||||
},
|
||||
actionButtons: ColorPickerActionButtons(
|
||||
dialogOkButtonLabel: translate("OK"),
|
||||
dialogCancelButtonLabel: translate("Cancel")),
|
||||
showColorCode: true,
|
||||
);
|
||||
if (oldColor != newColor) {
|
||||
model.setTagColor(name, newColor);
|
||||
}
|
||||
}),
|
||||
getEntry(translate("Delete"), () {
|
||||
gFFI.abModel.deleteTag(name);
|
||||
Future.delayed(Duration.zero, () => Get.back());
|
||||
}),
|
||||
];
|
||||
|
||||
mod_menu.showMenu(
|
||||
context: context,
|
||||
position: pos,
|
||||
items: items
|
||||
.map((e) => e.build(
|
||||
context,
|
||||
MenuConfig(
|
||||
commonColor: CustomPopupMenuTheme.commonColor,
|
||||
height: CustomPopupMenuTheme.height,
|
||||
dividerHeight: CustomPopupMenuTheme.dividerHeight)))
|
||||
.expand((i) => i)
|
||||
.toList(),
|
||||
elevation: 8,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
MenuEntryButton<String> getEntry(String title, VoidCallback proc) {
|
||||
return MenuEntryButton<String>(
|
||||
childBuilder: (TextStyle? style) => Text(
|
||||
title,
|
||||
style: style,
|
||||
),
|
||||
proc: proc,
|
||||
dismissOnClicked: true,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
class AnimatedRotationWidget extends StatefulWidget {
|
||||
final VoidCallback onPressed;
|
||||
final ValueChanged<bool>? onHover;
|
||||
final Widget child;
|
||||
final RxBool? spinning;
|
||||
const AnimatedRotationWidget(
|
||||
{super.key,
|
||||
required this.onPressed,
|
||||
required this.child,
|
||||
this.spinning,
|
||||
this.onHover});
|
||||
|
||||
@override
|
||||
State<AnimatedRotationWidget> createState() => AnimatedRotationWidgetState();
|
||||
}
|
||||
|
||||
class AnimatedRotationWidgetState extends State<AnimatedRotationWidget> {
|
||||
double turns = 0.0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.spinning?.listen((v) {
|
||||
if (v && mounted) {
|
||||
setState(() {
|
||||
turns += 1;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedRotation(
|
||||
turns: turns,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
onEnd: () {
|
||||
if (widget.spinning?.value == true && mounted) {
|
||||
setState(() => turns += 1.0);
|
||||
}
|
||||
},
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
if (mounted) setState(() => turns += 1.0);
|
||||
widget.onPressed();
|
||||
},
|
||||
onHover: widget.onHover,
|
||||
child: widget.child));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:flutter_hbb/models/platform_model.dart';
|
||||
|
||||
const _kSystemSound = 'System Sound';
|
||||
|
||||
typedef AudioINputSetDevice = void Function(String device);
|
||||
typedef AudioInputBuilder = Widget Function(
|
||||
List<String> devices, String currentDevice, AudioINputSetDevice setDevice);
|
||||
|
||||
class AudioInput extends StatelessWidget {
|
||||
final AudioInputBuilder builder;
|
||||
final bool isCm;
|
||||
final bool isVoiceCall;
|
||||
|
||||
const AudioInput(
|
||||
{Key? key,
|
||||
required this.builder,
|
||||
required this.isCm,
|
||||
required this.isVoiceCall})
|
||||
: super(key: key);
|
||||
|
||||
static String getDefault() {
|
||||
if (bind.mainAudioSupportLoopback()) return translate(_kSystemSound);
|
||||
return '';
|
||||
}
|
||||
|
||||
static Future<String> getAudioInput(bool isCm, bool isVoiceCall) {
|
||||
if (isVoiceCall) {
|
||||
return bind.getVoiceCallInputDevice(isCm: isCm);
|
||||
} else {
|
||||
return bind.mainGetOption(key: 'audio-input');
|
||||
}
|
||||
}
|
||||
|
||||
static Future<String> getValue(bool isCm, bool isVoiceCall) async {
|
||||
String device = await getAudioInput(isCm, isVoiceCall);
|
||||
if (device.isNotEmpty) {
|
||||
return device;
|
||||
} else {
|
||||
return getDefault();
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> setDevice(
|
||||
String device, bool isCm, bool isVoiceCall) async {
|
||||
if (device == getDefault()) device = '';
|
||||
if (isVoiceCall) {
|
||||
await bind.setVoiceCallInputDevice(isCm: isCm, device: device);
|
||||
} else {
|
||||
await bind.mainSetOption(key: 'audio-input', value: device);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<Map<String, Object>> getDevicesInfo(
|
||||
bool isCm, bool isVoiceCall) async {
|
||||
List<String> devices = (await bind.mainGetSoundInputs()).toList();
|
||||
if (bind.mainAudioSupportLoopback()) {
|
||||
devices.insert(0, translate(_kSystemSound));
|
||||
}
|
||||
String current = await getValue(isCm, isVoiceCall);
|
||||
return {'devices': devices, 'current': current};
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return futureBuilder(
|
||||
future: getDevicesInfo(isCm, isVoiceCall),
|
||||
hasData: (data) {
|
||||
String currentDevice = data['current'];
|
||||
List<String> devices = data['devices'] as List<String>;
|
||||
if (devices.isEmpty) {
|
||||
return const Offstage();
|
||||
}
|
||||
return builder(devices, currentDevice, (devices) {
|
||||
setDevice(devices, isCm, isVoiceCall);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hbb/common/formatter/id_formatter.dart';
|
||||
import '../../../models/platform_model.dart';
|
||||
import 'package:flutter_hbb/models/peer_model.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:flutter_hbb/common/widgets/peer_card.dart';
|
||||
|
||||
class AllPeersLoader {
|
||||
List<Peer> peers = [];
|
||||
|
||||
bool _isPeersLoading = false;
|
||||
bool _isPeersLoaded = false;
|
||||
|
||||
final String _listenerKey = 'AllPeersLoader';
|
||||
|
||||
late void Function(VoidCallback) setState;
|
||||
|
||||
bool get needLoad => !_isPeersLoaded && !_isPeersLoading;
|
||||
bool get isPeersLoaded => _isPeersLoaded;
|
||||
|
||||
AllPeersLoader();
|
||||
|
||||
void init(void Function(VoidCallback) setState) {
|
||||
this.setState = setState;
|
||||
gFFI.recentPeersModel.addListener(_mergeAllPeers);
|
||||
gFFI.lanPeersModel.addListener(_mergeAllPeers);
|
||||
gFFI.abModel.addPeerUpdateListener(_listenerKey, _mergeAllPeers);
|
||||
gFFI.groupModel.addPeerUpdateListener(_listenerKey, _mergeAllPeers);
|
||||
}
|
||||
|
||||
void clear() {
|
||||
gFFI.recentPeersModel.removeListener(_mergeAllPeers);
|
||||
gFFI.lanPeersModel.removeListener(_mergeAllPeers);
|
||||
gFFI.abModel.removePeerUpdateListener(_listenerKey);
|
||||
gFFI.groupModel.removePeerUpdateListener(_listenerKey);
|
||||
}
|
||||
|
||||
Future<void> getAllPeers() async {
|
||||
if (!needLoad) {
|
||||
return;
|
||||
}
|
||||
_isPeersLoading = true;
|
||||
|
||||
if (gFFI.recentPeersModel.peers.isEmpty) {
|
||||
bind.mainLoadRecentPeers();
|
||||
}
|
||||
if (gFFI.lanPeersModel.peers.isEmpty) {
|
||||
bind.mainLoadLanPeers();
|
||||
}
|
||||
// No need to care about peers from abModel, and group model.
|
||||
// Because they will pull data in `refreshCurrentUser()` on startup.
|
||||
|
||||
final startTime = DateTime.now();
|
||||
_mergeAllPeers();
|
||||
final diffTime = DateTime.now().difference(startTime).inMilliseconds;
|
||||
if (diffTime < 100) {
|
||||
await Future.delayed(Duration(milliseconds: diffTime));
|
||||
}
|
||||
}
|
||||
|
||||
void _mergeAllPeers() {
|
||||
Map<String, dynamic> combinedPeers = {};
|
||||
for (var p in gFFI.abModel.allPeers()) {
|
||||
if (!combinedPeers.containsKey(p.id)) {
|
||||
combinedPeers[p.id] = p.toJson();
|
||||
}
|
||||
}
|
||||
for (var p in gFFI.groupModel.peers.map((e) => Peer.copy(e)).toList()) {
|
||||
if (!combinedPeers.containsKey(p.id)) {
|
||||
combinedPeers[p.id] = p.toJson();
|
||||
}
|
||||
}
|
||||
|
||||
List<Peer> parsedPeers = [];
|
||||
for (var peer in combinedPeers.values) {
|
||||
parsedPeers.add(Peer.fromJson(peer));
|
||||
}
|
||||
|
||||
Set<String> peerIds = combinedPeers.keys.toSet();
|
||||
for (final peer in gFFI.lanPeersModel.peers) {
|
||||
if (!peerIds.contains(peer.id)) {
|
||||
parsedPeers.add(peer);
|
||||
peerIds.add(peer.id);
|
||||
}
|
||||
}
|
||||
|
||||
for (final peer in gFFI.recentPeersModel.peers) {
|
||||
if (!peerIds.contains(peer.id)) {
|
||||
parsedPeers.add(peer);
|
||||
peerIds.add(peer.id);
|
||||
}
|
||||
}
|
||||
for (final id in gFFI.recentPeersModel.restPeerIds) {
|
||||
if (!peerIds.contains(id)) {
|
||||
parsedPeers.add(Peer.fromJson({'id': id}));
|
||||
peerIds.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
peers = parsedPeers;
|
||||
setState(() {
|
||||
_isPeersLoading = false;
|
||||
_isPeersLoaded = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class AutocompletePeerTile extends StatefulWidget {
|
||||
final VoidCallback onSelect;
|
||||
final Peer peer;
|
||||
|
||||
const AutocompletePeerTile({
|
||||
Key? key,
|
||||
required this.onSelect,
|
||||
required this.peer,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
AutocompletePeerTileState createState() => AutocompletePeerTileState();
|
||||
}
|
||||
|
||||
class AutocompletePeerTileState extends State<AutocompletePeerTile> {
|
||||
List _frontN<T>(List list, int n) {
|
||||
if (list.length <= n) {
|
||||
return list;
|
||||
} else {
|
||||
return list.sublist(0, n);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final double tileRadius = 5;
|
||||
final name =
|
||||
'${widget.peer.username}${widget.peer.username.isNotEmpty && widget.peer.hostname.isNotEmpty ? '@' : ''}${widget.peer.hostname}';
|
||||
final greyStyle = TextStyle(
|
||||
fontSize: 11,
|
||||
color: Theme.of(context).textTheme.titleLarge?.color?.withOpacity(0.6));
|
||||
final child = GestureDetector(
|
||||
onTap: () => widget.onSelect(),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(left: 5, right: 5),
|
||||
child: Container(
|
||||
height: 42,
|
||||
margin: EdgeInsets.only(bottom: 5),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: str2color(
|
||||
'${widget.peer.id}${widget.peer.platform}', 0x7f),
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(tileRadius),
|
||||
bottomLeft: Radius.circular(tileRadius),
|
||||
),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
width: 42,
|
||||
height: null,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(6),
|
||||
child: getPlatformImage(widget.peer.platform,
|
||||
size: 30))),
|
||||
Expanded(
|
||||
child: Container(
|
||||
padding: EdgeInsets.only(left: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.background,
|
||||
borderRadius: BorderRadius.only(
|
||||
topRight: Radius.circular(tileRadius),
|
||||
bottomRight: Radius.circular(tileRadius),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
margin: EdgeInsets.only(top: 2),
|
||||
child: Container(
|
||||
margin: EdgeInsets.only(top: 2),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
margin:
|
||||
EdgeInsets.only(top: 2),
|
||||
child: Row(children: [
|
||||
getOnline(
|
||||
8, widget.peer.online),
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.peer.alias.isEmpty
|
||||
? formatID(
|
||||
widget.peer.id)
|
||||
: widget.peer.alias,
|
||||
overflow:
|
||||
TextOverflow.ellipsis,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleSmall,
|
||||
)),
|
||||
widget.peer.alias.isNotEmpty
|
||||
? Padding(
|
||||
padding:
|
||||
const EdgeInsets
|
||||
.only(
|
||||
left: 5,
|
||||
right: 5),
|
||||
child: Text(
|
||||
"(${widget.peer.id})",
|
||||
style: greyStyle,
|
||||
overflow:
|
||||
TextOverflow
|
||||
.ellipsis,
|
||||
))
|
||||
: Container(),
|
||||
])),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
name,
|
||||
style: greyStyle,
|
||||
textAlign: TextAlign.start,
|
||||
overflow:
|
||||
TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
)))),
|
||||
],
|
||||
)),
|
||||
)
|
||||
],
|
||||
))));
|
||||
final colors = _frontN(widget.peer.tags, 25)
|
||||
.map((e) => gFFI.abModel.getCurrentAbTagColor(e))
|
||||
.toList();
|
||||
return Tooltip(
|
||||
message: !(isDesktop || isWebDesktop)
|
||||
? ''
|
||||
: widget.peer.tags.isNotEmpty
|
||||
? '${translate('Tags')}: ${widget.peer.tags.join(', ')}'
|
||||
: '',
|
||||
child: Stack(children: [
|
||||
child,
|
||||
if (colors.isNotEmpty)
|
||||
Positioned(
|
||||
top: 5,
|
||||
right: 10,
|
||||
child: CustomPaint(
|
||||
painter: TagPainter(radius: 3, colors: colors),
|
||||
),
|
||||
)
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import 'package:dash_chat_2/dash_chat_2.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:flutter_hbb/models/chat_model.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../mobile/pages/home_page.dart';
|
||||
|
||||
enum ChatPageType {
|
||||
mobileMain,
|
||||
desktopCM,
|
||||
}
|
||||
|
||||
class ChatPage extends StatelessWidget implements PageShape {
|
||||
late final ChatModel chatModel;
|
||||
final ChatPageType? type;
|
||||
|
||||
ChatPage({ChatModel? chatModel, this.type}) {
|
||||
this.chatModel = chatModel ?? gFFI.chatModel;
|
||||
}
|
||||
|
||||
@override
|
||||
final title = translate("Chat");
|
||||
|
||||
@override
|
||||
final icon = unreadTopRightBuilder(gFFI.chatModel.mobileUnreadSum);
|
||||
|
||||
@override
|
||||
final appBarActions = [
|
||||
PopupMenuButton<MessageKey>(
|
||||
tooltip: "",
|
||||
icon: unreadTopRightBuilder(gFFI.chatModel.mobileUnreadSum,
|
||||
icon: Icon(Icons.group)),
|
||||
itemBuilder: (context) {
|
||||
// only mobile need [appBarActions], just bind gFFI.chatModel
|
||||
final chatModel = gFFI.chatModel;
|
||||
return chatModel.messages.entries.map((entry) {
|
||||
final key = entry.key;
|
||||
final user = entry.value.chatUser;
|
||||
final client = gFFI.serverModel.clients
|
||||
.firstWhereOrNull((e) => e.id == key.connId);
|
||||
final connected =
|
||||
gFFI.serverModel.clients.any((e) => e.id == key.connId);
|
||||
return PopupMenuItem<MessageKey>(
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
key.isOut
|
||||
? Icons.call_made_rounded
|
||||
: Icons.call_received_rounded,
|
||||
color: MyTheme.accent)
|
||||
.marginOnly(right: 6),
|
||||
Text("${user.firstName} ${user.id}"),
|
||||
if (connected)
|
||||
Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Color.fromARGB(255, 46, 205, 139)),
|
||||
).marginSymmetric(horizontal: 2),
|
||||
if (client != null)
|
||||
unreadMessageCountBuilder(client.unreadChatMessageCount)
|
||||
.marginOnly(left: 4)
|
||||
],
|
||||
),
|
||||
value: key,
|
||||
);
|
||||
}).toList();
|
||||
},
|
||||
onSelected: (key) {
|
||||
gFFI.chatModel.changeCurrentKey(key);
|
||||
})
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ChangeNotifierProvider.value(
|
||||
value: chatModel,
|
||||
child: Container(
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
child: Consumer<ChatModel>(
|
||||
builder: (context, chatModel, child) {
|
||||
final readOnly = type == ChatPageType.mobileMain &&
|
||||
(chatModel.currentKey.connId == ChatModel.clientModeID ||
|
||||
gFFI.serverModel.clients.every((e) =>
|
||||
e.id != chatModel.currentKey.connId ||
|
||||
chatModel.currentUser == null)) ||
|
||||
type == ChatPageType.desktopCM &&
|
||||
gFFI.serverModel.clients
|
||||
.firstWhereOrNull(
|
||||
(e) => e.id == chatModel.currentKey.connId)
|
||||
?.disconnected ==
|
||||
true;
|
||||
return Stack(
|
||||
children: [
|
||||
LayoutBuilder(builder: (context, constraints) {
|
||||
final chat = DashChat(
|
||||
onSend: chatModel.send,
|
||||
currentUser: chatModel.me,
|
||||
messages: chatModel
|
||||
.messages[chatModel.currentKey]?.chatMessages ??
|
||||
[],
|
||||
readOnly: readOnly,
|
||||
inputOptions: InputOptions(
|
||||
focusNode: chatModel.inputNode,
|
||||
textController: chatModel.textController,
|
||||
inputTextStyle: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Theme.of(context).textTheme.titleLarge?.color),
|
||||
inputDecoration: InputDecoration(
|
||||
isDense: true,
|
||||
hintText: translate('Write a message'),
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).colorScheme.background,
|
||||
contentPadding: EdgeInsets.all(10),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10.0),
|
||||
borderSide: const BorderSide(
|
||||
width: 1,
|
||||
style: BorderStyle.solid,
|
||||
),
|
||||
),
|
||||
),
|
||||
sendButtonBuilder: defaultSendButton(
|
||||
padding:
|
||||
EdgeInsets.symmetric(horizontal: 6, vertical: 0),
|
||||
color: MyTheme.accent,
|
||||
icon: Icons.send_rounded,
|
||||
),
|
||||
),
|
||||
messageOptions: MessageOptions(
|
||||
showOtherUsersAvatar: false,
|
||||
showOtherUsersName: false,
|
||||
textColor: Colors.white,
|
||||
maxWidth: constraints.maxWidth * 0.7,
|
||||
messageTextBuilder: (message, _, __) {
|
||||
final isOwnMessage = message.user.id.isBlank!;
|
||||
return Column(
|
||||
crossAxisAlignment: isOwnMessage
|
||||
? CrossAxisAlignment.end
|
||||
: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(message.text,
|
||||
style: TextStyle(color: Colors.white)),
|
||||
Text(
|
||||
"${message.createdAt.hour}:${message.createdAt.minute.toString().padLeft(2, '0')}",
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 8,
|
||||
),
|
||||
).marginOnly(top: 3),
|
||||
],
|
||||
);
|
||||
},
|
||||
messageDecorationBuilder:
|
||||
(message, previousMessage, nextMessage) {
|
||||
final isOwnMessage = message.user.id.isBlank!;
|
||||
return defaultMessageDecoration(
|
||||
color:
|
||||
isOwnMessage ? MyTheme.accent : Colors.blueGrey,
|
||||
borderTopLeft: 8,
|
||||
borderTopRight: 8,
|
||||
borderBottomRight: isOwnMessage ? 2 : 8,
|
||||
borderBottomLeft: isOwnMessage ? 8 : 2,
|
||||
);
|
||||
},
|
||||
),
|
||||
).workaroundFreezeLinuxMint();
|
||||
return SelectionArea(child: chat);
|
||||
}),
|
||||
],
|
||||
).paddingOnly(bottom: 8);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'package:auto_size_text/auto_size_text.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import '../../common.dart';
|
||||
|
||||
Widget getConnectionPageTitle(BuildContext context, bool isWeb) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
AutoSizeText(
|
||||
translate('Control Remote Desktop'),
|
||||
maxLines: 1,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleLarge
|
||||
?.merge(TextStyle(height: 1)),
|
||||
).marginOnly(right: 4),
|
||||
Tooltip(
|
||||
waitDuration: Duration(milliseconds: 300),
|
||||
message: translate(isWeb ? "web_id_input_tip" : "id_input_tip"),
|
||||
child: Icon(
|
||||
Icons.help_outline_outlined,
|
||||
size: 16,
|
||||
color: Theme.of(context)
|
||||
.textTheme
|
||||
.titleLarge
|
||||
?.color
|
||||
?.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
],
|
||||
)),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// https://github.com/rodrigobastosv/fancy_password_field
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:password_strength/password_strength.dart';
|
||||
|
||||
abstract class ValidationRule {
|
||||
String get name;
|
||||
bool validate(String value);
|
||||
}
|
||||
|
||||
class UppercaseValidationRule extends ValidationRule {
|
||||
@override
|
||||
String get name => translate('uppercase');
|
||||
@override
|
||||
bool validate(String value) {
|
||||
return value.runes.any((int rune) {
|
||||
var character = String.fromCharCode(rune);
|
||||
return character.toUpperCase() == character &&
|
||||
character.toLowerCase() != character;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class LowercaseValidationRule extends ValidationRule {
|
||||
@override
|
||||
String get name => translate('lowercase');
|
||||
|
||||
@override
|
||||
bool validate(String value) {
|
||||
return value.runes.any((int rune) {
|
||||
var character = String.fromCharCode(rune);
|
||||
return character.toLowerCase() == character &&
|
||||
character.toUpperCase() != character;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class DigitValidationRule extends ValidationRule {
|
||||
@override
|
||||
String get name => translate('digit');
|
||||
|
||||
@override
|
||||
bool validate(String value) {
|
||||
return value.contains(RegExp(r'[0-9]'));
|
||||
}
|
||||
}
|
||||
|
||||
class SpecialCharacterValidationRule extends ValidationRule {
|
||||
@override
|
||||
String get name => translate('special character');
|
||||
|
||||
@override
|
||||
bool validate(String value) {
|
||||
return value.contains(RegExp(r'[!@#$%^&*(),.?":{}|<>]'));
|
||||
}
|
||||
}
|
||||
|
||||
class MinCharactersValidationRule extends ValidationRule {
|
||||
final int _numberOfCharacters;
|
||||
MinCharactersValidationRule(this._numberOfCharacters);
|
||||
|
||||
@override
|
||||
String get name => translate('length>=$_numberOfCharacters');
|
||||
|
||||
@override
|
||||
bool validate(String value) {
|
||||
return value.length >= _numberOfCharacters;
|
||||
}
|
||||
}
|
||||
|
||||
class PasswordStrengthIndicator extends StatelessWidget {
|
||||
final RxString password;
|
||||
final double weakMedium = 0.33;
|
||||
final double mediumStrong = 0.67;
|
||||
const PasswordStrengthIndicator({Key? key, required this.password})
|
||||
: super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Obx(() {
|
||||
var strength = estimatePasswordStrength(password.value);
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _indicator(
|
||||
password.isEmpty ? Colors.grey : _getColor(strength))),
|
||||
Expanded(
|
||||
child: _indicator(password.isEmpty || strength < weakMedium
|
||||
? Colors.grey
|
||||
: _getColor(strength))),
|
||||
Expanded(
|
||||
child: _indicator(password.isEmpty || strength < mediumStrong
|
||||
? Colors.grey
|
||||
: _getColor(strength))),
|
||||
Text(password.isEmpty ? '' : translate(_getLabel(strength)))
|
||||
.marginOnly(left: password.isEmpty ? 0 : 8),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Widget _indicator(Color color) {
|
||||
return Container(
|
||||
height: 8,
|
||||
color: color,
|
||||
);
|
||||
}
|
||||
|
||||
String _getLabel(double strength) {
|
||||
if (strength < weakMedium) {
|
||||
return 'Weak';
|
||||
} else if (strength < mediumStrong) {
|
||||
return 'Medium';
|
||||
} else {
|
||||
return 'Strong';
|
||||
}
|
||||
}
|
||||
|
||||
Color _getColor(double strength) {
|
||||
if (strength < weakMedium) {
|
||||
return Colors.yellow;
|
||||
} else if (strength < mediumStrong) {
|
||||
return Colors.blue;
|
||||
} else {
|
||||
return Colors.green;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:debounce_throttle/debounce_throttle.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/models/model.dart';
|
||||
import 'package:flutter_hbb/models/platform_model.dart';
|
||||
import 'package:flutter_hbb/utils/scale.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
|
||||
/// Base class providing shared custom scale control logic for both mobile and desktop widgets.
|
||||
/// Implementations must provide [ffi] and [onScaleChanged] getters.
|
||||
abstract class CustomScaleControls<T extends StatefulWidget> extends State<T> {
|
||||
/// FFI instance for session interaction
|
||||
FFI get ffi;
|
||||
|
||||
/// Callback invoked when scale value changes
|
||||
ValueChanged<int>? get onScaleChanged;
|
||||
|
||||
late int _scaleValue;
|
||||
late final Debouncer<int> _debouncerScale;
|
||||
// Normalized slider position in [0, 1]. We map it nonlinearly to percent.
|
||||
double _scalePos = 0.0;
|
||||
|
||||
int get scaleValue => _scaleValue;
|
||||
double get scalePos => _scalePos;
|
||||
|
||||
int mapPosToPercent(double p) => _mapPosToPercent(p);
|
||||
|
||||
static const int minPercent = kScaleCustomMinPercent;
|
||||
static const int pivotPercent = kScaleCustomPivotPercent; // 100% should be at 1/3 of track
|
||||
static const int maxPercent = kScaleCustomMaxPercent;
|
||||
static const double pivotPos = kScaleCustomPivotPos; // first 1/3 → up to 100%
|
||||
static const double detentEpsilon = kScaleCustomDetentEpsilon; // snap range around pivot (~0.6%)
|
||||
|
||||
// Clamp helper for local use
|
||||
int _clampScale(int v) => clampCustomScalePercent(v);
|
||||
|
||||
// Map normalized position [0,1] → percent [5,1000] with 100 at 1/3 width.
|
||||
int _mapPosToPercent(double p) {
|
||||
if (p <= 0.0) return minPercent;
|
||||
if (p >= 1.0) return maxPercent;
|
||||
if (p <= pivotPos) {
|
||||
final q = p / pivotPos; // 0..1
|
||||
final v = minPercent + q * (pivotPercent - minPercent);
|
||||
return _clampScale(v.round());
|
||||
} else {
|
||||
final q = (p - pivotPos) / (1.0 - pivotPos); // 0..1
|
||||
final v = pivotPercent + q * (maxPercent - pivotPercent);
|
||||
return _clampScale(v.round());
|
||||
}
|
||||
}
|
||||
|
||||
// Map percent [5,1000] → normalized position [0,1]
|
||||
double _mapPercentToPos(int percent) {
|
||||
final p = _clampScale(percent);
|
||||
if (p <= pivotPercent) {
|
||||
final q = (p - minPercent) / (pivotPercent - minPercent);
|
||||
return q * pivotPos;
|
||||
} else {
|
||||
final q = (p - pivotPercent) / (maxPercent - pivotPercent);
|
||||
return pivotPos + q * (1.0 - pivotPos);
|
||||
}
|
||||
}
|
||||
|
||||
// Snap normalized position to the pivot when close to it
|
||||
double _snapNormalizedPos(double p) {
|
||||
if ((p - pivotPos).abs() <= detentEpsilon) return pivotPos;
|
||||
if (p < 0.0) return 0.0;
|
||||
if (p > 1.0) return 1.0;
|
||||
return p;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scaleValue = 100;
|
||||
_debouncerScale = Debouncer<int>(
|
||||
kDebounceCustomScaleDuration,
|
||||
onChanged: (v) async {
|
||||
await _applyScale(v);
|
||||
},
|
||||
initialValue: _scaleValue,
|
||||
);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
try {
|
||||
final v = await getSessionCustomScalePercent(ffi.sessionId);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_scaleValue = v;
|
||||
_scalePos = _mapPercentToPos(v);
|
||||
});
|
||||
}
|
||||
} catch (e, st) {
|
||||
debugPrint('[CustomScale] Failed to get initial value: $e');
|
||||
debugPrintStack(stackTrace: st);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _applyScale(int v) async {
|
||||
v = clampCustomScalePercent(v);
|
||||
setState(() {
|
||||
_scaleValue = v;
|
||||
});
|
||||
try {
|
||||
await bind.sessionSetFlutterOption(
|
||||
sessionId: ffi.sessionId,
|
||||
k: kCustomScalePercentKey,
|
||||
v: v.toString());
|
||||
final curStyle = await bind.sessionGetViewStyle(sessionId: ffi.sessionId);
|
||||
if (curStyle != kRemoteViewStyleCustom) {
|
||||
await bind.sessionSetViewStyle(
|
||||
sessionId: ffi.sessionId, value: kRemoteViewStyleCustom);
|
||||
}
|
||||
await ffi.canvasModel.updateViewStyle();
|
||||
if (isMobile) {
|
||||
HapticFeedback.selectionClick();
|
||||
}
|
||||
onScaleChanged?.call(v);
|
||||
} catch (e, st) {
|
||||
debugPrint('[CustomScale] Apply failed: $e');
|
||||
debugPrintStack(stackTrace: st);
|
||||
}
|
||||
}
|
||||
|
||||
void nudgeScale(int delta) {
|
||||
final next = _clampScale(_scaleValue + delta);
|
||||
setState(() {
|
||||
_scaleValue = next;
|
||||
_scalePos = _mapPercentToPos(next);
|
||||
});
|
||||
onScaleChanged?.call(next);
|
||||
_debouncerScale.value = next;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_debouncerScale.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void onSliderChanged(double v) {
|
||||
final snapped = _snapNormalizedPos(v);
|
||||
final next = _mapPosToPercent(snapped);
|
||||
if (next != _scaleValue || snapped != _scalePos) {
|
||||
setState(() {
|
||||
_scalePos = snapped;
|
||||
_scaleValue = next;
|
||||
});
|
||||
onScaleChanged?.call(next);
|
||||
_debouncerScale.value = next;
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,797 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_hbb/common/widgets/remote_input.dart';
|
||||
|
||||
enum GestureState {
|
||||
none,
|
||||
oneFingerPan,
|
||||
twoFingerScale,
|
||||
threeFingerVerticalDrag
|
||||
}
|
||||
|
||||
class CustomTouchGestureRecognizer extends ScaleGestureRecognizer {
|
||||
CustomTouchGestureRecognizer({
|
||||
Object? debugOwner,
|
||||
Set<PointerDeviceKind>? supportedDevices,
|
||||
}) : super(
|
||||
debugOwner: debugOwner,
|
||||
supportedDevices: supportedDevices,
|
||||
) {
|
||||
_init();
|
||||
}
|
||||
|
||||
// oneFingerPan
|
||||
GestureDragStartCallback? onOneFingerPanStart;
|
||||
GestureDragUpdateCallback? onOneFingerPanUpdate;
|
||||
GestureDragEndCallback? onOneFingerPanEnd;
|
||||
GestureDragCancelCallback? onOneFingerPanCancel;
|
||||
|
||||
// twoFingerScale : scale + pan event
|
||||
GestureScaleStartCallback? onTwoFingerScaleStart;
|
||||
GestureScaleUpdateCallback? onTwoFingerScaleUpdate;
|
||||
GestureScaleEndCallback? onTwoFingerScaleEnd;
|
||||
|
||||
// threeFingerVerticalDrag
|
||||
GestureDragStartCallback? onThreeFingerVerticalDragStart;
|
||||
GestureDragUpdateCallback? onThreeFingerVerticalDragUpdate;
|
||||
GestureDragEndCallback? onThreeFingerVerticalDragEnd;
|
||||
|
||||
var _currentState = GestureState.none;
|
||||
Timer? _debounceTimer;
|
||||
|
||||
void _init() {
|
||||
debugPrint("CustomTouchGestureRecognizer init");
|
||||
// onStart = (d) {};
|
||||
onUpdate = (d) {
|
||||
_debounceTimer?.cancel();
|
||||
if (d.pointerCount == 1 && _currentState != GestureState.oneFingerPan) {
|
||||
onOneFingerStartDebounce(d);
|
||||
} else if (d.pointerCount == 2 &&
|
||||
_currentState != GestureState.twoFingerScale) {
|
||||
onTwoFingerStartDebounce(d);
|
||||
} else if (d.pointerCount == 3 &&
|
||||
_currentState != GestureState.threeFingerVerticalDrag) {
|
||||
_currentState = GestureState.threeFingerVerticalDrag;
|
||||
if (onThreeFingerVerticalDragStart != null) {
|
||||
onThreeFingerVerticalDragStart!(
|
||||
DragStartDetails(globalPosition: d.localFocalPoint));
|
||||
}
|
||||
debugPrint("start threeFingerScale");
|
||||
}
|
||||
if (_currentState != GestureState.none) {
|
||||
switch (_currentState) {
|
||||
case GestureState.oneFingerPan:
|
||||
if (onOneFingerPanUpdate != null) {
|
||||
onOneFingerPanUpdate!(_getDragUpdateDetails(d));
|
||||
}
|
||||
break;
|
||||
case GestureState.twoFingerScale:
|
||||
if (onTwoFingerScaleUpdate != null) {
|
||||
onTwoFingerScaleUpdate!(d);
|
||||
}
|
||||
break;
|
||||
case GestureState.threeFingerVerticalDrag:
|
||||
if (onThreeFingerVerticalDragUpdate != null) {
|
||||
onThreeFingerVerticalDragUpdate!(_getDragUpdateDetails(d));
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
onEnd = (d) {
|
||||
debugPrint("ScaleGestureRecognizer onEnd");
|
||||
_debounceTimer?.cancel();
|
||||
// end
|
||||
switch (_currentState) {
|
||||
case GestureState.oneFingerPan:
|
||||
debugPrint("OneFingerState.pan onEnd");
|
||||
if (onOneFingerPanEnd != null) {
|
||||
onOneFingerPanEnd!(_getDragEndDetails(d));
|
||||
}
|
||||
break;
|
||||
case GestureState.twoFingerScale:
|
||||
debugPrint("TwoFingerState.scale onEnd");
|
||||
if (onTwoFingerScaleEnd != null) {
|
||||
onTwoFingerScaleEnd!(d);
|
||||
}
|
||||
if (isSpecialHoldDragActive) {
|
||||
// If we are in special drag mode, we need to reset the state.
|
||||
// Otherwise, the next `onTwoFingerScaleUpdate()` will handle a wrong `focalPoint`.
|
||||
_currentState = GestureState.none;
|
||||
return;
|
||||
}
|
||||
break;
|
||||
case GestureState.threeFingerVerticalDrag:
|
||||
debugPrint("ThreeFingerState.vertical onEnd");
|
||||
if (onThreeFingerVerticalDragEnd != null) {
|
||||
onThreeFingerVerticalDragEnd!(_getDragEndDetails(d));
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
_debounceTimer = Timer(Duration(milliseconds: 200), () {
|
||||
_currentState = GestureState.none;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// FIXME: This debounce logic is not working properly.
|
||||
// If we move our finger very fast, we won't be able to detect the "oneFingerPan" event sometimes.
|
||||
void onOneFingerStartDebounce(ScaleUpdateDetails d) {
|
||||
start(ScaleUpdateDetails d) {
|
||||
_currentState = GestureState.oneFingerPan;
|
||||
if (onOneFingerPanStart != null) {
|
||||
onOneFingerPanStart!(DragStartDetails(
|
||||
localPosition: d.localFocalPoint, globalPosition: d.focalPoint));
|
||||
}
|
||||
}
|
||||
|
||||
if (_currentState != GestureState.none) {
|
||||
_debounceTimer = Timer(Duration(milliseconds: 200), () {
|
||||
start(d);
|
||||
debugPrint("debounce start oneFingerPan");
|
||||
});
|
||||
} else {
|
||||
start(d);
|
||||
debugPrint("start oneFingerPan");
|
||||
}
|
||||
}
|
||||
|
||||
void onTwoFingerStartDebounce(ScaleUpdateDetails d) {
|
||||
start(ScaleUpdateDetails d) {
|
||||
_currentState = GestureState.twoFingerScale;
|
||||
if (onTwoFingerScaleStart != null) {
|
||||
onTwoFingerScaleStart!(ScaleStartDetails(
|
||||
localFocalPoint: d.localFocalPoint, focalPoint: d.focalPoint));
|
||||
}
|
||||
}
|
||||
|
||||
if (_currentState == GestureState.threeFingerVerticalDrag) {
|
||||
_debounceTimer = Timer(Duration(milliseconds: 200), () {
|
||||
start(d);
|
||||
debugPrint("debounce start twoFingerScale");
|
||||
});
|
||||
} else {
|
||||
start(d);
|
||||
debugPrint("start twoFingerScale");
|
||||
}
|
||||
}
|
||||
|
||||
DragUpdateDetails _getDragUpdateDetails(ScaleUpdateDetails d) =>
|
||||
DragUpdateDetails(
|
||||
globalPosition: d.focalPoint,
|
||||
localPosition: d.localFocalPoint,
|
||||
delta: d.focalPointDelta);
|
||||
|
||||
DragEndDetails _getDragEndDetails(ScaleEndDetails d) =>
|
||||
DragEndDetails(velocity: d.velocity);
|
||||
|
||||
@override
|
||||
void rejectGesture(int pointer) {
|
||||
super.rejectGesture(pointer);
|
||||
switch (_currentState) {
|
||||
case GestureState.oneFingerPan:
|
||||
if (onOneFingerPanCancel != null) {
|
||||
onOneFingerPanCancel!();
|
||||
}
|
||||
break;
|
||||
case GestureState.twoFingerScale:
|
||||
// Reset scale state if needed, currently self-contained
|
||||
break;
|
||||
case GestureState.threeFingerVerticalDrag:
|
||||
// Reset drag state if needed, currently self-contained
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
_currentState = GestureState.none;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
PointerDownEvent? _lastPointerDownEvent;
|
||||
|
||||
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;
|
||||
_lastPointerDownEvent = event;
|
||||
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());
|
||||
_secondTap = null;
|
||||
_isStart = false;
|
||||
}
|
||||
}
|
||||
} 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(
|
||||
kind: _lastPointerDownEvent?.kind,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
void _reject(_TapTracker tracker) {
|
||||
try {
|
||||
_checkCancel();
|
||||
_isStart = false;
|
||||
_trackers.remove(tracker.pointer);
|
||||
tracker.entry.resolve(GestureDisposition.rejected);
|
||||
_freezeTracker(tracker);
|
||||
_reset();
|
||||
} catch (e) {
|
||||
debugPrint("Failed to _reject:$e");
|
||||
}
|
||||
}
|
||||
|
||||
@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;
|
||||
|
||||
PointerDownEvent? _lastPointerDownEvent;
|
||||
|
||||
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;
|
||||
_lastPointerDownEvent = event;
|
||||
_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(
|
||||
kind: _lastPointerDownEvent?.kind,
|
||||
));
|
||||
}
|
||||
_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,
|
||||
}) : 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}) {
|
||||
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,
|
||||
GestureDragCancelCallback? onOneFingerPanCancel,
|
||||
GestureScaleUpdateCallback? onTwoFingerScaleUpdate,
|
||||
GestureScaleEndCallback? onTwoFingerScaleEnd,
|
||||
GestureDragUpdateCallback? onThreeFingerVerticalDragUpdate,
|
||||
}) {
|
||||
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
|
||||
..onOneFingerPanCancel = onOneFingerPanCancel
|
||||
..onTwoFingerScaleUpdate = onTwoFingerScaleUpdate
|
||||
..onTwoFingerScaleEnd = onTwoFingerScaleEnd
|
||||
..onThreeFingerVerticalDragUpdate = onThreeFingerVerticalDragUpdate;
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,790 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hbb/common/hbbs/hbbs.dart';
|
||||
import 'package:flutter_hbb/models/platform_model.dart';
|
||||
import 'package:flutter_hbb/models/user_model.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../common.dart';
|
||||
import './dialog.dart';
|
||||
|
||||
const kOpSvgList = [
|
||||
'github',
|
||||
'gitlab',
|
||||
'google',
|
||||
'apple',
|
||||
'okta',
|
||||
'facebook',
|
||||
'azure',
|
||||
'auth0',
|
||||
'microsoft'
|
||||
];
|
||||
|
||||
class _IconOP extends StatelessWidget {
|
||||
final String op;
|
||||
final String? icon;
|
||||
final EdgeInsets margin;
|
||||
const _IconOP(
|
||||
{Key? key,
|
||||
required this.op,
|
||||
required this.icon,
|
||||
this.margin = const EdgeInsets.symmetric(horizontal: 4.0)})
|
||||
: super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final svgFile =
|
||||
kOpSvgList.contains(op.toLowerCase()) ? op.toLowerCase() : 'default';
|
||||
return Container(
|
||||
margin: margin,
|
||||
child: icon == null
|
||||
? SvgPicture.asset(
|
||||
'assets/auth-$svgFile.svg',
|
||||
width: 20,
|
||||
)
|
||||
: SvgPicture.string(
|
||||
icon!,
|
||||
width: 20,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ButtonOP extends StatelessWidget {
|
||||
final String op;
|
||||
final RxString curOP;
|
||||
final String? icon;
|
||||
final Color primaryColor;
|
||||
final double height;
|
||||
final Function() onTap;
|
||||
|
||||
const ButtonOP({
|
||||
Key? key,
|
||||
required this.op,
|
||||
required this.curOP,
|
||||
required this.icon,
|
||||
required this.primaryColor,
|
||||
required this.height,
|
||||
required this.onTap,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final opLabel = {
|
||||
'github': 'GitHub',
|
||||
'gitlab': 'GitLab'
|
||||
}[op.toLowerCase()] ??
|
||||
toCapitalized(op);
|
||||
return Row(children: [
|
||||
Container(
|
||||
height: height,
|
||||
width: 200,
|
||||
child: Obx(() => ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: curOP.value.isEmpty || curOP.value == op
|
||||
? primaryColor
|
||||
: Colors.grey,
|
||||
).copyWith(elevation: ButtonStyleButton.allOrNull(0.0)),
|
||||
onPressed: curOP.value.isEmpty || curOP.value == op ? onTap : null,
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 30,
|
||||
child: _IconOP(
|
||||
op: op,
|
||||
icon: icon,
|
||||
margin: EdgeInsets.only(right: 5),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Center(
|
||||
child: Text(translate("Continue with {$opLabel}"))),
|
||||
),
|
||||
),
|
||||
],
|
||||
))),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
class ConfigOP {
|
||||
final String op;
|
||||
final String? icon;
|
||||
ConfigOP({required this.op, required this.icon});
|
||||
}
|
||||
|
||||
class WidgetOP extends StatefulWidget {
|
||||
final ConfigOP config;
|
||||
final RxString curOP;
|
||||
final Function(Map<String, dynamic>) cbLogin;
|
||||
const WidgetOP({
|
||||
Key? key,
|
||||
required this.config,
|
||||
required this.curOP,
|
||||
required this.cbLogin,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() {
|
||||
return _WidgetOPState();
|
||||
}
|
||||
}
|
||||
|
||||
class _WidgetOPState extends State<WidgetOP> {
|
||||
Timer? _updateTimer;
|
||||
String _stateMsg = '';
|
||||
String _failedMsg = '';
|
||||
String _url = '';
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
_updateTimer?.cancel();
|
||||
}
|
||||
|
||||
_beginQueryState() {
|
||||
_updateTimer = Timer.periodic(Duration(seconds: 1), (timer) {
|
||||
_updateState();
|
||||
});
|
||||
}
|
||||
|
||||
_updateState() {
|
||||
bind.mainAccountAuthResult().then((result) {
|
||||
if (result.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final resultMap = jsonDecode(result);
|
||||
if (resultMap == null) {
|
||||
return;
|
||||
}
|
||||
final String stateMsg = resultMap['state_msg'];
|
||||
String failedMsg = resultMap['failed_msg'];
|
||||
final String? url = resultMap['url'];
|
||||
final bool urlLaunched = (resultMap['url_launched'] as bool?) ?? false;
|
||||
final authBody = resultMap['auth_body'];
|
||||
if (_stateMsg != stateMsg || _failedMsg != failedMsg) {
|
||||
if (_url.isEmpty && url != null && url.isNotEmpty) {
|
||||
if (!urlLaunched) {
|
||||
launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
|
||||
}
|
||||
_url = url;
|
||||
}
|
||||
if (authBody != null) {
|
||||
_updateTimer?.cancel();
|
||||
widget.curOP.value = '';
|
||||
widget.cbLogin(authBody as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_stateMsg = stateMsg;
|
||||
_failedMsg = failedMsg;
|
||||
if (failedMsg.isNotEmpty) {
|
||||
widget.curOP.value = '';
|
||||
_updateTimer?.cancel();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_resetState() {
|
||||
_stateMsg = '';
|
||||
_failedMsg = '';
|
||||
_url = '';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
ButtonOP(
|
||||
op: widget.config.op,
|
||||
curOP: widget.curOP,
|
||||
icon: widget.config.icon,
|
||||
primaryColor: str2color(widget.config.op, 0x7f),
|
||||
height: 36,
|
||||
onTap: () async {
|
||||
_resetState();
|
||||
widget.curOP.value = widget.config.op;
|
||||
await bind.mainAccountAuth(op: widget.config.op, rememberMe: true);
|
||||
_beginQueryState();
|
||||
},
|
||||
),
|
||||
Obx(() {
|
||||
if (widget.curOP.isNotEmpty &&
|
||||
widget.curOP.value != widget.config.op) {
|
||||
_failedMsg = '';
|
||||
}
|
||||
return Offstage(
|
||||
offstage:
|
||||
_failedMsg.isEmpty && widget.curOP.value != widget.config.op,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
if (_stateMsg.isNotEmpty && _failedMsg.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: SelectableText(
|
||||
translate(_stateMsg),
|
||||
style: DefaultTextStyle.of(context)
|
||||
.style
|
||||
.copyWith(fontSize: 12),
|
||||
),
|
||||
),
|
||||
if (_failedMsg.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Builder(builder: (context) {
|
||||
final errorColor =
|
||||
Theme.of(context).colorScheme.error;
|
||||
final bgColor = Theme.of(context)
|
||||
.colorScheme
|
||||
.errorContainer
|
||||
.withOpacity(0.3);
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8.0, vertical: 6.0),
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
borderRadius: BorderRadius.circular(4.0),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.error_outline,
|
||||
color: errorColor, size: 16),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: SelectableText(
|
||||
translate(_failedMsg),
|
||||
style: DefaultTextStyle.of(context)
|
||||
.style
|
||||
.copyWith(
|
||||
fontSize: 13,
|
||||
color: errorColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
Obx(
|
||||
() => Offstage(
|
||||
offstage: widget.curOP.value != widget.config.op,
|
||||
child: const SizedBox(
|
||||
height: 5.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
Obx(
|
||||
() => Offstage(
|
||||
offstage: widget.curOP.value != widget.config.op,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: 20),
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
widget.curOP.value = '';
|
||||
_updateTimer?.cancel();
|
||||
_resetState();
|
||||
bind.mainAccountAuthCancel();
|
||||
},
|
||||
child: Text(
|
||||
translate('Cancel'),
|
||||
style: TextStyle(fontSize: 15),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LoginWidgetOP extends StatelessWidget {
|
||||
final List<ConfigOP> ops;
|
||||
final RxString curOP;
|
||||
final Function(Map<String, dynamic>) cbLogin;
|
||||
|
||||
LoginWidgetOP({
|
||||
Key? key,
|
||||
required this.ops,
|
||||
required this.curOP,
|
||||
required this.cbLogin,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var children = ops
|
||||
.map((op) => [
|
||||
WidgetOP(
|
||||
config: op,
|
||||
curOP: curOP,
|
||||
cbLogin: cbLogin,
|
||||
),
|
||||
const Divider(
|
||||
indent: 5,
|
||||
endIndent: 5,
|
||||
)
|
||||
])
|
||||
.expand((i) => i)
|
||||
.toList();
|
||||
if (children.isNotEmpty) {
|
||||
children.removeLast();
|
||||
}
|
||||
return SingleChildScrollView(
|
||||
child: Container(
|
||||
width: 200,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: children,
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
class LoginWidgetUserPass extends StatelessWidget {
|
||||
final TextEditingController username;
|
||||
final TextEditingController pass;
|
||||
final String? usernameMsg;
|
||||
final String? passMsg;
|
||||
final bool isInProgress;
|
||||
final RxString curOP;
|
||||
final Function() onLogin;
|
||||
final FocusNode? userFocusNode;
|
||||
const LoginWidgetUserPass({
|
||||
Key? key,
|
||||
this.userFocusNode,
|
||||
required this.username,
|
||||
required this.pass,
|
||||
required this.usernameMsg,
|
||||
required this.passMsg,
|
||||
required this.isInProgress,
|
||||
required this.curOP,
|
||||
required this.onLogin,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.all(0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(height: 8.0),
|
||||
DialogTextField(
|
||||
title: translate(DialogTextField.kUsernameTitle),
|
||||
controller: username,
|
||||
focusNode: userFocusNode,
|
||||
prefixIcon: DialogTextField.kUsernameIcon,
|
||||
errorText: usernameMsg),
|
||||
PasswordWidget(
|
||||
controller: pass,
|
||||
autoFocus: false,
|
||||
reRequestFocus: true,
|
||||
errorText: passMsg,
|
||||
),
|
||||
// NOT use Offstage to wrap LinearProgressIndicator
|
||||
if (isInProgress) const LinearProgressIndicator(),
|
||||
const SizedBox(height: 12.0),
|
||||
FittedBox(
|
||||
child:
|
||||
Row(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||
Container(
|
||||
height: 38,
|
||||
width: 200,
|
||||
child: Obx(() => ElevatedButton(
|
||||
child: Text(
|
||||
translate('Login'),
|
||||
style: TextStyle(fontSize: 16),
|
||||
),
|
||||
onPressed:
|
||||
curOP.value.isEmpty || curOP.value == 'rustdesk'
|
||||
? () {
|
||||
onLogin();
|
||||
}
|
||||
: null,
|
||||
)),
|
||||
),
|
||||
])),
|
||||
],
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
const kAuthReqTypeOidc = 'oidc/';
|
||||
|
||||
// call this directly
|
||||
Future<bool?> loginDialog() async {
|
||||
var username =
|
||||
TextEditingController(text: UserModel.getLocalUserInfo()?['name'] ?? '');
|
||||
var password = TextEditingController();
|
||||
final userFocusNode = FocusNode()..requestFocus();
|
||||
Timer(Duration(milliseconds: 100), () => userFocusNode..requestFocus());
|
||||
|
||||
String? usernameMsg;
|
||||
String? passwordMsg;
|
||||
var isInProgress = false;
|
||||
final RxString curOP = ''.obs;
|
||||
// Track hover state for the close icon
|
||||
bool isCloseHovered = false;
|
||||
|
||||
final loginOptions = [].obs;
|
||||
Future.delayed(Duration.zero, () async {
|
||||
loginOptions.value = await UserModel.queryOidcLoginOptions();
|
||||
});
|
||||
|
||||
final res = await gFFI.dialogManager.show<bool>((setState, close, context) {
|
||||
username.addListener(() {
|
||||
if (usernameMsg != null) {
|
||||
setState(() => usernameMsg = null);
|
||||
}
|
||||
});
|
||||
|
||||
password.addListener(() {
|
||||
if (passwordMsg != null) {
|
||||
setState(() => passwordMsg = null);
|
||||
}
|
||||
});
|
||||
|
||||
onDialogCancel() {
|
||||
isInProgress = false;
|
||||
close(false);
|
||||
}
|
||||
|
||||
handleLoginResponse(LoginResponse resp, bool storeIfAccessToken,
|
||||
void Function([dynamic])? close) async {
|
||||
switch (resp.type) {
|
||||
case HttpType.kAuthResTypeToken:
|
||||
if (resp.access_token != null) {
|
||||
if (storeIfAccessToken) {
|
||||
await bind.mainSetLocalOption(
|
||||
key: 'access_token', value: resp.access_token!);
|
||||
await bind.mainSetLocalOption(
|
||||
key: 'user_info', value: jsonEncode(resp.user ?? {}));
|
||||
}
|
||||
if (close != null) {
|
||||
close(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
break;
|
||||
case HttpType.kAuthResTypeEmailCheck:
|
||||
bool? isEmailVerification;
|
||||
if (resp.tfa_type == null ||
|
||||
resp.tfa_type == HttpType.kAuthResTypeEmailCheck) {
|
||||
isEmailVerification = true;
|
||||
} else if (resp.tfa_type == HttpType.kAuthResTypeTfaCheck) {
|
||||
isEmailVerification = false;
|
||||
} else {
|
||||
passwordMsg = "Failed, bad tfa type from server";
|
||||
}
|
||||
if (isEmailVerification != null) {
|
||||
if (isMobile) {
|
||||
if (close != null) close(null);
|
||||
verificationCodeDialog(
|
||||
resp.user, resp.secret, isEmailVerification);
|
||||
} else {
|
||||
setState(() => isInProgress = false);
|
||||
// Workaround for web, close the dialog first, then show the verification code dialog.
|
||||
// Otherwise, the text field will keep selecting the text and we can't input the code.
|
||||
// Not sure why this happens.
|
||||
if (isWeb && close != null) close(null);
|
||||
final res = await verificationCodeDialog(
|
||||
resp.user, resp.secret, isEmailVerification);
|
||||
if (res == true) {
|
||||
if (!isWeb && close != null) close(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
default:
|
||||
passwordMsg = "Failed, bad response from server";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
onLogin() async {
|
||||
// validate
|
||||
if (username.text.isEmpty) {
|
||||
setState(() => usernameMsg = translate('Username missed'));
|
||||
return;
|
||||
}
|
||||
if (password.text.isEmpty) {
|
||||
setState(() => passwordMsg = translate('Password missed'));
|
||||
return;
|
||||
}
|
||||
curOP.value = 'rustdesk';
|
||||
setState(() => isInProgress = true);
|
||||
try {
|
||||
final resp = await gFFI.userModel.login(LoginRequest(
|
||||
username: username.text,
|
||||
password: password.text,
|
||||
id: await bind.mainGetMyId(),
|
||||
uuid: await bind.mainGetUuid(),
|
||||
autoLogin: true,
|
||||
type: HttpType.kAuthReqTypeAccount));
|
||||
await handleLoginResponse(resp, true, close);
|
||||
} on RequestException catch (err) {
|
||||
passwordMsg = translate(err.cause);
|
||||
} catch (err) {
|
||||
passwordMsg = "Unknown Error: $err";
|
||||
}
|
||||
curOP.value = '';
|
||||
setState(() => isInProgress = false);
|
||||
}
|
||||
|
||||
thirdAuthWidget() => Obx(() {
|
||||
return Offstage(
|
||||
offstage: loginOptions.isEmpty,
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(
|
||||
height: 8.0,
|
||||
),
|
||||
Center(
|
||||
child: Text(
|
||||
translate('or'),
|
||||
style: TextStyle(fontSize: 16),
|
||||
)),
|
||||
const SizedBox(
|
||||
height: 8.0,
|
||||
),
|
||||
LoginWidgetOP(
|
||||
ops: loginOptions
|
||||
.map((e) => ConfigOP(op: e['name'], icon: e['icon']))
|
||||
.toList(),
|
||||
curOP: curOP,
|
||||
cbLogin: (Map<String, dynamic> authBody) async {
|
||||
LoginResponse? resp;
|
||||
try {
|
||||
// access_token is already stored in the rust side.
|
||||
resp =
|
||||
gFFI.userModel.getLoginResponseFromAuthBody(authBody);
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'Failed to parse oidc login body: "$authBody"');
|
||||
}
|
||||
close(true);
|
||||
|
||||
if (resp != null) {
|
||||
handleLoginResponse(resp, false, null);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
final title = Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
translate('Login'),
|
||||
).marginOnly(top: MyTheme.dialogPadding),
|
||||
MouseRegion(
|
||||
onEnter: (_) => setState(() => isCloseHovered = true),
|
||||
onExit: (_) => setState(() => isCloseHovered = false),
|
||||
child: InkWell(
|
||||
child: Icon(
|
||||
Icons.close,
|
||||
size: 25,
|
||||
// No need to handle the branch of null.
|
||||
// Because we can ensure the color is not null when debug.
|
||||
color: isCloseHovered
|
||||
? Colors.white
|
||||
: Theme.of(context)
|
||||
.textTheme
|
||||
.titleLarge
|
||||
?.color
|
||||
?.withOpacity(0.55),
|
||||
),
|
||||
onTap: onDialogCancel,
|
||||
hoverColor: Colors.red,
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
).marginOnly(top: 10, right: 15),
|
||||
],
|
||||
);
|
||||
final titlePadding = EdgeInsets.fromLTRB(MyTheme.dialogPadding, 0, 0, 0);
|
||||
|
||||
return CustomAlertDialog(
|
||||
title: title,
|
||||
titlePadding: titlePadding,
|
||||
contentBoxConstraints: BoxConstraints(minWidth: 400),
|
||||
content: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(
|
||||
height: 8.0,
|
||||
),
|
||||
LoginWidgetUserPass(
|
||||
username: username,
|
||||
pass: password,
|
||||
usernameMsg: usernameMsg,
|
||||
passMsg: passwordMsg,
|
||||
isInProgress: isInProgress,
|
||||
curOP: curOP,
|
||||
onLogin: onLogin,
|
||||
userFocusNode: userFocusNode,
|
||||
),
|
||||
thirdAuthWidget(),
|
||||
],
|
||||
),
|
||||
onCancel: onDialogCancel,
|
||||
onSubmit: onLogin,
|
||||
);
|
||||
});
|
||||
|
||||
if (res != null) {
|
||||
await UserModel.updateOtherModels();
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
Future<bool?> verificationCodeDialog(
|
||||
UserPayload? user, String? secret, bool isEmailVerification) async {
|
||||
var autoLogin = true;
|
||||
var isInProgress = false;
|
||||
String? errorText;
|
||||
|
||||
final code = TextEditingController();
|
||||
|
||||
final res = await gFFI.dialogManager.show<bool>((setState, close, context) {
|
||||
void onVerify() async {
|
||||
setState(() => isInProgress = true);
|
||||
|
||||
try {
|
||||
final resp = await gFFI.userModel.login(LoginRequest(
|
||||
verificationCode: code.text,
|
||||
tfaCode: isEmailVerification ? null : code.text,
|
||||
secret: secret,
|
||||
username: user?.name,
|
||||
id: await bind.mainGetMyId(),
|
||||
uuid: await bind.mainGetUuid(),
|
||||
autoLogin: autoLogin,
|
||||
type: HttpType.kAuthReqTypeEmailCode));
|
||||
|
||||
switch (resp.type) {
|
||||
case HttpType.kAuthResTypeToken:
|
||||
if (resp.access_token != null) {
|
||||
await bind.mainSetLocalOption(
|
||||
key: 'access_token', value: resp.access_token!);
|
||||
close(true);
|
||||
return;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
errorText = "Failed, bad response from server";
|
||||
break;
|
||||
}
|
||||
} on RequestException catch (err) {
|
||||
errorText = translate(err.cause);
|
||||
} catch (err) {
|
||||
errorText = "Unknown Error: $err";
|
||||
}
|
||||
|
||||
setState(() => isInProgress = false);
|
||||
}
|
||||
|
||||
final codeField = isEmailVerification
|
||||
? DialogEmailCodeField(
|
||||
controller: code,
|
||||
errorText: errorText,
|
||||
readyCallback: onVerify,
|
||||
onChanged: () => errorText = null,
|
||||
)
|
||||
: Dialog2FaField(
|
||||
controller: code,
|
||||
errorText: errorText,
|
||||
readyCallback: onVerify,
|
||||
onChanged: () => errorText = null,
|
||||
);
|
||||
|
||||
getOnSubmit() => codeField.isReady ? onVerify : null;
|
||||
|
||||
return CustomAlertDialog(
|
||||
title: Text(translate("Verification code")),
|
||||
contentBoxConstraints: BoxConstraints(maxWidth: 300),
|
||||
content: Column(
|
||||
children: [
|
||||
Offstage(
|
||||
offstage: !isEmailVerification || user?.email == null,
|
||||
child: TextField(
|
||||
decoration: InputDecoration(
|
||||
labelText: "Email", prefixIcon: Icon(Icons.email)),
|
||||
readOnly: true,
|
||||
controller: TextEditingController(text: user?.email),
|
||||
).workaroundFreezeLinuxMint()),
|
||||
isEmailVerification ? const SizedBox(height: 8) : const Offstage(),
|
||||
codeField,
|
||||
/*
|
||||
CheckboxListTile(
|
||||
contentPadding: const EdgeInsets.all(0),
|
||||
dense: true,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
title: Row(children: [
|
||||
Expanded(child: Text(translate("Trust this device")))
|
||||
]),
|
||||
value: trustThisDevice,
|
||||
onChanged: (v) {
|
||||
if (v == null) return;
|
||||
setState(() => trustThisDevice = !trustThisDevice);
|
||||
},
|
||||
),
|
||||
*/
|
||||
// NOT use Offstage to wrap LinearProgressIndicator
|
||||
if (isInProgress) const LinearProgressIndicator(),
|
||||
],
|
||||
),
|
||||
onCancel: close,
|
||||
onSubmit: getOnSubmit(),
|
||||
actions: [
|
||||
dialogButton("Cancel", onPressed: close, isOutline: true),
|
||||
dialogButton("Verify", onPressed: getOnSubmit()),
|
||||
]);
|
||||
});
|
||||
// For verification code, desktop update other models in login dialog, mobile need to close login dialog first,
|
||||
// otherwise the soft keyboard will jump out on each key press, so mobile update in verification code dialog.
|
||||
if (isMobile && res == true) {
|
||||
await UserModel.updateOtherModels();
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
void logOutConfirmDialog() {
|
||||
gFFI.dialogManager.show((setState, close, context) {
|
||||
submit() {
|
||||
close();
|
||||
gFFI.userModel.logOut();
|
||||
}
|
||||
|
||||
return CustomAlertDialog(
|
||||
content: Text(translate("logout_tip")),
|
||||
actions: [
|
||||
dialogButton(translate("Cancel"), onPressed: close, isOutline: true),
|
||||
dialogButton(translate("OK"), onPressed: submit),
|
||||
],
|
||||
onSubmit: submit,
|
||||
onCancel: close,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hbb/common/hbbs/hbbs.dart';
|
||||
import 'package:flutter_hbb/common/widgets/login.dart';
|
||||
import 'package:flutter_hbb/common/widgets/peers_view.dart';
|
||||
import 'package:flutter_hbb/models/state_model.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import '../../common.dart';
|
||||
|
||||
class MyGroup extends StatefulWidget {
|
||||
final EdgeInsets? menuPadding;
|
||||
const MyGroup({Key? key, this.menuPadding}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() {
|
||||
return _MyGroupState();
|
||||
}
|
||||
}
|
||||
|
||||
class _MyGroupState extends State<MyGroup> {
|
||||
RxBool get isSelectedDeviceGroup => gFFI.groupModel.isSelectedDeviceGroup;
|
||||
RxString get selectedAccessibleItemName =>
|
||||
gFFI.groupModel.selectedAccessibleItemName;
|
||||
RxString get searchAccessibleItemNameText =>
|
||||
gFFI.groupModel.searchAccessibleItemNameText;
|
||||
static TextEditingController searchUserController = TextEditingController();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Obx(() {
|
||||
if (!gFFI.userModel.isLogin) {
|
||||
return Center(
|
||||
child: ElevatedButton(
|
||||
onPressed: loginDialog, child: Text(translate("Login"))));
|
||||
} else if (gFFI.userModel.networkError.isNotEmpty) {
|
||||
return netWorkErrorWidget();
|
||||
} else if (gFFI.groupModel.groupLoading.value && gFFI.groupModel.emtpy) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
children: [
|
||||
buildErrorBanner(context,
|
||||
loading: gFFI.groupModel.groupLoading,
|
||||
err: gFFI.groupModel.groupLoadError,
|
||||
retry: null,
|
||||
close: () => gFFI.groupModel.groupLoadError.value = ''),
|
||||
Expanded(
|
||||
child: Obx(() => stateGlobal.isPortrait.isTrue
|
||||
? _buildPortrait()
|
||||
: _buildLandscape())),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildLandscape() {
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border:
|
||||
Border.all(color: Theme.of(context).colorScheme.background)),
|
||||
child: Container(
|
||||
width: 150,
|
||||
height: double.infinity,
|
||||
child: Column(
|
||||
children: [
|
||||
_buildLeftHeader(),
|
||||
Expanded(
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
child: _buildLeftList(),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
).marginOnly(right: 12.0),
|
||||
Expanded(
|
||||
child: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: MyGroupPeerView(
|
||||
menuPadding: widget.menuPadding,
|
||||
)),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPortrait() {
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border:
|
||||
Border.all(color: Theme.of(context).colorScheme.background)),
|
||||
child: Container(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildLeftHeader(),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
child: _buildLeftList(),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
).marginOnly(bottom: 12.0),
|
||||
Expanded(
|
||||
child: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: MyGroupPeerView(
|
||||
menuPadding: widget.menuPadding,
|
||||
)),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLeftHeader() {
|
||||
final fontSize = 14.0;
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: searchUserController,
|
||||
onChanged: (value) {
|
||||
searchAccessibleItemNameText.value = value;
|
||||
selectedAccessibleItemName.value = '';
|
||||
},
|
||||
textAlignVertical: TextAlignVertical.center,
|
||||
style: TextStyle(fontSize: fontSize),
|
||||
decoration: InputDecoration(
|
||||
filled: false,
|
||||
prefixIcon: Icon(
|
||||
Icons.search_rounded,
|
||||
color: Theme.of(context).hintColor,
|
||||
).paddingOnly(top: 2),
|
||||
hintText: translate("Search"),
|
||||
hintStyle: TextStyle(fontSize: fontSize),
|
||||
border: InputBorder.none,
|
||||
isDense: true,
|
||||
),
|
||||
).workaroundFreezeLinuxMint()),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLeftList() {
|
||||
return Obx(() {
|
||||
final userItems = gFFI.groupModel.users.where((p0) {
|
||||
if (searchAccessibleItemNameText.isNotEmpty) {
|
||||
final search = searchAccessibleItemNameText.value.toLowerCase();
|
||||
return p0.name.toLowerCase().contains(search) ||
|
||||
p0.displayNameOrName.toLowerCase().contains(search);
|
||||
}
|
||||
return true;
|
||||
}).toList();
|
||||
// Count occurrences of each displayNameOrName to detect duplicates
|
||||
final displayNameCount = <String, int>{};
|
||||
for (final u in userItems) {
|
||||
final dn = u.displayNameOrName;
|
||||
displayNameCount[dn] = (displayNameCount[dn] ?? 0) + 1;
|
||||
}
|
||||
final deviceGroupItems = gFFI.groupModel.deviceGroups.where((p0) {
|
||||
if (searchAccessibleItemNameText.isNotEmpty) {
|
||||
return p0.name
|
||||
.toLowerCase()
|
||||
.contains(searchAccessibleItemNameText.value.toLowerCase());
|
||||
}
|
||||
return true;
|
||||
}).toList();
|
||||
listView(bool isPortrait) => ListView.builder(
|
||||
shrinkWrap: isPortrait,
|
||||
itemCount: deviceGroupItems.length + userItems.length,
|
||||
itemBuilder: (context, index) => index < deviceGroupItems.length
|
||||
? _buildDeviceGroupItem(deviceGroupItems[index])
|
||||
: _buildUserItem(userItems[index - deviceGroupItems.length],
|
||||
displayNameCount));
|
||||
var maxHeight = max(MediaQuery.of(context).size.height / 6, 100.0);
|
||||
return Obx(() => stateGlobal.isPortrait.isFalse
|
||||
? listView(false)
|
||||
: LimitedBox(maxHeight: maxHeight, child: listView(true)));
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildUserItem(UserPayload user, Map<String, int> displayNameCount) {
|
||||
final username = user.name;
|
||||
final dn = user.displayNameOrName;
|
||||
final isDuplicate = (displayNameCount[dn] ?? 0) > 1;
|
||||
final displayName =
|
||||
isDuplicate && user.displayName.trim().isNotEmpty
|
||||
? '${user.displayName} (@$username)'
|
||||
: dn;
|
||||
return InkWell(onTap: () {
|
||||
isSelectedDeviceGroup.value = false;
|
||||
if (selectedAccessibleItemName.value != username) {
|
||||
selectedAccessibleItemName.value = username;
|
||||
} else {
|
||||
selectedAccessibleItemName.value = '';
|
||||
}
|
||||
}, child: Obx(
|
||||
() {
|
||||
bool selected = !isSelectedDeviceGroup.value &&
|
||||
selectedAccessibleItemName.value == username;
|
||||
final isMe = username == gFFI.userModel.userName.value;
|
||||
final colorMe = MyTheme.color(context).me!;
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? MyTheme.color(context).highlight : null,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
width: 0.7,
|
||||
color: Theme.of(context).dividerColor.withOpacity(0.1))),
|
||||
),
|
||||
child: Container(
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 20,
|
||||
height: 20,
|
||||
decoration: BoxDecoration(
|
||||
color: str2color(username, 0xAF),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Align(
|
||||
alignment: Alignment.center,
|
||||
child: Center(
|
||||
child: Text(
|
||||
displayName.characters.first.toUpperCase(),
|
||||
style: TextStyle(color: Colors.white),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
).marginOnly(right: 4),
|
||||
if (isMe) Flexible(child: Text(displayName)),
|
||||
if (isMe)
|
||||
Flexible(
|
||||
child: Container(
|
||||
margin: EdgeInsets.only(left: 5),
|
||||
padding: EdgeInsets.symmetric(horizontal: 3, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: colorMe.withAlpha(20),
|
||||
borderRadius: BorderRadius.all(Radius.circular(2)),
|
||||
border: Border.all(color: colorMe.withAlpha(100))),
|
||||
child: Text(
|
||||
translate('Me'),
|
||||
style: TextStyle(
|
||||
color: colorMe.withAlpha(200), fontSize: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!isMe) Expanded(child: Text(displayName)),
|
||||
],
|
||||
).paddingSymmetric(vertical: 4),
|
||||
),
|
||||
);
|
||||
},
|
||||
)).marginSymmetric(horizontal: 12).marginOnly(bottom: 6);
|
||||
}
|
||||
|
||||
Widget _buildDeviceGroupItem(DeviceGroupPayload deviceGroup) {
|
||||
final name = deviceGroup.name;
|
||||
return InkWell(onTap: () {
|
||||
isSelectedDeviceGroup.value = true;
|
||||
if (selectedAccessibleItemName.value != name) {
|
||||
selectedAccessibleItemName.value = name;
|
||||
} else {
|
||||
selectedAccessibleItemName.value = '';
|
||||
}
|
||||
}, child: Obx(
|
||||
() {
|
||||
bool selected = isSelectedDeviceGroup.value &&
|
||||
selectedAccessibleItemName.value == name;
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? MyTheme.color(context).highlight : null,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
width: 0.7,
|
||||
color: Theme.of(context).dividerColor.withOpacity(0.1))),
|
||||
),
|
||||
child: Container(
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: Icon(IconFont.deviceGroupOutline,
|
||||
color: MyTheme.accent, size: 19),
|
||||
).marginOnly(right: 4),
|
||||
Expanded(child: Text(name)),
|
||||
],
|
||||
).paddingSymmetric(vertical: 4),
|
||||
),
|
||||
);
|
||||
},
|
||||
)).marginSymmetric(horizontal: 12).marginOnly(bottom: 6);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,674 @@
|
||||
import 'package:auto_size_text/auto_size_text.dart';
|
||||
import 'package:debounce_throttle/debounce_throttle.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:flutter_hbb/models/platform_model.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../consts.dart';
|
||||
import '../../desktop/widgets/tabbar_widget.dart';
|
||||
import '../../models/chat_model.dart';
|
||||
import '../../models/model.dart';
|
||||
import 'chat_page.dart';
|
||||
|
||||
class DraggableChatWindow extends StatelessWidget {
|
||||
const DraggableChatWindow(
|
||||
{Key? key,
|
||||
this.position = Offset.zero,
|
||||
required this.width,
|
||||
required this.height,
|
||||
required this.chatModel})
|
||||
: super(key: key);
|
||||
|
||||
final Offset position;
|
||||
final double width;
|
||||
final double height;
|
||||
final ChatModel chatModel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (draggablePositions.chatWindow.isInvalid()) {
|
||||
draggablePositions.chatWindow.update(position);
|
||||
}
|
||||
return isIOS
|
||||
? IOSDraggable(
|
||||
position: draggablePositions.chatWindow,
|
||||
chatModel: chatModel,
|
||||
width: width,
|
||||
height: height,
|
||||
builder: (context) {
|
||||
return Column(
|
||||
children: [
|
||||
_buildMobileAppBar(context),
|
||||
Expanded(
|
||||
child: ChatPage(chatModel: chatModel),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
)
|
||||
: Draggable(
|
||||
checkKeyboard: true,
|
||||
checkScreenSize: true,
|
||||
position: draggablePositions.chatWindow,
|
||||
width: width,
|
||||
height: height,
|
||||
chatModel: chatModel,
|
||||
builder: (context, onPanUpdate) {
|
||||
final child = Scaffold(
|
||||
resizeToAvoidBottomInset: false,
|
||||
appBar: CustomAppBar(
|
||||
onPanUpdate: onPanUpdate,
|
||||
appBar: (isDesktop || isWebDesktop)
|
||||
? _buildDesktopAppBar(context)
|
||||
: _buildMobileAppBar(context),
|
||||
),
|
||||
body: ChatPage(chatModel: chatModel),
|
||||
);
|
||||
return Container(
|
||||
decoration:
|
||||
BoxDecoration(border: Border.all(color: MyTheme.border)),
|
||||
child: child);
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildMobileAppBar(BuildContext context) {
|
||||
return Container(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
height: 50,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 15),
|
||||
child: Text(
|
||||
translate("Chat"),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontFamily: 'WorkSans',
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 20),
|
||||
)),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
chatModel.hideChatWindowOverlay();
|
||||
},
|
||||
icon: const Icon(
|
||||
Icons.keyboard_arrow_down,
|
||||
color: Colors.white,
|
||||
)),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
chatModel.hideChatWindowOverlay();
|
||||
chatModel.hideChatIconOverlay();
|
||||
},
|
||||
icon: const Icon(
|
||||
Icons.close,
|
||||
color: Colors.white,
|
||||
))
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDesktopAppBar(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Theme.of(context).hintColor.withOpacity(0.4)))),
|
||||
height: 38,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 8),
|
||||
child: Obx(() => Opacity(
|
||||
opacity: chatModel.isWindowFocus.value ? 1.0 : 0.4,
|
||||
child: Row(children: [
|
||||
Icon(Icons.chat_bubble_outline,
|
||||
size: 20, color: Theme.of(context).colorScheme.primary),
|
||||
SizedBox(width: 6),
|
||||
Text(translate("Chat"))
|
||||
])))),
|
||||
Padding(
|
||||
padding: EdgeInsets.all(2),
|
||||
child: ActionIcon(
|
||||
message: 'Close',
|
||||
icon: IconFont.close,
|
||||
onTap: chatModel.hideChatWindowOverlay,
|
||||
isClose: true,
|
||||
boxSize: 32,
|
||||
))
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 => const Size.fromHeight(kToolbarHeight);
|
||||
}
|
||||
|
||||
/// floating buttons of back/home/recent actions for android
|
||||
class DraggableMobileActions extends StatelessWidget {
|
||||
DraggableMobileActions(
|
||||
{this.onBackPressed,
|
||||
this.onRecentPressed,
|
||||
this.onHomePressed,
|
||||
this.onHidePressed,
|
||||
required this.position,
|
||||
required this.width,
|
||||
required this.height,
|
||||
required this.scale});
|
||||
|
||||
final double scale;
|
||||
final DraggableKeyPosition position;
|
||||
final double width;
|
||||
final double height;
|
||||
final VoidCallback? onBackPressed;
|
||||
final VoidCallback? onHomePressed;
|
||||
final VoidCallback? onRecentPressed;
|
||||
final VoidCallback? onHidePressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Draggable(
|
||||
position: position,
|
||||
width: scale * width,
|
||||
height: scale * height,
|
||||
builder: (_, onPanUpdate) {
|
||||
return GestureDetector(
|
||||
onPanUpdate: onPanUpdate,
|
||||
child: Card(
|
||||
color: Colors.transparent,
|
||||
shadowColor: Colors.transparent,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: MyTheme.accent.withOpacity(0.4),
|
||||
borderRadius:
|
||||
BorderRadius.all(Radius.circular(15 * scale))),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
IconButton(
|
||||
color: Colors.white,
|
||||
onPressed: onBackPressed,
|
||||
splashRadius: kDesktopIconButtonSplashRadius,
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
iconSize: 24 * scale),
|
||||
IconButton(
|
||||
color: Colors.white,
|
||||
onPressed: onHomePressed,
|
||||
splashRadius: kDesktopIconButtonSplashRadius,
|
||||
icon: const Icon(Icons.home),
|
||||
iconSize: 24 * scale),
|
||||
IconButton(
|
||||
color: Colors.white,
|
||||
onPressed: onRecentPressed,
|
||||
splashRadius: kDesktopIconButtonSplashRadius,
|
||||
icon: const Icon(Icons.more_horiz),
|
||||
iconSize: 24 * scale),
|
||||
const VerticalDivider(
|
||||
width: 0,
|
||||
thickness: 2,
|
||||
indent: 10,
|
||||
endIndent: 10,
|
||||
),
|
||||
IconButton(
|
||||
color: Colors.white,
|
||||
onPressed: onHidePressed,
|
||||
splashRadius: kDesktopIconButtonSplashRadius,
|
||||
icon: const Icon(Icons.keyboard_arrow_down),
|
||||
iconSize: 24 * scale),
|
||||
],
|
||||
),
|
||||
)));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class DraggableKeyPosition {
|
||||
final String key;
|
||||
Offset _pos;
|
||||
late Debouncer<int> _debouncerStore;
|
||||
DraggableKeyPosition(this.key)
|
||||
: _pos = DraggablePositions.kInvalidDraggablePosition;
|
||||
|
||||
get pos => _pos;
|
||||
|
||||
_loadPosition(String k) {
|
||||
final value = bind.getLocalFlutterOption(k: k);
|
||||
if (value.isNotEmpty) {
|
||||
final parts = value.split(',');
|
||||
if (parts.length == 2) {
|
||||
return Offset(double.parse(parts[0]), double.parse(parts[1]));
|
||||
}
|
||||
}
|
||||
return DraggablePositions.kInvalidDraggablePosition;
|
||||
}
|
||||
|
||||
load() {
|
||||
_pos = _loadPosition(key);
|
||||
_debouncerStore = Debouncer<int>(const Duration(milliseconds: 500),
|
||||
onChanged: (v) => _store(), initialValue: 0);
|
||||
}
|
||||
|
||||
update(Offset pos) {
|
||||
_pos = pos;
|
||||
_triggerStore();
|
||||
}
|
||||
|
||||
// Adjust position to keep it in the screen
|
||||
// Only used for desktop and web desktop
|
||||
tryAdjust(double w, double h, double scale) {
|
||||
final size = MediaQuery.of(Get.context!).size;
|
||||
w = w * scale;
|
||||
h = h * scale;
|
||||
double x = _pos.dx;
|
||||
double y = _pos.dy;
|
||||
if (x + w > size.width) {
|
||||
x = size.width - w;
|
||||
}
|
||||
final tabBarHeight = isDesktop ? kDesktopRemoteTabBarHeight : 0;
|
||||
if (y + h > (size.height - tabBarHeight)) {
|
||||
y = size.height - tabBarHeight - h;
|
||||
}
|
||||
if (x < 0) {
|
||||
x = 0;
|
||||
}
|
||||
if (y < 0) {
|
||||
y = 0;
|
||||
}
|
||||
if (x != _pos.dx || y != _pos.dy) {
|
||||
update(Offset(x, y));
|
||||
}
|
||||
}
|
||||
|
||||
isInvalid() {
|
||||
return _pos == DraggablePositions.kInvalidDraggablePosition;
|
||||
}
|
||||
|
||||
_triggerStore() => _debouncerStore.value = _debouncerStore.value + 1;
|
||||
_store() {
|
||||
bind.setLocalFlutterOption(k: key, v: '${_pos.dx},${_pos.dy}');
|
||||
}
|
||||
}
|
||||
|
||||
class DraggablePositions {
|
||||
static const kChatWindow = 'draggablePositionChat';
|
||||
static const kMobileActions = 'draggablePositionMobile';
|
||||
static const kIOSDraggable = 'draggablePositionIOS';
|
||||
|
||||
static const kInvalidDraggablePosition = Offset(-999999, -999999);
|
||||
final chatWindow = DraggableKeyPosition(kChatWindow);
|
||||
final mobileActions = DraggableKeyPosition(kMobileActions);
|
||||
final iOSDraggable = DraggableKeyPosition(kIOSDraggable);
|
||||
|
||||
load() {
|
||||
chatWindow.load();
|
||||
mobileActions.load();
|
||||
iOSDraggable.load();
|
||||
}
|
||||
}
|
||||
|
||||
DraggablePositions draggablePositions = DraggablePositions();
|
||||
|
||||
class Draggable extends StatefulWidget {
|
||||
Draggable(
|
||||
{Key? key,
|
||||
this.checkKeyboard = false,
|
||||
this.checkScreenSize = false,
|
||||
required this.position,
|
||||
required this.width,
|
||||
required this.height,
|
||||
this.chatModel,
|
||||
required this.builder})
|
||||
: super(key: key);
|
||||
|
||||
final bool checkKeyboard;
|
||||
final bool checkScreenSize;
|
||||
final DraggableKeyPosition position;
|
||||
final double width;
|
||||
final double height;
|
||||
final ChatModel? chatModel;
|
||||
final Widget Function(BuildContext, GestureDragUpdateCallback) builder;
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _DraggableState(chatModel);
|
||||
}
|
||||
|
||||
class _DraggableState extends State<Draggable> {
|
||||
late ChatModel? _chatModel;
|
||||
bool _keyboardVisible = false;
|
||||
double _saveHeight = 0;
|
||||
double _lastBottomHeight = 0;
|
||||
|
||||
_DraggableState(ChatModel? chatModel) {
|
||||
_chatModel = chatModel;
|
||||
}
|
||||
|
||||
get position => widget.position.pos;
|
||||
|
||||
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(() {
|
||||
widget.position.update(Offset(x, y));
|
||||
});
|
||||
_chatModel?.setChatWindowPosition(position);
|
||||
}
|
||||
|
||||
checkScreenSize() {
|
||||
// Ensure the draggable always stays within current screen bounds
|
||||
widget.position.tryAdjust(widget.width, widget.height, 1);
|
||||
}
|
||||
|
||||
checkKeyboard() {
|
||||
final bottomHeight = MediaQuery.of(context).viewInsets.bottom;
|
||||
final currentVisible = bottomHeight != 0;
|
||||
|
||||
// save
|
||||
if (!_keyboardVisible && currentVisible) {
|
||||
_saveHeight = position.dy;
|
||||
}
|
||||
|
||||
// reset
|
||||
if (_lastBottomHeight > 0 && bottomHeight == 0) {
|
||||
setState(() {
|
||||
widget.position.update(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(() {
|
||||
widget.position.update(Offset(position.dx, y));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_keyboardVisible = currentVisible;
|
||||
_lastBottomHeight = bottomHeight;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.checkKeyboard) {
|
||||
checkKeyboard();
|
||||
}
|
||||
if (widget.checkScreenSize) {
|
||||
checkScreenSize();
|
||||
}
|
||||
return Stack(children: [
|
||||
Positioned(
|
||||
top: position.dy,
|
||||
left: position.dx,
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
child: widget.builder(context, onPanUpdate))
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
class IOSDraggable extends StatefulWidget {
|
||||
const IOSDraggable(
|
||||
{Key? key,
|
||||
this.chatModel,
|
||||
required this.position,
|
||||
required this.width,
|
||||
required this.height,
|
||||
required this.builder})
|
||||
: super(key: key);
|
||||
|
||||
final DraggableKeyPosition position;
|
||||
final ChatModel? chatModel;
|
||||
final double width;
|
||||
final double height;
|
||||
final Widget Function(BuildContext) builder;
|
||||
|
||||
@override
|
||||
IOSDraggableState createState() =>
|
||||
IOSDraggableState(chatModel, width, height);
|
||||
}
|
||||
|
||||
class IOSDraggableState extends State<IOSDraggable> {
|
||||
late ChatModel? _chatModel;
|
||||
late double _width;
|
||||
late double _height;
|
||||
bool _keyboardVisible = false;
|
||||
double _saveHeight = 0;
|
||||
double _lastBottomHeight = 0;
|
||||
|
||||
IOSDraggableState(ChatModel? chatModel, double w, double h) {
|
||||
_chatModel = chatModel;
|
||||
_width = w;
|
||||
_height = h;
|
||||
}
|
||||
|
||||
DraggableKeyPosition get position => widget.position;
|
||||
|
||||
checkKeyboard() {
|
||||
final bottomHeight = MediaQuery.of(context).viewInsets.bottom;
|
||||
final currentVisible = bottomHeight != 0;
|
||||
|
||||
// save
|
||||
if (!_keyboardVisible && currentVisible) {
|
||||
_saveHeight = position.pos.dy;
|
||||
}
|
||||
|
||||
// reset
|
||||
if (_lastBottomHeight > 0 && bottomHeight == 0) {
|
||||
setState(() {
|
||||
position.update(Offset(position.pos.dx, _saveHeight));
|
||||
});
|
||||
}
|
||||
|
||||
// onKeyboardVisible
|
||||
if (_keyboardVisible && currentVisible) {
|
||||
final sumHeight = bottomHeight + _height;
|
||||
final contextHeight = MediaQuery.of(context).size.height;
|
||||
if (sumHeight + position.pos.dy > contextHeight) {
|
||||
final y = contextHeight - sumHeight;
|
||||
setState(() {
|
||||
position.update(Offset(position.pos.dx, y));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_keyboardVisible = currentVisible;
|
||||
_lastBottomHeight = bottomHeight;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
position.tryAdjust(_width, _height, 1);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
checkKeyboard();
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
left: position.pos.dx,
|
||||
top: position.pos.dy,
|
||||
child: GestureDetector(
|
||||
onPanUpdate: (details) {
|
||||
setState(() {
|
||||
position.update(position.pos + details.delta);
|
||||
});
|
||||
_chatModel?.setChatWindowPosition(position.pos);
|
||||
},
|
||||
child: Material(
|
||||
child: Container(
|
||||
width: _width,
|
||||
height: _height,
|
||||
decoration:
|
||||
BoxDecoration(border: Border.all(color: MyTheme.border)),
|
||||
child: widget.builder(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class QualityMonitor extends StatelessWidget {
|
||||
final QualityMonitorModel qualityMonitorModel;
|
||||
QualityMonitor(this.qualityMonitorModel);
|
||||
|
||||
Widget _row(String info, String? value, {Color? rightColor}) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 8,
|
||||
child: AutoSizeText(info,
|
||||
style: TextStyle(color: Color.fromARGB(255, 210, 210, 210)),
|
||||
textAlign: TextAlign.right,
|
||||
maxLines: 1)),
|
||||
Spacer(flex: 1),
|
||||
Expanded(
|
||||
flex: 8,
|
||||
child: AutoSizeText(value ?? '',
|
||||
style: TextStyle(color: rightColor ?? Colors.white),
|
||||
maxLines: 1)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => ChangeNotifierProvider.value(
|
||||
value: qualityMonitorModel,
|
||||
child: Consumer<QualityMonitorModel>(
|
||||
builder: (context, qualityMonitorModel, child) => qualityMonitorModel
|
||||
.show
|
||||
? Container(
|
||||
constraints: BoxConstraints(maxWidth: 200),
|
||||
padding: const EdgeInsets.all(8),
|
||||
color: MyTheme.canvasColor.withAlpha(150),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_row("Speed", qualityMonitorModel.data.speed ?? '-'),
|
||||
_row("FPS", qualityMonitorModel.data.fps ?? '-'),
|
||||
// let delay be 0 if fps is 0
|
||||
_row(
|
||||
"Delay",
|
||||
"${qualityMonitorModel.data.delay == null ? '-' : (qualityMonitorModel.data.fps ?? "").replaceAll(' ', '').replaceAll('0', '').isEmpty ? 0 : qualityMonitorModel.data.delay}ms",
|
||||
rightColor: Colors.green),
|
||||
_row("Target Bitrate",
|
||||
"${qualityMonitorModel.data.targetBitrate ?? '-'}kb"),
|
||||
_row(
|
||||
"Codec", qualityMonitorModel.data.codecFormat ?? '-'),
|
||||
_row("Chroma", qualityMonitorModel.data.chroma ?? '-'),
|
||||
],
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink()));
|
||||
}
|
||||
|
||||
class BlockableOverlayState extends OverlayKeyState {
|
||||
final _middleBlocked = false.obs;
|
||||
|
||||
VoidCallback? onMiddleBlockedClick; // to-do use listener
|
||||
|
||||
RxBool get middleBlocked => _middleBlocked;
|
||||
|
||||
void addMiddleBlockedListener(void Function(bool) cb) {
|
||||
_middleBlocked.listen(cb);
|
||||
}
|
||||
|
||||
void setMiddleBlocked(bool blocked) {
|
||||
if (blocked != _middleBlocked.value) {
|
||||
_middleBlocked.value = blocked;
|
||||
}
|
||||
}
|
||||
|
||||
void applyFfi(FFI ffi) {
|
||||
ffi.dialogManager.setOverlayState(this);
|
||||
ffi.chatModel.setOverlayState(this);
|
||||
// make remote page penetrable automatically, effective for chat over remote
|
||||
onMiddleBlockedClick = () {
|
||||
setMiddleBlocked(false);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class BlockableOverlay extends StatelessWidget {
|
||||
final Widget underlying;
|
||||
final List<OverlayEntry>? upperLayer;
|
||||
|
||||
final BlockableOverlayState state;
|
||||
|
||||
BlockableOverlay(
|
||||
{required this.underlying, required this.state, this.upperLayer});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final initialEntries = [
|
||||
OverlayEntry(builder: (_) => underlying),
|
||||
|
||||
/// middle layer
|
||||
OverlayEntry(
|
||||
builder: (context) => Obx(() => Listener(
|
||||
onPointerDown: (_) {
|
||||
state.onMiddleBlockedClick?.call();
|
||||
},
|
||||
child: Container(
|
||||
color:
|
||||
state.middleBlocked.value ? Colors.transparent : null)))),
|
||||
];
|
||||
|
||||
if (upperLayer != null) {
|
||||
initialEntries.addAll(upperLayer!);
|
||||
}
|
||||
|
||||
/// set key
|
||||
return Overlay(key: state.key, initialEntries: initialEntries);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,598 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:dynamic_layouts/dynamic_layouts.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/models/ab_model.dart';
|
||||
import 'package:flutter_hbb/models/peer_tab_model.dart';
|
||||
import 'package:flutter_hbb/models/state_model.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:visibility_detector/visibility_detector.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import '../../common.dart';
|
||||
import '../../models/peer_model.dart';
|
||||
import '../../models/platform_model.dart';
|
||||
import 'peer_card.dart';
|
||||
|
||||
typedef PeerFilter = bool Function(Peer peer);
|
||||
typedef PeerCardBuilder = Widget Function(Peer peer);
|
||||
|
||||
class PeerSortType {
|
||||
static const String remoteId = 'Remote ID';
|
||||
static const String remoteHost = 'Remote Host';
|
||||
static const String username = 'Username';
|
||||
static const String status = 'Status';
|
||||
|
||||
static List<String> values = [
|
||||
PeerSortType.remoteId,
|
||||
PeerSortType.remoteHost,
|
||||
PeerSortType.username,
|
||||
PeerSortType.status
|
||||
];
|
||||
}
|
||||
|
||||
class LoadEvent {
|
||||
static const String recent = 'load_recent_peers';
|
||||
static const String favorite = 'load_fav_peers';
|
||||
static const String lan = 'load_lan_peers';
|
||||
static const String addressBook = 'load_address_book_peers';
|
||||
static const String group = 'load_group_peers';
|
||||
}
|
||||
|
||||
class PeersModelName {
|
||||
static const String recent = 'recent peer';
|
||||
static const String favorite = 'fav peer';
|
||||
static const String lan = 'discovered peer';
|
||||
static const String addressBook = 'address book peer';
|
||||
static const String group = 'group peer';
|
||||
}
|
||||
|
||||
/// for peer search text, global obs value
|
||||
final peerSearchText = "".obs;
|
||||
|
||||
/// for peer sort, global obs value
|
||||
RxString? _peerSort;
|
||||
RxString get peerSort {
|
||||
_peerSort ??= bind.getLocalFlutterOption(k: kOptionPeerSorting).obs;
|
||||
return _peerSort!;
|
||||
}
|
||||
|
||||
// list for listener
|
||||
RxList<RxString> get obslist => [peerSearchText, peerSort].obs;
|
||||
|
||||
final peerSearchTextController =
|
||||
TextEditingController(text: peerSearchText.value);
|
||||
|
||||
class _PeersView extends StatefulWidget {
|
||||
final Peers peers;
|
||||
final PeerFilter? peerFilter;
|
||||
final PeerCardBuilder peerCardBuilder;
|
||||
final PeerTabIndex peerTabIndex;
|
||||
|
||||
const _PeersView(
|
||||
{required this.peers,
|
||||
required this.peerCardBuilder,
|
||||
required this.peerTabIndex,
|
||||
this.peerFilter,
|
||||
Key? key})
|
||||
: super(key: key);
|
||||
|
||||
@override
|
||||
_PeersViewState createState() => _PeersViewState();
|
||||
}
|
||||
|
||||
/// State for the peer widget.
|
||||
class _PeersViewState extends State<_PeersView>
|
||||
with WindowListener, WidgetsBindingObserver {
|
||||
static const int _maxQueryCount = 3;
|
||||
final HashMap<String, String> _emptyMessages = HashMap.from({
|
||||
LoadEvent.recent: 'empty_recent_tip',
|
||||
LoadEvent.favorite: 'empty_favorite_tip',
|
||||
LoadEvent.lan: 'empty_lan_tip',
|
||||
LoadEvent.addressBook: 'empty_address_book_tip',
|
||||
});
|
||||
final space = (isDesktop || isWebDesktop) ? 12.0 : 8.0;
|
||||
final _curPeers = <String>{};
|
||||
var _lastChangeTime = DateTime.now();
|
||||
var _lastQueryPeers = <String>{};
|
||||
var _lastQueryTime = DateTime.now();
|
||||
var _lastWindowRestoreTime = DateTime.now();
|
||||
var _queryCount = 0;
|
||||
var _exit = false;
|
||||
bool _isActive = true;
|
||||
|
||||
final _scrollController = ScrollController();
|
||||
|
||||
_PeersViewState() {
|
||||
_startCheckOnlines();
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
windowManager.addListener(this);
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
windowManager.removeListener(this);
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_exit = true;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowFocus() {
|
||||
_queryCount = 0;
|
||||
_isActive = true;
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowBlur() {
|
||||
// We need this comparison because window restore (on Windows) also triggers `onWindowBlur()`.
|
||||
// Maybe it's a bug of the window manager, but the source code seems to be correct.
|
||||
//
|
||||
// Although `onWindowRestore()` is called after `onWindowBlur()` in my test,
|
||||
// we need the following comparison to ensure that `_isActive` is true in the end.
|
||||
if (isWindows &&
|
||||
DateTime.now().difference(_lastWindowRestoreTime) <
|
||||
const Duration(milliseconds: 300)) {
|
||||
return;
|
||||
}
|
||||
_queryCount = _maxQueryCount;
|
||||
_isActive = false;
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowRestore() {
|
||||
// Window restore (on MacOS and Linux) also triggers `onWindowFocus()`.
|
||||
// But on Windows, it triggers `onWindowBlur()`, mybe it's a bug of the window manager.
|
||||
if (!isWindows) return;
|
||||
_queryCount = 0;
|
||||
_isActive = true;
|
||||
_lastWindowRestoreTime = DateTime.now();
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowMinimize() {
|
||||
// Window minimize also triggers `onWindowBlur()`.
|
||||
}
|
||||
|
||||
// This function is required for mobile.
|
||||
// `onWindowFocus` works fine for desktop.
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
super.didChangeAppLifecycleState(state);
|
||||
if (isDesktop || isWebDesktop) return;
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
_isActive = true;
|
||||
_queryCount = 0;
|
||||
} else if (state == AppLifecycleState.inactive) {
|
||||
_isActive = false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// We should avoid too many rebuilds. MacOS(m1, 14.6.1) on Flutter 3.19.6.
|
||||
// Continious rebuilds of `ChangeNotifierProvider` will cause memory leak.
|
||||
// Simple demo can reproduce this issue.
|
||||
return ChangeNotifierProvider<Peers>.value(
|
||||
value: widget.peers,
|
||||
child: Consumer<Peers>(builder: (context, peers, child) {
|
||||
if (peers.peers.isEmpty) {
|
||||
gFFI.peerTabModel.setCurrentTabCachedPeers([]);
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.sentiment_very_dissatisfied_rounded,
|
||||
color: Theme.of(context).tabBarTheme.labelColor,
|
||||
size: 40,
|
||||
).paddingOnly(bottom: 10),
|
||||
Text(
|
||||
translate(
|
||||
_emptyMessages[widget.peers.loadEvent] ?? 'Empty',
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).tabBarTheme.labelColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return _buildPeersView(peers);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
onVisibilityChanged(VisibilityInfo info) {
|
||||
final peerId = _peerId((info.key as ValueKey).value);
|
||||
if (info.visibleFraction > 0.00001) {
|
||||
_curPeers.add(peerId);
|
||||
} else {
|
||||
_curPeers.remove(peerId);
|
||||
}
|
||||
_lastChangeTime = DateTime.now();
|
||||
}
|
||||
|
||||
String _cardId(String id) => widget.peers.name + id;
|
||||
String _peerId(String cardId) => cardId.replaceAll(widget.peers.name, '');
|
||||
|
||||
Widget _buildPeersView(Peers peers) {
|
||||
final updateEvent = peers.event;
|
||||
final body = ObxValue<RxList>((filters) {
|
||||
return FutureBuilder<List<Peer>>(
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
var peers = snapshot.data!;
|
||||
if (peers.length > 1000) peers = peers.sublist(0, 1000);
|
||||
gFFI.peerTabModel.setCurrentTabCachedPeers(peers);
|
||||
buildOnePeer(Peer peer, bool isPortrait) {
|
||||
final visibilityChild = VisibilityDetector(
|
||||
key: ValueKey(_cardId(peer.id)),
|
||||
onVisibilityChanged: onVisibilityChanged,
|
||||
child: widget.peerCardBuilder(peer),
|
||||
);
|
||||
// `Provider.of<PeerTabModel>(context)` will causes infinete loop.
|
||||
// Because `gFFI.peerTabModel.setCurrentTabCachedPeers(peers)` will trigger `notifyListeners()`.
|
||||
//
|
||||
// No need to listen the currentTab change event.
|
||||
// Because the currentTab change event will trigger the peers change event,
|
||||
// and the peers change event will trigger _buildPeersView().
|
||||
return !isPortrait
|
||||
? Obx(() => peerCardUiType.value == PeerUiType.list
|
||||
? Container(height: 45, child: visibilityChild)
|
||||
: peerCardUiType.value == PeerUiType.grid
|
||||
? SizedBox(
|
||||
width: 220, height: 140, child: visibilityChild)
|
||||
: SizedBox(
|
||||
width: 220, height: 42, child: visibilityChild))
|
||||
: Container(child: visibilityChild);
|
||||
}
|
||||
|
||||
// We should avoid too many rebuilds. Win10(Some machines) on Flutter 3.19.6.
|
||||
// Continious rebuilds of `ListView.builder` will cause memory leak.
|
||||
// Simple demo can reproduce this issue.
|
||||
final Widget child = Obx(() => stateGlobal.isPortrait.isTrue
|
||||
? ListView.builder(
|
||||
itemCount: peers.length,
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
return buildOnePeer(peers[index], true).marginOnly(
|
||||
top: index == 0 ? 0 : space / 2, bottom: space / 2);
|
||||
},
|
||||
)
|
||||
: peerCardUiType.value == PeerUiType.list
|
||||
? ListView.builder(
|
||||
controller: _scrollController,
|
||||
itemCount: peers.length,
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
return buildOnePeer(peers[index], false).marginOnly(
|
||||
right: space,
|
||||
top: index == 0 ? 0 : space / 2,
|
||||
bottom: space / 2);
|
||||
},
|
||||
)
|
||||
: DynamicGridView.builder(
|
||||
gridDelegate: SliverGridDelegateWithWrapping(
|
||||
mainAxisSpacing: space / 2,
|
||||
crossAxisSpacing: space),
|
||||
itemCount: peers.length,
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
return buildOnePeer(peers[index], false);
|
||||
}));
|
||||
|
||||
if (updateEvent == UpdateEvent.load) {
|
||||
_curPeers.clear();
|
||||
_curPeers.addAll(peers.map((e) => e.id));
|
||||
_queryOnlines(true);
|
||||
}
|
||||
return child;
|
||||
} else {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
}
|
||||
},
|
||||
future: matchPeers(filters[0].value, filters[1].value, peers.peers),
|
||||
);
|
||||
}, obslist);
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
var _queryInterval = const Duration(seconds: 20);
|
||||
|
||||
void _startCheckOnlines() {
|
||||
() async {
|
||||
final p = await bind.mainIsUsingPublicServer();
|
||||
if (!p) {
|
||||
_queryInterval = const Duration(seconds: 6);
|
||||
}
|
||||
while (!_exit) {
|
||||
final now = DateTime.now();
|
||||
if (!setEquals(_curPeers, _lastQueryPeers)) {
|
||||
if (now.difference(_lastChangeTime) > const Duration(seconds: 1)) {
|
||||
_queryOnlines(false);
|
||||
}
|
||||
} else {
|
||||
final skipIfIsWeb =
|
||||
isWeb && !(stateGlobal.isWebVisible && stateGlobal.isInMainPage);
|
||||
final skipIfMobile =
|
||||
(isAndroid || isIOS) && !stateGlobal.isInMainPage;
|
||||
final skipIfNotActive = skipIfIsWeb || skipIfMobile || !_isActive;
|
||||
if (!skipIfNotActive && (_queryCount < _maxQueryCount || !p)) {
|
||||
if (now.difference(_lastQueryTime) >= _queryInterval) {
|
||||
if (_curPeers.isNotEmpty) {
|
||||
bind.queryOnlines(ids: _curPeers.toList(growable: false));
|
||||
_lastQueryTime = DateTime.now();
|
||||
_queryCount += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
await Future.delayed(const Duration(milliseconds: 300));
|
||||
}
|
||||
}();
|
||||
}
|
||||
|
||||
_queryOnlines(bool isLoadEvent) {
|
||||
if (_curPeers.isNotEmpty) {
|
||||
bind.queryOnlines(ids: _curPeers.toList(growable: false));
|
||||
_queryCount = 0;
|
||||
}
|
||||
_lastQueryPeers = {..._curPeers};
|
||||
if (isLoadEvent) {
|
||||
_lastChangeTime = DateTime.now();
|
||||
} else {
|
||||
_lastQueryTime = DateTime.now().subtract(_queryInterval);
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Peer>>? matchPeers(
|
||||
String searchText, String sortedBy, List<Peer> peers) async {
|
||||
if (widget.peerFilter != null) {
|
||||
peers = peers.where((peer) => widget.peerFilter!(peer)).toList();
|
||||
}
|
||||
|
||||
// fallback to id sorting
|
||||
if (!PeerSortType.values.contains(sortedBy)) {
|
||||
sortedBy = PeerSortType.remoteId;
|
||||
bind.setLocalFlutterOption(
|
||||
k: kOptionPeerSorting,
|
||||
v: sortedBy,
|
||||
);
|
||||
}
|
||||
|
||||
if (widget.peers.loadEvent != LoadEvent.recent) {
|
||||
switch (sortedBy) {
|
||||
case PeerSortType.remoteId:
|
||||
peers.sort((p1, p2) => p1.getId().compareTo(p2.getId()));
|
||||
break;
|
||||
case PeerSortType.remoteHost:
|
||||
peers.sort((p1, p2) =>
|
||||
p1.hostname.toLowerCase().compareTo(p2.hostname.toLowerCase()));
|
||||
break;
|
||||
case PeerSortType.username:
|
||||
peers.sort((p1, p2) =>
|
||||
p1.username.toLowerCase().compareTo(p2.username.toLowerCase()));
|
||||
break;
|
||||
case PeerSortType.status:
|
||||
peers.sort((p1, p2) => p1.online ? -1 : 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
searchText = searchText.trim();
|
||||
if (searchText.isEmpty) {
|
||||
return peers;
|
||||
}
|
||||
searchText = searchText.toLowerCase();
|
||||
final matches = await Future.wait(
|
||||
peers.map((peer) => matchPeer(searchText, peer, widget.peerTabIndex)));
|
||||
final filteredList = List<Peer>.empty(growable: true);
|
||||
for (var i = 0; i < peers.length; i++) {
|
||||
if (matches[i]) {
|
||||
filteredList.add(peers[i]);
|
||||
}
|
||||
}
|
||||
|
||||
return filteredList;
|
||||
}
|
||||
}
|
||||
|
||||
abstract class BasePeersView extends StatelessWidget {
|
||||
final PeerTabIndex peerTabIndex;
|
||||
final PeerFilter? peerFilter;
|
||||
final PeerCardBuilder peerCardBuilder;
|
||||
|
||||
const BasePeersView({
|
||||
Key? key,
|
||||
required this.peerTabIndex,
|
||||
this.peerFilter,
|
||||
required this.peerCardBuilder,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Peers peers;
|
||||
switch (peerTabIndex) {
|
||||
case PeerTabIndex.recent:
|
||||
peers = gFFI.recentPeersModel;
|
||||
break;
|
||||
case PeerTabIndex.fav:
|
||||
peers = gFFI.favoritePeersModel;
|
||||
break;
|
||||
case PeerTabIndex.lan:
|
||||
peers = gFFI.lanPeersModel;
|
||||
break;
|
||||
case PeerTabIndex.ab:
|
||||
peers = gFFI.abModel.peersModel;
|
||||
break;
|
||||
case PeerTabIndex.group:
|
||||
peers = gFFI.groupModel.peersModel;
|
||||
break;
|
||||
}
|
||||
return _PeersView(
|
||||
peers: peers,
|
||||
peerFilter: peerFilter,
|
||||
peerCardBuilder: peerCardBuilder,
|
||||
peerTabIndex: peerTabIndex);
|
||||
}
|
||||
}
|
||||
|
||||
class RecentPeersView extends BasePeersView {
|
||||
RecentPeersView(
|
||||
{Key? key, EdgeInsets? menuPadding, ScrollController? scrollController})
|
||||
: super(
|
||||
key: key,
|
||||
peerTabIndex: PeerTabIndex.recent,
|
||||
peerCardBuilder: (Peer peer) => RecentPeerCard(
|
||||
peer: peer,
|
||||
menuPadding: menuPadding,
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final widget = super.build(context);
|
||||
bind.mainLoadRecentPeers();
|
||||
return widget;
|
||||
}
|
||||
}
|
||||
|
||||
class FavoritePeersView extends BasePeersView {
|
||||
FavoritePeersView(
|
||||
{Key? key, EdgeInsets? menuPadding, ScrollController? scrollController})
|
||||
: super(
|
||||
key: key,
|
||||
peerTabIndex: PeerTabIndex.fav,
|
||||
peerCardBuilder: (Peer peer) => FavoritePeerCard(
|
||||
peer: peer,
|
||||
menuPadding: menuPadding,
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final widget = super.build(context);
|
||||
bind.mainLoadFavPeers();
|
||||
return widget;
|
||||
}
|
||||
}
|
||||
|
||||
class DiscoveredPeersView extends BasePeersView {
|
||||
DiscoveredPeersView(
|
||||
{Key? key, EdgeInsets? menuPadding, ScrollController? scrollController})
|
||||
: super(
|
||||
key: key,
|
||||
peerTabIndex: PeerTabIndex.lan,
|
||||
peerCardBuilder: (Peer peer) => DiscoveredPeerCard(
|
||||
peer: peer,
|
||||
menuPadding: menuPadding,
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final widget = super.build(context);
|
||||
bind.mainLoadLanPeers();
|
||||
bind.mainDiscover();
|
||||
return widget;
|
||||
}
|
||||
}
|
||||
|
||||
class AddressBookPeersView extends BasePeersView {
|
||||
AddressBookPeersView(
|
||||
{Key? key, EdgeInsets? menuPadding, ScrollController? scrollController})
|
||||
: super(
|
||||
key: key,
|
||||
peerTabIndex: PeerTabIndex.ab,
|
||||
peerFilter: (Peer peer) =>
|
||||
_hitTag(gFFI.abModel.selectedTags, peer.tags),
|
||||
peerCardBuilder: (Peer peer) => AddressBookPeerCard(
|
||||
peer: peer,
|
||||
menuPadding: menuPadding,
|
||||
),
|
||||
);
|
||||
|
||||
static bool _hitTag(List<dynamic> selectedTags, List<dynamic> idents) {
|
||||
if (selectedTags.isEmpty) {
|
||||
return true;
|
||||
}
|
||||
// The result of a no-tag union with normal tags, still allows normal tags to perform union or intersection operations.
|
||||
final selectedNormalTags =
|
||||
selectedTags.where((tag) => tag != kUntagged).toList();
|
||||
if (selectedTags.contains(kUntagged)) {
|
||||
if (idents.isEmpty) return true;
|
||||
if (selectedNormalTags.isEmpty) return false;
|
||||
}
|
||||
if (gFFI.abModel.filterByIntersection.value) {
|
||||
for (final tag in selectedNormalTags) {
|
||||
if (!idents.contains(tag)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} else {
|
||||
for (final tag in selectedNormalTags) {
|
||||
if (idents.contains(tag)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class MyGroupPeerView extends BasePeersView {
|
||||
MyGroupPeerView(
|
||||
{Key? key, EdgeInsets? menuPadding, ScrollController? scrollController})
|
||||
: super(
|
||||
key: key,
|
||||
peerTabIndex: PeerTabIndex.group,
|
||||
peerFilter: filter,
|
||||
peerCardBuilder: (Peer peer) => MyGroupPeerCard(
|
||||
peer: peer,
|
||||
menuPadding: menuPadding,
|
||||
),
|
||||
);
|
||||
|
||||
static bool filter(Peer peer) {
|
||||
final model = gFFI.groupModel;
|
||||
if (model.searchAccessibleItemNameText.isNotEmpty) {
|
||||
final text = model.searchAccessibleItemNameText.value.toLowerCase();
|
||||
final searchPeersOfUser = model.users.any((user) =>
|
||||
user.name == peer.loginName &&
|
||||
(user.name.toLowerCase().contains(text) ||
|
||||
user.displayNameOrName.toLowerCase().contains(text)));
|
||||
final searchPeersOfDeviceGroup =
|
||||
peer.device_group_name.toLowerCase().contains(text) &&
|
||||
model.deviceGroups.any((g) => g.name == peer.device_group_name);
|
||||
if (!searchPeersOfUser && !searchPeersOfDeviceGroup) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (model.selectedAccessibleItemName.isNotEmpty) {
|
||||
if (model.isSelectedDeviceGroup.value) {
|
||||
if (model.selectedAccessibleItemName.value != peer.device_group_name) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
if (model.selectedAccessibleItemName.value != peer.loginName) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,692 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
|
||||
import 'package:flutter_hbb/models/platform_model.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/models/model.dart';
|
||||
import 'package:flutter_hbb/models/input_model.dart';
|
||||
|
||||
import './gestures.dart';
|
||||
|
||||
class RawKeyFocusScope extends StatelessWidget {
|
||||
final FocusNode? focusNode;
|
||||
final ValueChanged<bool>? onFocusChange;
|
||||
final InputModel inputModel;
|
||||
final Widget child;
|
||||
|
||||
RawKeyFocusScope({
|
||||
this.focusNode,
|
||||
this.onFocusChange,
|
||||
required this.inputModel,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// https://github.com/flutter/flutter/issues/154053
|
||||
final useRawKeyEvents = isLinux && !isWeb;
|
||||
// FIXME: On Windows, `AltGr` will generate `Alt` and `Control` key events,
|
||||
// while `Alt` and `Control` are separated key events for en-US input method.
|
||||
return FocusScope(
|
||||
autofocus: true,
|
||||
child: Focus(
|
||||
autofocus: true,
|
||||
canRequestFocus: true,
|
||||
focusNode: focusNode,
|
||||
onFocusChange: onFocusChange,
|
||||
onKey: useRawKeyEvents
|
||||
? (FocusNode data, RawKeyEvent event) =>
|
||||
inputModel.handleRawKeyEvent(event)
|
||||
: null,
|
||||
onKeyEvent: useRawKeyEvents
|
||||
? null
|
||||
: (FocusNode node, KeyEvent event) =>
|
||||
inputModel.handleKeyEvent(event),
|
||||
child: child));
|
||||
}
|
||||
}
|
||||
|
||||
// For virtual mouse when using the mouse mode on mobile.
|
||||
// Special hold-drag mode: one finger holds a button (left/right button), another finger pans.
|
||||
// This flag is to override the scale gesture to a pan gesture.
|
||||
bool isSpecialHoldDragActive = false;
|
||||
// Cache the last focal point to calculate deltas in special hold-drag mode.
|
||||
Offset _lastSpecialHoldDragFocalPoint = Offset.zero;
|
||||
|
||||
class RawTouchGestureDetectorRegion extends StatefulWidget {
|
||||
final Widget child;
|
||||
final FFI ffi;
|
||||
final bool isCamera;
|
||||
late final InputModel inputModel = ffi.inputModel;
|
||||
late final FfiModel ffiModel = ffi.ffiModel;
|
||||
|
||||
RawTouchGestureDetectorRegion({
|
||||
required this.child,
|
||||
required this.ffi,
|
||||
this.isCamera = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<RawTouchGestureDetectorRegion> createState() =>
|
||||
_RawTouchGestureDetectorRegionState();
|
||||
}
|
||||
|
||||
/// touchMode only:
|
||||
/// LongPress -> right click
|
||||
/// OneFingerPan -> start/end -> left down start/end
|
||||
/// onDoubleTapDown -> move to
|
||||
/// onLongPressDown => move to
|
||||
///
|
||||
/// mouseMode only:
|
||||
/// DoubleFiner -> right click
|
||||
/// HoldDrag -> left drag
|
||||
class _RawTouchGestureDetectorRegionState
|
||||
extends State<RawTouchGestureDetectorRegion> {
|
||||
Offset _cacheLongPressPosition = Offset(0, 0);
|
||||
// Timestamp of the last long press event.
|
||||
int _cacheLongPressPositionTs = 0;
|
||||
double _mouseScrollIntegral = 0; // mouse scroll speed controller
|
||||
double _scale = 1;
|
||||
|
||||
// Workaround tap down event when two fingers are used to scale(mobile)
|
||||
TapDownDetails? _lastTapDownDetails;
|
||||
|
||||
PointerDeviceKind? lastDeviceKind;
|
||||
|
||||
// For touch mode, onDoubleTap
|
||||
// `onDoubleTap()` does not provide the position of the tap event.
|
||||
Offset _lastPosOfDoubleTapDown = Offset.zero;
|
||||
bool _touchModePanStarted = false;
|
||||
Offset _doubleFinerTapPosition = Offset.zero;
|
||||
|
||||
// For mouse mode, we need to block the events when the cursor is in a blocked area.
|
||||
// So we need to cache the last tap down position.
|
||||
Offset? _lastTapDownPositionForMouseMode;
|
||||
// Cache global position for onTap (which lacks position info).
|
||||
Offset? _lastTapDownGlobalPosition;
|
||||
|
||||
FFI get ffi => widget.ffi;
|
||||
FfiModel get ffiModel => widget.ffiModel;
|
||||
InputModel get inputModel => widget.inputModel;
|
||||
bool get handleTouch => (isDesktop || isWebDesktop) || ffiModel.touchMode;
|
||||
SessionID get sessionId => ffi.sessionId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return RawGestureDetector(
|
||||
child: widget.child,
|
||||
gestures: makeGestures(context),
|
||||
);
|
||||
}
|
||||
|
||||
bool isNotTouchBasedDevice() {
|
||||
return !kTouchBasedDeviceKinds.contains(lastDeviceKind);
|
||||
}
|
||||
|
||||
// Mobile, mouse mode.
|
||||
// Check if should block the mouse tap event (`_lastTapDownPositionForMouseMode`).
|
||||
bool shouldBlockMouseModeEvent() {
|
||||
return _lastTapDownPositionForMouseMode != null &&
|
||||
ffi.cursorModel.shouldBlock(_lastTapDownPositionForMouseMode!.dx,
|
||||
_lastTapDownPositionForMouseMode!.dy);
|
||||
}
|
||||
|
||||
onTapDown(TapDownDetails d) async {
|
||||
lastDeviceKind = d.kind;
|
||||
_lastTapDownGlobalPosition = d.globalPosition;
|
||||
if (isNotTouchBasedDevice()) {
|
||||
return;
|
||||
}
|
||||
if (handleTouch) {
|
||||
_lastPosOfDoubleTapDown = d.localPosition;
|
||||
// Desktop or mobile "Touch mode"
|
||||
_lastTapDownDetails = d;
|
||||
} else {
|
||||
_lastTapDownPositionForMouseMode = d.localPosition;
|
||||
}
|
||||
}
|
||||
|
||||
onTapUp(TapUpDetails d) async {
|
||||
final TapDownDetails? lastTapDownDetails = _lastTapDownDetails;
|
||||
_lastTapDownDetails = null;
|
||||
if (isNotTouchBasedDevice()) {
|
||||
return;
|
||||
}
|
||||
// Filter duplicate touch tap events on iOS (Magic Mouse issue).
|
||||
if (inputModel.shouldIgnoreTouchTap(d.globalPosition)) {
|
||||
return;
|
||||
}
|
||||
if (handleTouch) {
|
||||
final isMoved =
|
||||
await ffi.cursorModel.move(d.localPosition.dx, d.localPosition.dy);
|
||||
if (isMoved) {
|
||||
// If pan already handled 'down', don't send it again.
|
||||
if (lastTapDownDetails != null && !_touchModePanStarted) {
|
||||
await inputModel.tapDown(MouseButtons.left);
|
||||
}
|
||||
await inputModel.tapUp(MouseButtons.left);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onTap() async {
|
||||
if (isNotTouchBasedDevice()) {
|
||||
return;
|
||||
}
|
||||
// Filter duplicate touch tap events on iOS (Magic Mouse issue).
|
||||
final lastPos = _lastTapDownGlobalPosition;
|
||||
if (lastPos != null && inputModel.shouldIgnoreTouchTap(lastPos)) {
|
||||
return;
|
||||
}
|
||||
if (!handleTouch) {
|
||||
// Cannot use `_lastTapDownDetails` because Flutter calls `onTapUp` before `onTap`, clearing the cached details.
|
||||
// Using `_lastTapDownPositionForMouseMode` instead.
|
||||
if (shouldBlockMouseModeEvent()) {
|
||||
return;
|
||||
}
|
||||
// Mobile, "Mouse mode"
|
||||
await inputModel.tap(MouseButtons.left);
|
||||
}
|
||||
}
|
||||
|
||||
onDoubleTapDown(TapDownDetails d) async {
|
||||
lastDeviceKind = d.kind;
|
||||
if (isNotTouchBasedDevice()) {
|
||||
return;
|
||||
}
|
||||
if (handleTouch) {
|
||||
_lastPosOfDoubleTapDown = d.localPosition;
|
||||
await ffi.cursorModel.move(d.localPosition.dx, d.localPosition.dy);
|
||||
} else {
|
||||
_lastTapDownPositionForMouseMode = d.localPosition;
|
||||
}
|
||||
}
|
||||
|
||||
onDoubleTap() async {
|
||||
if (isNotTouchBasedDevice()) {
|
||||
return;
|
||||
}
|
||||
if (ffiModel.touchMode && ffi.cursorModel.lastIsBlocked) {
|
||||
return;
|
||||
}
|
||||
if (handleTouch &&
|
||||
!ffi.cursorModel.isInRemoteRect(_lastPosOfDoubleTapDown)) {
|
||||
return;
|
||||
}
|
||||
// Check if the position is in a blocked area when using the mouse mode.
|
||||
if (!handleTouch) {
|
||||
if (shouldBlockMouseModeEvent()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await inputModel.tap(MouseButtons.left);
|
||||
await inputModel.tap(MouseButtons.left);
|
||||
}
|
||||
|
||||
onLongPressDown(LongPressDownDetails d) async {
|
||||
lastDeviceKind = d.kind;
|
||||
if (isNotTouchBasedDevice()) {
|
||||
return;
|
||||
}
|
||||
if (handleTouch) {
|
||||
_lastPosOfDoubleTapDown = d.localPosition;
|
||||
_cacheLongPressPosition = d.localPosition;
|
||||
if (!ffi.cursorModel.isInRemoteRect(d.localPosition)) {
|
||||
return;
|
||||
}
|
||||
_cacheLongPressPositionTs = DateTime.now().millisecondsSinceEpoch;
|
||||
if (ffiModel.isPeerMobile) {
|
||||
await ffi.cursorModel
|
||||
.move(_cacheLongPressPosition.dx, _cacheLongPressPosition.dy);
|
||||
await inputModel.tapDown(MouseButtons.left);
|
||||
}
|
||||
} else {
|
||||
_lastTapDownPositionForMouseMode = d.localPosition;
|
||||
}
|
||||
}
|
||||
|
||||
onLongPressUp() async {
|
||||
if (isNotTouchBasedDevice()) {
|
||||
return;
|
||||
}
|
||||
if (handleTouch) {
|
||||
await inputModel.tapUp(MouseButtons.left);
|
||||
}
|
||||
}
|
||||
|
||||
// for mobiles
|
||||
onLongPress() async {
|
||||
if (isNotTouchBasedDevice()) {
|
||||
return;
|
||||
}
|
||||
if (!ffi.ffiModel.isPeerMobile) {
|
||||
if (handleTouch) {
|
||||
final isMoved = await ffi.cursorModel
|
||||
.move(_cacheLongPressPosition.dx, _cacheLongPressPosition.dy);
|
||||
if (!isMoved) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (shouldBlockMouseModeEvent()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await inputModel.tap(MouseButtons.right);
|
||||
} else {
|
||||
// It's better to send a message to tell the controlled device that the long press event is triggered.
|
||||
// We're now using a `TimerTask` in `InputService.kt` to decide whether to trigger the long press event.
|
||||
// It's not accurate and it's better to use the same detection logic in the controlling side.
|
||||
}
|
||||
}
|
||||
|
||||
onLongPressMoveUpdate(LongPressMoveUpdateDetails d) async {
|
||||
if (!ffiModel.isPeerMobile || isNotTouchBasedDevice()) {
|
||||
return;
|
||||
}
|
||||
if (handleTouch) {
|
||||
if (!ffi.cursorModel.isInRemoteRect(d.localPosition)) {
|
||||
return;
|
||||
}
|
||||
await ffi.cursorModel.move(d.localPosition.dx, d.localPosition.dy);
|
||||
}
|
||||
}
|
||||
|
||||
onDoubleFinerTapDown(TapDownDetails d) async {
|
||||
lastDeviceKind = d.kind;
|
||||
if (isNotTouchBasedDevice()) {
|
||||
return;
|
||||
}
|
||||
_doubleFinerTapPosition = d.localPosition;
|
||||
// ignore for desktop and mobile
|
||||
}
|
||||
|
||||
onDoubleFinerTap(TapDownDetails d) async {
|
||||
lastDeviceKind = d.kind;
|
||||
if (isNotTouchBasedDevice()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// mobile mouse mode or desktop touch screen
|
||||
final isMobileMouseMode = isMobile && !ffiModel.touchMode;
|
||||
// We can't use `d.localPosition` here because it's always (0, 0) on desktop.
|
||||
final isDesktopInRemoteRect = (isDesktop || isWebDesktop) &&
|
||||
ffi.cursorModel.isInRemoteRect(_doubleFinerTapPosition);
|
||||
if (isMobileMouseMode || isDesktopInRemoteRect) {
|
||||
await inputModel.tap(MouseButtons.right);
|
||||
}
|
||||
}
|
||||
|
||||
onHoldDragStart(DragStartDetails d) async {
|
||||
lastDeviceKind = d.kind;
|
||||
if (isNotTouchBasedDevice()) {
|
||||
return;
|
||||
}
|
||||
if (!handleTouch) {
|
||||
if (isSpecialHoldDragActive) return;
|
||||
await inputModel.sendMouse('down', MouseButtons.left);
|
||||
}
|
||||
}
|
||||
|
||||
onHoldDragUpdate(DragUpdateDetails d) async {
|
||||
if (isNotTouchBasedDevice()) {
|
||||
return;
|
||||
}
|
||||
if (!handleTouch) {
|
||||
if (isSpecialHoldDragActive) return;
|
||||
await ffi.cursorModel.updatePan(d.delta, d.localPosition, handleTouch);
|
||||
}
|
||||
}
|
||||
|
||||
onHoldDragEnd(DragEndDetails d) async {
|
||||
if (isNotTouchBasedDevice()) {
|
||||
return;
|
||||
}
|
||||
if (!handleTouch) {
|
||||
await inputModel.sendMouse('up', MouseButtons.left);
|
||||
}
|
||||
}
|
||||
|
||||
onOneFingerPanStart(BuildContext context, DragStartDetails d) async {
|
||||
final TapDownDetails? lastTapDownDetails = _lastTapDownDetails;
|
||||
_lastTapDownDetails = null;
|
||||
lastDeviceKind = d.kind ?? lastDeviceKind;
|
||||
if (isNotTouchBasedDevice()) {
|
||||
return;
|
||||
}
|
||||
if (handleTouch) {
|
||||
if (lastTapDownDetails != null) {
|
||||
await ffi.cursorModel.move(lastTapDownDetails.localPosition.dx,
|
||||
lastTapDownDetails.localPosition.dy);
|
||||
}
|
||||
if (ffi.cursorModel.shouldBlock(d.localPosition.dx, d.localPosition.dy)) {
|
||||
return;
|
||||
}
|
||||
if (!ffi.cursorModel.isInRemoteRect(d.localPosition)) {
|
||||
return;
|
||||
}
|
||||
|
||||
_touchModePanStarted = true;
|
||||
if (isDesktop || isWebDesktop) {
|
||||
ffi.cursorModel.trySetRemoteWindowCoords();
|
||||
}
|
||||
|
||||
// Workaround for the issue that the first pan event is sent a long time after the start event.
|
||||
// If the time interval between the start event and the first pan event is less than 500ms,
|
||||
// we consider to use the long press position as the start position.
|
||||
//
|
||||
// TODO: We should find a better way to send the first pan event as soon as possible.
|
||||
if (DateTime.now().millisecondsSinceEpoch - _cacheLongPressPositionTs <
|
||||
500) {
|
||||
await ffi.cursorModel
|
||||
.move(_cacheLongPressPosition.dx, _cacheLongPressPosition.dy);
|
||||
}
|
||||
// In relative mouse mode, skip mouse down - only send movement via sendMobileRelativeMouseMove
|
||||
if (!inputModel.relativeMouseMode.value) {
|
||||
await inputModel.sendMouse('down', MouseButtons.left);
|
||||
}
|
||||
await ffi.cursorModel.move(d.localPosition.dx, d.localPosition.dy);
|
||||
} else {
|
||||
final offset = ffi.cursorModel.offset;
|
||||
final cursorX = offset.dx;
|
||||
final cursorY = offset.dy;
|
||||
final visible =
|
||||
ffi.cursorModel.getVisibleRect().inflate(1); // extend edges
|
||||
final size = MediaQueryData.fromView(View.of(context)).size;
|
||||
if (!visible.contains(Offset(cursorX, cursorY))) {
|
||||
await ffi.cursorModel.move(size.width / 2, size.height / 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onOneFingerPanUpdate(DragUpdateDetails d) async {
|
||||
if (isNotTouchBasedDevice()) {
|
||||
return;
|
||||
}
|
||||
if (ffi.cursorModel.shouldBlock(d.localPosition.dx, d.localPosition.dy)) {
|
||||
return;
|
||||
}
|
||||
if (handleTouch && !_touchModePanStarted) {
|
||||
return;
|
||||
}
|
||||
// In relative mouse mode, send delta directly without position tracking.
|
||||
if (inputModel.relativeMouseMode.value) {
|
||||
await inputModel.sendMobileRelativeMouseMove(d.delta.dx, d.delta.dy);
|
||||
} else {
|
||||
await ffi.cursorModel.updatePan(d.delta, d.localPosition, handleTouch);
|
||||
}
|
||||
}
|
||||
|
||||
onOneFingerPanEnd(DragEndDetails d) async {
|
||||
_touchModePanStarted = false;
|
||||
if (isNotTouchBasedDevice()) {
|
||||
return;
|
||||
}
|
||||
if (isDesktop || isWebDesktop) {
|
||||
ffi.cursorModel.clearRemoteWindowCoords();
|
||||
}
|
||||
if (handleTouch) {
|
||||
// In relative mouse mode, skip mouse up - matches the skipped mouse down in onOneFingerPanStart
|
||||
if (!inputModel.relativeMouseMode.value) {
|
||||
await inputModel.sendMouse('up', MouseButtons.left);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reset `_touchModePanStarted` if the one-finger pan gesture is cancelled
|
||||
// or rejected by the gesture arena. Without this, the flag can remain
|
||||
// stuck in the "started" state and cause issues such as the Magic Mouse
|
||||
// double-click problem on iPad with magic mouse.
|
||||
onOneFingerPanCancel() {
|
||||
_touchModePanStarted = false;
|
||||
}
|
||||
|
||||
// scale + pan event
|
||||
onTwoFingerScaleStart(ScaleStartDetails d) {
|
||||
_lastTapDownDetails = null;
|
||||
if (isNotTouchBasedDevice()) {
|
||||
return;
|
||||
}
|
||||
if (isSpecialHoldDragActive) {
|
||||
// Initialize the last focal point to calculate deltas manually.
|
||||
_lastSpecialHoldDragFocalPoint = d.focalPoint;
|
||||
}
|
||||
}
|
||||
|
||||
onTwoFingerScaleUpdate(ScaleUpdateDetails d) async {
|
||||
if (isNotTouchBasedDevice()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If in special drag mode, perform a pan instead of a scale.
|
||||
if (isSpecialHoldDragActive) {
|
||||
// Calculate delta manually to avoid the jumpy behavior.
|
||||
final delta = d.focalPoint - _lastSpecialHoldDragFocalPoint;
|
||||
_lastSpecialHoldDragFocalPoint = d.focalPoint;
|
||||
await ffi.cursorModel.updatePan(delta * 2.0, d.focalPoint, handleTouch);
|
||||
return;
|
||||
}
|
||||
|
||||
if ((isDesktop || isWebDesktop)) {
|
||||
final scale = ((d.scale - _scale) * 1000).toInt();
|
||||
_scale = d.scale;
|
||||
|
||||
if (scale != 0) {
|
||||
if (widget.isCamera) return;
|
||||
await bind.sessionSendPointer(
|
||||
sessionId: sessionId,
|
||||
msg: json.encode(
|
||||
PointerEventToRust(kPointerEventKindTouch, 'scale', scale)
|
||||
.toJson()));
|
||||
}
|
||||
} else {
|
||||
// mobile
|
||||
ffi.canvasModel.updateScale(d.scale / _scale, d.focalPoint);
|
||||
_scale = d.scale;
|
||||
ffi.canvasModel.panX(d.focalPointDelta.dx);
|
||||
ffi.canvasModel.panY(d.focalPointDelta.dy);
|
||||
}
|
||||
}
|
||||
|
||||
onTwoFingerScaleEnd(ScaleEndDetails d) async {
|
||||
if (isNotTouchBasedDevice()) {
|
||||
return;
|
||||
}
|
||||
if ((isDesktop || isWebDesktop)) {
|
||||
if (widget.isCamera) return;
|
||||
await bind.sessionSendPointer(
|
||||
sessionId: sessionId,
|
||||
msg: json.encode(
|
||||
PointerEventToRust(kPointerEventKindTouch, 'scale', 0).toJson()));
|
||||
} else {
|
||||
// mobile
|
||||
_scale = 1;
|
||||
// No idea why we need to set the view style to "" here.
|
||||
// bind.sessionSetViewStyle(sessionId: sessionId, value: "");
|
||||
}
|
||||
if (!isSpecialHoldDragActive) {
|
||||
await inputModel.sendMouse('up', MouseButtons.left);
|
||||
}
|
||||
}
|
||||
|
||||
get onHoldDragCancel => null;
|
||||
get onThreeFingerVerticalDragUpdate => ffi.ffiModel.isPeerAndroid
|
||||
? null
|
||||
: (d) {
|
||||
_mouseScrollIntegral += d.delta.dy / 4;
|
||||
if (_mouseScrollIntegral > 1) {
|
||||
inputModel.scroll(1);
|
||||
_mouseScrollIntegral = 0;
|
||||
} else if (_mouseScrollIntegral < -1) {
|
||||
inputModel.scroll(-1);
|
||||
_mouseScrollIntegral = 0;
|
||||
}
|
||||
};
|
||||
|
||||
makeGestures(BuildContext context) {
|
||||
return <Type, GestureRecognizerFactory>{
|
||||
// Official
|
||||
TapGestureRecognizer:
|
||||
GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
|
||||
() => TapGestureRecognizer(
|
||||
supportedDevices: kTouchBasedDeviceKinds,
|
||||
), (instance) {
|
||||
instance
|
||||
..onTapDown = onTapDown
|
||||
..onTapUp = onTapUp
|
||||
..onTap = onTap;
|
||||
}),
|
||||
DoubleTapGestureRecognizer:
|
||||
GestureRecognizerFactoryWithHandlers<DoubleTapGestureRecognizer>(
|
||||
() => DoubleTapGestureRecognizer(
|
||||
supportedDevices: kTouchBasedDeviceKinds,
|
||||
), (instance) {
|
||||
instance
|
||||
..onDoubleTapDown = onDoubleTapDown
|
||||
..onDoubleTap = onDoubleTap;
|
||||
}),
|
||||
LongPressGestureRecognizer:
|
||||
GestureRecognizerFactoryWithHandlers<LongPressGestureRecognizer>(
|
||||
() => LongPressGestureRecognizer(
|
||||
supportedDevices: kTouchBasedDeviceKinds,
|
||||
), (instance) {
|
||||
instance
|
||||
..onLongPressDown = onLongPressDown
|
||||
..onLongPressUp = onLongPressUp
|
||||
..onLongPress = onLongPress
|
||||
..onLongPressMoveUpdate = onLongPressMoveUpdate;
|
||||
}),
|
||||
// Customized
|
||||
HoldTapMoveGestureRecognizer:
|
||||
GestureRecognizerFactoryWithHandlers<HoldTapMoveGestureRecognizer>(
|
||||
() => HoldTapMoveGestureRecognizer(
|
||||
supportedDevices: kTouchBasedDeviceKinds,
|
||||
),
|
||||
(instance) => instance
|
||||
..onHoldDragStart = onHoldDragStart
|
||||
..onHoldDragUpdate = onHoldDragUpdate
|
||||
..onHoldDragCancel = onHoldDragCancel
|
||||
..onHoldDragEnd = onHoldDragEnd),
|
||||
DoubleFinerTapGestureRecognizer:
|
||||
GestureRecognizerFactoryWithHandlers<DoubleFinerTapGestureRecognizer>(
|
||||
() => DoubleFinerTapGestureRecognizer(
|
||||
supportedDevices: kTouchBasedDeviceKinds,
|
||||
), (instance) {
|
||||
instance
|
||||
..onDoubleFinerTap = onDoubleFinerTap
|
||||
..onDoubleFinerTapDown = onDoubleFinerTapDown;
|
||||
}),
|
||||
CustomTouchGestureRecognizer:
|
||||
GestureRecognizerFactoryWithHandlers<CustomTouchGestureRecognizer>(
|
||||
() => CustomTouchGestureRecognizer(
|
||||
supportedDevices: kTouchBasedDeviceKinds,
|
||||
), (instance) {
|
||||
instance.onOneFingerPanStart =
|
||||
(DragStartDetails d) => onOneFingerPanStart(context, d);
|
||||
instance
|
||||
..onOneFingerPanUpdate = onOneFingerPanUpdate
|
||||
..onOneFingerPanEnd = onOneFingerPanEnd
|
||||
..onOneFingerPanCancel = onOneFingerPanCancel
|
||||
..onTwoFingerScaleStart = onTwoFingerScaleStart
|
||||
..onTwoFingerScaleUpdate = onTwoFingerScaleUpdate
|
||||
..onTwoFingerScaleEnd = onTwoFingerScaleEnd
|
||||
..onThreeFingerVerticalDragUpdate = onThreeFingerVerticalDragUpdate;
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class RawPointerMouseRegion extends StatelessWidget {
|
||||
final InputModel inputModel;
|
||||
final Widget child;
|
||||
final MouseCursor? cursor;
|
||||
final PointerEnterEventListener? onEnter;
|
||||
final PointerExitEventListener? onExit;
|
||||
final PointerDownEventListener? onPointerDown;
|
||||
final PointerUpEventListener? onPointerUp;
|
||||
|
||||
RawPointerMouseRegion({
|
||||
this.onEnter,
|
||||
this.onExit,
|
||||
this.cursor,
|
||||
this.onPointerDown,
|
||||
this.onPointerUp,
|
||||
required this.inputModel,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Listener(
|
||||
onPointerHover: inputModel.onPointHoverImage,
|
||||
onPointerDown: (evt) {
|
||||
onPointerDown?.call(evt);
|
||||
inputModel.onPointDownImage(evt);
|
||||
},
|
||||
onPointerUp: (evt) {
|
||||
onPointerUp?.call(evt);
|
||||
inputModel.onPointUpImage(evt);
|
||||
},
|
||||
onPointerMove: inputModel.onPointMoveImage,
|
||||
onPointerSignal: inputModel.onPointerSignalImage,
|
||||
onPointerPanZoomStart: inputModel.onPointerPanZoomStart,
|
||||
onPointerPanZoomUpdate: inputModel.onPointerPanZoomUpdate,
|
||||
onPointerPanZoomEnd: inputModel.onPointerPanZoomEnd,
|
||||
child: MouseRegion(
|
||||
cursor: inputModel.isViewOnly
|
||||
? MouseCursor.defer
|
||||
: (cursor ?? MouseCursor.defer),
|
||||
onEnter: onEnter,
|
||||
onExit: onExit,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CameraRawPointerMouseRegion extends StatelessWidget {
|
||||
final InputModel inputModel;
|
||||
final Widget child;
|
||||
final PointerEnterEventListener? onEnter;
|
||||
final PointerExitEventListener? onExit;
|
||||
final PointerDownEventListener? onPointerDown;
|
||||
final PointerUpEventListener? onPointerUp;
|
||||
|
||||
CameraRawPointerMouseRegion({
|
||||
this.onEnter,
|
||||
this.onExit,
|
||||
this.onPointerDown,
|
||||
this.onPointerUp,
|
||||
required this.inputModel,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Listener(
|
||||
onPointerHover: (evt) {
|
||||
final offset = evt.position;
|
||||
double x = offset.dx;
|
||||
double y = max(0.0, offset.dy);
|
||||
inputModel.handlePointerDevicePos(
|
||||
kPointerEventKindMouse, x, y, true, kMouseEventTypeDefault);
|
||||
},
|
||||
onPointerDown: (evt) {
|
||||
onPointerDown?.call(evt);
|
||||
},
|
||||
onPointerUp: (evt) {
|
||||
onPointerUp?.call(evt);
|
||||
},
|
||||
child: MouseRegion(
|
||||
cursor: MouseCursor.defer,
|
||||
onEnter: onEnter,
|
||||
onExit: onExit,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
import 'package:debounce_throttle/debounce_throttle.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/models/platform_model.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
customImageQualityWidget(
|
||||
{required double initQuality,
|
||||
required double initFps,
|
||||
required Function(double)? setQuality,
|
||||
required Function(double)? setFps,
|
||||
required bool showFps,
|
||||
required bool showMoreQuality}) {
|
||||
if (initQuality < kMinQuality ||
|
||||
initQuality > (showMoreQuality ? kMaxMoreQuality : kMaxQuality)) {
|
||||
initQuality = kDefaultQuality;
|
||||
}
|
||||
if (initFps < kMinFps || initFps > kMaxFps) {
|
||||
initFps = kDefaultFps;
|
||||
}
|
||||
final qualityValue = initQuality.obs;
|
||||
final fpsValue = initFps.obs;
|
||||
|
||||
final RxBool moreQualityChecked = RxBool(qualityValue.value > kMaxQuality);
|
||||
final debouncerQuality = Debouncer<double>(
|
||||
Duration(milliseconds: 1000),
|
||||
onChanged: setQuality,
|
||||
initialValue: qualityValue.value,
|
||||
);
|
||||
final debouncerFps = Debouncer<double>(
|
||||
Duration(milliseconds: 1000),
|
||||
onChanged: setFps,
|
||||
initialValue: fpsValue.value,
|
||||
);
|
||||
|
||||
onMoreChanged(bool? value) {
|
||||
if (value == null) return;
|
||||
moreQualityChecked.value = value;
|
||||
if (!value && qualityValue.value > 100) {
|
||||
qualityValue.value = 100;
|
||||
}
|
||||
debouncerQuality.value = qualityValue.value;
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Obx(() => Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Slider(
|
||||
value: qualityValue.value,
|
||||
min: kMinQuality,
|
||||
max: moreQualityChecked.value ? kMaxMoreQuality : kMaxQuality,
|
||||
divisions: moreQualityChecked.value
|
||||
? ((kMaxMoreQuality - kMinQuality) / 10).round()
|
||||
: ((kMaxQuality - kMinQuality) / 5).round(),
|
||||
onChanged: setQuality == null
|
||||
? null
|
||||
: (double value) async {
|
||||
qualityValue.value = value;
|
||||
debouncerQuality.value = value;
|
||||
},
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: Text(
|
||||
'${qualityValue.value.round()}%',
|
||||
style: const TextStyle(fontSize: 15),
|
||||
)),
|
||||
Expanded(
|
||||
flex: isMobile ? 2 : 1,
|
||||
child: Text(
|
||||
translate('Bitrate'),
|
||||
style: const TextStyle(fontSize: 15),
|
||||
)),
|
||||
// mobile doesn't have enough space
|
||||
if (showMoreQuality && !isMobile)
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: moreQualityChecked.value,
|
||||
onChanged: onMoreChanged,
|
||||
),
|
||||
Expanded(
|
||||
child: Text(translate('More')),
|
||||
)
|
||||
],
|
||||
))
|
||||
],
|
||||
)),
|
||||
if (showMoreQuality && isMobile)
|
||||
Obx(() => Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Checkbox(
|
||||
value: moreQualityChecked.value,
|
||||
onChanged: onMoreChanged,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(translate('More')),
|
||||
)
|
||||
],
|
||||
)),
|
||||
if (showFps)
|
||||
Obx(() => Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Slider(
|
||||
value: fpsValue.value,
|
||||
min: kMinFps,
|
||||
max: kMaxFps,
|
||||
divisions: ((kMaxFps - kMinFps) / 5).round(),
|
||||
onChanged: setFps == null
|
||||
? null
|
||||
: (double value) async {
|
||||
fpsValue.value = value;
|
||||
debouncerFps.value = value;
|
||||
},
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: Text(
|
||||
'${fpsValue.value.round()}',
|
||||
style: const TextStyle(fontSize: 15),
|
||||
)),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Text(
|
||||
translate('FPS'),
|
||||
style: const TextStyle(fontSize: 15),
|
||||
))
|
||||
],
|
||||
)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
customImageQualitySetting() {
|
||||
final qualityKey = 'custom_image_quality';
|
||||
final fpsKey = 'custom-fps';
|
||||
|
||||
final initQuality =
|
||||
(double.tryParse(bind.mainGetUserDefaultOption(key: qualityKey)) ??
|
||||
kDefaultQuality);
|
||||
final isQuanlityFixed = isOptionFixed(qualityKey);
|
||||
final initFps =
|
||||
(double.tryParse(bind.mainGetUserDefaultOption(key: fpsKey)) ??
|
||||
kDefaultFps);
|
||||
final isFpsFixed = isOptionFixed(fpsKey);
|
||||
|
||||
return customImageQualityWidget(
|
||||
initQuality: initQuality,
|
||||
initFps: initFps,
|
||||
setQuality: isQuanlityFixed
|
||||
? null
|
||||
: (v) {
|
||||
bind.mainSetUserDefaultOption(
|
||||
key: qualityKey, value: v.toString());
|
||||
},
|
||||
setFps: isFpsFixed
|
||||
? null
|
||||
: (v) {
|
||||
bind.mainSetUserDefaultOption(key: fpsKey, value: v.toString());
|
||||
},
|
||||
showFps: true,
|
||||
showMoreQuality: true);
|
||||
}
|
||||
|
||||
List<Widget> ServerConfigImportExportWidgets(
|
||||
List<TextEditingController> controllers,
|
||||
List<RxString> errMsgs,
|
||||
) {
|
||||
import() {
|
||||
Clipboard.getData(Clipboard.kTextPlain).then((value) {
|
||||
importConfig(controllers, errMsgs, value?.text);
|
||||
});
|
||||
}
|
||||
|
||||
export() {
|
||||
final text = ServerConfig(
|
||||
idServer: controllers[0].text.trim(),
|
||||
relayServer: controllers[1].text.trim(),
|
||||
apiServer: controllers[2].text.trim(),
|
||||
key: controllers[3].text.trim())
|
||||
.encode();
|
||||
debugPrint("ServerConfig export: $text");
|
||||
Clipboard.setData(ClipboardData(text: text));
|
||||
showToast(translate('Export server configuration successfully'));
|
||||
}
|
||||
|
||||
return [
|
||||
Tooltip(
|
||||
message: translate('Import server config'),
|
||||
child: IconButton(
|
||||
icon: Icon(Icons.paste, color: Colors.grey), onPressed: import),
|
||||
),
|
||||
Tooltip(
|
||||
message: translate('Export Server Config'),
|
||||
child: IconButton(
|
||||
icon: Icon(Icons.copy, color: Colors.grey), onPressed: export))
|
||||
];
|
||||
}
|
||||
|
||||
List<(String, String)> otherDefaultSettings() {
|
||||
List<(String, String)> v = [
|
||||
('View Mode', kOptionViewOnly),
|
||||
if ((isDesktop || isWebDesktop))
|
||||
('show_monitors_tip', kKeyShowMonitorsToolbar),
|
||||
if ((isDesktop || isWebDesktop))
|
||||
('Collapse toolbar', kOptionCollapseToolbar),
|
||||
('Show remote cursor', kOptionShowRemoteCursor),
|
||||
('Follow remote cursor', kOptionFollowRemoteCursor),
|
||||
('Follow remote window focus', kOptionFollowRemoteWindow),
|
||||
if ((isDesktop || isWebDesktop)) ('Zoom cursor', kOptionZoomCursor),
|
||||
('Show quality monitor', kOptionShowQualityMonitor),
|
||||
('Mute', kOptionDisableAudio),
|
||||
if (isDesktop) ('Enable file copy and paste', kOptionEnableFileCopyPaste),
|
||||
('Disable clipboard', kOptionDisableClipboard),
|
||||
('Lock after session end', kOptionLockAfterSessionEnd),
|
||||
('Privacy mode', kOptionPrivacyMode),
|
||||
('True color (4:4:4)', kOptionI444),
|
||||
('Reverse mouse wheel', kKeyReverseMouseWheel),
|
||||
('swap-left-right-mouse', kOptionSwapLeftRightMouse),
|
||||
if (isDesktop)
|
||||
(
|
||||
'Show displays as individual windows',
|
||||
kKeyShowDisplaysAsIndividualWindows
|
||||
),
|
||||
if (isDesktop)
|
||||
(
|
||||
'Use all my displays for the remote session',
|
||||
kKeyUseAllMyDisplaysForTheRemoteSession
|
||||
),
|
||||
('Keep terminal sessions on disconnect', kOptionTerminalPersistent),
|
||||
];
|
||||
|
||||
return v;
|
||||
}
|
||||
|
||||
class TrackpadSpeedWidget extends StatefulWidget {
|
||||
final SimpleWrapper<int> value;
|
||||
// If null, no debouncer will be applied.
|
||||
final Function(int)? onDebouncer;
|
||||
|
||||
TrackpadSpeedWidget({Key? key, required this.value, this.onDebouncer});
|
||||
|
||||
@override
|
||||
TrackpadSpeedWidgetState createState() => TrackpadSpeedWidgetState();
|
||||
}
|
||||
|
||||
class TrackpadSpeedWidgetState extends State<TrackpadSpeedWidget> {
|
||||
final TextEditingController _controller = TextEditingController();
|
||||
late final Debouncer<int> debouncerSpeed;
|
||||
|
||||
set value(int v) => widget.value.value = v;
|
||||
int get value => widget.value.value;
|
||||
|
||||
void updateValue(int newValue) {
|
||||
setState(() {
|
||||
value = newValue.clamp(kMinTrackpadSpeed, kMaxTrackpadSpeed);
|
||||
// Scale the trackpad speed value to a percentage for display purposes.
|
||||
_controller.text = value.toString();
|
||||
if (widget.onDebouncer != null) {
|
||||
debouncerSpeed.setValue(value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
debouncerSpeed = Debouncer<int>(
|
||||
Duration(milliseconds: 1000),
|
||||
onChanged: widget.onDebouncer,
|
||||
initialValue: widget.value.value,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_controller.text.isEmpty) {
|
||||
_controller.text = value.toString();
|
||||
}
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Slider(
|
||||
value: value.toDouble(),
|
||||
min: kMinTrackpadSpeed.toDouble(),
|
||||
max: kMaxTrackpadSpeed.toDouble(),
|
||||
divisions: ((kMaxTrackpadSpeed - kMinTrackpadSpeed) / 10).round(),
|
||||
onChanged: (double v) => updateValue(v.round()),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 56,
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
keyboardType: TextInputType.number,
|
||||
textAlign: TextAlign.center,
|
||||
onSubmitted: (text) {
|
||||
int? v = int.tryParse(text);
|
||||
if (v != null) {
|
||||
updateValue(v);
|
||||
}
|
||||
},
|
||||
style: const TextStyle(fontSize: 13),
|
||||
decoration: InputDecoration(
|
||||
contentPadding:
|
||||
EdgeInsets.symmetric(vertical: 8.0, horizontal: 12.0),
|
||||
),
|
||||
),
|
||||
).marginOnly(right: 8.0),
|
||||
Text(
|
||||
'%',
|
||||
style: const TextStyle(fontSize: 15),
|
||||
)
|
||||
],
|
||||
)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user