view_camera_page.dart 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722
  1. import 'dart:async';
  2. import 'dart:ui' as ui;
  3. import 'package:flutter/material.dart';
  4. import 'package:flutter/services.dart';
  5. import 'package:flutter_hbb/common/shared_state.dart';
  6. import 'package:flutter_hbb/common/widgets/toolbar.dart';
  7. import 'package:flutter_hbb/consts.dart';
  8. import 'package:flutter_hbb/models/chat_model.dart';
  9. import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
  10. import 'package:flutter_svg/svg.dart';
  11. import 'package:get/get.dart';
  12. import 'package:provider/provider.dart';
  13. import 'package:wakelock_plus/wakelock_plus.dart';
  14. import '../../common.dart';
  15. import '../../common/widgets/overlay.dart';
  16. import '../../common/widgets/dialog.dart';
  17. import '../../common/widgets/remote_input.dart';
  18. import '../../models/input_model.dart';
  19. import '../../models/model.dart';
  20. import '../../models/platform_model.dart';
  21. import '../../utils/image.dart';
  22. final initText = '1' * 1024;
  23. // Workaround for Android (default input method, Microsoft SwiftKey keyboard) when using physical keyboard.
  24. // When connecting a physical keyboard, `KeyEvent.physicalKey.usbHidUsage` are wrong is using Microsoft SwiftKey keyboard.
  25. // https://github.com/flutter/flutter/issues/159384
  26. // https://github.com/flutter/flutter/issues/159383
  27. void _disableAndroidSoftKeyboard({bool? isKeyboardVisible}) {
  28. if (isAndroid) {
  29. if (isKeyboardVisible != true) {
  30. // `enable_soft_keyboard` will be set to `true` when clicking the keyboard icon, in `openKeyboard()`.
  31. gFFI.invokeMethod("enable_soft_keyboard", false);
  32. }
  33. }
  34. }
  35. class ViewCameraPage extends StatefulWidget {
  36. ViewCameraPage(
  37. {Key? key, required this.id, this.password, this.isSharedPassword})
  38. : super(key: key);
  39. final String id;
  40. final String? password;
  41. final bool? isSharedPassword;
  42. @override
  43. State<ViewCameraPage> createState() => _ViewCameraPageState(id);
  44. }
  45. class _ViewCameraPageState extends State<ViewCameraPage>
  46. with WidgetsBindingObserver {
  47. Timer? _timer;
  48. bool _showBar = !isWebDesktop;
  49. bool _showGestureHelp = false;
  50. Orientation? _currentOrientation;
  51. double _viewInsetsBottom = 0;
  52. Timer? _timerDidChangeMetrics;
  53. final _blockableOverlayState = BlockableOverlayState();
  54. final keyboardVisibilityController = KeyboardVisibilityController();
  55. final FocusNode _mobileFocusNode = FocusNode();
  56. final FocusNode _physicalFocusNode = FocusNode();
  57. var _showEdit = false; // use soft keyboard
  58. InputModel get inputModel => gFFI.inputModel;
  59. SessionID get sessionId => gFFI.sessionId;
  60. final TextEditingController _textController =
  61. TextEditingController(text: initText);
  62. _ViewCameraPageState(String id) {
  63. initSharedStates(id);
  64. gFFI.chatModel.voiceCallStatus.value = VoiceCallStatus.notStarted;
  65. gFFI.dialogManager.loadMobileActionsOverlayVisible();
  66. }
  67. @override
  68. void initState() {
  69. super.initState();
  70. gFFI.ffiModel.updateEventListener(sessionId, widget.id);
  71. gFFI.start(
  72. widget.id,
  73. isViewCamera: true,
  74. password: widget.password,
  75. isSharedPassword: widget.isSharedPassword,
  76. );
  77. WidgetsBinding.instance.addPostFrameCallback((_) {
  78. SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: []);
  79. gFFI.dialogManager
  80. .showLoading(translate('Connecting...'), onCancel: closeConnection);
  81. });
  82. if (!isWeb) {
  83. WakelockPlus.enable();
  84. }
  85. _physicalFocusNode.requestFocus();
  86. gFFI.inputModel.listenToMouse(true);
  87. gFFI.qualityMonitorModel.checkShowQualityMonitor(sessionId);
  88. gFFI.chatModel
  89. .changeCurrentKey(MessageKey(widget.id, ChatModel.clientModeID));
  90. _blockableOverlayState.applyFfi(gFFI);
  91. gFFI.imageModel.addCallbackOnFirstImage((String peerId) {
  92. gFFI.recordingModel
  93. .updateStatus(bind.sessionGetIsRecording(sessionId: gFFI.sessionId));
  94. if (gFFI.recordingModel.start) {
  95. showToast(translate('Automatically record outgoing sessions'));
  96. }
  97. _disableAndroidSoftKeyboard(
  98. isKeyboardVisible: keyboardVisibilityController.isVisible);
  99. });
  100. WidgetsBinding.instance.addObserver(this);
  101. }
  102. @override
  103. Future<void> dispose() async {
  104. WidgetsBinding.instance.removeObserver(this);
  105. // https://github.com/flutter/flutter/issues/64935
  106. super.dispose();
  107. gFFI.dialogManager.hideMobileActionsOverlay(store: false);
  108. gFFI.inputModel.listenToMouse(false);
  109. gFFI.imageModel.disposeImage();
  110. gFFI.cursorModel.disposeImages();
  111. await gFFI.invokeMethod("enable_soft_keyboard", true);
  112. _mobileFocusNode.dispose();
  113. _physicalFocusNode.dispose();
  114. await gFFI.close();
  115. _timer?.cancel();
  116. _timerDidChangeMetrics?.cancel();
  117. gFFI.dialogManager.dismissAll();
  118. await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
  119. overlays: SystemUiOverlay.values);
  120. if (!isWeb) {
  121. await WakelockPlus.disable();
  122. }
  123. removeSharedStates(widget.id);
  124. // `on_voice_call_closed` should be called when the connection is ended.
  125. // The inner logic of `on_voice_call_closed` will check if the voice call is active.
  126. // Only one client is considered here for now.
  127. gFFI.chatModel.onVoiceCallClosed("End connetion");
  128. }
  129. @override
  130. void didChangeAppLifecycleState(AppLifecycleState state) {}
  131. @override
  132. void didChangeMetrics() {
  133. // If the soft keyboard is visible and the canvas has been changed(panned or scaled)
  134. // Don't try reset the view style and focus the cursor.
  135. if (gFFI.cursorModel.lastKeyboardIsVisible &&
  136. gFFI.canvasModel.isMobileCanvasChanged) {
  137. return;
  138. }
  139. final newBottom = MediaQueryData.fromView(ui.window).viewInsets.bottom;
  140. _timerDidChangeMetrics?.cancel();
  141. _timerDidChangeMetrics = Timer(Duration(milliseconds: 100), () async {
  142. // We need this comparation because poping up the floating action will also trigger `didChangeMetrics()`.
  143. if (newBottom != _viewInsetsBottom) {
  144. gFFI.canvasModel.mobileFocusCanvasCursor();
  145. _viewInsetsBottom = newBottom;
  146. }
  147. });
  148. }
  149. // to-do: It should be better to use transparent color instead of the bgColor.
  150. // But for now, the transparent color will cause the canvas to be white.
  151. // I'm sure that the white color is caused by the Overlay widget in BlockableOverlay.
  152. // But I don't know why and how to fix it.
  153. Widget emptyOverlay(Color bgColor) => BlockableOverlay(
  154. /// the Overlay key will be set with _blockableOverlayState in BlockableOverlay
  155. /// see override build() in [BlockableOverlay]
  156. state: _blockableOverlayState,
  157. underlying: Container(
  158. color: bgColor,
  159. ),
  160. );
  161. Widget _bottomWidget() => (_showBar && gFFI.ffiModel.pi.displays.isNotEmpty
  162. ? getBottomAppBar()
  163. : Offstage());
  164. @override
  165. Widget build(BuildContext context) {
  166. final keyboardIsVisible =
  167. keyboardVisibilityController.isVisible && _showEdit;
  168. final showActionButton = !_showBar || keyboardIsVisible || _showGestureHelp;
  169. return WillPopScope(
  170. onWillPop: () async {
  171. clientClose(sessionId, gFFI.dialogManager);
  172. return false;
  173. },
  174. child: Scaffold(
  175. // workaround for https://github.com/rustdesk/rustdesk/issues/3131
  176. floatingActionButtonLocation: keyboardIsVisible
  177. ? FABLocation(FloatingActionButtonLocation.endFloat, 0, -35)
  178. : null,
  179. floatingActionButton: !showActionButton
  180. ? null
  181. : FloatingActionButton(
  182. mini: !keyboardIsVisible,
  183. child: Icon(
  184. (keyboardIsVisible || _showGestureHelp)
  185. ? Icons.expand_more
  186. : Icons.expand_less,
  187. color: Colors.white,
  188. ),
  189. backgroundColor: MyTheme.accent,
  190. onPressed: () {
  191. setState(() {
  192. if (keyboardIsVisible) {
  193. _showEdit = false;
  194. gFFI.invokeMethod("enable_soft_keyboard", false);
  195. _mobileFocusNode.unfocus();
  196. _physicalFocusNode.requestFocus();
  197. } else if (_showGestureHelp) {
  198. _showGestureHelp = false;
  199. } else {
  200. _showBar = !_showBar;
  201. }
  202. });
  203. }),
  204. bottomNavigationBar: Obx(() => Stack(
  205. alignment: Alignment.bottomCenter,
  206. children: [
  207. gFFI.ffiModel.pi.isSet.isTrue &&
  208. gFFI.ffiModel.waitForFirstImage.isTrue
  209. ? emptyOverlay(MyTheme.canvasColor)
  210. : () {
  211. gFFI.ffiModel.tryShowAndroidActionsOverlay();
  212. return Offstage();
  213. }(),
  214. _bottomWidget(),
  215. gFFI.ffiModel.pi.isSet.isFalse
  216. ? emptyOverlay(MyTheme.canvasColor)
  217. : Offstage(),
  218. ],
  219. )),
  220. body: Obx(
  221. () => getRawPointerAndKeyBody(Overlay(
  222. initialEntries: [
  223. OverlayEntry(builder: (context) {
  224. return Container(
  225. color: kColorCanvas,
  226. child: SafeArea(
  227. child: OrientationBuilder(builder: (ctx, orientation) {
  228. if (_currentOrientation != orientation) {
  229. Timer(const Duration(milliseconds: 200), () {
  230. gFFI.dialogManager
  231. .resetMobileActionsOverlay(ffi: gFFI);
  232. _currentOrientation = orientation;
  233. gFFI.canvasModel.updateViewStyle();
  234. });
  235. }
  236. return Container(
  237. color: MyTheme.canvasColor,
  238. child: inputModel.isPhysicalMouse.value
  239. ? getBodyForMobile()
  240. : RawTouchGestureDetectorRegion(
  241. child: getBodyForMobile(),
  242. ffi: gFFI,
  243. isCamera: true,
  244. ),
  245. );
  246. }),
  247. ),
  248. );
  249. })
  250. ],
  251. )),
  252. )),
  253. );
  254. }
  255. Widget getRawPointerAndKeyBody(Widget child) {
  256. return CameraRawPointerMouseRegion(
  257. inputModel: inputModel,
  258. // Disable RawKeyFocusScope before the connecting is established.
  259. // The "Delete" key on the soft keyboard may be grabbed when inputting the password dialog.
  260. child: gFFI.ffiModel.pi.isSet.isTrue
  261. ? RawKeyFocusScope(
  262. focusNode: _physicalFocusNode,
  263. inputModel: inputModel,
  264. child: child)
  265. : child,
  266. );
  267. }
  268. Widget getBottomAppBar() {
  269. return BottomAppBar(
  270. elevation: 10,
  271. color: MyTheme.accent,
  272. child: Row(
  273. mainAxisSize: MainAxisSize.max,
  274. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  275. children: <Widget>[
  276. Row(
  277. children: <Widget>[
  278. IconButton(
  279. color: Colors.white,
  280. icon: Icon(Icons.clear),
  281. onPressed: () {
  282. clientClose(sessionId, gFFI.dialogManager);
  283. },
  284. ),
  285. IconButton(
  286. color: Colors.white,
  287. icon: Icon(Icons.tv),
  288. onPressed: () {
  289. setState(() => _showEdit = false);
  290. showOptions(context, widget.id, gFFI.dialogManager);
  291. },
  292. )
  293. ] +
  294. (isWeb
  295. ? []
  296. : <Widget>[
  297. futureBuilder(
  298. future: gFFI.invokeMethod(
  299. "get_value", "KEY_IS_SUPPORT_VOICE_CALL"),
  300. hasData: (isSupportVoiceCall) => IconButton(
  301. color: Colors.white,
  302. icon: isAndroid && isSupportVoiceCall
  303. ? SvgPicture.asset('assets/chat.svg',
  304. colorFilter: ColorFilter.mode(
  305. Colors.white, BlendMode.srcIn))
  306. : Icon(Icons.message),
  307. onPressed: () =>
  308. isAndroid && isSupportVoiceCall
  309. ? showChatOptions(widget.id)
  310. : onPressedTextChat(widget.id),
  311. ))
  312. ]) +
  313. [
  314. IconButton(
  315. color: Colors.white,
  316. icon: Icon(Icons.more_vert),
  317. onPressed: () {
  318. setState(() => _showEdit = false);
  319. showActions(widget.id);
  320. },
  321. ),
  322. ]),
  323. Obx(() => IconButton(
  324. color: Colors.white,
  325. icon: Icon(Icons.expand_more),
  326. onPressed: gFFI.ffiModel.waitForFirstImage.isTrue
  327. ? null
  328. : () {
  329. setState(() => _showBar = !_showBar);
  330. },
  331. )),
  332. ],
  333. ),
  334. );
  335. }
  336. Widget getBodyForMobile() {
  337. return Container(
  338. color: MyTheme.canvasColor,
  339. child: Stack(children: () {
  340. final paints = [
  341. ImagePaint(),
  342. Positioned(
  343. top: 10,
  344. right: 10,
  345. child: QualityMonitor(gFFI.qualityMonitorModel),
  346. ),
  347. SizedBox(
  348. width: 0,
  349. height: 0,
  350. child: !_showEdit
  351. ? Container()
  352. : TextFormField(
  353. textInputAction: TextInputAction.newline,
  354. autocorrect: false,
  355. // Flutter 3.16.9 Android.
  356. // `enableSuggestions` causes secure keyboard to be shown.
  357. // https://github.com/flutter/flutter/issues/139143
  358. // https://github.com/flutter/flutter/issues/146540
  359. // enableSuggestions: false,
  360. autofocus: true,
  361. focusNode: _mobileFocusNode,
  362. maxLines: null,
  363. controller: _textController,
  364. // trick way to make backspace work always
  365. keyboardType: TextInputType.multiline,
  366. // `onChanged` may be called depending on the input method if this widget is wrapped in
  367. // `Focus(onKeyEvent: ..., child: ...)`
  368. // For `Backspace` button in the soft keyboard:
  369. // en/fr input method:
  370. // 1. The button will not trigger `onKeyEvent` if the text field is not empty.
  371. // 2. The button will trigger `onKeyEvent` if the text field is empty.
  372. // ko/zh/ja input method: the button will trigger `onKeyEvent`
  373. // and the event will not popup if `KeyEventResult.handled` is returned.
  374. onChanged: null,
  375. ).workaroundFreezeLinuxMint(),
  376. ),
  377. ];
  378. return paints;
  379. }()));
  380. }
  381. Widget getBodyForDesktopWithListener() {
  382. var paints = <Widget>[ImagePaint()];
  383. return Container(
  384. color: MyTheme.canvasColor, child: Stack(children: paints));
  385. }
  386. List<TTextMenu> _getMobileActionMenus() {
  387. if (gFFI.ffiModel.pi.platform != kPeerPlatformAndroid ||
  388. !gFFI.ffiModel.keyboard) {
  389. return [];
  390. }
  391. final enabled = versionCmp(gFFI.ffiModel.pi.version, '1.2.7') >= 0;
  392. if (!enabled) return [];
  393. return [
  394. TTextMenu(
  395. child: Text(translate('Back')),
  396. onPressed: () => gFFI.inputModel.onMobileBack(),
  397. ),
  398. TTextMenu(
  399. child: Text(translate('Home')),
  400. onPressed: () => gFFI.inputModel.onMobileHome(),
  401. ),
  402. TTextMenu(
  403. child: Text(translate('Apps')),
  404. onPressed: () => gFFI.inputModel.onMobileApps(),
  405. ),
  406. TTextMenu(
  407. child: Text(translate('Volume up')),
  408. onPressed: () => gFFI.inputModel.onMobileVolumeUp(),
  409. ),
  410. TTextMenu(
  411. child: Text(translate('Volume down')),
  412. onPressed: () => gFFI.inputModel.onMobileVolumeDown(),
  413. ),
  414. TTextMenu(
  415. child: Text(translate('Power')),
  416. onPressed: () => gFFI.inputModel.onMobilePower(),
  417. ),
  418. ];
  419. }
  420. void showActions(String id) async {
  421. final size = MediaQuery.of(context).size;
  422. final x = 120.0;
  423. final y = size.height;
  424. final mobileActionMenus = _getMobileActionMenus();
  425. final menus = toolbarControls(context, id, gFFI);
  426. final List<PopupMenuEntry<int>> more = [
  427. ...mobileActionMenus
  428. .asMap()
  429. .entries
  430. .map((e) =>
  431. PopupMenuItem<int>(child: e.value.getChild(), value: e.key))
  432. .toList(),
  433. if (mobileActionMenus.isNotEmpty) PopupMenuDivider(),
  434. ...menus
  435. .asMap()
  436. .entries
  437. .map((e) => PopupMenuItem<int>(
  438. child: e.value.getChild(),
  439. value: e.key + mobileActionMenus.length))
  440. .toList(),
  441. ];
  442. () async {
  443. var index = await showMenu(
  444. context: context,
  445. position: RelativeRect.fromLTRB(x, y, x, y),
  446. items: more,
  447. elevation: 8,
  448. );
  449. if (index != null) {
  450. if (index < mobileActionMenus.length) {
  451. mobileActionMenus[index].onPressed?.call();
  452. } else if (index < mobileActionMenus.length + more.length) {
  453. menus[index - mobileActionMenus.length].onPressed?.call();
  454. }
  455. }
  456. }();
  457. }
  458. onPressedTextChat(String id) {
  459. gFFI.chatModel.changeCurrentKey(MessageKey(id, ChatModel.clientModeID));
  460. gFFI.chatModel.toggleChatOverlay();
  461. }
  462. showChatOptions(String id) async {
  463. onPressVoiceCall() => bind.sessionRequestVoiceCall(sessionId: sessionId);
  464. onPressEndVoiceCall() => bind.sessionCloseVoiceCall(sessionId: sessionId);
  465. makeTextMenu(String label, Widget icon, VoidCallback onPressed,
  466. {TextStyle? labelStyle}) =>
  467. TTextMenu(
  468. child: Text(translate(label), style: labelStyle),
  469. trailingIcon: Transform.scale(
  470. scale: (isDesktop || isWebDesktop) ? 0.8 : 1,
  471. child: IgnorePointer(
  472. child: IconButton(
  473. onPressed: null,
  474. icon: icon,
  475. ),
  476. ),
  477. ),
  478. onPressed: onPressed,
  479. );
  480. final isInVoice = [
  481. VoiceCallStatus.waitingForResponse,
  482. VoiceCallStatus.connected
  483. ].contains(gFFI.chatModel.voiceCallStatus.value);
  484. final menus = [
  485. makeTextMenu('Text chat', Icon(Icons.message, color: MyTheme.accent),
  486. () => onPressedTextChat(widget.id)),
  487. isInVoice
  488. ? makeTextMenu(
  489. 'End voice call',
  490. SvgPicture.asset(
  491. 'assets/call_wait.svg',
  492. colorFilter:
  493. ColorFilter.mode(Colors.redAccent, BlendMode.srcIn),
  494. ),
  495. onPressEndVoiceCall,
  496. labelStyle: TextStyle(color: Colors.redAccent))
  497. : makeTextMenu(
  498. 'Voice call',
  499. SvgPicture.asset(
  500. 'assets/call_wait.svg',
  501. colorFilter: ColorFilter.mode(MyTheme.accent, BlendMode.srcIn),
  502. ),
  503. onPressVoiceCall),
  504. ];
  505. final menuItems = menus
  506. .asMap()
  507. .entries
  508. .map((e) => PopupMenuItem<int>(child: e.value.getChild(), value: e.key))
  509. .toList();
  510. Future.delayed(Duration.zero, () async {
  511. final size = MediaQuery.of(context).size;
  512. final x = 120.0;
  513. final y = size.height;
  514. var index = await showMenu(
  515. context: context,
  516. position: RelativeRect.fromLTRB(x, y, x, y),
  517. items: menuItems,
  518. elevation: 8,
  519. );
  520. if (index != null && index < menus.length) {
  521. menus[index].onPressed?.call();
  522. }
  523. });
  524. }
  525. }
  526. class ImagePaint extends StatelessWidget {
  527. @override
  528. Widget build(BuildContext context) {
  529. final m = Provider.of<ImageModel>(context);
  530. final c = Provider.of<CanvasModel>(context);
  531. var s = c.scale;
  532. final adjust = c.getAdjustY();
  533. return CustomPaint(
  534. painter: ImagePainter(
  535. image: m.image, x: c.x / s, y: (c.y + adjust) / s, scale: s),
  536. );
  537. }
  538. }
  539. void showOptions(
  540. BuildContext context, String id, OverlayDialogManager dialogManager) async {
  541. var displays = <Widget>[];
  542. final pi = gFFI.ffiModel.pi;
  543. final image = gFFI.ffiModel.getConnectionImage();
  544. if (image != null) {
  545. displays.add(Padding(padding: const EdgeInsets.only(top: 8), child: image));
  546. }
  547. if (pi.displays.length > 1 && pi.currentDisplay != kAllDisplayValue) {
  548. final cur = pi.currentDisplay;
  549. final children = <Widget>[];
  550. for (var i = 0; i < pi.displays.length; ++i) {
  551. children.add(InkWell(
  552. onTap: () {
  553. if (i == cur) return;
  554. openMonitorInTheSameTab(i, gFFI, pi);
  555. gFFI.dialogManager.dismissAll();
  556. },
  557. child: Ink(
  558. width: 40,
  559. height: 40,
  560. decoration: BoxDecoration(
  561. border: Border.all(color: Theme.of(context).hintColor),
  562. borderRadius: BorderRadius.circular(2),
  563. color: i == cur
  564. ? Theme.of(context).primaryColor.withOpacity(0.6)
  565. : null),
  566. child: Center(
  567. child: Text((i + 1).toString(),
  568. style: TextStyle(
  569. color: i == cur ? Colors.white : Colors.black87,
  570. fontWeight: FontWeight.bold))))));
  571. }
  572. displays.add(Padding(
  573. padding: const EdgeInsets.only(top: 8),
  574. child: Wrap(
  575. alignment: WrapAlignment.center,
  576. spacing: 8,
  577. children: children,
  578. )));
  579. }
  580. if (displays.isNotEmpty) {
  581. displays.add(const Divider(color: MyTheme.border));
  582. }
  583. List<TRadioMenu<String>> viewStyleRadios =
  584. await toolbarViewStyle(context, id, gFFI);
  585. List<TRadioMenu<String>> imageQualityRadios =
  586. await toolbarImageQuality(context, id, gFFI);
  587. List<TRadioMenu<String>> codecRadios = await toolbarCodec(context, id, gFFI);
  588. List<TToggleMenu> displayToggles =
  589. await toolbarDisplayToggle(context, id, gFFI);
  590. dialogManager.show((setState, close, context) {
  591. var viewStyle =
  592. (viewStyleRadios.isNotEmpty ? viewStyleRadios[0].groupValue : '').obs;
  593. var imageQuality =
  594. (imageQualityRadios.isNotEmpty ? imageQualityRadios[0].groupValue : '')
  595. .obs;
  596. var codec = (codecRadios.isNotEmpty ? codecRadios[0].groupValue : '').obs;
  597. final radios = [
  598. for (var e in viewStyleRadios)
  599. Obx(() => getRadio<String>(
  600. e.child,
  601. e.value,
  602. viewStyle.value,
  603. e.onChanged != null
  604. ? (v) {
  605. e.onChanged?.call(v);
  606. if (v != null) viewStyle.value = v;
  607. }
  608. : null)),
  609. const Divider(color: MyTheme.border),
  610. for (var e in imageQualityRadios)
  611. Obx(() => getRadio<String>(
  612. e.child,
  613. e.value,
  614. imageQuality.value,
  615. e.onChanged != null
  616. ? (v) {
  617. e.onChanged?.call(v);
  618. if (v != null) imageQuality.value = v;
  619. }
  620. : null)),
  621. const Divider(color: MyTheme.border),
  622. for (var e in codecRadios)
  623. Obx(() => getRadio<String>(
  624. e.child,
  625. e.value,
  626. codec.value,
  627. e.onChanged != null
  628. ? (v) {
  629. e.onChanged?.call(v);
  630. if (v != null) codec.value = v;
  631. }
  632. : null)),
  633. if (codecRadios.isNotEmpty) const Divider(color: MyTheme.border),
  634. ];
  635. final rxToggleValues = displayToggles.map((e) => e.value.obs).toList();
  636. final displayTogglesList = displayToggles
  637. .asMap()
  638. .entries
  639. .map((e) => Obx(() => CheckboxListTile(
  640. contentPadding: EdgeInsets.zero,
  641. visualDensity: VisualDensity.compact,
  642. value: rxToggleValues[e.key].value,
  643. onChanged: e.value.onChanged != null
  644. ? (v) {
  645. e.value.onChanged?.call(v);
  646. if (v != null) rxToggleValues[e.key].value = v;
  647. }
  648. : null,
  649. title: e.value.child)))
  650. .toList();
  651. final toggles = [
  652. ...displayTogglesList,
  653. ];
  654. var popupDialogMenus = List<Widget>.empty(growable: true);
  655. if (popupDialogMenus.isNotEmpty) {
  656. popupDialogMenus.add(const Divider(color: MyTheme.border));
  657. }
  658. return CustomAlertDialog(
  659. content: Column(
  660. mainAxisSize: MainAxisSize.min,
  661. children: displays + radios + popupDialogMenus + toggles),
  662. );
  663. }, clickMaskDismiss: true, backDismiss: true).then((value) {
  664. _disableAndroidSoftKeyboard();
  665. });
  666. }
  667. class FABLocation extends FloatingActionButtonLocation {
  668. FloatingActionButtonLocation location;
  669. double offsetX;
  670. double offsetY;
  671. FABLocation(this.location, this.offsetX, this.offsetY);
  672. @override
  673. Offset getOffset(ScaffoldPrelayoutGeometry scaffoldGeometry) {
  674. final offset = location.getOffset(scaffoldGeometry);
  675. return Offset(offset.dx + offsetX, offset.dy + offsetY);
  676. }
  677. }