mirror of
https://github.com/rustdesk/rustdesk.git
synced 2024-12-04 03:49:25 +08:00
da57fcb641
* add http(s) proxy * Add front-end translation * fix ui description * For linux platform, add rustls support * fix: Fix the proxy address test function. * add: Added default prompts for agency agreement and some multi-language translations * add: Http proxy request client * fix: add async http proxy func and format the code * add: Preliminary support for flutter front-end calling rust back-end http request * Optimize HTTP calls * Optimize HTTP calls * fix: Optimize HTTP requests, refine translations, and fix dependencies
2130 lines
66 KiB
Dart
2130 lines
66 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:file_picker/file_picker.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_hbb/common.dart';
|
|
import 'package:flutter_hbb/common/widgets/setting_widgets.dart';
|
|
import 'package:flutter_hbb/consts.dart';
|
|
import 'package:flutter_hbb/desktop/pages/desktop_home_page.dart';
|
|
import 'package:flutter_hbb/desktop/pages/desktop_tab_page.dart';
|
|
import 'package:flutter_hbb/models/platform_model.dart';
|
|
import 'package:flutter_hbb/models/server_model.dart';
|
|
import 'package:flutter_hbb/plugin/manager.dart';
|
|
import 'package:flutter_hbb/plugin/widgets/desktop_settings.dart';
|
|
import 'package:get/get.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:url_launcher/url_launcher.dart';
|
|
import 'package:url_launcher/url_launcher_string.dart';
|
|
import 'package:flutter_hbb/desktop/widgets/scroll_wrapper.dart';
|
|
|
|
import '../../common/widgets/dialog.dart';
|
|
import '../../common/widgets/login.dart';
|
|
|
|
const double _kTabWidth = 200;
|
|
const double _kTabHeight = 42;
|
|
const double _kCardFixedWidth = 540;
|
|
const double _kCardLeftMargin = 15;
|
|
const double _kContentHMargin = 15;
|
|
const double _kContentHSubMargin = _kContentHMargin + 33;
|
|
const double _kCheckBoxLeftMargin = 10;
|
|
const double _kRadioLeftMargin = 10;
|
|
const double _kListViewBottomMargin = 15;
|
|
const double _kTitleFontSize = 20;
|
|
const double _kContentFontSize = 15;
|
|
const Color _accentColor = MyTheme.accent;
|
|
const String _kSettingPageControllerTag = 'settingPageController';
|
|
const String _kSettingPageIndexTag = 'settingPageIndex';
|
|
const int _kPageCount = 6;
|
|
|
|
class _TabInfo {
|
|
late final String label;
|
|
late final IconData unselected;
|
|
late final IconData selected;
|
|
_TabInfo(this.label, this.unselected, this.selected);
|
|
}
|
|
|
|
class DesktopSettingPage extends StatefulWidget {
|
|
final int initialPage;
|
|
|
|
const DesktopSettingPage({Key? key, required this.initialPage})
|
|
: super(key: key);
|
|
|
|
@override
|
|
State<DesktopSettingPage> createState() => _DesktopSettingPageState();
|
|
|
|
static void switch2page(int page) {
|
|
if (page >= _kPageCount) return;
|
|
try {
|
|
if (Get.isRegistered<PageController>(tag: _kSettingPageControllerTag)) {
|
|
DesktopTabPage.onAddSetting(initialPage: page);
|
|
PageController controller = Get.find(tag: _kSettingPageControllerTag);
|
|
RxInt selectedIndex = Get.find(tag: _kSettingPageIndexTag);
|
|
selectedIndex.value = page;
|
|
controller.jumpToPage(page);
|
|
} else {
|
|
DesktopTabPage.onAddSetting(initialPage: page);
|
|
}
|
|
} catch (e) {
|
|
debugPrintStack(label: '$e');
|
|
}
|
|
}
|
|
}
|
|
|
|
class _DesktopSettingPageState extends State<DesktopSettingPage>
|
|
with TickerProviderStateMixin, AutomaticKeepAliveClientMixin {
|
|
late PageController controller;
|
|
late RxInt selectedIndex;
|
|
|
|
@override
|
|
bool get wantKeepAlive => true;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
selectedIndex =
|
|
(widget.initialPage < _kPageCount ? widget.initialPage : 0).obs;
|
|
Get.put<RxInt>(selectedIndex, tag: _kSettingPageIndexTag);
|
|
controller = PageController(initialPage: widget.initialPage);
|
|
Get.put<PageController>(controller, tag: _kSettingPageControllerTag);
|
|
controller.addListener(() {
|
|
if (controller.page != null) {
|
|
selectedIndex.value = controller.page!.toInt();
|
|
}
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
super.dispose();
|
|
Get.delete<PageController>(tag: _kSettingPageControllerTag);
|
|
Get.delete<RxInt>(tag: _kSettingPageIndexTag);
|
|
}
|
|
|
|
List<_TabInfo> _settingTabs() {
|
|
final List<_TabInfo> settingTabs = <_TabInfo>[
|
|
_TabInfo('General', Icons.settings_outlined, Icons.settings),
|
|
if (!bind.isOutgoingOnly() && !bind.isDisableSettings())
|
|
_TabInfo('Security', Icons.enhanced_encryption_outlined,
|
|
Icons.enhanced_encryption),
|
|
if (!bind.isDisableSettings())
|
|
_TabInfo('Network', Icons.link_outlined, Icons.link),
|
|
if (!bind.isIncomingOnly())
|
|
_TabInfo(
|
|
'Display', Icons.desktop_windows_outlined, Icons.desktop_windows),
|
|
if (!isWeb && !bind.isIncomingOnly() && bind.pluginFeatureIsEnabled())
|
|
_TabInfo('Plugin', Icons.extension_outlined, Icons.extension),
|
|
if (!bind.isDisableAccount())
|
|
_TabInfo('Account', Icons.person_outline, Icons.person),
|
|
_TabInfo('About', Icons.info_outline, Icons.info)
|
|
];
|
|
return settingTabs;
|
|
}
|
|
|
|
List<Widget> _children() {
|
|
final children = [
|
|
_General(),
|
|
if (!bind.isOutgoingOnly() && !bind.isDisableSettings()) _Safety(),
|
|
if (!bind.isDisableSettings()) _Network(),
|
|
if (!bind.isIncomingOnly()) _Display(),
|
|
if (!isWeb && !bind.isIncomingOnly() && bind.pluginFeatureIsEnabled())
|
|
_Plugin(),
|
|
if (!bind.isDisableAccount()) _Account(),
|
|
_About(),
|
|
];
|
|
return children;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
super.build(context);
|
|
return Scaffold(
|
|
backgroundColor: Theme.of(context).colorScheme.background,
|
|
body: Row(
|
|
children: <Widget>[
|
|
SizedBox(
|
|
width: _kTabWidth,
|
|
child: Column(
|
|
children: [
|
|
_header(),
|
|
Flexible(child: _listView(tabs: _settingTabs())),
|
|
],
|
|
),
|
|
),
|
|
const VerticalDivider(width: 1),
|
|
Expanded(
|
|
child: Container(
|
|
color: Theme.of(context).scaffoldBackgroundColor,
|
|
child: DesktopScrollWrapper(
|
|
scrollController: controller,
|
|
child: PageView(
|
|
controller: controller,
|
|
physics: NeverScrollableScrollPhysics(),
|
|
children: _children(),
|
|
)),
|
|
),
|
|
)
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _header() {
|
|
return Row(
|
|
children: [
|
|
SizedBox(
|
|
height: 62,
|
|
child: Text(
|
|
translate('Settings'),
|
|
textAlign: TextAlign.left,
|
|
style: const TextStyle(
|
|
color: _accentColor,
|
|
fontSize: _kTitleFontSize,
|
|
fontWeight: FontWeight.w400,
|
|
),
|
|
),
|
|
).marginOnly(left: 20, top: 10),
|
|
const Spacer(),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _listView({required List<_TabInfo> tabs}) {
|
|
final scrollController = ScrollController();
|
|
return DesktopScrollWrapper(
|
|
scrollController: scrollController,
|
|
child: ListView(
|
|
physics: DraggableNeverScrollableScrollPhysics(),
|
|
controller: scrollController,
|
|
children: tabs
|
|
.asMap()
|
|
.entries
|
|
.map((tab) => _listItem(tab: tab.value, index: tab.key))
|
|
.toList(),
|
|
));
|
|
}
|
|
|
|
Widget _listItem({required _TabInfo tab, required int index}) {
|
|
return Obx(() {
|
|
bool selected = index == selectedIndex.value;
|
|
return SizedBox(
|
|
width: _kTabWidth,
|
|
height: _kTabHeight,
|
|
child: InkWell(
|
|
onTap: () {
|
|
if (selectedIndex.value != index) {
|
|
controller.jumpToPage(index);
|
|
}
|
|
selectedIndex.value = index;
|
|
},
|
|
child: Row(children: [
|
|
Container(
|
|
width: 4,
|
|
height: _kTabHeight * 0.7,
|
|
color: selected ? _accentColor : null,
|
|
),
|
|
Icon(
|
|
selected ? tab.selected : tab.unselected,
|
|
color: selected ? _accentColor : null,
|
|
size: 20,
|
|
).marginOnly(left: 13, right: 10),
|
|
Text(
|
|
translate(tab.label),
|
|
style: TextStyle(
|
|
color: selected ? _accentColor : null,
|
|
fontWeight: FontWeight.w400,
|
|
fontSize: _kContentFontSize),
|
|
),
|
|
]),
|
|
),
|
|
);
|
|
});
|
|
}
|
|
}
|
|
|
|
//#region pages
|
|
|
|
class _General extends StatefulWidget {
|
|
const _General({Key? key}) : super(key: key);
|
|
|
|
@override
|
|
State<_General> createState() => _GeneralState();
|
|
}
|
|
|
|
class _GeneralState extends State<_General> {
|
|
final RxBool serviceStop = Get.find<RxBool>(tag: 'stop-service');
|
|
RxBool serviceBtnEnabled = true.obs;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final scrollController = ScrollController();
|
|
return DesktopScrollWrapper(
|
|
scrollController: scrollController,
|
|
child: ListView(
|
|
physics: DraggableNeverScrollableScrollPhysics(),
|
|
controller: scrollController,
|
|
children: [
|
|
service(),
|
|
theme(),
|
|
hwcodec(),
|
|
audio(context),
|
|
record(context),
|
|
_Card(title: 'Language', children: [language()]),
|
|
other()
|
|
],
|
|
).marginOnly(bottom: _kListViewBottomMargin));
|
|
}
|
|
|
|
Widget theme() {
|
|
final current = MyTheme.getThemeModePreference().toShortString();
|
|
onChanged(String value) {
|
|
MyTheme.changeDarkMode(MyTheme.themeModeFromString(value));
|
|
setState(() {});
|
|
}
|
|
|
|
return _Card(title: 'Theme', children: [
|
|
_Radio<String>(context,
|
|
value: 'light',
|
|
groupValue: current,
|
|
label: 'Light',
|
|
onChanged: onChanged),
|
|
_Radio<String>(context,
|
|
value: 'dark',
|
|
groupValue: current,
|
|
label: 'Dark',
|
|
onChanged: onChanged),
|
|
_Radio<String>(context,
|
|
value: 'system',
|
|
groupValue: current,
|
|
label: 'Follow System',
|
|
onChanged: onChanged),
|
|
]);
|
|
}
|
|
|
|
Widget service() {
|
|
if (bind.isOutgoingOnly()) {
|
|
return const Offstage();
|
|
}
|
|
|
|
return _Card(title: 'Service', children: [
|
|
Obx(() => _Button(serviceStop.value ? 'Start' : 'Stop', () {
|
|
() async {
|
|
serviceBtnEnabled.value = false;
|
|
await start_service(serviceStop.value);
|
|
// enable the button after 1 second
|
|
Future.delayed(const Duration(seconds: 1), () {
|
|
serviceBtnEnabled.value = true;
|
|
});
|
|
}();
|
|
}, enabled: serviceBtnEnabled.value))
|
|
]);
|
|
}
|
|
|
|
Widget other() {
|
|
final children = <Widget>[
|
|
if (!bind.isIncomingOnly())
|
|
_OptionCheckBox(context, 'Confirm before closing multiple tabs',
|
|
'enable-confirm-closing-tabs',
|
|
isServer: false),
|
|
_OptionCheckBox(context, 'Adaptive bitrate', 'enable-abr'),
|
|
wallpaper(),
|
|
if (!bind.isIncomingOnly()) ...[
|
|
_OptionCheckBox(
|
|
context,
|
|
'Open connection in new tab',
|
|
kOptionOpenNewConnInTabs,
|
|
isServer: false,
|
|
),
|
|
// though this is related to GUI, but opengl problem affects all users, so put in config rather than local
|
|
Tooltip(
|
|
message: translate('software_render_tip'),
|
|
child: _OptionCheckBox(context, "Always use software rendering",
|
|
'allow-always-software-render'),
|
|
),
|
|
_OptionCheckBox(
|
|
context,
|
|
'Check for software update on startup',
|
|
'enable-check-update',
|
|
isServer: false,
|
|
)
|
|
],
|
|
];
|
|
if (bind.mainShowOption(key: 'allow-linux-headless')) {
|
|
children.add(_OptionCheckBox(
|
|
context, 'Allow linux headless', 'allow-linux-headless'));
|
|
}
|
|
return _Card(title: 'Other', children: children);
|
|
}
|
|
|
|
Widget wallpaper() {
|
|
if (bind.isOutgoingOnly()) {
|
|
return const Offstage();
|
|
}
|
|
|
|
return futureBuilder(future: () async {
|
|
final support = await bind.mainSupportRemoveWallpaper();
|
|
return support;
|
|
}(), hasData: (data) {
|
|
if (data is bool && data == true) {
|
|
final option = 'allow-remove-wallpaper';
|
|
bool value = mainGetBoolOptionSync(option);
|
|
return Row(
|
|
children: [
|
|
Flexible(
|
|
child: _OptionCheckBox(
|
|
context,
|
|
'Remove wallpaper during incoming sessions',
|
|
option,
|
|
update: () {
|
|
setState(() {});
|
|
},
|
|
),
|
|
),
|
|
if (value)
|
|
_CountDownButton(
|
|
text: 'Test',
|
|
second: 5,
|
|
onPressed: () {
|
|
bind.mainTestWallpaper(second: 5);
|
|
},
|
|
)
|
|
],
|
|
);
|
|
}
|
|
|
|
return Offstage();
|
|
});
|
|
}
|
|
|
|
Widget hwcodec() {
|
|
final hwcodec = bind.mainHasHwcodec();
|
|
final vram = bind.mainHasVram();
|
|
return Offstage(
|
|
offstage: !(hwcodec || vram),
|
|
child: _Card(title: 'Hardware Codec', children: [
|
|
_OptionCheckBox(
|
|
context,
|
|
'Enable hardware codec',
|
|
'enable-hwcodec',
|
|
update: () {
|
|
if (mainGetBoolOptionSync('enable-hwcodec')) {
|
|
bind.mainCheckHwcodec();
|
|
}
|
|
},
|
|
)
|
|
]),
|
|
);
|
|
}
|
|
|
|
Widget audio(BuildContext context) {
|
|
if (bind.isOutgoingOnly()) {
|
|
return const Offstage();
|
|
}
|
|
|
|
String getDefault() {
|
|
if (isWindows) return translate('System Sound');
|
|
return '';
|
|
}
|
|
|
|
Future<String> getValue() async {
|
|
String device = await bind.mainGetOption(key: 'audio-input');
|
|
if (device.isNotEmpty) {
|
|
return device;
|
|
} else {
|
|
return getDefault();
|
|
}
|
|
}
|
|
|
|
setDevice(String device) {
|
|
if (device == getDefault()) device = '';
|
|
bind.mainSetOption(key: 'audio-input', value: device);
|
|
}
|
|
|
|
return futureBuilder(future: () async {
|
|
List<String> devices = (await bind.mainGetSoundInputs()).toList();
|
|
if (isWindows) {
|
|
devices.insert(0, translate('System Sound'));
|
|
}
|
|
String current = await getValue();
|
|
return {'devices': devices, 'current': current};
|
|
}(), hasData: (data) {
|
|
String currentDevice = data['current'];
|
|
List<String> devices = data['devices'] as List<String>;
|
|
if (devices.isEmpty) {
|
|
return const Offstage();
|
|
}
|
|
return _Card(title: 'Audio Input Device', children: [
|
|
...devices.map((device) => _Radio<String>(context,
|
|
value: device,
|
|
groupValue: currentDevice,
|
|
autoNewLine: false,
|
|
label: device, onChanged: (value) {
|
|
setDevice(value);
|
|
setState(() {});
|
|
}))
|
|
]);
|
|
});
|
|
}
|
|
|
|
Widget record(BuildContext context) {
|
|
return futureBuilder(future: () async {
|
|
String defaultDirectory = await bind.mainDefaultVideoSaveDirectory();
|
|
// canLaunchUrl blocked on windows portable, user SYSTEM
|
|
return {'dir': defaultDirectory, 'canlaunch': true};
|
|
}(), hasData: (data) {
|
|
Map<String, dynamic> map = data as Map<String, dynamic>;
|
|
String dir = map['dir']!;
|
|
String customDirectory =
|
|
bind.mainGetOptionSync(key: 'video-save-directory');
|
|
if (customDirectory.isNotEmpty) {
|
|
dir = customDirectory;
|
|
}
|
|
bool canlaunch = map['canlaunch']! as bool;
|
|
|
|
return _Card(title: 'Recording', children: [
|
|
_OptionCheckBox(context, 'Automatically record incoming sessions',
|
|
'allow-auto-record-incoming'),
|
|
Row(
|
|
children: [
|
|
Text('${translate("Directory")}:'),
|
|
Expanded(
|
|
child: GestureDetector(
|
|
onTap: canlaunch ? () => launchUrl(Uri.file(dir)) : null,
|
|
child: Text(
|
|
dir,
|
|
softWrap: true,
|
|
style:
|
|
const TextStyle(decoration: TextDecoration.underline),
|
|
)).marginOnly(left: 10),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: () async {
|
|
String? initialDirectory;
|
|
if (await Directory.fromUri(Uri.directory(dir))
|
|
.exists()) {
|
|
initialDirectory = dir;
|
|
}
|
|
String? selectedDirectory = await FilePicker.platform
|
|
.getDirectoryPath(initialDirectory: initialDirectory);
|
|
if (selectedDirectory != null) {
|
|
await bind.mainSetOption(
|
|
key: 'video-save-directory',
|
|
value: selectedDirectory);
|
|
setState(() {});
|
|
}
|
|
},
|
|
child: Text(translate('Change')))
|
|
.marginOnly(left: 5),
|
|
],
|
|
).marginOnly(left: _kContentHMargin),
|
|
]);
|
|
});
|
|
}
|
|
|
|
Widget language() {
|
|
return futureBuilder(future: () async {
|
|
String langs = await bind.mainGetLangs();
|
|
return {'langs': langs};
|
|
}(), hasData: (res) {
|
|
Map<String, String> data = res as Map<String, String>;
|
|
List<dynamic> langsList = jsonDecode(data['langs']!);
|
|
Map<String, String> langsMap = {for (var v in langsList) v[0]: v[1]};
|
|
List<String> keys = langsMap.keys.toList();
|
|
List<String> values = langsMap.values.toList();
|
|
keys.insert(0, '');
|
|
values.insert(0, translate('Default'));
|
|
String currentKey = bind.mainGetLocalOption(key: kCommConfKeyLang);
|
|
if (!keys.contains(currentKey)) {
|
|
currentKey = '';
|
|
}
|
|
return ComboBox(
|
|
keys: keys,
|
|
values: values,
|
|
initialKey: currentKey,
|
|
onChanged: (key) async {
|
|
await bind.mainSetLocalOption(key: kCommConfKeyLang, value: key);
|
|
reloadAllWindows();
|
|
bind.mainChangeLanguage(lang: key);
|
|
},
|
|
).marginOnly(left: _kContentHMargin);
|
|
});
|
|
}
|
|
}
|
|
|
|
enum _AccessMode {
|
|
custom,
|
|
full,
|
|
view,
|
|
}
|
|
|
|
class _Safety extends StatefulWidget {
|
|
const _Safety({Key? key}) : super(key: key);
|
|
|
|
@override
|
|
State<_Safety> createState() => _SafetyState();
|
|
}
|
|
|
|
class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
|
|
@override
|
|
bool get wantKeepAlive => true;
|
|
bool locked = bind.mainIsInstalled();
|
|
final scrollController = ScrollController();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
super.build(context);
|
|
return DesktopScrollWrapper(
|
|
scrollController: scrollController,
|
|
child: SingleChildScrollView(
|
|
physics: DraggableNeverScrollableScrollPhysics(),
|
|
controller: scrollController,
|
|
child: Column(
|
|
children: [
|
|
_lock(locked, 'Unlock Security Settings', () {
|
|
locked = false;
|
|
setState(() => {});
|
|
}),
|
|
AbsorbPointer(
|
|
absorbing: locked,
|
|
child: Column(children: [
|
|
permissions(context),
|
|
password(context),
|
|
_Card(title: '2FA', children: [tfa()]),
|
|
_Card(title: 'ID', children: [changeId()]),
|
|
more(context),
|
|
]),
|
|
),
|
|
],
|
|
)).marginOnly(bottom: _kListViewBottomMargin));
|
|
}
|
|
|
|
Widget tfa() {
|
|
bool enabled = !locked;
|
|
// Simple temp wrapper for PR check
|
|
tmpWrapper() {
|
|
RxBool has2fa = bind.mainHasValid2FaSync().obs;
|
|
update() async {
|
|
has2fa.value = bind.mainHasValid2FaSync();
|
|
}
|
|
|
|
onChanged(bool? checked) async {
|
|
change2fa(callback: update);
|
|
}
|
|
|
|
return GestureDetector(
|
|
child: InkWell(
|
|
child: Obx(() => Row(
|
|
children: [
|
|
Checkbox(
|
|
value: has2fa.value,
|
|
onChanged: enabled ? onChanged : null)
|
|
.marginOnly(right: 5),
|
|
Expanded(
|
|
child: Text(
|
|
translate('enable-2fa-title'),
|
|
style:
|
|
TextStyle(color: disabledTextColor(context, enabled)),
|
|
))
|
|
],
|
|
)),
|
|
),
|
|
onTap: () {
|
|
onChanged(!has2fa.value);
|
|
},
|
|
).marginOnly(left: _kCheckBoxLeftMargin);
|
|
}
|
|
|
|
return tmpWrapper();
|
|
}
|
|
|
|
Widget changeId() {
|
|
return ChangeNotifierProvider.value(
|
|
value: gFFI.serverModel,
|
|
child: Consumer<ServerModel>(builder: ((context, model, child) {
|
|
return _Button('Change ID', changeIdDialog,
|
|
enabled: !locked && model.connectStatus > 0);
|
|
})));
|
|
}
|
|
|
|
Widget permissions(context) {
|
|
bool enabled = !locked;
|
|
// Simple temp wrapper for PR check
|
|
tmpWrapper() {
|
|
String accessMode = bind.mainGetOptionSync(key: 'access-mode');
|
|
_AccessMode mode;
|
|
if (accessMode == 'full') {
|
|
mode = _AccessMode.full;
|
|
} else if (accessMode == 'view') {
|
|
mode = _AccessMode.view;
|
|
} else {
|
|
mode = _AccessMode.custom;
|
|
}
|
|
String initialKey;
|
|
bool? fakeValue;
|
|
switch (mode) {
|
|
case _AccessMode.custom:
|
|
initialKey = '';
|
|
fakeValue = null;
|
|
break;
|
|
case _AccessMode.full:
|
|
initialKey = 'full';
|
|
fakeValue = true;
|
|
break;
|
|
case _AccessMode.view:
|
|
initialKey = 'view';
|
|
fakeValue = false;
|
|
break;
|
|
}
|
|
|
|
return _Card(title: 'Permissions', children: [
|
|
ComboBox(
|
|
keys: [
|
|
'',
|
|
'full',
|
|
'view',
|
|
],
|
|
values: [
|
|
translate('Custom'),
|
|
translate('Full Access'),
|
|
translate('Screen Share'),
|
|
],
|
|
enabled: enabled,
|
|
initialKey: initialKey,
|
|
onChanged: (mode) async {
|
|
await bind.mainSetOption(key: 'access-mode', value: mode);
|
|
setState(() {});
|
|
}).marginOnly(left: _kContentHMargin),
|
|
Column(
|
|
children: [
|
|
_OptionCheckBox(context, 'Enable keyboard/mouse', 'enable-keyboard',
|
|
enabled: enabled, fakeValue: fakeValue),
|
|
_OptionCheckBox(context, 'Enable clipboard', 'enable-clipboard',
|
|
enabled: enabled, fakeValue: fakeValue),
|
|
_OptionCheckBox(
|
|
context, 'Enable file transfer', 'enable-file-transfer',
|
|
enabled: enabled, fakeValue: fakeValue),
|
|
_OptionCheckBox(context, 'Enable audio', 'enable-audio',
|
|
enabled: enabled, fakeValue: fakeValue),
|
|
_OptionCheckBox(context, 'Enable TCP tunneling', 'enable-tunnel',
|
|
enabled: enabled, fakeValue: fakeValue),
|
|
_OptionCheckBox(
|
|
context, 'Enable remote restart', 'enable-remote-restart',
|
|
enabled: enabled, fakeValue: fakeValue),
|
|
_OptionCheckBox(
|
|
context, 'Enable recording session', 'enable-record-session',
|
|
enabled: enabled, fakeValue: fakeValue),
|
|
if (isWindows)
|
|
_OptionCheckBox(
|
|
context, 'Enable blocking user input', 'enable-block-input',
|
|
enabled: enabled, fakeValue: fakeValue),
|
|
_OptionCheckBox(context, 'Enable remote configuration modification',
|
|
'allow-remote-config-modification',
|
|
enabled: enabled, fakeValue: fakeValue),
|
|
],
|
|
),
|
|
]);
|
|
}
|
|
|
|
return tmpWrapper();
|
|
}
|
|
|
|
Widget password(BuildContext context) {
|
|
return ChangeNotifierProvider.value(
|
|
value: gFFI.serverModel,
|
|
child: Consumer<ServerModel>(builder: ((context, model, child) {
|
|
List<String> passwordKeys = [
|
|
kUseTemporaryPassword,
|
|
kUsePermanentPassword,
|
|
kUseBothPasswords,
|
|
];
|
|
List<String> passwordValues = [
|
|
translate('Use one-time password'),
|
|
translate('Use permanent password'),
|
|
translate('Use both passwords'),
|
|
];
|
|
bool tmpEnabled = model.verificationMethod != kUsePermanentPassword;
|
|
bool permEnabled = model.verificationMethod != kUseTemporaryPassword;
|
|
String currentValue =
|
|
passwordValues[passwordKeys.indexOf(model.verificationMethod)];
|
|
List<Widget> radios = passwordValues
|
|
.map((value) => _Radio<String>(
|
|
context,
|
|
value: value,
|
|
groupValue: currentValue,
|
|
label: value,
|
|
onChanged: ((value) {
|
|
() async {
|
|
await model.setVerificationMethod(
|
|
passwordKeys[passwordValues.indexOf(value)]);
|
|
await model.updatePasswordModel();
|
|
}();
|
|
}),
|
|
enabled: !locked,
|
|
))
|
|
.toList();
|
|
|
|
var onChanged = tmpEnabled && !locked
|
|
? (value) {
|
|
if (value != null) {
|
|
() async {
|
|
await model.setTemporaryPasswordLength(value.toString());
|
|
await model.updatePasswordModel();
|
|
}();
|
|
}
|
|
}
|
|
: null;
|
|
List<Widget> lengthRadios = ['6', '8', '10']
|
|
.map((value) => GestureDetector(
|
|
child: Row(
|
|
children: [
|
|
Radio(
|
|
value: value,
|
|
groupValue: model.temporaryPasswordLength,
|
|
onChanged: onChanged),
|
|
Text(
|
|
value,
|
|
style: TextStyle(
|
|
color: disabledTextColor(
|
|
context, onChanged != null)),
|
|
),
|
|
],
|
|
).paddingOnly(right: 10),
|
|
onTap: () => onChanged?.call(value),
|
|
))
|
|
.toList();
|
|
|
|
final modeKeys = ['password', 'click', ''];
|
|
final modeValues = [
|
|
translate('Accept sessions via password'),
|
|
translate('Accept sessions via click'),
|
|
translate('Accept sessions via both'),
|
|
];
|
|
var modeInitialKey = model.approveMode;
|
|
if (!modeKeys.contains(modeInitialKey)) modeInitialKey = '';
|
|
final usePassword = model.approveMode != 'click';
|
|
|
|
return _Card(title: 'Password', children: [
|
|
ComboBox(
|
|
enabled: !locked,
|
|
keys: modeKeys,
|
|
values: modeValues,
|
|
initialKey: modeInitialKey,
|
|
onChanged: (key) => model.setApproveMode(key),
|
|
).marginOnly(left: _kContentHMargin),
|
|
if (usePassword) radios[0],
|
|
if (usePassword)
|
|
_SubLabeledWidget(
|
|
context,
|
|
'One-time password length',
|
|
Row(
|
|
children: [
|
|
...lengthRadios,
|
|
],
|
|
),
|
|
enabled: tmpEnabled && !locked),
|
|
if (usePassword) radios[1],
|
|
if (usePassword)
|
|
_SubButton('Set permanent password', setPasswordDialog,
|
|
permEnabled && !locked),
|
|
// if (usePassword)
|
|
// hide_cm(!locked).marginOnly(left: _kContentHSubMargin - 6),
|
|
if (usePassword) radios[2],
|
|
]);
|
|
})));
|
|
}
|
|
|
|
Widget more(BuildContext context) {
|
|
bool enabled = !locked;
|
|
return _Card(title: 'Security', children: [
|
|
shareRdp(context, enabled),
|
|
_OptionCheckBox(context, 'Deny LAN discovery', 'enable-lan-discovery',
|
|
reverse: true, enabled: enabled),
|
|
...directIp(context),
|
|
whitelist(),
|
|
...autoDisconnect(context),
|
|
if (bind.mainIsInstalled())
|
|
_OptionCheckBox(context, 'allow-only-conn-window-open-tip',
|
|
'allow-only-conn-window-open',
|
|
reverse: false, enabled: enabled),
|
|
]);
|
|
}
|
|
|
|
shareRdp(BuildContext context, bool enabled) {
|
|
onChanged(bool b) async {
|
|
await bind.mainSetShareRdp(enable: b);
|
|
setState(() {});
|
|
}
|
|
|
|
bool value = bind.mainIsShareRdp();
|
|
return Offstage(
|
|
offstage: !(isWindows && bind.mainIsInstalled()),
|
|
child: GestureDetector(
|
|
child: Row(
|
|
children: [
|
|
Checkbox(
|
|
value: value,
|
|
onChanged: enabled ? (_) => onChanged(!value) : null)
|
|
.marginOnly(right: 5),
|
|
Expanded(
|
|
child: Text(translate('Enable RDP session sharing'),
|
|
style:
|
|
TextStyle(color: disabledTextColor(context, enabled))),
|
|
)
|
|
],
|
|
).marginOnly(left: _kCheckBoxLeftMargin),
|
|
onTap: enabled ? () => onChanged(!value) : null),
|
|
);
|
|
}
|
|
|
|
List<Widget> directIp(BuildContext context) {
|
|
TextEditingController controller = TextEditingController();
|
|
update() => setState(() {});
|
|
RxBool applyEnabled = false.obs;
|
|
return [
|
|
_OptionCheckBox(context, 'Enable direct IP access', 'direct-server',
|
|
update: update, enabled: !locked),
|
|
() {
|
|
// Simple temp wrapper for PR check
|
|
tmpWrapper() {
|
|
bool enabled = option2bool(
|
|
'direct-server', bind.mainGetOptionSync(key: 'direct-server'));
|
|
if (!enabled) applyEnabled.value = false;
|
|
controller.text = bind.mainGetOptionSync(key: 'direct-access-port');
|
|
return Offstage(
|
|
offstage: !enabled,
|
|
child: _SubLabeledWidget(
|
|
context,
|
|
'Port',
|
|
Row(children: [
|
|
SizedBox(
|
|
width: 95,
|
|
child: TextField(
|
|
controller: controller,
|
|
enabled: enabled && !locked,
|
|
onChanged: (_) => applyEnabled.value = true,
|
|
inputFormatters: [
|
|
FilteringTextInputFormatter.allow(RegExp(
|
|
r'^([0-9]|[1-9]\d|[1-9]\d{2}|[1-9]\d{3}|[1-5]\d{4}|6[0-4]\d{3}|65[0-4]\d{2}|655[0-2]\d|6553[0-5])$')),
|
|
],
|
|
decoration: const InputDecoration(
|
|
hintText: '21118',
|
|
contentPadding:
|
|
EdgeInsets.symmetric(vertical: 12, horizontal: 12),
|
|
),
|
|
).marginOnly(right: 15),
|
|
),
|
|
Obx(() => ElevatedButton(
|
|
onPressed: applyEnabled.value && enabled && !locked
|
|
? () async {
|
|
applyEnabled.value = false;
|
|
await bind.mainSetOption(
|
|
key: 'direct-access-port',
|
|
value: controller.text);
|
|
}
|
|
: null,
|
|
child: Text(
|
|
translate('Apply'),
|
|
),
|
|
))
|
|
]),
|
|
enabled: enabled && !locked,
|
|
),
|
|
);
|
|
}
|
|
|
|
return tmpWrapper();
|
|
}(),
|
|
];
|
|
}
|
|
|
|
Widget whitelist() {
|
|
bool enabled = !locked;
|
|
// Simple temp wrapper for PR check
|
|
tmpWrapper() {
|
|
RxBool hasWhitelist =
|
|
bind.mainGetOptionSync(key: 'whitelist').isNotEmpty.obs;
|
|
update() async {
|
|
hasWhitelist.value =
|
|
bind.mainGetOptionSync(key: 'whitelist').isNotEmpty;
|
|
}
|
|
|
|
onChanged(bool? checked) async {
|
|
changeWhiteList(callback: update);
|
|
}
|
|
|
|
return GestureDetector(
|
|
child: Tooltip(
|
|
message: translate('whitelist_tip'),
|
|
child: Obx(() => Row(
|
|
children: [
|
|
Checkbox(
|
|
value: hasWhitelist.value,
|
|
onChanged: enabled ? onChanged : null)
|
|
.marginOnly(right: 5),
|
|
Offstage(
|
|
offstage: !hasWhitelist.value,
|
|
child: const Icon(Icons.warning_amber_rounded,
|
|
color: Color.fromARGB(255, 255, 204, 0))
|
|
.marginOnly(right: 5),
|
|
),
|
|
Expanded(
|
|
child: Text(
|
|
translate('Use IP Whitelisting'),
|
|
style:
|
|
TextStyle(color: disabledTextColor(context, enabled)),
|
|
))
|
|
],
|
|
)),
|
|
),
|
|
onTap: () {
|
|
onChanged(!hasWhitelist.value);
|
|
},
|
|
).marginOnly(left: _kCheckBoxLeftMargin);
|
|
}
|
|
|
|
return tmpWrapper();
|
|
}
|
|
|
|
Widget hide_cm(bool enabled) {
|
|
return ChangeNotifierProvider.value(
|
|
value: gFFI.serverModel,
|
|
child: Consumer<ServerModel>(builder: (context, model, child) {
|
|
final enableHideCm = model.approveMode == 'password' &&
|
|
model.verificationMethod == kUsePermanentPassword;
|
|
onHideCmChanged(bool? b) {
|
|
if (b != null) {
|
|
bind.mainSetOption(
|
|
key: 'allow-hide-cm', value: bool2option('allow-hide-cm', b));
|
|
}
|
|
}
|
|
|
|
return Tooltip(
|
|
message: enableHideCm ? "" : translate('hide_cm_tip'),
|
|
child: GestureDetector(
|
|
onTap:
|
|
enableHideCm ? () => onHideCmChanged(!model.hideCm) : null,
|
|
child: Row(
|
|
children: [
|
|
Checkbox(
|
|
value: model.hideCm,
|
|
onChanged: enabled && enableHideCm
|
|
? onHideCmChanged
|
|
: null)
|
|
.marginOnly(right: 5),
|
|
Expanded(
|
|
child: Text(
|
|
translate('Hide connection management window'),
|
|
style: TextStyle(
|
|
color: disabledTextColor(
|
|
context, enabled && enableHideCm)),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
));
|
|
}));
|
|
}
|
|
|
|
List<Widget> autoDisconnect(BuildContext context) {
|
|
TextEditingController controller = TextEditingController();
|
|
update() => setState(() {});
|
|
RxBool applyEnabled = false.obs;
|
|
final optionKey = 'allow-auto-disconnect';
|
|
final timeoutKey = 'auto-disconnect-timeout';
|
|
return [
|
|
_OptionCheckBox(context, 'auto_disconnect_option_tip', optionKey,
|
|
update: update, enabled: !locked),
|
|
() {
|
|
bool enabled =
|
|
option2bool(optionKey, bind.mainGetOptionSync(key: optionKey));
|
|
if (!enabled) applyEnabled.value = false;
|
|
controller.text = bind.mainGetOptionSync(key: timeoutKey);
|
|
return Offstage(
|
|
offstage: !enabled,
|
|
child: _SubLabeledWidget(
|
|
context,
|
|
'Timeout in minutes',
|
|
Row(children: [
|
|
SizedBox(
|
|
width: 95,
|
|
child: TextField(
|
|
controller: controller,
|
|
enabled: enabled && !locked,
|
|
onChanged: (_) => applyEnabled.value = true,
|
|
inputFormatters: [
|
|
FilteringTextInputFormatter.allow(RegExp(
|
|
r'^([0-9]|[1-9]\d|[1-9]\d{2}|[1-9]\d{3}|[1-5]\d{4}|6[0-4]\d{3}|65[0-4]\d{2}|655[0-2]\d|6553[0-5])$')),
|
|
],
|
|
decoration: const InputDecoration(
|
|
hintText: '10',
|
|
contentPadding:
|
|
EdgeInsets.symmetric(vertical: 12, horizontal: 12),
|
|
),
|
|
).marginOnly(right: 15),
|
|
),
|
|
Obx(() => ElevatedButton(
|
|
onPressed: applyEnabled.value && enabled && !locked
|
|
? () async {
|
|
applyEnabled.value = false;
|
|
await bind.mainSetOption(
|
|
key: timeoutKey, value: controller.text);
|
|
}
|
|
: null,
|
|
child: Text(
|
|
translate('Apply'),
|
|
),
|
|
))
|
|
]),
|
|
enabled: enabled && !locked,
|
|
),
|
|
);
|
|
}(),
|
|
];
|
|
}
|
|
}
|
|
|
|
class _Network extends StatefulWidget {
|
|
const _Network({Key? key}) : super(key: key);
|
|
|
|
@override
|
|
State<_Network> createState() => _NetworkState();
|
|
}
|
|
|
|
class _NetworkState extends State<_Network> with AutomaticKeepAliveClientMixin {
|
|
@override
|
|
bool get wantKeepAlive => true;
|
|
bool locked = bind.mainIsInstalled();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
super.build(context);
|
|
bool enabled = !locked;
|
|
final scrollController = ScrollController();
|
|
return DesktopScrollWrapper(
|
|
scrollController: scrollController,
|
|
child: ListView(
|
|
controller: scrollController,
|
|
physics: DraggableNeverScrollableScrollPhysics(),
|
|
children: [
|
|
_lock(locked, 'Unlock Network Settings', () {
|
|
locked = false;
|
|
setState(() => {});
|
|
}),
|
|
AbsorbPointer(
|
|
absorbing: locked,
|
|
child: Column(children: [
|
|
server(enabled),
|
|
_Card(title: 'Proxy', children: [
|
|
_Button('Socks5/Http(s) Proxy', changeSocks5Proxy,
|
|
enabled: enabled),
|
|
]),
|
|
]),
|
|
),
|
|
]).marginOnly(bottom: _kListViewBottomMargin));
|
|
}
|
|
|
|
server(bool enabled) {
|
|
// Simple temp wrapper for PR check
|
|
tmpWrapper() {
|
|
// Setting page is not modal, oldOptions should only be used when getting options, never when setting.
|
|
Map<String, dynamic> oldOptions = jsonDecode(bind.mainGetOptionsSync());
|
|
old(String key) {
|
|
return (oldOptions[key] ?? '').trim();
|
|
}
|
|
|
|
RxString idErrMsg = ''.obs;
|
|
RxString relayErrMsg = ''.obs;
|
|
RxString apiErrMsg = ''.obs;
|
|
var idController =
|
|
TextEditingController(text: old('custom-rendezvous-server'));
|
|
var relayController = TextEditingController(text: old('relay-server'));
|
|
var apiController = TextEditingController(text: old('api-server'));
|
|
var keyController = TextEditingController(text: old('key'));
|
|
final controllers = [
|
|
idController,
|
|
relayController,
|
|
apiController,
|
|
keyController,
|
|
];
|
|
final errMsgs = [
|
|
idErrMsg,
|
|
relayErrMsg,
|
|
apiErrMsg,
|
|
];
|
|
|
|
submit() async {
|
|
bool result = await setServerConfig(
|
|
null,
|
|
errMsgs,
|
|
ServerConfig(
|
|
idServer: idController.text,
|
|
relayServer: relayController.text,
|
|
apiServer: apiController.text,
|
|
key: keyController.text));
|
|
if (result) {
|
|
setState(() {});
|
|
showToast(translate('Successful'));
|
|
} else {
|
|
showToast(translate('Failed'));
|
|
}
|
|
}
|
|
|
|
bool secure = !enabled;
|
|
return _Card(
|
|
title: 'ID/Relay Server',
|
|
title_suffix: ServerConfigImportExportWidgets(controllers, errMsgs),
|
|
children: [
|
|
Column(
|
|
children: [
|
|
Obx(() => _LabeledTextField(context, 'ID Server', idController,
|
|
idErrMsg.value, enabled, secure)),
|
|
Obx(() => _LabeledTextField(context, 'Relay Server',
|
|
relayController, relayErrMsg.value, enabled, secure)),
|
|
Obx(() => _LabeledTextField(context, 'API Server',
|
|
apiController, apiErrMsg.value, enabled, secure)),
|
|
_LabeledTextField(
|
|
context, 'Key', keyController, '', enabled, secure),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
children: [_Button('Apply', submit, enabled: enabled)],
|
|
).marginOnly(top: 10),
|
|
],
|
|
)
|
|
]);
|
|
}
|
|
|
|
return tmpWrapper();
|
|
}
|
|
}
|
|
|
|
class _Display extends StatefulWidget {
|
|
const _Display({Key? key}) : super(key: key);
|
|
|
|
@override
|
|
State<_Display> createState() => _DisplayState();
|
|
}
|
|
|
|
class _DisplayState extends State<_Display> {
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final scrollController = ScrollController();
|
|
return DesktopScrollWrapper(
|
|
scrollController: scrollController,
|
|
child: ListView(
|
|
controller: scrollController,
|
|
physics: DraggableNeverScrollableScrollPhysics(),
|
|
children: [
|
|
viewStyle(context),
|
|
scrollStyle(context),
|
|
imageQuality(context),
|
|
codec(context),
|
|
privacyModeImpl(context),
|
|
other(context),
|
|
]).marginOnly(bottom: _kListViewBottomMargin));
|
|
}
|
|
|
|
Widget viewStyle(BuildContext context) {
|
|
final key = 'view_style';
|
|
onChanged(String value) async {
|
|
await bind.mainSetUserDefaultOption(key: key, value: value);
|
|
setState(() {});
|
|
}
|
|
|
|
final groupValue = bind.mainGetUserDefaultOption(key: key);
|
|
return _Card(title: 'Default View Style', children: [
|
|
_Radio(context,
|
|
value: kRemoteViewStyleOriginal,
|
|
groupValue: groupValue,
|
|
label: 'Scale original',
|
|
onChanged: onChanged),
|
|
_Radio(context,
|
|
value: kRemoteViewStyleAdaptive,
|
|
groupValue: groupValue,
|
|
label: 'Scale adaptive',
|
|
onChanged: onChanged),
|
|
]);
|
|
}
|
|
|
|
Widget scrollStyle(BuildContext context) {
|
|
final key = 'scroll_style';
|
|
onChanged(String value) async {
|
|
await bind.mainSetUserDefaultOption(key: key, value: value);
|
|
setState(() {});
|
|
}
|
|
|
|
final groupValue = bind.mainGetUserDefaultOption(key: key);
|
|
return _Card(title: 'Default Scroll Style', children: [
|
|
_Radio(context,
|
|
value: kRemoteScrollStyleAuto,
|
|
groupValue: groupValue,
|
|
label: 'ScrollAuto',
|
|
onChanged: onChanged),
|
|
_Radio(context,
|
|
value: kRemoteScrollStyleBar,
|
|
groupValue: groupValue,
|
|
label: 'Scrollbar',
|
|
onChanged: onChanged),
|
|
]);
|
|
}
|
|
|
|
Widget imageQuality(BuildContext context) {
|
|
final key = 'image_quality';
|
|
onChanged(String value) async {
|
|
await bind.mainSetUserDefaultOption(key: key, value: value);
|
|
setState(() {});
|
|
}
|
|
|
|
final groupValue = bind.mainGetUserDefaultOption(key: key);
|
|
return _Card(title: 'Default Image Quality', children: [
|
|
_Radio(context,
|
|
value: kRemoteImageQualityBest,
|
|
groupValue: groupValue,
|
|
label: 'Good image quality',
|
|
onChanged: onChanged),
|
|
_Radio(context,
|
|
value: kRemoteImageQualityBalanced,
|
|
groupValue: groupValue,
|
|
label: 'Balanced',
|
|
onChanged: onChanged),
|
|
_Radio(context,
|
|
value: kRemoteImageQualityLow,
|
|
groupValue: groupValue,
|
|
label: 'Optimize reaction time',
|
|
onChanged: onChanged),
|
|
_Radio(context,
|
|
value: kRemoteImageQualityCustom,
|
|
groupValue: groupValue,
|
|
label: 'Custom',
|
|
onChanged: onChanged),
|
|
Offstage(
|
|
offstage: groupValue != kRemoteImageQualityCustom,
|
|
child: customImageQualitySetting(),
|
|
)
|
|
]);
|
|
}
|
|
|
|
Widget codec(BuildContext context) {
|
|
final key = 'codec-preference';
|
|
onChanged(String value) async {
|
|
await bind.mainSetUserDefaultOption(key: key, value: value);
|
|
setState(() {});
|
|
}
|
|
|
|
final groupValue = bind.mainGetUserDefaultOption(key: key);
|
|
var hwRadios = [];
|
|
try {
|
|
final Map codecsJson = jsonDecode(bind.mainSupportedHwdecodings());
|
|
final h264 = codecsJson['h264'] ?? false;
|
|
final h265 = codecsJson['h265'] ?? false;
|
|
if (h264) {
|
|
hwRadios.add(_Radio(context,
|
|
value: 'h264',
|
|
groupValue: groupValue,
|
|
label: 'H264',
|
|
onChanged: onChanged));
|
|
}
|
|
if (h265) {
|
|
hwRadios.add(_Radio(context,
|
|
value: 'h265',
|
|
groupValue: groupValue,
|
|
label: 'H265',
|
|
onChanged: onChanged));
|
|
}
|
|
} catch (e) {
|
|
debugPrint("failed to parse supported hwdecodings, err=$e");
|
|
}
|
|
return _Card(title: 'Default Codec', children: [
|
|
_Radio(context,
|
|
value: 'auto',
|
|
groupValue: groupValue,
|
|
label: 'Auto',
|
|
onChanged: onChanged),
|
|
_Radio(context,
|
|
value: 'vp8',
|
|
groupValue: groupValue,
|
|
label: 'VP8',
|
|
onChanged: onChanged),
|
|
_Radio(context,
|
|
value: 'vp9',
|
|
groupValue: groupValue,
|
|
label: 'VP9',
|
|
onChanged: onChanged),
|
|
_Radio(context,
|
|
value: 'av1',
|
|
groupValue: groupValue,
|
|
label: 'AV1',
|
|
onChanged: onChanged),
|
|
...hwRadios,
|
|
]);
|
|
}
|
|
|
|
Widget privacyModeImpl(BuildContext context) {
|
|
final supportedPrivacyModeImpls = bind.mainSupportedPrivacyModeImpls();
|
|
late final List<dynamic> privacyModeImpls;
|
|
try {
|
|
privacyModeImpls = jsonDecode(supportedPrivacyModeImpls);
|
|
} catch (e) {
|
|
debugPrint('failed to parse supported privacy mode impls, err=$e');
|
|
return Offstage();
|
|
}
|
|
if (privacyModeImpls.length < 2) {
|
|
return Offstage();
|
|
}
|
|
|
|
final key = 'privacy-mode-impl-key';
|
|
onChanged(String value) async {
|
|
await bind.mainSetOption(key: key, value: value);
|
|
setState(() {});
|
|
}
|
|
|
|
String groupValue = bind.mainGetOptionSync(key: key);
|
|
if (groupValue.isEmpty) {
|
|
groupValue = bind.mainDefaultPrivacyModeImpl();
|
|
}
|
|
return _Card(
|
|
title: 'Privacy mode',
|
|
children: privacyModeImpls.map((impl) {
|
|
final d = impl as List<dynamic>;
|
|
return _Radio(context,
|
|
value: d[0] as String,
|
|
groupValue: groupValue,
|
|
label: d[1] as String,
|
|
onChanged: onChanged);
|
|
}).toList(),
|
|
);
|
|
}
|
|
|
|
Widget otherRow(String label, String key) {
|
|
final value = bind.mainGetUserDefaultOption(key: key) == 'Y';
|
|
onChanged(bool b) async {
|
|
await bind.mainSetUserDefaultOption(key: key, value: b ? 'Y' : '');
|
|
setState(() {});
|
|
}
|
|
|
|
return GestureDetector(
|
|
child: Row(
|
|
children: [
|
|
Checkbox(value: value, onChanged: (_) => onChanged(!value))
|
|
.marginOnly(right: 5),
|
|
Expanded(
|
|
child: Text(translate(label)),
|
|
)
|
|
],
|
|
).marginOnly(left: _kCheckBoxLeftMargin),
|
|
onTap: () => onChanged(!value));
|
|
}
|
|
|
|
Widget other(BuildContext context) {
|
|
final children =
|
|
otherDefaultSettings().map((e) => otherRow(e.$1, e.$2)).toList();
|
|
return _Card(title: 'Other Default Options', children: children);
|
|
}
|
|
}
|
|
|
|
class _Account extends StatefulWidget {
|
|
const _Account({Key? key}) : super(key: key);
|
|
|
|
@override
|
|
State<_Account> createState() => _AccountState();
|
|
}
|
|
|
|
class _AccountState extends State<_Account> {
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final scrollController = ScrollController();
|
|
return DesktopScrollWrapper(
|
|
scrollController: scrollController,
|
|
child: ListView(
|
|
physics: DraggableNeverScrollableScrollPhysics(),
|
|
controller: scrollController,
|
|
children: [
|
|
_Card(title: 'Account', children: [accountAction(), useInfo()]),
|
|
],
|
|
).marginOnly(bottom: _kListViewBottomMargin));
|
|
}
|
|
|
|
Widget accountAction() {
|
|
return Obx(() => _Button(
|
|
gFFI.userModel.userName.value.isEmpty ? 'Login' : 'Logout',
|
|
() => {
|
|
gFFI.userModel.userName.value.isEmpty
|
|
? loginDialog()
|
|
: logOutConfirmDialog()
|
|
}));
|
|
}
|
|
|
|
Widget useInfo() {
|
|
text(String key, String value) {
|
|
return Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: SelectionArea(child: Text('${translate(key)}: $value'))
|
|
.marginSymmetric(vertical: 4),
|
|
);
|
|
}
|
|
|
|
return Obx(() => Offstage(
|
|
offstage: gFFI.userModel.userName.value.isEmpty,
|
|
child: Column(
|
|
children: [
|
|
text('Username', gFFI.userModel.userName.value),
|
|
// text('Group', gFFI.groupModel.groupName.value),
|
|
],
|
|
),
|
|
)).marginOnly(left: 18, top: 16);
|
|
}
|
|
}
|
|
|
|
class _Checkbox extends StatefulWidget {
|
|
final String label;
|
|
final bool Function() getValue;
|
|
final Future<void> Function(bool) setValue;
|
|
|
|
const _Checkbox(
|
|
{Key? key,
|
|
required this.label,
|
|
required this.getValue,
|
|
required this.setValue})
|
|
: super(key: key);
|
|
|
|
@override
|
|
State<_Checkbox> createState() => _CheckboxState();
|
|
}
|
|
|
|
class _CheckboxState extends State<_Checkbox> {
|
|
var value = false;
|
|
|
|
@override
|
|
initState() {
|
|
super.initState();
|
|
value = widget.getValue();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
onChanged(bool b) async {
|
|
await widget.setValue(b);
|
|
setState(() {
|
|
value = widget.getValue();
|
|
});
|
|
}
|
|
|
|
return GestureDetector(
|
|
child: Row(
|
|
children: [
|
|
Checkbox(
|
|
value: value,
|
|
onChanged: (_) => onChanged(!value),
|
|
).marginOnly(right: 5),
|
|
Expanded(
|
|
child: Text(translate(widget.label)),
|
|
)
|
|
],
|
|
).marginOnly(left: _kCheckBoxLeftMargin),
|
|
onTap: () => onChanged(!value),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Plugin extends StatefulWidget {
|
|
const _Plugin({Key? key}) : super(key: key);
|
|
|
|
@override
|
|
State<_Plugin> createState() => _PluginState();
|
|
}
|
|
|
|
class _PluginState extends State<_Plugin> {
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
bind.pluginListReload();
|
|
final scrollController = ScrollController();
|
|
return DesktopScrollWrapper(
|
|
scrollController: scrollController,
|
|
child: ChangeNotifierProvider.value(
|
|
value: pluginManager,
|
|
child: Consumer<PluginManager>(builder: (context, model, child) {
|
|
return ListView(
|
|
physics: DraggableNeverScrollableScrollPhysics(),
|
|
controller: scrollController,
|
|
children: model.plugins.map((entry) => pluginCard(entry)).toList(),
|
|
).marginOnly(bottom: _kListViewBottomMargin);
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget pluginCard(PluginInfo plugin) {
|
|
return ChangeNotifierProvider.value(
|
|
value: plugin,
|
|
child: Consumer<PluginInfo>(
|
|
builder: (context, model, child) => DesktopSettingsCard(plugin: model),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget accountAction() {
|
|
return Obx(() => _Button(
|
|
gFFI.userModel.userName.value.isEmpty ? 'Login' : 'Logout',
|
|
() => {
|
|
gFFI.userModel.userName.value.isEmpty
|
|
? loginDialog()
|
|
: logOutConfirmDialog()
|
|
}));
|
|
}
|
|
}
|
|
|
|
class _About extends StatefulWidget {
|
|
const _About({Key? key}) : super(key: key);
|
|
|
|
@override
|
|
State<_About> createState() => _AboutState();
|
|
}
|
|
|
|
class _AboutState extends State<_About> {
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return futureBuilder(future: () async {
|
|
final license = await bind.mainGetLicense();
|
|
final version = await bind.mainGetVersion();
|
|
final buildDate = await bind.mainGetBuildDate();
|
|
final fingerprint = await bind.mainGetFingerprint();
|
|
return {
|
|
'license': license,
|
|
'version': version,
|
|
'buildDate': buildDate,
|
|
'fingerprint': fingerprint
|
|
};
|
|
}(), hasData: (data) {
|
|
final license = data['license'].toString();
|
|
final version = data['version'].toString();
|
|
final buildDate = data['buildDate'].toString();
|
|
final fingerprint = data['fingerprint'].toString();
|
|
const linkStyle = TextStyle(decoration: TextDecoration.underline);
|
|
final scrollController = ScrollController();
|
|
return DesktopScrollWrapper(
|
|
scrollController: scrollController,
|
|
child: SingleChildScrollView(
|
|
controller: scrollController,
|
|
physics: DraggableNeverScrollableScrollPhysics(),
|
|
child: _Card(title: '${translate('About')} RustDesk', children: [
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const SizedBox(
|
|
height: 8.0,
|
|
),
|
|
SelectionArea(
|
|
child: Text('${translate('Version')}: $version')
|
|
.marginSymmetric(vertical: 4.0)),
|
|
SelectionArea(
|
|
child: Text('${translate('Build Date')}: $buildDate')
|
|
.marginSymmetric(vertical: 4.0)),
|
|
SelectionArea(
|
|
child: Text('${translate('Fingerprint')}: $fingerprint')
|
|
.marginSymmetric(vertical: 4.0)),
|
|
InkWell(
|
|
onTap: () {
|
|
launchUrlString('https://rustdesk.com/privacy.html');
|
|
},
|
|
child: Text(
|
|
translate('Privacy Statement'),
|
|
style: linkStyle,
|
|
).marginSymmetric(vertical: 4.0)),
|
|
InkWell(
|
|
onTap: () {
|
|
launchUrlString('https://rustdesk.com');
|
|
},
|
|
child: Text(
|
|
translate('Website'),
|
|
style: linkStyle,
|
|
).marginSymmetric(vertical: 4.0)),
|
|
Container(
|
|
decoration: const BoxDecoration(color: Color(0xFF2c8cff)),
|
|
padding:
|
|
const EdgeInsets.symmetric(vertical: 24, horizontal: 8),
|
|
child: SelectionArea(
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Copyright © ${DateTime.now().toString().substring(0, 4)} Purslane Ltd.\n$license',
|
|
style: const TextStyle(color: Colors.white),
|
|
),
|
|
Text(
|
|
translate('Slogan_tip'),
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.w800,
|
|
color: Colors.white),
|
|
)
|
|
],
|
|
),
|
|
),
|
|
],
|
|
)),
|
|
).marginSymmetric(vertical: 4.0)
|
|
],
|
|
).marginOnly(left: _kContentHMargin)
|
|
]),
|
|
));
|
|
});
|
|
}
|
|
}
|
|
|
|
//#endregion
|
|
|
|
//#region components
|
|
|
|
// ignore: non_constant_identifier_names
|
|
Widget _Card(
|
|
{required String title,
|
|
required List<Widget> children,
|
|
List<Widget>? title_suffix}) {
|
|
return Row(
|
|
children: [
|
|
Flexible(
|
|
child: SizedBox(
|
|
width: _kCardFixedWidth,
|
|
child: Card(
|
|
child: Column(
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
translate(title),
|
|
textAlign: TextAlign.start,
|
|
style: const TextStyle(
|
|
fontSize: _kTitleFontSize,
|
|
),
|
|
)),
|
|
...?title_suffix
|
|
],
|
|
).marginOnly(left: _kContentHMargin, top: 10, bottom: 10),
|
|
...children
|
|
.map((e) => e.marginOnly(top: 4, right: _kContentHMargin)),
|
|
],
|
|
).marginOnly(bottom: 10),
|
|
).marginOnly(left: _kCardLeftMargin, top: 15),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
// ignore: non_constant_identifier_names
|
|
Widget _OptionCheckBox(BuildContext context, String label, String key,
|
|
{Function()? update,
|
|
bool reverse = false,
|
|
bool enabled = true,
|
|
Icon? checkedIcon,
|
|
bool? fakeValue,
|
|
bool isServer = true}) {
|
|
bool value =
|
|
isServer ? mainGetBoolOptionSync(key) : mainGetLocalBoolOptionSync(key);
|
|
if (reverse) value = !value;
|
|
var ref = value.obs;
|
|
onChanged(option) async {
|
|
if (option != null) {
|
|
if (reverse) option = !option;
|
|
isServer
|
|
? await mainSetBoolOption(key, option)
|
|
: await mainSetLocalBoolOption(key, option);
|
|
final readOption = isServer
|
|
? mainGetBoolOptionSync(key)
|
|
: mainGetLocalBoolOptionSync(key);
|
|
if (reverse) {
|
|
ref.value = !readOption;
|
|
} else {
|
|
ref.value = readOption;
|
|
}
|
|
update?.call();
|
|
}
|
|
}
|
|
|
|
if (fakeValue != null) {
|
|
ref.value = fakeValue;
|
|
enabled = false;
|
|
}
|
|
|
|
return GestureDetector(
|
|
child: Obx(
|
|
() => Row(
|
|
children: [
|
|
Checkbox(value: ref.value, onChanged: enabled ? onChanged : null)
|
|
.marginOnly(right: 5),
|
|
Offstage(
|
|
offstage: !ref.value || checkedIcon == null,
|
|
child: checkedIcon?.marginOnly(right: 5),
|
|
),
|
|
Expanded(
|
|
child: Text(
|
|
translate(label),
|
|
style: TextStyle(color: disabledTextColor(context, enabled)),
|
|
))
|
|
],
|
|
),
|
|
).marginOnly(left: _kCheckBoxLeftMargin),
|
|
onTap: enabled
|
|
? () {
|
|
onChanged(!ref.value);
|
|
}
|
|
: null,
|
|
);
|
|
}
|
|
|
|
// ignore: non_constant_identifier_names
|
|
Widget _Radio<T>(BuildContext context,
|
|
{required T value,
|
|
required T groupValue,
|
|
required String label,
|
|
required Function(T value) onChanged,
|
|
bool autoNewLine = true,
|
|
bool enabled = true}) {
|
|
var onChange = enabled
|
|
? (T? value) {
|
|
if (value != null) {
|
|
onChanged(value);
|
|
}
|
|
}
|
|
: null;
|
|
return GestureDetector(
|
|
child: Row(
|
|
children: [
|
|
Radio<T>(value: value, groupValue: groupValue, onChanged: onChange),
|
|
Expanded(
|
|
child: Text(translate(label),
|
|
overflow: autoNewLine ? null : TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: _kContentFontSize,
|
|
color: disabledTextColor(context, enabled)))
|
|
.marginOnly(left: 5),
|
|
),
|
|
],
|
|
).marginOnly(left: _kRadioLeftMargin),
|
|
onTap: () => onChange?.call(value),
|
|
);
|
|
}
|
|
|
|
// ignore: non_constant_identifier_names
|
|
Widget _Button(String label, Function() onPressed,
|
|
{bool enabled = true, String? tip}) {
|
|
var button = ElevatedButton(
|
|
onPressed: enabled ? onPressed : null,
|
|
child: Text(
|
|
translate(label),
|
|
).marginSymmetric(horizontal: 15),
|
|
);
|
|
StatefulWidget child;
|
|
if (tip == null) {
|
|
child = button;
|
|
} else {
|
|
child = Tooltip(message: translate(tip), child: button);
|
|
}
|
|
return Row(children: [
|
|
child,
|
|
]).marginOnly(left: _kContentHMargin);
|
|
}
|
|
|
|
// ignore: non_constant_identifier_names
|
|
Widget _SubButton(String label, Function() onPressed, [bool enabled = true]) {
|
|
return Row(
|
|
children: [
|
|
ElevatedButton(
|
|
onPressed: enabled ? onPressed : null,
|
|
child: Text(
|
|
translate(label),
|
|
).marginSymmetric(horizontal: 15),
|
|
),
|
|
],
|
|
).marginOnly(left: _kContentHSubMargin);
|
|
}
|
|
|
|
// ignore: non_constant_identifier_names
|
|
Widget _SubLabeledWidget(BuildContext context, String label, Widget child,
|
|
{bool enabled = true}) {
|
|
return Row(
|
|
children: [
|
|
Text(
|
|
'${translate(label)}: ',
|
|
style: TextStyle(color: disabledTextColor(context, enabled)),
|
|
),
|
|
SizedBox(
|
|
width: 10,
|
|
),
|
|
child,
|
|
],
|
|
).marginOnly(left: _kContentHSubMargin);
|
|
}
|
|
|
|
Widget _lock(
|
|
bool locked,
|
|
String label,
|
|
Function() onUnlock,
|
|
) {
|
|
return Offstage(
|
|
offstage: !locked,
|
|
child: Row(
|
|
children: [
|
|
Flexible(
|
|
child: SizedBox(
|
|
width: _kCardFixedWidth,
|
|
child: Card(
|
|
child: ElevatedButton(
|
|
child: SizedBox(
|
|
height: 25,
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
const Icon(
|
|
Icons.security_sharp,
|
|
size: 20,
|
|
),
|
|
Text(translate(label)).marginOnly(left: 5),
|
|
]).marginSymmetric(vertical: 2)),
|
|
onPressed: () async {
|
|
bool checked = await callMainCheckSuperUserPermission();
|
|
if (checked) {
|
|
onUnlock();
|
|
}
|
|
},
|
|
).marginSymmetric(horizontal: 2, vertical: 4),
|
|
).marginOnly(left: _kCardLeftMargin),
|
|
).marginOnly(top: 10),
|
|
),
|
|
],
|
|
));
|
|
}
|
|
|
|
_LabeledTextField(
|
|
BuildContext context,
|
|
String label,
|
|
TextEditingController controller,
|
|
String errorText,
|
|
bool enabled,
|
|
bool secure) {
|
|
return Row(
|
|
children: [
|
|
ConstrainedBox(
|
|
constraints: const BoxConstraints(minWidth: 140),
|
|
child: Text(
|
|
'${translate(label)}:',
|
|
textAlign: TextAlign.right,
|
|
style: TextStyle(
|
|
fontSize: 16, color: disabledTextColor(context, enabled)),
|
|
).marginOnly(right: 10)),
|
|
Expanded(
|
|
child: TextField(
|
|
controller: controller,
|
|
enabled: enabled,
|
|
obscureText: secure,
|
|
decoration: InputDecoration(
|
|
errorText: errorText.isNotEmpty ? errorText : null),
|
|
style: TextStyle(
|
|
color: disabledTextColor(context, enabled),
|
|
)),
|
|
),
|
|
],
|
|
).marginOnly(bottom: 8);
|
|
}
|
|
|
|
class _CountDownButton extends StatefulWidget {
|
|
_CountDownButton({
|
|
Key? key,
|
|
required this.text,
|
|
required this.second,
|
|
required this.onPressed,
|
|
}) : super(key: key);
|
|
final String text;
|
|
final VoidCallback? onPressed;
|
|
final int second;
|
|
|
|
@override
|
|
State<_CountDownButton> createState() => _CountDownButtonState();
|
|
}
|
|
|
|
class _CountDownButtonState extends State<_CountDownButton> {
|
|
bool _isButtonDisabled = false;
|
|
|
|
late int _countdownSeconds = widget.second;
|
|
|
|
Timer? _timer;
|
|
|
|
@override
|
|
void dispose() {
|
|
_timer?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
void _startCountdownTimer() {
|
|
_timer = Timer.periodic(Duration(seconds: 1), (timer) {
|
|
if (_countdownSeconds <= 0) {
|
|
setState(() {
|
|
_isButtonDisabled = false;
|
|
});
|
|
timer.cancel();
|
|
} else {
|
|
setState(() {
|
|
_countdownSeconds--;
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ElevatedButton(
|
|
onPressed: _isButtonDisabled
|
|
? null
|
|
: () {
|
|
widget.onPressed?.call();
|
|
setState(() {
|
|
_isButtonDisabled = true;
|
|
_countdownSeconds = widget.second;
|
|
});
|
|
_startCountdownTimer();
|
|
},
|
|
child: Text(
|
|
_isButtonDisabled ? '$_countdownSeconds s' : translate(widget.text),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
//#endregion
|
|
|
|
//#region dialogs
|
|
|
|
void changeSocks5Proxy() async {
|
|
var socks = await bind.mainGetSocks();
|
|
|
|
String proxy = '';
|
|
String proxyMsg = '';
|
|
String username = '';
|
|
String password = '';
|
|
if (socks.length == 3) {
|
|
proxy = socks[0];
|
|
username = socks[1];
|
|
password = socks[2];
|
|
}
|
|
var proxyController = TextEditingController(text: proxy);
|
|
var userController = TextEditingController(text: username);
|
|
var pwdController = TextEditingController(text: password);
|
|
RxBool obscure = true.obs;
|
|
|
|
var isInProgress = false;
|
|
gFFI.dialogManager.show((setState, close, context) {
|
|
submit() async {
|
|
setState(() {
|
|
proxyMsg = '';
|
|
isInProgress = true;
|
|
});
|
|
cancel() {
|
|
setState(() {
|
|
isInProgress = false;
|
|
});
|
|
}
|
|
|
|
proxy = proxyController.text.trim();
|
|
username = userController.text.trim();
|
|
password = pwdController.text.trim();
|
|
|
|
if (proxy.isNotEmpty) {
|
|
proxyMsg = translate(await bind.mainTestIfValidServer(server: proxy));
|
|
if (proxyMsg.isEmpty) {
|
|
// ignore
|
|
} else {
|
|
cancel();
|
|
return;
|
|
}
|
|
}
|
|
await bind.mainSetSocks(
|
|
proxy: proxy, username: username, password: password);
|
|
close();
|
|
}
|
|
|
|
return CustomAlertDialog(
|
|
title: Text(translate('Socks5/Http(s) Proxy')),
|
|
content: ConstrainedBox(
|
|
constraints: const BoxConstraints(minWidth: 500),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
ConstrainedBox(
|
|
constraints: const BoxConstraints(minWidth: 140),
|
|
child: Text(
|
|
'${translate("Server")}:',
|
|
textAlign: TextAlign.right,
|
|
).marginOnly(right: 10)),
|
|
Expanded(
|
|
child: TextField(
|
|
decoration: InputDecoration(
|
|
errorText: proxyMsg.isNotEmpty ? proxyMsg : null,
|
|
hintText: translate('Default protocol and port are Socks5 and 1080'),
|
|
),
|
|
controller: proxyController,
|
|
autofocus: true,
|
|
),
|
|
),
|
|
],
|
|
).marginOnly(bottom: 8),
|
|
Row(
|
|
children: [
|
|
ConstrainedBox(
|
|
constraints: const BoxConstraints(minWidth: 140),
|
|
child: Text(
|
|
'${translate("Username")}:',
|
|
textAlign: TextAlign.right,
|
|
).marginOnly(right: 10)),
|
|
Expanded(
|
|
child: TextField(
|
|
controller: userController,
|
|
),
|
|
),
|
|
],
|
|
).marginOnly(bottom: 8),
|
|
Row(
|
|
children: [
|
|
ConstrainedBox(
|
|
constraints: const BoxConstraints(minWidth: 140),
|
|
child: Text(
|
|
'${translate("Password")}:',
|
|
textAlign: TextAlign.right,
|
|
).marginOnly(right: 10)),
|
|
Expanded(
|
|
child: Obx(() => TextField(
|
|
obscureText: obscure.value,
|
|
decoration: InputDecoration(
|
|
suffixIcon: IconButton(
|
|
onPressed: () => obscure.value = !obscure.value,
|
|
icon: Icon(obscure.value
|
|
? Icons.visibility_off
|
|
: Icons.visibility))),
|
|
controller: pwdController,
|
|
)),
|
|
),
|
|
],
|
|
),
|
|
// NOT use Offstage to wrap LinearProgressIndicator
|
|
if (isInProgress)
|
|
const LinearProgressIndicator().marginOnly(top: 8),
|
|
],
|
|
),
|
|
),
|
|
actions: [
|
|
dialogButton('Cancel', onPressed: close, isOutline: true),
|
|
dialogButton('OK', onPressed: submit),
|
|
],
|
|
onSubmit: submit,
|
|
onCancel: close,
|
|
);
|
|
});
|
|
}
|
|
|
|
//#endregion
|