refactor: split desktop & mobile

This commit is contained in:
Kingtous
2022-05-24 23:33:00 +08:00
parent bd1895b0f6
commit a81e2f9859
19 changed files with 43 additions and 43 deletions

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,95 @@
import 'package:flutter/material.dart';
import 'package:flutter_hbb/mobile/pages/chat_page.dart';
import 'package:flutter_hbb/mobile/pages/server_page.dart';
import 'package:flutter_hbb/mobile/pages/settings_page.dart';
import '../../common.dart';
import '../widgets/overlay.dart';
import 'connection_page.dart';
abstract class PageShape extends Widget {
final String title = "";
final Icon icon = Icon(null);
final List<Widget> appBarActions = [];
}
class HomePage extends StatefulWidget {
HomePage({Key? key}) : super(key: key);
@override
_HomePageState createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
var _selectedIndex = 0;
final List<PageShape> _pages = [];
@override
void initState() {
super.initState();
_pages.add(ConnectionPage());
if (isAndroid) {
_pages.addAll([chatPage, ServerPage()]);
}
_pages.add(SettingsPage());
}
@override
Widget build(BuildContext context) {
return WillPopScope(
onWillPop: () async {
if (_selectedIndex != 0) {
setState(() {
_selectedIndex = 0;
});
} else {
return true;
}
return false;
},
child: Scaffold(
backgroundColor: MyTheme.grayBg,
appBar: AppBar(
centerTitle: true,
title: Text("RustDesk"),
actions: _pages.elementAt(_selectedIndex).appBarActions,
),
bottomNavigationBar: BottomNavigationBar(
key: navigationBarKey,
items: _pages
.map((page) =>
BottomNavigationBarItem(icon: page.icon, label: page.title))
.toList(),
currentIndex: _selectedIndex,
type: BottomNavigationBarType.fixed,
selectedItemColor: MyTheme.accent,
unselectedItemColor: MyTheme.darkGray,
onTap: (index) => setState(() {
// close chat overlay when go chat page
if (index == 1 && _selectedIndex != index) {
hideChatIconOverlay();
hideChatWindowOverlay();
}
_selectedIndex = index;
}),
),
body: _pages.elementAt(_selectedIndex),
));
}
}
class WebHomePage extends StatelessWidget {
final connectionPage = ConnectionPage();
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: MyTheme.grayBg,
appBar: AppBar(
centerTitle: true,
title: Text("RustDesk" + (isWeb ? " (Beta) " : "")),
actions: connectionPage.appBarActions,
),
body: connectionPage,
);
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,210 @@
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common.dart';
import 'package:toggle_switch/toggle_switch.dart';
import '../../models/model.dart';
class GestureIcons {
static const String _family = 'gestureicons';
GestureIcons._();
static const IconData icon_mouse = IconData(0xe65c, fontFamily: _family);
static const IconData icon_Tablet_Touch =
IconData(0xe9ce, fontFamily: _family);
static const IconData icon_gesture_f_drag =
IconData(0xe686, fontFamily: _family);
static const IconData icon_Mobile_Touch =
IconData(0xe9cd, fontFamily: _family);
static const IconData icon_gesture_press =
IconData(0xe66c, fontFamily: _family);
static const IconData icon_gesture_tap =
IconData(0xe66f, fontFamily: _family);
static const IconData icon_gesture_pinch =
IconData(0xe66a, fontFamily: _family);
static const IconData icon_gesture_press_hold =
IconData(0xe66b, fontFamily: _family);
static const IconData icon_gesture_f_drag_up_down_ =
IconData(0xe685, fontFamily: _family);
static const IconData icon_gesture_f_tap_ =
IconData(0xe68e, fontFamily: _family);
static const IconData icon_gesture_f_swipe_right =
IconData(0xe68f, fontFamily: _family);
static const IconData icon_gesture_f_double_tap =
IconData(0xe691, fontFamily: _family);
static const IconData icon_gesture_f_three_fingers =
IconData(0xe687, fontFamily: _family);
}
typedef OnTouchModeChange = void Function(bool);
class GestureHelp extends StatefulWidget {
GestureHelp(
{Key? key, required this.touchMode, required this.onTouchModeChange})
: super(key: key);
final bool touchMode;
final OnTouchModeChange onTouchModeChange;
@override
State<StatefulWidget> createState() => _GestureHelpState();
}
class _GestureHelpState extends State<GestureHelp> {
var _selectedIndex;
var _touchMode;
@override
void initState() {
_touchMode = widget.touchMode;
_selectedIndex = _touchMode ? 1 : 0;
super.initState();
}
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
final space = 12.0;
var width = size.width - 2 * space;
final minWidth = 90;
if (size.width > minWidth + 2 * space) {
final n = (size.width / (minWidth + 2 * space)).floor();
width = size.width / n - 2 * space;
}
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
ToggleSwitch(
initialLabelIndex: _selectedIndex,
inactiveBgColor: MyTheme.darkGray,
totalSwitches: 2,
minWidth: 150,
fontSize: 15,
iconSize: 18,
labels: [translate("Mouse mode"), translate("Touch mode")],
icons: [Icons.mouse, Icons.touch_app],
onToggle: (index) {
setState(() {
if (_selectedIndex != index) {
_selectedIndex = index ?? 0;
_touchMode = index == 0 ? false : true;
widget.onTouchModeChange(_touchMode);
}
});
},
),
const SizedBox(height: 30),
Container(
child: Wrap(
spacing: space,
runSpacing: 2 * space,
children: _touchMode
? [
GestureInfo(
width,
GestureIcons.icon_Mobile_Touch,
translate("One-Finger Tap"),
translate("Left Mouse")),
GestureInfo(
width,
GestureIcons.icon_gesture_press_hold,
translate("One-Long Tap"),
translate("Right Mouse")),
GestureInfo(
width,
GestureIcons.icon_gesture_f_swipe_right,
translate("One-Finger Move"),
translate("Mouse Drag")),
GestureInfo(
width,
GestureIcons.icon_gesture_f_three_fingers,
translate("Three-Finger vertically"),
translate("Mouse Wheel")),
GestureInfo(
width,
GestureIcons.icon_gesture_f_drag,
translate("Two-Finger Move"),
translate("Canvas Move")),
GestureInfo(
width,
GestureIcons.icon_gesture_pinch,
translate("Pinch to Zoom"),
translate("Canvas Zoom")),
]
: [
GestureInfo(
width,
GestureIcons.icon_Mobile_Touch,
translate("One-Finger Tap"),
translate("Left Mouse")),
GestureInfo(
width,
GestureIcons.icon_gesture_press_hold,
translate("One-Long Tap"),
translate("Right Mouse")),
GestureInfo(
width,
GestureIcons.icon_gesture_f_swipe_right,
translate("Double Tap & Move"),
translate("Mouse Drag")),
GestureInfo(
width,
GestureIcons.icon_gesture_f_three_fingers,
translate("Three-Finger vertically"),
translate("Mouse Wheel")),
GestureInfo(
width,
GestureIcons.icon_gesture_f_drag,
translate("Two-Finger Move"),
translate("Canvas Move")),
GestureInfo(
width,
GestureIcons.icon_gesture_pinch,
translate("Pinch to Zoom"),
translate("Canvas Zoom")),
],
)),
],
)));
}
}
class GestureInfo extends StatelessWidget {
const GestureInfo(this.width, this.icon, this.fromText, this.toText,
{Key? key})
: super(key: key);
final String fromText;
final String toText;
final IconData icon;
final double width;
final iconSize = 35.0;
final iconColor = MyTheme.accent;
@override
Widget build(BuildContext context) {
return Container(
width: this.width,
child: Column(
children: [
Icon(
icon,
size: iconSize,
color: iconColor,
),
SizedBox(height: 6),
Text(fromText,
textAlign: TextAlign.center,
style: TextStyle(fontSize: 9, color: Colors.grey)),
SizedBox(height: 3),
Text(toText,
textAlign: TextAlign.center,
style: TextStyle(fontSize: 12, color: Colors.black))
],
));
}
}

View File

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

View File

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