view_camera_page.dart 26 KB

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