mirror of
https://github.com/rustdesk/rustdesk.git
synced 2025-06-07 18:02:48 +08:00
refactor: move peer_widget / peercard_widget / peer_tab_page & move connect
new address_book class; add peer tab onPageChanged android settings_page.dart add dark mode opt peer_tab_page search bar, add mobile peer_tab support
This commit is contained in:
parent
0c407994cd
commit
9e6e842247
@ -1007,3 +1007,29 @@ Future<bool> restoreWindowPosition(WindowType type, {int? windowId}) async {
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Connect to a peer with [id].
|
||||||
|
/// If [isFileTransfer], starts a session only for file transfer.
|
||||||
|
/// If [isTcpTunneling], starts a session only for tcp tunneling.
|
||||||
|
/// If [isRDP], starts a session only for rdp.
|
||||||
|
void connect(BuildContext context, String id,
|
||||||
|
{bool isFileTransfer = false,
|
||||||
|
bool isTcpTunneling = false,
|
||||||
|
bool isRDP = false}) async {
|
||||||
|
if (id == '') return;
|
||||||
|
id = id.replaceAll(' ', '');
|
||||||
|
assert(!(isFileTransfer && isTcpTunneling && isRDP),
|
||||||
|
"more than one connect type");
|
||||||
|
|
||||||
|
FocusScopeNode currentFocus = FocusScope.of(context);
|
||||||
|
if (isFileTransfer) {
|
||||||
|
await rustDeskWinManager.newFileTransfer(id);
|
||||||
|
} else if (isTcpTunneling || isRDP) {
|
||||||
|
await rustDeskWinManager.newPortForward(id, isRDP);
|
||||||
|
} else {
|
||||||
|
await rustDeskWinManager.newRemoteDesktop(id);
|
||||||
|
}
|
||||||
|
if (!currentFocus.hasPrimaryFocus) {
|
||||||
|
currentFocus.unfocus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
415
flutter/lib/common/widgets/address_book.dart
Normal file
415
flutter/lib/common/widgets/address_book.dart
Normal file
@ -0,0 +1,415 @@
|
|||||||
|
import 'package:contextmenu/contextmenu.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hbb/common/widgets/peer_widget.dart';
|
||||||
|
import 'package:flutter_hbb/models/ab_model.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../../common.dart';
|
||||||
|
import '../../desktop/pages/desktop_home_page.dart';
|
||||||
|
import '../../models/platform_model.dart';
|
||||||
|
|
||||||
|
class AddressBook extends StatefulWidget {
|
||||||
|
const AddressBook({Key? key}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<StatefulWidget> createState() {
|
||||||
|
return _AddressBookState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AddressBookState extends State<AddressBook> {
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => gFFI.abModel.getAb());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => FutureBuilder<Widget>(
|
||||||
|
future: buildAddressBook(context),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.hasData) {
|
||||||
|
return snapshot.data!;
|
||||||
|
} else {
|
||||||
|
return const Offstage();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
handleLogin() {
|
||||||
|
loginDialog().then((success) {
|
||||||
|
if (success) {
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Widget> buildAddressBook(BuildContext context) async {
|
||||||
|
final token = await bind.mainGetLocalOption(key: 'access_token');
|
||||||
|
if (token.trim().isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: InkWell(
|
||||||
|
onTap: handleLogin,
|
||||||
|
child: Text(
|
||||||
|
translate("Login"),
|
||||||
|
style: const TextStyle(decoration: TextDecoration.underline),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final model = gFFI.abModel;
|
||||||
|
return FutureBuilder(
|
||||||
|
future: model.getAb(),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.hasData) {
|
||||||
|
return _buildAddressBook(context);
|
||||||
|
} else if (snapshot.hasError) {
|
||||||
|
return Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Text(translate("${snapshot.error}")),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {});
|
||||||
|
},
|
||||||
|
child: Text(translate("Retry")))
|
||||||
|
],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
if (model.abLoading) {
|
||||||
|
return const Center(
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
);
|
||||||
|
} else if (model.abError.isNotEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Text(translate(model.abError)),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {});
|
||||||
|
},
|
||||||
|
child: Text(translate("Retry")))
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return const Offstage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAddressBook(BuildContext context) {
|
||||||
|
return Consumer<AbModel>(
|
||||||
|
builder: (context, model, child) => Row(
|
||||||
|
children: [
|
||||||
|
Card(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
side: const BorderSide(color: MyTheme.grayBg)),
|
||||||
|
child: Container(
|
||||||
|
width: 200,
|
||||||
|
height: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12.0, vertical: 8.0),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(translate('Tags')),
|
||||||
|
InkWell(
|
||||||
|
child: PopupMenuButton(
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
PopupMenuItem(
|
||||||
|
value: 'add-id',
|
||||||
|
child: Text(translate("Add ID")),
|
||||||
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
value: 'add-tag',
|
||||||
|
child: Text(translate("Add Tag")),
|
||||||
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
value: 'unset-all-tag',
|
||||||
|
child: Text(
|
||||||
|
translate("Unselect all tags")),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onSelected: handleAbOp,
|
||||||
|
child: const Icon(Icons.more_vert_outlined)),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
height: double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: MyTheme.darkGray)),
|
||||||
|
child: Obx(
|
||||||
|
() => Wrap(
|
||||||
|
children: gFFI.abModel.tags
|
||||||
|
.map((e) =>
|
||||||
|
buildTag(e, gFFI.abModel.selectedTags,
|
||||||
|
onTap: () {
|
||||||
|
//
|
||||||
|
if (gFFI.abModel.selectedTags
|
||||||
|
.contains(e)) {
|
||||||
|
gFFI.abModel.selectedTags.remove(e);
|
||||||
|
} else {
|
||||||
|
gFFI.abModel.selectedTags.add(e);
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).marginSymmetric(vertical: 8.0),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).marginOnly(right: 8.0),
|
||||||
|
Expanded(
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.topLeft,
|
||||||
|
child: AddressBookPeerWidget()),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget buildTag(String tagName, RxList<dynamic> rxTags, {Function()? onTap}) {
|
||||||
|
return ContextMenuArea(
|
||||||
|
width: 100,
|
||||||
|
builder: (context) => [
|
||||||
|
ListTile(
|
||||||
|
title: Text(translate("Delete")),
|
||||||
|
onTap: () {
|
||||||
|
gFFI.abModel.deleteTag(tagName);
|
||||||
|
gFFI.abModel.updateAb();
|
||||||
|
Future.delayed(Duration.zero, () => Get.back());
|
||||||
|
},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Obx(
|
||||||
|
() => Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: rxTags.contains(tagName) ? Colors.blue : null,
|
||||||
|
border: Border.all(color: MyTheme.darkGray),
|
||||||
|
borderRadius: BorderRadius.circular(10)),
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 4.0, vertical: 8.0),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 2.0, horizontal: 8.0),
|
||||||
|
child: Text(
|
||||||
|
tagName,
|
||||||
|
style: TextStyle(
|
||||||
|
color: rxTags.contains(tagName) ? MyTheme.white : null),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// tag operation
|
||||||
|
void handleAbOp(String value) {
|
||||||
|
if (value == 'add-id') {
|
||||||
|
abAddId();
|
||||||
|
} else if (value == 'add-tag') {
|
||||||
|
abAddTag();
|
||||||
|
} else if (value == 'unset-all-tag') {
|
||||||
|
gFFI.abModel.unsetSelectedTags();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void abAddId() async {
|
||||||
|
var field = "";
|
||||||
|
var msg = "";
|
||||||
|
var isInProgress = false;
|
||||||
|
TextEditingController controller = TextEditingController(text: field);
|
||||||
|
|
||||||
|
gFFI.dialogManager.show((setState, close) {
|
||||||
|
submit() async {
|
||||||
|
setState(() {
|
||||||
|
msg = "";
|
||||||
|
isInProgress = true;
|
||||||
|
});
|
||||||
|
field = controller.text.trim();
|
||||||
|
if (field.isEmpty) {
|
||||||
|
// pass
|
||||||
|
} else {
|
||||||
|
final ids = field.trim().split(RegExp(r"[\s,;\n]+"));
|
||||||
|
field = ids.join(',');
|
||||||
|
for (final newId in ids) {
|
||||||
|
if (gFFI.abModel.idContainBy(newId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
gFFI.abModel.addId(newId);
|
||||||
|
}
|
||||||
|
await gFFI.abModel.updateAb();
|
||||||
|
this.setState(() {});
|
||||||
|
// final currentPeers
|
||||||
|
}
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
|
||||||
|
return CustomAlertDialog(
|
||||||
|
title: Text(translate("Add ID")),
|
||||||
|
content: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(translate("whitelist_sep")),
|
||||||
|
const SizedBox(
|
||||||
|
height: 8.0,
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
maxLines: null,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
errorText: msg.isEmpty ? null : translate(msg),
|
||||||
|
),
|
||||||
|
controller: controller,
|
||||||
|
focusNode: FocusNode()..requestFocus()),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(
|
||||||
|
height: 4.0,
|
||||||
|
),
|
||||||
|
Offstage(
|
||||||
|
offstage: !isInProgress, child: const LinearProgressIndicator())
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: close, child: Text(translate("Cancel"))),
|
||||||
|
TextButton(onPressed: submit, child: Text(translate("OK"))),
|
||||||
|
],
|
||||||
|
onSubmit: submit,
|
||||||
|
onCancel: close,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void abAddTag() async {
|
||||||
|
var field = "";
|
||||||
|
var msg = "";
|
||||||
|
var isInProgress = false;
|
||||||
|
TextEditingController controller = TextEditingController(text: field);
|
||||||
|
gFFI.dialogManager.show((setState, close) {
|
||||||
|
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 (final tag in tags) {
|
||||||
|
gFFI.abModel.addTag(tag);
|
||||||
|
}
|
||||||
|
await gFFI.abModel.updateAb();
|
||||||
|
// 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(
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
errorText: msg.isEmpty ? null : translate(msg),
|
||||||
|
),
|
||||||
|
controller: controller,
|
||||||
|
focusNode: FocusNode()..requestFocus(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(
|
||||||
|
height: 4.0,
|
||||||
|
),
|
||||||
|
Offstage(
|
||||||
|
offstage: !isInProgress, child: const LinearProgressIndicator())
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: close, child: Text(translate("Cancel"))),
|
||||||
|
TextButton(onPressed: submit, child: Text(translate("OK"))),
|
||||||
|
],
|
||||||
|
onSubmit: submit,
|
||||||
|
onCancel: close,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void abEditTag(String id) {
|
||||||
|
var isInProgress = false;
|
||||||
|
|
||||||
|
final tags = List.of(gFFI.abModel.tags);
|
||||||
|
var selectedTag = gFFI.abModel.getPeerTags(id).obs;
|
||||||
|
|
||||||
|
gFFI.dialogManager.show((setState, close) {
|
||||||
|
submit() async {
|
||||||
|
setState(() {
|
||||||
|
isInProgress = true;
|
||||||
|
});
|
||||||
|
gFFI.abModel.changeTagForPeer(id, selectedTag);
|
||||||
|
await gFFI.abModel.updateAb();
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
|
||||||
|
return CustomAlertDialog(
|
||||||
|
title: Text(translate("Edit Tag")),
|
||||||
|
content: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
||||||
|
child: Wrap(
|
||||||
|
children: tags
|
||||||
|
.map((e) => buildTag(e, selectedTag, onTap: () {
|
||||||
|
if (selectedTag.contains(e)) {
|
||||||
|
selectedTag.remove(e);
|
||||||
|
} else {
|
||||||
|
selectedTag.add(e);
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.toList(growable: false),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Offstage(
|
||||||
|
offstage: !isInProgress, child: const LinearProgressIndicator())
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: close, child: Text(translate("Cancel"))),
|
||||||
|
TextButton(onPressed: submit, child: Text(translate("OK"))),
|
||||||
|
],
|
||||||
|
onSubmit: submit,
|
||||||
|
onCancel: close,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
271
flutter/lib/common/widgets/peer_tab_page.dart
Normal file
271
flutter/lib/common/widgets/peer_tab_page.dart
Normal file
@ -0,0 +1,271 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hbb/common/widgets/peer_widget.dart';
|
||||||
|
import 'package:flutter_hbb/common/widgets/peercard_widget.dart';
|
||||||
|
import 'package:flutter_hbb/consts.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
|
||||||
|
import '../../common.dart';
|
||||||
|
import '../../models/platform_model.dart';
|
||||||
|
|
||||||
|
class PeerTabPage extends StatefulWidget {
|
||||||
|
final List<String> tabs;
|
||||||
|
final List<Widget> children;
|
||||||
|
const PeerTabPage({required this.tabs, required this.children, Key? key})
|
||||||
|
: super(key: key);
|
||||||
|
@override
|
||||||
|
State<PeerTabPage> createState() => _PeerTabPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PeerTabPageState extends State<PeerTabPage>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late final PageController _pageController = PageController();
|
||||||
|
final RxInt _tabIndex = 0.obs;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
}
|
||||||
|
|
||||||
|
// hard code for now
|
||||||
|
void _handleTabSelection(int index) {
|
||||||
|
// reset search text
|
||||||
|
peerSearchText.value = "";
|
||||||
|
peerSearchTextController.clear();
|
||||||
|
_tabIndex.value = index;
|
||||||
|
_pageController.jumpToPage(index);
|
||||||
|
switch (index) {
|
||||||
|
case 0:
|
||||||
|
bind.mainLoadRecentPeers();
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
bind.mainLoadFavPeers();
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
bind.mainDiscover();
|
||||||
|
break;
|
||||||
|
case 3:
|
||||||
|
gFFI.abModel.getAb();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_pageController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
textBaseline: TextBaseline.ideographic,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
height: 28,
|
||||||
|
child: Container(
|
||||||
|
constraints: isDesktop ? null : kMobilePageConstraints,
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Expanded(child: _createTabBar(context)),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
const PeerSearchBar(),
|
||||||
|
Offstage(
|
||||||
|
offstage: !isDesktop,
|
||||||
|
child: _createPeerViewTypeSwitch(context)
|
||||||
|
.marginOnly(left: 13)),
|
||||||
|
],
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
_createTabBarView(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _createTabBar(BuildContext context) {
|
||||||
|
return ListView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
shrinkWrap: true,
|
||||||
|
controller: ScrollController(),
|
||||||
|
children: super.widget.tabs.asMap().entries.map((t) {
|
||||||
|
return Obx(() => GestureDetector(
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _tabIndex.value == t.key
|
||||||
|
? MyTheme.color(context).bg
|
||||||
|
: null,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Text(
|
||||||
|
t.value,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
height: 1,
|
||||||
|
fontSize: 14,
|
||||||
|
color: _tabIndex.value == t.key
|
||||||
|
? MyTheme.color(context).text
|
||||||
|
: MyTheme.color(context).lightText),
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
onTap: () => _handleTabSelection(t.key),
|
||||||
|
));
|
||||||
|
}).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _createTabBarView() {
|
||||||
|
final verticalMargin = isDesktop ? 12.0 : 6.0;
|
||||||
|
return Expanded(
|
||||||
|
child: PageView(
|
||||||
|
physics: const BouncingScrollPhysics(),
|
||||||
|
controller: _pageController,
|
||||||
|
children: super.widget.children,
|
||||||
|
onPageChanged: (to) => _tabIndex.value = to)
|
||||||
|
.marginSymmetric(vertical: verticalMargin));
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _createPeerViewTypeSwitch(BuildContext context) {
|
||||||
|
final activeDeco = BoxDecoration(color: MyTheme.color(context).bg);
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Obx(
|
||||||
|
() => Container(
|
||||||
|
padding: const EdgeInsets.all(4.0),
|
||||||
|
decoration:
|
||||||
|
peerCardUiType.value == PeerUiType.grid ? activeDeco : null,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () {
|
||||||
|
peerCardUiType.value = PeerUiType.grid;
|
||||||
|
},
|
||||||
|
child: Icon(
|
||||||
|
Icons.grid_view_rounded,
|
||||||
|
size: 18,
|
||||||
|
color: peerCardUiType.value == PeerUiType.grid
|
||||||
|
? MyTheme.color(context).text
|
||||||
|
: MyTheme.color(context).lightText,
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Obx(
|
||||||
|
() => Container(
|
||||||
|
padding: const EdgeInsets.all(4.0),
|
||||||
|
decoration:
|
||||||
|
peerCardUiType.value == PeerUiType.list ? activeDeco : null,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () {
|
||||||
|
peerCardUiType.value = PeerUiType.list;
|
||||||
|
},
|
||||||
|
child: Icon(
|
||||||
|
Icons.list,
|
||||||
|
size: 18,
|
||||||
|
color: peerCardUiType.value == PeerUiType.list
|
||||||
|
? MyTheme.color(context).text
|
||||||
|
: MyTheme.color(context).lightText,
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PeerSearchBar extends StatefulWidget {
|
||||||
|
const PeerSearchBar({Key? key}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<StatefulWidget> createState() => _PeerSearchBarState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PeerSearchBarState extends State<PeerSearchBar> {
|
||||||
|
var drawer = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return drawer
|
||||||
|
? _buildSearchBar()
|
||||||
|
: IconButton(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
padding: const EdgeInsets.only(right: 2),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
drawer = true;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.search_rounded,
|
||||||
|
color: MyTheme.dark,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSearchBar() {
|
||||||
|
RxBool focused = false.obs;
|
||||||
|
FocusNode focusNode = FocusNode();
|
||||||
|
focusNode.addListener(() => focused.value = focusNode.hasFocus);
|
||||||
|
return Container(
|
||||||
|
width: 120,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: MyTheme.color(context).bg,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Obx(() => Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.search_rounded,
|
||||||
|
color: MyTheme.color(context).placeholder,
|
||||||
|
).marginSymmetric(horizontal: 4),
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
autofocus: true,
|
||||||
|
controller: peerSearchTextController,
|
||||||
|
onChanged: (searchText) {
|
||||||
|
peerSearchText.value = searchText;
|
||||||
|
},
|
||||||
|
focusNode: focusNode,
|
||||||
|
textAlign: TextAlign.start,
|
||||||
|
maxLines: 1,
|
||||||
|
cursorColor: MyTheme.color(context).lightText,
|
||||||
|
cursorHeight: 18,
|
||||||
|
cursorWidth: 1,
|
||||||
|
style: const TextStyle(fontSize: 14),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
contentPadding:
|
||||||
|
const EdgeInsets.symmetric(vertical: 6),
|
||||||
|
hintText:
|
||||||
|
focused.value ? null : translate("Search ID"),
|
||||||
|
hintStyle: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: MyTheme.color(context).placeholder),
|
||||||
|
border: InputBorder.none,
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Icon(Icons.close),
|
||||||
|
IconButton(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
padding: const EdgeInsets.only(right: 2),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
peerSearchTextController.clear();
|
||||||
|
peerSearchText.value = "";
|
||||||
|
drawer = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.close,
|
||||||
|
color: MyTheme.dark,
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -39,7 +39,7 @@ class _PeerWidget extends StatefulWidget {
|
|||||||
/// State for the peer widget.
|
/// State for the peer widget.
|
||||||
class _PeerWidgetState extends State<_PeerWidget> with WindowListener {
|
class _PeerWidgetState extends State<_PeerWidget> with WindowListener {
|
||||||
static const int _maxQueryCount = 3;
|
static const int _maxQueryCount = 3;
|
||||||
|
final space = isDesktop ? 12.0 : 8.0;
|
||||||
final _curPeers = <String>{};
|
final _curPeers = <String>{};
|
||||||
var _lastChangeTime = DateTime.now();
|
var _lastChangeTime = DateTime.now();
|
||||||
var _lastQueryPeers = <String>{};
|
var _lastQueryPeers = <String>{};
|
||||||
@ -47,6 +47,17 @@ class _PeerWidgetState extends State<_PeerWidget> with WindowListener {
|
|||||||
var _queryCoun = 0;
|
var _queryCoun = 0;
|
||||||
var _exit = false;
|
var _exit = false;
|
||||||
|
|
||||||
|
late final mobileWidth = () {
|
||||||
|
const minWidth = 320.0;
|
||||||
|
final windowWidth = MediaQuery.of(context).size.width;
|
||||||
|
var width = windowWidth - 2 * space;
|
||||||
|
if (windowWidth > minWidth + 2 * space) {
|
||||||
|
final n = (windowWidth / (minWidth + 2 * space)).floor();
|
||||||
|
width = windowWidth / n - 2 * space;
|
||||||
|
}
|
||||||
|
return width;
|
||||||
|
}();
|
||||||
|
|
||||||
_PeerWidgetState() {
|
_PeerWidgetState() {
|
||||||
_startCheckOnlines();
|
_startCheckOnlines();
|
||||||
}
|
}
|
||||||
@ -76,7 +87,6 @@ class _PeerWidgetState extends State<_PeerWidget> with WindowListener {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
const space = 12.0;
|
|
||||||
return ChangeNotifierProvider<Peers>(
|
return ChangeNotifierProvider<Peers>(
|
||||||
create: (context) => widget.peers,
|
create: (context) => widget.peers,
|
||||||
child: Consumer<Peers>(
|
child: Consumer<Peers>(
|
||||||
@ -93,32 +103,36 @@ class _PeerWidgetState extends State<_PeerWidget> with WindowListener {
|
|||||||
final peers = snapshot.data!;
|
final peers = snapshot.data!;
|
||||||
final cards = <Widget>[];
|
final cards = <Widget>[];
|
||||||
for (final peer in peers) {
|
for (final peer in peers) {
|
||||||
|
final visibilityChild = VisibilityDetector(
|
||||||
|
key: ValueKey(peer.id),
|
||||||
|
onVisibilityChanged: (info) {
|
||||||
|
final peerId = (info.key as ValueKey).value;
|
||||||
|
if (info.visibleFraction > 0.00001) {
|
||||||
|
_curPeers.add(peerId);
|
||||||
|
} else {
|
||||||
|
_curPeers.remove(peerId);
|
||||||
|
}
|
||||||
|
_lastChangeTime = DateTime.now();
|
||||||
|
},
|
||||||
|
child: widget.peerCardWidgetFunc(peer),
|
||||||
|
);
|
||||||
cards.add(Offstage(
|
cards.add(Offstage(
|
||||||
key: ValueKey("off${peer.id}"),
|
key: ValueKey("off${peer.id}"),
|
||||||
offstage: widget.offstageFunc(peer),
|
offstage: widget.offstageFunc(peer),
|
||||||
child: Obx(
|
child: isDesktop
|
||||||
() => SizedBox(
|
? Obx(
|
||||||
width: 220,
|
() => SizedBox(
|
||||||
height:
|
width: 220,
|
||||||
peerCardUiType.value == PeerUiType.grid
|
height: peerCardUiType.value ==
|
||||||
? 140
|
PeerUiType.grid
|
||||||
: 42,
|
? 140
|
||||||
child: VisibilityDetector(
|
: 42,
|
||||||
key: ValueKey(peer.id),
|
child: visibilityChild,
|
||||||
onVisibilityChanged: (info) {
|
),
|
||||||
final peerId =
|
)
|
||||||
(info.key as ValueKey).value;
|
: SizedBox(
|
||||||
if (info.visibleFraction > 0.00001) {
|
width: mobileWidth,
|
||||||
_curPeers.add(peerId);
|
child: visibilityChild)));
|
||||||
} else {
|
|
||||||
_curPeers.remove(peerId);
|
|
||||||
}
|
|
||||||
_lastChangeTime = DateTime.now();
|
|
||||||
},
|
|
||||||
child: widget.peerCardWidgetFunc(peer),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
return Wrap(
|
return Wrap(
|
||||||
spacing: space, runSpacing: space, children: cards);
|
spacing: space, runSpacing: space, children: cards);
|
||||||
@ -273,6 +287,7 @@ class AddressBookPeerWidget extends BasePeerWidget {
|
|||||||
);
|
);
|
||||||
|
|
||||||
static List<Peer> _loadPeers() {
|
static List<Peer> _loadPeers() {
|
||||||
|
debugPrint("_loadPeers : ${gFFI.abModel.peers.toString()}");
|
||||||
return gFFI.abModel.peers.map((e) {
|
return gFFI.abModel.peers.map((e) {
|
||||||
return Peer.fromJson(e['id'], e);
|
return Peer.fromJson(e['id'], e);
|
||||||
}).toList();
|
}).toList();
|
||||||
@ -296,7 +311,7 @@ class AddressBookPeerWidget extends BasePeerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final widget = super.build(context);
|
final widget = super.build(context);
|
||||||
gFFI.abModel.updateAb();
|
// gFFI.abModel.updateAb();
|
||||||
return widget;
|
return widget;
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -8,9 +8,8 @@ import '../../common/formatter/id_formatter.dart';
|
|||||||
import '../../models/model.dart';
|
import '../../models/model.dart';
|
||||||
import '../../models/peer_model.dart';
|
import '../../models/peer_model.dart';
|
||||||
import '../../models/platform_model.dart';
|
import '../../models/platform_model.dart';
|
||||||
import './material_mod_popup_menu.dart' as mod_menu;
|
import '../../desktop/widgets/material_mod_popup_menu.dart' as mod_menu;
|
||||||
import './popup_menu.dart';
|
import '../../desktop/widgets/popup_menu.dart';
|
||||||
import './utils.dart';
|
|
||||||
|
|
||||||
class _PopupMenuTheme {
|
class _PopupMenuTheme {
|
||||||
static const Color commonColor = MyTheme.accent;
|
static const Color commonColor = MyTheme.accent;
|
||||||
@ -55,6 +54,50 @@ class _PeerCardState extends State<_PeerCard>
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
super.build(context);
|
super.build(context);
|
||||||
|
if (isDesktop) {
|
||||||
|
return _buildDesktop();
|
||||||
|
} else {
|
||||||
|
return _buildMobile();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMobile() {
|
||||||
|
final peer = super.widget.peer;
|
||||||
|
return Card(
|
||||||
|
margin: EdgeInsets.zero,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: !isWebDesktop ? () => connect(context, peer.id) : null,
|
||||||
|
onDoubleTap: isWebDesktop ? () => connect(context, peer.id) : null,
|
||||||
|
onLongPressStart: (details) {
|
||||||
|
final x = details.globalPosition.dx;
|
||||||
|
final y = details.globalPosition.dy;
|
||||||
|
_menuPos = RelativeRect.fromLTRB(x, y, x, y);
|
||||||
|
_showPeerMenu(peer.id);
|
||||||
|
},
|
||||||
|
child: ListTile(
|
||||||
|
contentPadding: const EdgeInsets.only(left: 12),
|
||||||
|
subtitle: Text('${peer.username}@${peer.hostname}'),
|
||||||
|
title: Text(peer.id),
|
||||||
|
leading: Container(
|
||||||
|
padding: const EdgeInsets.all(6),
|
||||||
|
color: str2color('${peer.id}${peer.platform}', 0x7f),
|
||||||
|
child: getPlatformImage(peer.platform)),
|
||||||
|
trailing: InkWell(
|
||||||
|
child: const Padding(
|
||||||
|
padding: 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(peer.id);
|
||||||
|
}),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDesktop() {
|
||||||
final peer = super.widget.peer;
|
final peer = super.widget.peer;
|
||||||
var deco = Rx<BoxDecoration?>(BoxDecoration(
|
var deco = Rx<BoxDecoration?>(BoxDecoration(
|
||||||
border: Border.all(color: Colors.transparent, width: _borderWidth),
|
border: Border.all(color: Colors.transparent, width: _borderWidth),
|
||||||
@ -264,7 +307,7 @@ class _PeerCardState extends State<_PeerCard>
|
|||||||
final y = e.position.dy;
|
final y = e.position.dy;
|
||||||
_menuPos = RelativeRect.fromLTRB(x, y, x, y);
|
_menuPos = RelativeRect.fromLTRB(x, y, x, y);
|
||||||
},
|
},
|
||||||
onPointerUp: (_) => _showPeerMenu(context, peer.id),
|
onPointerUp: (_) => _showPeerMenu(peer.id),
|
||||||
child: MouseRegion(
|
child: MouseRegion(
|
||||||
onEnter: (_) => _iconMoreHover.value = true,
|
onEnter: (_) => _iconMoreHover.value = true,
|
||||||
onExit: (_) => _iconMoreHover.value = false,
|
onExit: (_) => _iconMoreHover.value = false,
|
||||||
@ -281,7 +324,7 @@ class _PeerCardState extends State<_PeerCard>
|
|||||||
|
|
||||||
/// Show the peer menu and handle user's choice.
|
/// Show the peer menu and handle user's choice.
|
||||||
/// User might remove the peer or send a file to the peer.
|
/// User might remove the peer or send a file to the peer.
|
||||||
void _showPeerMenu(BuildContext context, String id) async {
|
void _showPeerMenu(String id) async {
|
||||||
await mod_menu.showMenu(
|
await mod_menu.showMenu(
|
||||||
context: context,
|
context: context,
|
||||||
position: _menuPos,
|
position: _menuPos,
|
@ -1,3 +1,5 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
const double kDesktopRemoteTabBarHeight = 28.0;
|
const double kDesktopRemoteTabBarHeight = 28.0;
|
||||||
|
|
||||||
/// [kAppTypeMain] used by 'Desktop Main Page' , 'Mobile (Client and Server)' , 'Desktop CM Page'
|
/// [kAppTypeMain] used by 'Desktop Main Page' , 'Mobile (Client and Server)' , 'Desktop CM Page'
|
||||||
@ -17,6 +19,8 @@ const int kDesktopDefaultDisplayHeight = 720;
|
|||||||
|
|
||||||
const kInvalidValueStr = "InvalidValueStr";
|
const kInvalidValueStr = "InvalidValueStr";
|
||||||
|
|
||||||
|
const kMobilePageConstraints = BoxConstraints(maxWidth: 600);
|
||||||
|
|
||||||
/// flutter/packages/flutter/lib/src/services/keyboard_key.dart -> _keyLabels
|
/// flutter/packages/flutter/lib/src/services/keyboard_key.dart -> _keyLabels
|
||||||
/// see [LogicalKeyboardKey.keyLabel]
|
/// see [LogicalKeyboardKey.keyLabel]
|
||||||
const Map<int, String> logicalKeyMap = <int, String>{
|
const Map<int, String> logicalKeyMap = <int, String>{
|
||||||
|
@ -1,17 +1,15 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:contextmenu/contextmenu.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hbb/desktop/pages/desktop_home_page.dart';
|
import 'package:flutter_hbb/common/widgets/address_book.dart';
|
||||||
import 'package:flutter_hbb/desktop/widgets/peer_widget.dart';
|
|
||||||
import 'package:flutter_hbb/desktop/widgets/peercard_widget.dart';
|
|
||||||
import 'package:flutter_hbb/utils/multi_window_manager.dart';
|
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:url_launcher/url_launcher_string.dart';
|
import 'package:url_launcher/url_launcher_string.dart';
|
||||||
|
|
||||||
import '../../common.dart';
|
import '../../common.dart';
|
||||||
import '../../common/formatter/id_formatter.dart';
|
import '../../common/formatter/id_formatter.dart';
|
||||||
|
import '../../common/widgets/peer_tab_page.dart';
|
||||||
|
import '../../common/widgets/peer_widget.dart';
|
||||||
import '../../models/platform_model.dart';
|
import '../../models/platform_model.dart';
|
||||||
|
|
||||||
/// Connection page for connecting to a remote peer.
|
/// Connection page for connecting to a remote peer.
|
||||||
@ -66,7 +64,7 @@ class _ConnectionPageState extends State<ConnectionPage> {
|
|||||||
SizedBox(height: 12),
|
SizedBox(height: 12),
|
||||||
Divider(),
|
Divider(),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _PeerTabbedPage(
|
child: PeerTabPage(
|
||||||
tabs: [
|
tabs: [
|
||||||
translate('Recent Sessions'),
|
translate('Recent Sessions'),
|
||||||
translate('Favorites'),
|
translate('Favorites'),
|
||||||
@ -77,15 +75,7 @@ class _ConnectionPageState extends State<ConnectionPage> {
|
|||||||
RecentPeerWidget(),
|
RecentPeerWidget(),
|
||||||
FavoritePeerWidget(),
|
FavoritePeerWidget(),
|
||||||
DiscoveredPeerWidget(),
|
DiscoveredPeerWidget(),
|
||||||
FutureBuilder<Widget>(
|
const AddressBook(),
|
||||||
future: buildAddressBook(context),
|
|
||||||
builder: (context, snapshot) {
|
|
||||||
if (snapshot.hasData) {
|
|
||||||
return snapshot.data!;
|
|
||||||
} else {
|
|
||||||
return const Offstage();
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
],
|
],
|
||||||
)),
|
)),
|
||||||
],
|
],
|
||||||
@ -102,23 +92,7 @@ class _ConnectionPageState extends State<ConnectionPage> {
|
|||||||
/// Connects to the selected peer.
|
/// Connects to the selected peer.
|
||||||
void onConnect({bool isFileTransfer = false}) {
|
void onConnect({bool isFileTransfer = false}) {
|
||||||
final id = _idController.id;
|
final id = _idController.id;
|
||||||
connect(id, isFileTransfer: isFileTransfer);
|
connect(context, id, isFileTransfer: isFileTransfer);
|
||||||
}
|
|
||||||
|
|
||||||
/// Connect to a peer with [id].
|
|
||||||
/// If [isFileTransfer], starts a session only for file transfer.
|
|
||||||
void connect(String id, {bool isFileTransfer = false}) async {
|
|
||||||
if (id == '') return;
|
|
||||||
id = id.replaceAll(' ', '');
|
|
||||||
if (isFileTransfer) {
|
|
||||||
await rustDeskWinManager.newFileTransfer(id);
|
|
||||||
} else {
|
|
||||||
await rustDeskWinManager.newRemoteDesktop(id);
|
|
||||||
}
|
|
||||||
FocusScopeNode currentFocus = FocusScope.of(context);
|
|
||||||
if (!currentFocus.hasPrimaryFocus) {
|
|
||||||
currentFocus.unfocus();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// UI for the search bar.
|
/// UI for the search bar.
|
||||||
@ -372,604 +346,4 @@ class _ConnectionPageState extends State<ConnectionPage> {
|
|||||||
svcStatusCode.value = status["status_num"];
|
svcStatusCode.value = status["status_num"];
|
||||||
svcIsUsingPublicServer.value = await bind.mainIsUsingPublicServer();
|
svcIsUsingPublicServer.value = await bind.mainIsUsingPublicServer();
|
||||||
}
|
}
|
||||||
|
|
||||||
handleLogin() {
|
|
||||||
loginDialog().then((success) {
|
|
||||||
if (success) {
|
|
||||||
setState(() {});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<Widget> buildAddressBook(BuildContext context) async {
|
|
||||||
final token = await bind.mainGetLocalOption(key: 'access_token');
|
|
||||||
if (token.trim().isEmpty) {
|
|
||||||
return Center(
|
|
||||||
child: InkWell(
|
|
||||||
onTap: handleLogin,
|
|
||||||
child: Text(
|
|
||||||
translate("Login"),
|
|
||||||
style: TextStyle(decoration: TextDecoration.underline),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
final model = gFFI.abModel;
|
|
||||||
return FutureBuilder(
|
|
||||||
future: model.getAb(),
|
|
||||||
builder: (context, snapshot) {
|
|
||||||
if (snapshot.hasData) {
|
|
||||||
return _buildAddressBook(context);
|
|
||||||
} else if (snapshot.hasError) {
|
|
||||||
return Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Text(translate("${snapshot.error}")),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () {
|
|
||||||
setState(() {});
|
|
||||||
},
|
|
||||||
child: Text(translate("Retry")))
|
|
||||||
],
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
if (model.abLoading) {
|
|
||||||
return const Center(
|
|
||||||
child: CircularProgressIndicator(),
|
|
||||||
);
|
|
||||||
} else if (model.abError.isNotEmpty) {
|
|
||||||
return Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Text(translate("${model.abError}")),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () {
|
|
||||||
setState(() {});
|
|
||||||
},
|
|
||||||
child: Text(translate("Retry")))
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return Offstage();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildAddressBook(BuildContext context) {
|
|
||||||
return Row(
|
|
||||||
children: [
|
|
||||||
Card(
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
side: BorderSide(color: MyTheme.grayBg)),
|
|
||||||
child: Container(
|
|
||||||
width: 200,
|
|
||||||
height: double.infinity,
|
|
||||||
padding: EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Text(translate('Tags')),
|
|
||||||
InkWell(
|
|
||||||
child: PopupMenuButton(
|
|
||||||
itemBuilder: (context) => [
|
|
||||||
PopupMenuItem(
|
|
||||||
child: Text(translate("Add ID")),
|
|
||||||
value: 'add-id',
|
|
||||||
),
|
|
||||||
PopupMenuItem(
|
|
||||||
child: Text(translate("Add Tag")),
|
|
||||||
value: 'add-tag',
|
|
||||||
),
|
|
||||||
PopupMenuItem(
|
|
||||||
child: Text(translate("Unselect all tags")),
|
|
||||||
value: 'unset-all-tag',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
onSelected: handleAbOp,
|
|
||||||
child: Icon(Icons.more_vert_outlined)),
|
|
||||||
)
|
|
||||||
],
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: Container(
|
|
||||||
width: double.infinity,
|
|
||||||
height: double.infinity,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
border: Border.all(color: MyTheme.darkGray)),
|
|
||||||
child: Obx(
|
|
||||||
() => Wrap(
|
|
||||||
children: gFFI.abModel.tags
|
|
||||||
.map((e) => buildTag(e, gFFI.abModel.selectedTags,
|
|
||||||
onTap: () {
|
|
||||||
//
|
|
||||||
if (gFFI.abModel.selectedTags.contains(e)) {
|
|
||||||
gFFI.abModel.selectedTags.remove(e);
|
|
||||||
} else {
|
|
||||||
gFFI.abModel.selectedTags.add(e);
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
.toList(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
).marginSymmetric(vertical: 8.0),
|
|
||||||
)
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
).marginOnly(right: 8.0),
|
|
||||||
Expanded(
|
|
||||||
child: Align(
|
|
||||||
alignment: Alignment.topLeft, child: AddressBookPeerWidget()),
|
|
||||||
)
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget buildTag(String tagName, RxList<dynamic> rxTags, {Function()? onTap}) {
|
|
||||||
return ContextMenuArea(
|
|
||||||
width: 100,
|
|
||||||
builder: (context) => [
|
|
||||||
ListTile(
|
|
||||||
title: Text(translate("Delete")),
|
|
||||||
onTap: () {
|
|
||||||
gFFI.abModel.deleteTag(tagName);
|
|
||||||
gFFI.abModel.updateAb();
|
|
||||||
Future.delayed(Duration.zero, () => Get.back());
|
|
||||||
},
|
|
||||||
)
|
|
||||||
],
|
|
||||||
child: GestureDetector(
|
|
||||||
onTap: onTap,
|
|
||||||
child: Obx(
|
|
||||||
() => Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: rxTags.contains(tagName) ? Colors.blue : null,
|
|
||||||
border: Border.all(color: MyTheme.darkGray),
|
|
||||||
borderRadius: BorderRadius.circular(10)),
|
|
||||||
margin: EdgeInsets.symmetric(horizontal: 4.0, vertical: 8.0),
|
|
||||||
padding: EdgeInsets.symmetric(vertical: 2.0, horizontal: 8.0),
|
|
||||||
child: Text(
|
|
||||||
tagName,
|
|
||||||
style: TextStyle(
|
|
||||||
color: rxTags.contains(tagName) ? MyTheme.white : null),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// tag operation
|
|
||||||
void handleAbOp(String value) {
|
|
||||||
if (value == 'add-id') {
|
|
||||||
abAddId();
|
|
||||||
} else if (value == 'add-tag') {
|
|
||||||
abAddTag();
|
|
||||||
} else if (value == 'unset-all-tag') {
|
|
||||||
gFFI.abModel.unsetSelectedTags();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void abAddId() async {
|
|
||||||
var field = "";
|
|
||||||
var msg = "";
|
|
||||||
var isInProgress = false;
|
|
||||||
TextEditingController controller = TextEditingController(text: field);
|
|
||||||
|
|
||||||
gFFI.dialogManager.show((setState, close) {
|
|
||||||
submit() async {
|
|
||||||
setState(() {
|
|
||||||
msg = "";
|
|
||||||
isInProgress = true;
|
|
||||||
});
|
|
||||||
field = controller.text.trim();
|
|
||||||
if (field.isEmpty) {
|
|
||||||
// pass
|
|
||||||
} else {
|
|
||||||
final ids = field.trim().split(RegExp(r"[\s,;\n]+"));
|
|
||||||
field = ids.join(',');
|
|
||||||
for (final newId in ids) {
|
|
||||||
if (gFFI.abModel.idContainBy(newId)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
gFFI.abModel.addId(newId);
|
|
||||||
}
|
|
||||||
await gFFI.abModel.updateAb();
|
|
||||||
this.setState(() {});
|
|
||||||
// final currentPeers
|
|
||||||
}
|
|
||||||
close();
|
|
||||||
}
|
|
||||||
|
|
||||||
return CustomAlertDialog(
|
|
||||||
title: Text(translate("Add ID")),
|
|
||||||
content: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(translate("whitelist_sep")),
|
|
||||||
const SizedBox(
|
|
||||||
height: 8.0,
|
|
||||||
),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: TextField(
|
|
||||||
maxLines: null,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
border: const OutlineInputBorder(),
|
|
||||||
errorText: msg.isEmpty ? null : translate(msg),
|
|
||||||
),
|
|
||||||
controller: controller,
|
|
||||||
focusNode: FocusNode()..requestFocus()),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(
|
|
||||||
height: 4.0,
|
|
||||||
),
|
|
||||||
Offstage(
|
|
||||||
offstage: !isInProgress, child: const LinearProgressIndicator())
|
|
||||||
],
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(onPressed: close, child: Text(translate("Cancel"))),
|
|
||||||
TextButton(onPressed: submit, child: Text(translate("OK"))),
|
|
||||||
],
|
|
||||||
onSubmit: submit,
|
|
||||||
onCancel: close,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void abAddTag() async {
|
|
||||||
var field = "";
|
|
||||||
var msg = "";
|
|
||||||
var isInProgress = false;
|
|
||||||
TextEditingController controller = TextEditingController(text: field);
|
|
||||||
gFFI.dialogManager.show((setState, close) {
|
|
||||||
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 (final tag in tags) {
|
|
||||||
gFFI.abModel.addTag(tag);
|
|
||||||
}
|
|
||||||
await gFFI.abModel.updateAb();
|
|
||||||
// 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(
|
|
||||||
border: const OutlineInputBorder(),
|
|
||||||
errorText: msg.isEmpty ? null : translate(msg),
|
|
||||||
),
|
|
||||||
controller: controller,
|
|
||||||
focusNode: FocusNode()..requestFocus(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(
|
|
||||||
height: 4.0,
|
|
||||||
),
|
|
||||||
Offstage(
|
|
||||||
offstage: !isInProgress, child: const LinearProgressIndicator())
|
|
||||||
],
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(onPressed: close, child: Text(translate("Cancel"))),
|
|
||||||
TextButton(onPressed: submit, child: Text(translate("OK"))),
|
|
||||||
],
|
|
||||||
onSubmit: submit,
|
|
||||||
onCancel: close,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void abEditTag(String id) {
|
|
||||||
var isInProgress = false;
|
|
||||||
|
|
||||||
final tags = List.of(gFFI.abModel.tags);
|
|
||||||
var selectedTag = gFFI.abModel.getPeerTags(id).obs;
|
|
||||||
|
|
||||||
gFFI.dialogManager.show((setState, close) {
|
|
||||||
submit() async {
|
|
||||||
setState(() {
|
|
||||||
isInProgress = true;
|
|
||||||
});
|
|
||||||
gFFI.abModel.changeTagForPeer(id, selectedTag);
|
|
||||||
await gFFI.abModel.updateAb();
|
|
||||||
close();
|
|
||||||
}
|
|
||||||
|
|
||||||
return CustomAlertDialog(
|
|
||||||
title: Text(translate("Edit Tag")),
|
|
||||||
content: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
padding:
|
|
||||||
const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
|
||||||
child: Wrap(
|
|
||||||
children: tags
|
|
||||||
.map((e) => buildTag(e, selectedTag, onTap: () {
|
|
||||||
if (selectedTag.contains(e)) {
|
|
||||||
selectedTag.remove(e);
|
|
||||||
} else {
|
|
||||||
selectedTag.add(e);
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
.toList(growable: false),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Offstage(
|
|
||||||
offstage: !isInProgress, child: const LinearProgressIndicator())
|
|
||||||
],
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(onPressed: close, child: Text(translate("Cancel"))),
|
|
||||||
TextButton(onPressed: submit, child: Text(translate("OK"))),
|
|
||||||
],
|
|
||||||
onSubmit: submit,
|
|
||||||
onCancel: close,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _PeerTabbedPage extends StatefulWidget {
|
|
||||||
final List<String> tabs;
|
|
||||||
final List<Widget> children;
|
|
||||||
const _PeerTabbedPage({required this.tabs, required this.children, Key? key})
|
|
||||||
: super(key: key);
|
|
||||||
@override
|
|
||||||
_PeerTabbedPageState createState() => _PeerTabbedPageState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _PeerTabbedPageState extends State<_PeerTabbedPage>
|
|
||||||
with SingleTickerProviderStateMixin {
|
|
||||||
late PageController _pageController = PageController();
|
|
||||||
RxInt _tabIndex = 0.obs;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
}
|
|
||||||
|
|
||||||
// hard code for now
|
|
||||||
void _handleTabSelection(int index) {
|
|
||||||
// reset search text
|
|
||||||
peerSearchText.value = "";
|
|
||||||
peerSearchTextController.clear();
|
|
||||||
_tabIndex.value = index;
|
|
||||||
_pageController.jumpToPage(index);
|
|
||||||
switch (index) {
|
|
||||||
case 0:
|
|
||||||
bind.mainLoadRecentPeers();
|
|
||||||
break;
|
|
||||||
case 1:
|
|
||||||
bind.mainLoadFavPeers();
|
|
||||||
break;
|
|
||||||
case 2:
|
|
||||||
bind.mainDiscover();
|
|
||||||
break;
|
|
||||||
case 3:
|
|
||||||
gFFI.abModel.updateAb();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_pageController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Column(
|
|
||||||
textBaseline: TextBaseline.ideographic,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
height: 28,
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(child: _createTabBar(context)),
|
|
||||||
_createSearchBar(context),
|
|
||||||
_createPeerViewTypeSwitch(context),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
_createTabBarView(),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _createTabBar(BuildContext context) {
|
|
||||||
return ListView(
|
|
||||||
scrollDirection: Axis.horizontal,
|
|
||||||
shrinkWrap: true,
|
|
||||||
controller: ScrollController(),
|
|
||||||
children: super.widget.tabs.asMap().entries.map((t) {
|
|
||||||
return Obx(() => GestureDetector(
|
|
||||||
child: Container(
|
|
||||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: _tabIndex.value == t.key
|
|
||||||
? MyTheme.color(context).bg
|
|
||||||
: null,
|
|
||||||
borderRadius: BorderRadius.circular(2),
|
|
||||||
),
|
|
||||||
child: Align(
|
|
||||||
alignment: Alignment.center,
|
|
||||||
child: Text(
|
|
||||||
t.value,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: TextStyle(
|
|
||||||
height: 1,
|
|
||||||
fontSize: 14,
|
|
||||||
color: _tabIndex.value == t.key
|
|
||||||
? MyTheme.color(context).text
|
|
||||||
: MyTheme.color(context).lightText),
|
|
||||||
),
|
|
||||||
)),
|
|
||||||
onTap: () => _handleTabSelection(t.key),
|
|
||||||
));
|
|
||||||
}).toList());
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _createTabBarView() {
|
|
||||||
return Expanded(
|
|
||||||
child: PageView(
|
|
||||||
controller: _pageController, children: super.widget.children)
|
|
||||||
.marginSymmetric(vertical: 12));
|
|
||||||
}
|
|
||||||
|
|
||||||
_createSearchBar(BuildContext context) {
|
|
||||||
RxBool focused = false.obs;
|
|
||||||
FocusNode focusNode = FocusNode();
|
|
||||||
focusNode.addListener(() => focused.value = focusNode.hasFocus);
|
|
||||||
RxBool rowHover = false.obs;
|
|
||||||
RxBool clearHover = false.obs;
|
|
||||||
return Container(
|
|
||||||
width: 120,
|
|
||||||
height: 25,
|
|
||||||
margin: EdgeInsets.only(right: 13),
|
|
||||||
decoration: BoxDecoration(color: MyTheme.color(context).bg),
|
|
||||||
child: Obx(() => Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: MouseRegion(
|
|
||||||
onEnter: (_) => rowHover.value = true,
|
|
||||||
onExit: (_) => rowHover.value = false,
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
IconFont.search,
|
|
||||||
size: 16,
|
|
||||||
color: MyTheme.color(context).placeholder,
|
|
||||||
).marginSymmetric(horizontal: 4),
|
|
||||||
Expanded(
|
|
||||||
child: TextField(
|
|
||||||
controller: peerSearchTextController,
|
|
||||||
onChanged: (searchText) {
|
|
||||||
peerSearchText.value = searchText;
|
|
||||||
},
|
|
||||||
focusNode: focusNode,
|
|
||||||
textAlign: TextAlign.start,
|
|
||||||
maxLines: 1,
|
|
||||||
cursorColor: MyTheme.color(context).lightText,
|
|
||||||
cursorHeight: 18,
|
|
||||||
cursorWidth: 1,
|
|
||||||
style: TextStyle(fontSize: 14),
|
|
||||||
decoration: InputDecoration(
|
|
||||||
contentPadding: EdgeInsets.symmetric(vertical: 6),
|
|
||||||
hintText:
|
|
||||||
focused.value ? null : translate("Search ID"),
|
|
||||||
hintStyle: TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
color: MyTheme.color(context).placeholder),
|
|
||||||
border: InputBorder.none,
|
|
||||||
isDense: true,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Offstage(
|
|
||||||
offstage: !(peerSearchText.value.isNotEmpty &&
|
|
||||||
(rowHover.value || clearHover.value)),
|
|
||||||
child: InkWell(
|
|
||||||
onHover: (value) => clearHover.value = value,
|
|
||||||
child: Icon(
|
|
||||||
IconFont.round_close,
|
|
||||||
size: 16,
|
|
||||||
color: clearHover.value
|
|
||||||
? MyTheme.color(context).text
|
|
||||||
: MyTheme.color(context).placeholder,
|
|
||||||
).marginSymmetric(horizontal: 4),
|
|
||||||
onTap: () {
|
|
||||||
peerSearchTextController.clear();
|
|
||||||
peerSearchText.value = "";
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
],
|
|
||||||
)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
_createPeerViewTypeSwitch(BuildContext context) {
|
|
||||||
final activeDeco = BoxDecoration(color: MyTheme.color(context).bg);
|
|
||||||
return Row(
|
|
||||||
children: [
|
|
||||||
Obx(
|
|
||||||
() => Container(
|
|
||||||
padding: EdgeInsets.all(4.0),
|
|
||||||
decoration:
|
|
||||||
peerCardUiType.value == PeerUiType.grid ? activeDeco : null,
|
|
||||||
child: InkWell(
|
|
||||||
onTap: () {
|
|
||||||
peerCardUiType.value = PeerUiType.grid;
|
|
||||||
},
|
|
||||||
child: Icon(
|
|
||||||
Icons.grid_view_rounded,
|
|
||||||
size: 18,
|
|
||||||
color: peerCardUiType.value == PeerUiType.grid
|
|
||||||
? MyTheme.color(context).text
|
|
||||||
: MyTheme.color(context).lightText,
|
|
||||||
)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Obx(
|
|
||||||
() => Container(
|
|
||||||
padding: EdgeInsets.all(4.0),
|
|
||||||
decoration:
|
|
||||||
peerCardUiType.value == PeerUiType.list ? activeDeco : null,
|
|
||||||
child: InkWell(
|
|
||||||
onTap: () {
|
|
||||||
peerCardUiType.value = PeerUiType.list;
|
|
||||||
},
|
|
||||||
child: Icon(
|
|
||||||
Icons.list,
|
|
||||||
size: 18,
|
|
||||||
color: peerCardUiType.value == PeerUiType.list
|
|
||||||
? MyTheme.color(context).text
|
|
||||||
: MyTheme.color(context).lightText,
|
|
||||||
)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -15,7 +15,6 @@ import '../../models/platform_model.dart';
|
|||||||
import '../../common/shared_state.dart';
|
import '../../common/shared_state.dart';
|
||||||
import './popup_menu.dart';
|
import './popup_menu.dart';
|
||||||
import './material_mod_popup_menu.dart' as mod_menu;
|
import './material_mod_popup_menu.dart' as mod_menu;
|
||||||
import './utils.dart';
|
|
||||||
|
|
||||||
class _MenubarTheme {
|
class _MenubarTheme {
|
||||||
static const Color commonColor = MyTheme.accent;
|
static const Color commonColor = MyTheme.accent;
|
||||||
|
@ -1,28 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_hbb/utils/multi_window_manager.dart';
|
|
||||||
|
|
||||||
/// Connect to a peer with [id].
|
|
||||||
/// If [isFileTransfer], starts a session only for file transfer.
|
|
||||||
/// If [isTcpTunneling], starts a session only for tcp tunneling.
|
|
||||||
/// If [isRDP], starts a session only for rdp.
|
|
||||||
void connect(BuildContext context, String id,
|
|
||||||
{bool isFileTransfer = false,
|
|
||||||
bool isTcpTunneling = false,
|
|
||||||
bool isRDP = false}) async {
|
|
||||||
if (id == '') return;
|
|
||||||
id = id.replaceAll(' ', '');
|
|
||||||
assert(!(isFileTransfer && isTcpTunneling && isRDP),
|
|
||||||
"more than one connect type");
|
|
||||||
|
|
||||||
FocusScopeNode currentFocus = FocusScope.of(context);
|
|
||||||
if (isFileTransfer) {
|
|
||||||
await rustDeskWinManager.newFileTransfer(id);
|
|
||||||
} else if (isTcpTunneling || isRDP) {
|
|
||||||
await rustDeskWinManager.newPortForward(id, isRDP);
|
|
||||||
} else {
|
|
||||||
await rustDeskWinManager.newRemoteDesktop(id);
|
|
||||||
}
|
|
||||||
if (!currentFocus.hasPrimaryFocus) {
|
|
||||||
currentFocus.unfocus();
|
|
||||||
}
|
|
||||||
}
|
|
@ -2,12 +2,16 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hbb/mobile/pages/file_manager_page.dart';
|
import 'package:flutter_hbb/mobile/pages/file_manager_page.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
import '../../common.dart';
|
import '../../common.dart';
|
||||||
|
import '../../common/widgets/address_book.dart';
|
||||||
|
import '../../common/widgets/peer_tab_page.dart';
|
||||||
|
import '../../common/widgets/peer_widget.dart';
|
||||||
|
import '../../consts.dart';
|
||||||
import '../../models/model.dart';
|
import '../../models/model.dart';
|
||||||
import '../../models/peer_model.dart';
|
|
||||||
import '../../models/platform_model.dart';
|
import '../../models/platform_model.dart';
|
||||||
import 'home_page.dart';
|
import 'home_page.dart';
|
||||||
import 'remote_page.dart';
|
import 'remote_page.dart';
|
||||||
@ -19,16 +23,16 @@ class ConnectionPage extends StatefulWidget implements PageShape {
|
|||||||
ConnectionPage({Key? key}) : super(key: key);
|
ConnectionPage({Key? key}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
final icon = Icon(Icons.connected_tv);
|
final icon = const Icon(Icons.connected_tv);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
final title = translate("Connection");
|
final title = translate("Connection");
|
||||||
|
|
||||||
@override
|
@override
|
||||||
final appBarActions = !isAndroid ? <Widget>[WebMenu()] : <Widget>[];
|
final appBarActions = !isAndroid ? <Widget>[const WebMenu()] : <Widget>[];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_ConnectionPageState createState() => _ConnectionPageState();
|
State<ConnectionPage> createState() => _ConnectionPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// State for the connection page.
|
/// State for the connection page.
|
||||||
@ -38,7 +42,6 @@ class _ConnectionPageState extends State<ConnectionPage> {
|
|||||||
|
|
||||||
/// Update url. If it's not null, means an update is available.
|
/// Update url. If it's not null, means an update is available.
|
||||||
var _updateUrl = '';
|
var _updateUrl = '';
|
||||||
var _menuPos;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@ -54,9 +57,8 @@ class _ConnectionPageState extends State<ConnectionPage> {
|
|||||||
}();
|
}();
|
||||||
}
|
}
|
||||||
if (isAndroid) {
|
if (isAndroid) {
|
||||||
Timer(Duration(seconds: 5), () async {
|
Timer(const Duration(seconds: 5), () async {
|
||||||
_updateUrl = await bind.mainGetSoftwareUpdateUrl();
|
_updateUrl = await bind.mainGetSoftwareUpdateUrl();
|
||||||
;
|
|
||||||
if (_updateUrl.isNotEmpty) setState(() {});
|
if (_updateUrl.isNotEmpty) setState(() {});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -65,19 +67,29 @@ class _ConnectionPageState extends State<ConnectionPage> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
Provider.of<FfiModel>(context);
|
Provider.of<FfiModel>(context);
|
||||||
return SingleChildScrollView(
|
return Column(
|
||||||
controller: ScrollController(),
|
mainAxisAlignment: MainAxisAlignment.start,
|
||||||
child: Column(
|
mainAxisSize: MainAxisSize.max,
|
||||||
mainAxisAlignment: MainAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
mainAxisSize: MainAxisSize.max,
|
children: <Widget>[
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
getUpdateUI(),
|
||||||
children: <Widget>[
|
getSearchBarUI(),
|
||||||
getUpdateUI(),
|
Expanded(
|
||||||
getSearchBarUI(),
|
child: PeerTabPage(
|
||||||
Container(height: 12),
|
tabs: [
|
||||||
getPeers(),
|
translate('Recent Sessions'),
|
||||||
]),
|
translate('Favorites'),
|
||||||
);
|
translate('Discovered'),
|
||||||
|
translate('Address Book')
|
||||||
|
],
|
||||||
|
children: [
|
||||||
|
RecentPeerWidget(),
|
||||||
|
FavoritePeerWidget(),
|
||||||
|
DiscoveredPeerWidget(),
|
||||||
|
const AddressBook(),
|
||||||
|
],
|
||||||
|
)),
|
||||||
|
]).marginOnly(top: 2, left: 12, right: 12);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Callback for the connect button.
|
/// Callback for the connect button.
|
||||||
@ -122,10 +134,10 @@ class _ConnectionPageState extends State<ConnectionPage> {
|
|||||||
/// If [_updateUrl] is not empty, shows a button to update the software.
|
/// If [_updateUrl] is not empty, shows a button to update the software.
|
||||||
Widget getUpdateUI() {
|
Widget getUpdateUI() {
|
||||||
return _updateUrl.isEmpty
|
return _updateUrl.isEmpty
|
||||||
? SizedBox(height: 0)
|
? const SizedBox(height: 0)
|
||||||
: InkWell(
|
: InkWell(
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
final url = _updateUrl + '.apk';
|
final url = '$_updateUrl.apk';
|
||||||
if (await canLaunchUrl(Uri.parse(url))) {
|
if (await canLaunchUrl(Uri.parse(url))) {
|
||||||
await launchUrl(Uri.parse(url));
|
await launchUrl(Uri.parse(url));
|
||||||
}
|
}
|
||||||
@ -134,79 +146,77 @@ class _ConnectionPageState extends State<ConnectionPage> {
|
|||||||
alignment: AlignmentDirectional.center,
|
alignment: AlignmentDirectional.center,
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
color: Colors.pinkAccent,
|
color: Colors.pinkAccent,
|
||||||
padding: EdgeInsets.symmetric(vertical: 12),
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
child: Text(translate('Download new version'),
|
child: Text(translate('Download new version'),
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.white, fontWeight: FontWeight.bold))));
|
color: Colors.white, fontWeight: FontWeight.bold))));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// UI for the search bar.
|
/// UI for the search bar.
|
||||||
/// Search for a peer and connect to it if the id exists.
|
/// Search for a peer and connect to it if the id exists.
|
||||||
Widget getSearchBarUI() {
|
Widget getSearchBarUI() {
|
||||||
var w = Padding(
|
final w = SizedBox(
|
||||||
padding: const EdgeInsets.fromLTRB(16.0, 8.0, 16.0, 0.0),
|
height: 84,
|
||||||
child: Container(
|
child: Padding(
|
||||||
height: 84,
|
padding: const EdgeInsets.only(top: 8, bottom: 8),
|
||||||
child: Padding(
|
child: Ink(
|
||||||
padding: const EdgeInsets.only(top: 8, bottom: 8),
|
decoration: const BoxDecoration(
|
||||||
child: Ink(
|
color: MyTheme.white,
|
||||||
decoration: BoxDecoration(
|
borderRadius: BorderRadius.all(Radius.circular(13)),
|
||||||
color: MyTheme.white,
|
),
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(13)),
|
child: Row(
|
||||||
),
|
children: <Widget>[
|
||||||
child: Row(
|
Expanded(
|
||||||
children: <Widget>[
|
child: Container(
|
||||||
Expanded(
|
padding: const EdgeInsets.only(left: 16, right: 16),
|
||||||
child: Container(
|
child: TextField(
|
||||||
padding: const EdgeInsets.only(left: 16, right: 16),
|
autocorrect: false,
|
||||||
child: TextField(
|
enableSuggestions: false,
|
||||||
autocorrect: false,
|
keyboardType: TextInputType.visiblePassword,
|
||||||
enableSuggestions: false,
|
// keyboardType: TextInputType.number,
|
||||||
keyboardType: TextInputType.visiblePassword,
|
style: const TextStyle(
|
||||||
// keyboardType: TextInputType.number,
|
fontFamily: 'WorkSans',
|
||||||
style: TextStyle(
|
fontWeight: FontWeight.bold,
|
||||||
fontFamily: 'WorkSans',
|
fontSize: 30,
|
||||||
fontWeight: FontWeight.bold,
|
color: MyTheme.idColor,
|
||||||
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,
|
|
||||||
),
|
),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: translate('Remote ID'),
|
||||||
|
// hintText: 'Enter your remote ID',
|
||||||
|
border: InputBorder.none,
|
||||||
|
helperStyle: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 16,
|
||||||
|
color: MyTheme.darkGray,
|
||||||
|
),
|
||||||
|
labelStyle: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 16,
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
color: MyTheme.darkGray,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
controller: _idController,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(
|
),
|
||||||
width: 60,
|
SizedBox(
|
||||||
height: 60,
|
width: 60,
|
||||||
child: IconButton(
|
height: 60,
|
||||||
icon: Icon(Icons.arrow_forward,
|
child: IconButton(
|
||||||
color: MyTheme.darkGray, size: 45),
|
icon: const Icon(Icons.arrow_forward,
|
||||||
onPressed: onConnect,
|
color: MyTheme.darkGray, size: 45),
|
||||||
),
|
onPressed: onConnect,
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return Center(
|
return Align(
|
||||||
child: Container(constraints: BoxConstraints(maxWidth: 600), child: w));
|
alignment: Alignment.topLeft,
|
||||||
|
child: Container(constraints: kMobilePageConstraints, child: w));
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -214,97 +224,13 @@ class _ConnectionPageState extends State<ConnectionPage> {
|
|||||||
_idController.dispose();
|
_idController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get all the saved peers.
|
|
||||||
Widget getPeers() {
|
|
||||||
final windowWidth = MediaQuery.of(context).size.width;
|
|
||||||
final space = 8.0;
|
|
||||||
var width = windowWidth - 2 * space;
|
|
||||||
final minWidth = 320.0;
|
|
||||||
if (windowWidth > minWidth + 2 * space) {
|
|
||||||
final n = (windowWidth / (minWidth + 2 * space)).floor();
|
|
||||||
width = windowWidth / n - 2 * space;
|
|
||||||
}
|
|
||||||
return FutureBuilder<List<Peer>>(
|
|
||||||
future: gFFI.peers(),
|
|
||||||
builder: (context, snapshot) {
|
|
||||||
final cards = <Widget>[];
|
|
||||||
if (snapshot.hasData) {
|
|
||||||
final peers = snapshot.data!;
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Show the peer menu and handle user's choice.
|
|
||||||
/// User might remove the peer or send a file to the peer.
|
|
||||||
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('Transfer File')), value: 'file')
|
|
||||||
]),
|
|
||||||
elevation: 8,
|
|
||||||
);
|
|
||||||
if (value == 'remove') {
|
|
||||||
setState(() => bind.mainRemovePeer(id: id));
|
|
||||||
() async {
|
|
||||||
removePreference(id);
|
|
||||||
}();
|
|
||||||
} else if (value == 'file') {
|
|
||||||
connect(id, isFileTransfer: true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class WebMenu extends StatefulWidget {
|
class WebMenu extends StatefulWidget {
|
||||||
|
const WebMenu({Key? key}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_WebMenuState createState() => _WebMenuState();
|
State<WebMenu> createState() => _WebMenuState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _WebMenuState extends State<WebMenu> {
|
class _WebMenuState extends State<WebMenu> {
|
||||||
@ -337,36 +263,36 @@ class _WebMenuState extends State<WebMenu> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
Provider.of<FfiModel>(context);
|
Provider.of<FfiModel>(context);
|
||||||
return PopupMenuButton<String>(
|
return PopupMenuButton<String>(
|
||||||
icon: Icon(Icons.more_vert),
|
icon: const Icon(Icons.more_vert),
|
||||||
itemBuilder: (context) {
|
itemBuilder: (context) {
|
||||||
return (isIOS
|
return (isIOS
|
||||||
? [
|
? [
|
||||||
PopupMenuItem(
|
const PopupMenuItem(
|
||||||
child: Icon(Icons.qr_code_scanner, color: Colors.black),
|
|
||||||
value: "scan",
|
value: "scan",
|
||||||
|
child: Icon(Icons.qr_code_scanner, color: Colors.black),
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
: <PopupMenuItem<String>>[]) +
|
: <PopupMenuItem<String>>[]) +
|
||||||
[
|
[
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
child: Text(translate('ID/Relay Server')),
|
|
||||||
value: "server",
|
value: "server",
|
||||||
|
child: Text(translate('ID/Relay Server')),
|
||||||
)
|
)
|
||||||
] +
|
] +
|
||||||
(url.contains('admin.rustdesk.com')
|
(url.contains('admin.rustdesk.com')
|
||||||
? <PopupMenuItem<String>>[]
|
? <PopupMenuItem<String>>[]
|
||||||
: [
|
: [
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
|
value: "login",
|
||||||
child: Text(username == null
|
child: Text(username == null
|
||||||
? translate("Login")
|
? translate("Login")
|
||||||
: translate("Logout") + ' ($username)'),
|
: '${translate("Logout")} ($username)'),
|
||||||
value: "login",
|
|
||||||
)
|
)
|
||||||
]) +
|
]) +
|
||||||
[
|
[
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
child: Text(translate('About') + ' RustDesk'),
|
|
||||||
value: "about",
|
value: "about",
|
||||||
|
child: Text('${translate('About')} RustDesk'),
|
||||||
)
|
)
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
@ -32,6 +32,7 @@ const url = 'https://rustdesk.com/';
|
|||||||
final _hasIgnoreBattery = androidVersion >= 26;
|
final _hasIgnoreBattery = androidVersion >= 26;
|
||||||
var _ignoreBatteryOpt = false;
|
var _ignoreBatteryOpt = false;
|
||||||
var _enableAbr = false;
|
var _enableAbr = false;
|
||||||
|
var _isDarkMode = false;
|
||||||
|
|
||||||
class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
||||||
String? username;
|
String? username;
|
||||||
@ -59,6 +60,8 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
|||||||
_enableAbr = enableAbrRes;
|
_enableAbr = enableAbrRes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_enableAbr = isDarkTheme();
|
||||||
|
|
||||||
if (update) {
|
if (update) {
|
||||||
setState(() {});
|
setState(() {});
|
||||||
}
|
}
|
||||||
@ -173,7 +176,18 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
|||||||
leading: Icon(Icons.translate),
|
leading: Icon(Icons.translate),
|
||||||
onPressed: (context) {
|
onPressed: (context) {
|
||||||
showLanguageSettings(gFFI.dialogManager);
|
showLanguageSettings(gFFI.dialogManager);
|
||||||
})
|
}),
|
||||||
|
SettingsTile.switchTile(
|
||||||
|
title: Text(translate('Dark Theme')),
|
||||||
|
leading: Icon(Icons.dark_mode),
|
||||||
|
initialValue: _isDarkMode,
|
||||||
|
onToggle: (v) {
|
||||||
|
setState(() {
|
||||||
|
_isDarkMode = !_isDarkMode;
|
||||||
|
MyTheme.changeTo(_isDarkMode);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
)
|
||||||
]),
|
]),
|
||||||
SettingsSection(
|
SettingsSection(
|
||||||
title: Text(translate("Enhancements")),
|
title: Text(translate("Enhancements")),
|
||||||
|
@ -101,7 +101,7 @@ class AbModel with ChangeNotifier {
|
|||||||
final resp =
|
final resp =
|
||||||
await http.post(Uri.parse(api), headers: authHeaders, body: body);
|
await http.post(Uri.parse(api), headers: authHeaders, body: body);
|
||||||
abLoading = false;
|
abLoading = false;
|
||||||
await getAb();
|
// await getAb(); // TODO
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
debugPrint("resp: ${resp.body}");
|
debugPrint("resp: ${resp.body}");
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user