view_camera_page.dart 26 KB

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