peer_tab_page.dart 33 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040
  1. import 'dart:ui' as ui;
  2. import 'package:bot_toast/bot_toast.dart';
  3. import 'package:flutter/material.dart';
  4. import 'package:flutter_hbb/common/widgets/address_book.dart';
  5. import 'package:flutter_hbb/common/widgets/dialog.dart';
  6. import 'package:flutter_hbb/common/widgets/my_group.dart';
  7. import 'package:flutter_hbb/common/widgets/peers_view.dart';
  8. import 'package:flutter_hbb/common/widgets/peer_card.dart';
  9. import 'package:flutter_hbb/consts.dart';
  10. import 'package:flutter_hbb/desktop/widgets/popup_menu.dart';
  11. import 'package:flutter_hbb/desktop/widgets/material_mod_popup_menu.dart'
  12. as mod_menu;
  13. import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
  14. import 'package:flutter_hbb/models/ab_model.dart';
  15. import 'package:flutter_hbb/models/peer_model.dart';
  16. import 'package:flutter_hbb/models/peer_tab_model.dart';
  17. import 'package:flutter_hbb/models/state_model.dart';
  18. import 'package:flutter_svg/flutter_svg.dart';
  19. import 'package:get/get.dart';
  20. import 'package:provider/provider.dart';
  21. import 'package:pull_down_button/pull_down_button.dart';
  22. import '../../common.dart';
  23. import '../../models/platform_model.dart';
  24. class PeerTabPage extends StatefulWidget {
  25. const PeerTabPage({Key? key}) : super(key: key);
  26. @override
  27. State<PeerTabPage> createState() => _PeerTabPageState();
  28. }
  29. class _TabEntry {
  30. final Widget widget;
  31. final Function({dynamic hint})? load;
  32. _TabEntry(this.widget, [this.load]);
  33. }
  34. EdgeInsets? _menuPadding() {
  35. return (isDesktop || isWebDesktop) ? kDesktopMenuPadding : null;
  36. }
  37. class _PeerTabPageState extends State<PeerTabPage>
  38. with SingleTickerProviderStateMixin {
  39. final List<_TabEntry> entries = [
  40. _TabEntry(RecentPeersView(
  41. menuPadding: _menuPadding(),
  42. )),
  43. _TabEntry(FavoritePeersView(
  44. menuPadding: _menuPadding(),
  45. )),
  46. _TabEntry(DiscoveredPeersView(
  47. menuPadding: _menuPadding(),
  48. )),
  49. _TabEntry(
  50. AddressBook(
  51. menuPadding: _menuPadding(),
  52. ),
  53. ({dynamic hint}) => gFFI.abModel.pullAb(
  54. force: hint == null ? ForcePullAb.listAndCurrent : null,
  55. quiet: false)),
  56. _TabEntry(
  57. MyGroup(
  58. menuPadding: _menuPadding(),
  59. ),
  60. ({dynamic hint}) => gFFI.groupModel.pull(force: hint == null),
  61. ),
  62. ];
  63. RelativeRect? mobileTabContextMenuPos;
  64. final isOptVisiableFixed = isOptionFixed(kOptionPeerTabVisible);
  65. _PeerTabPageState() {
  66. _loadLocalOptions();
  67. }
  68. void _loadLocalOptions() {
  69. final uiType = bind.getLocalFlutterOption(k: kOptionPeerCardUiType);
  70. if (uiType != '') {
  71. peerCardUiType.value = int.parse(uiType) == 0
  72. ? PeerUiType.grid
  73. : int.parse(uiType) == 1
  74. ? PeerUiType.tile
  75. : PeerUiType.list;
  76. }
  77. hideAbTagsPanel.value =
  78. bind.mainGetLocalOption(key: kOptionHideAbTagsPanel) == 'Y';
  79. }
  80. Future<void> handleTabSelection(int tabIndex) async {
  81. if (tabIndex < entries.length) {
  82. if (tabIndex != gFFI.peerTabModel.currentTab) {
  83. gFFI.peerTabModel.setCurrentTabCachedPeers([]);
  84. }
  85. gFFI.peerTabModel.setCurrentTab(tabIndex);
  86. entries[tabIndex].load?.call(hint: false);
  87. }
  88. }
  89. @override
  90. Widget build(BuildContext context) {
  91. final model = Provider.of<PeerTabModel>(context);
  92. Widget selectionWrap(Widget widget) {
  93. return model.multiSelectionMode ? createMultiSelectionBar(model) : widget;
  94. }
  95. return Column(
  96. textBaseline: TextBaseline.ideographic,
  97. crossAxisAlignment: CrossAxisAlignment.start,
  98. children: [
  99. Obx(() => SizedBox(
  100. height: 32,
  101. child: Container(
  102. padding: stateGlobal.isPortrait.isTrue
  103. ? EdgeInsets.symmetric(horizontal: 2)
  104. : null,
  105. child: selectionWrap(Row(
  106. crossAxisAlignment: CrossAxisAlignment.center,
  107. children: [
  108. Expanded(
  109. child: visibleContextMenuListener(
  110. _createSwitchBar(context))),
  111. if (stateGlobal.isPortrait.isTrue)
  112. ..._portraitRightActions(context)
  113. else
  114. ..._landscapeRightActions(context)
  115. ],
  116. )),
  117. ),
  118. ).paddingOnly(right: stateGlobal.isPortrait.isTrue ? 0 : 12)),
  119. _createPeersView(),
  120. ],
  121. );
  122. }
  123. Widget _createSwitchBar(BuildContext context) {
  124. final model = Provider.of<PeerTabModel>(context);
  125. var counter = -1;
  126. return ReorderableListView(
  127. buildDefaultDragHandles: false,
  128. onReorder: model.reorder,
  129. scrollDirection: Axis.horizontal,
  130. physics: NeverScrollableScrollPhysics(),
  131. children: model.visibleEnabledOrderedIndexs.map((t) {
  132. final selected = model.currentTab == t;
  133. final color = selected
  134. ? MyTheme.tabbar(context).selectedTextColor
  135. : MyTheme.tabbar(context).unSelectedTextColor
  136. ?..withOpacity(0.5);
  137. final hover = false.obs;
  138. final deco = BoxDecoration(
  139. color: Theme.of(context).colorScheme.background,
  140. borderRadius: BorderRadius.circular(6));
  141. final decoBorder = BoxDecoration(
  142. border: Border(
  143. bottom: BorderSide(width: 2, color: color!),
  144. ));
  145. counter += 1;
  146. return ReorderableDragStartListener(
  147. key: ValueKey(t),
  148. index: counter,
  149. child: Obx(() => Tooltip(
  150. preferBelow: false,
  151. message: model.tabTooltip(t),
  152. onTriggered: isMobile ? mobileShowTabVisibilityMenu : null,
  153. child: InkWell(
  154. child: Container(
  155. decoration: (hover.value
  156. ? (selected ? decoBorder : deco)
  157. : (selected ? decoBorder : null)),
  158. child: Icon(model.tabIcon(t), color: color)
  159. .paddingSymmetric(horizontal: 4),
  160. ).paddingSymmetric(horizontal: 4),
  161. onTap: isOptionFixed(kOptionPeerTabIndex)
  162. ? null
  163. : () async {
  164. await handleTabSelection(t);
  165. await bind.setLocalFlutterOption(
  166. k: kOptionPeerTabIndex, v: t.toString());
  167. },
  168. onHover: (value) => hover.value = value,
  169. ),
  170. )));
  171. }).toList());
  172. }
  173. Widget _createPeersView() {
  174. final model = Provider.of<PeerTabModel>(context);
  175. Widget child;
  176. if (model.visibleEnabledOrderedIndexs.isEmpty) {
  177. child = visibleContextMenuListener(Row(
  178. children: [Expanded(child: InkWell())],
  179. ));
  180. } else {
  181. if (model.visibleEnabledOrderedIndexs.contains(model.currentTab)) {
  182. child = entries[model.currentTab].widget;
  183. } else {
  184. debugPrint("should not happen! currentTab not in visibleIndexs");
  185. Future.delayed(Duration.zero, () {
  186. model.setCurrentTab(model.visibleEnabledOrderedIndexs[0]);
  187. });
  188. child = entries[0].widget;
  189. }
  190. }
  191. return Expanded(
  192. child: child.marginSymmetric(
  193. vertical: (isDesktop || isWebDesktop) ? 12.0 : 6.0));
  194. }
  195. Widget _createRefresh(
  196. {required PeerTabIndex index, required RxBool loading}) {
  197. final model = Provider.of<PeerTabModel>(context);
  198. final textColor = Theme.of(context).textTheme.titleLarge?.color;
  199. return Offstage(
  200. offstage: model.currentTab != index.index,
  201. child: Tooltip(
  202. message: translate('Refresh'),
  203. child: RefreshWidget(
  204. onPressed: () {
  205. if (gFFI.peerTabModel.currentTab < entries.length) {
  206. entries[gFFI.peerTabModel.currentTab].load?.call();
  207. }
  208. },
  209. spinning: loading,
  210. child: RotatedBox(
  211. quarterTurns: 2,
  212. child: Icon(
  213. Icons.refresh,
  214. size: 18,
  215. color: textColor,
  216. ))),
  217. ),
  218. );
  219. }
  220. Widget _createPeerViewTypeSwitch(BuildContext context) {
  221. return PeerViewDropdown();
  222. }
  223. Widget _createMultiSelection() {
  224. final textColor = Theme.of(context).textTheme.titleLarge?.color;
  225. final model = Provider.of<PeerTabModel>(context);
  226. return _hoverAction(
  227. toolTip: translate('Select'),
  228. context: context,
  229. onTap: () {
  230. model.setMultiSelectionMode(true);
  231. if (isMobile && Navigator.canPop(context)) {
  232. Navigator.pop(context);
  233. }
  234. },
  235. child: SvgPicture.asset(
  236. "assets/checkbox-outline.svg",
  237. width: 18,
  238. height: 18,
  239. colorFilter: svgColor(textColor),
  240. ),
  241. );
  242. }
  243. void mobileShowTabVisibilityMenu() {
  244. final model = gFFI.peerTabModel;
  245. final items = List<PopupMenuItem>.empty(growable: true);
  246. for (int i = 0; i < PeerTabModel.maxTabCount; i++) {
  247. if (!model.isEnabled[i]) continue;
  248. items.add(PopupMenuItem(
  249. height: kMinInteractiveDimension * 0.8,
  250. onTap: isOptVisiableFixed
  251. ? null
  252. : () => model.setTabVisible(i, !model.isVisibleEnabled[i]),
  253. enabled: !isOptVisiableFixed,
  254. child: Row(
  255. children: [
  256. Checkbox(
  257. value: model.isVisibleEnabled[i],
  258. onChanged: isOptVisiableFixed
  259. ? null
  260. : (_) {
  261. model.setTabVisible(i, !model.isVisibleEnabled[i]);
  262. if (Navigator.canPop(context)) {
  263. Navigator.pop(context);
  264. }
  265. }),
  266. Expanded(child: Text(model.tabTooltip(i))),
  267. ],
  268. ),
  269. ));
  270. }
  271. if (mobileTabContextMenuPos != null) {
  272. showMenu(
  273. context: context, position: mobileTabContextMenuPos!, items: items);
  274. }
  275. }
  276. Widget visibleContextMenuListener(Widget child) {
  277. if (!(isDesktop || isWebDesktop)) {
  278. return GestureDetector(
  279. onLongPressDown: (e) {
  280. final x = e.globalPosition.dx;
  281. final y = e.globalPosition.dy;
  282. mobileTabContextMenuPos = RelativeRect.fromLTRB(x, y, x, y);
  283. },
  284. onLongPressUp: () {
  285. mobileShowTabVisibilityMenu();
  286. },
  287. child: child,
  288. );
  289. } else {
  290. return Listener(
  291. onPointerDown: (e) {
  292. if (e.kind != ui.PointerDeviceKind.mouse) {
  293. return;
  294. }
  295. if (e.buttons == 2) {
  296. showRightMenu(
  297. (CancelFunc cancelFunc) {
  298. return visibleContextMenu(cancelFunc);
  299. },
  300. target: e.position,
  301. );
  302. }
  303. },
  304. child: child);
  305. }
  306. }
  307. Widget visibleContextMenu(CancelFunc cancelFunc) {
  308. final model = Provider.of<PeerTabModel>(context);
  309. final menu = List<MenuEntrySwitchSync>.empty(growable: true);
  310. for (int i = 0; i < model.orders.length; i++) {
  311. int tabIndex = model.orders[i];
  312. if (tabIndex < 0 || tabIndex >= PeerTabModel.maxTabCount) continue;
  313. if (!model.isEnabled[tabIndex]) continue;
  314. menu.add(MenuEntrySwitchSync(
  315. switchType: SwitchType.scheckbox,
  316. text: model.tabTooltip(tabIndex),
  317. currentValue: model.isVisibleEnabled[tabIndex],
  318. setter: (show) async {
  319. model.setTabVisible(tabIndex, show);
  320. // Do not hide the current menu (checkbox)
  321. // cancelFunc();
  322. },
  323. enabled: (!isOptVisiableFixed).obs));
  324. }
  325. return mod_menu.PopupMenu(
  326. items: menu
  327. .map((entry) => entry.build(
  328. context,
  329. const MenuConfig(
  330. commonColor: MyTheme.accent,
  331. height: 20.0,
  332. dividerHeight: 12.0,
  333. )))
  334. .expand((i) => i)
  335. .toList());
  336. }
  337. Widget createMultiSelectionBar(PeerTabModel model) {
  338. return Row(
  339. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  340. children: [
  341. Offstage(
  342. offstage: model.selectedPeers.isEmpty,
  343. child: Row(
  344. children: [
  345. deleteSelection(),
  346. addSelectionToFav(),
  347. addSelectionToAb(),
  348. editSelectionTags(),
  349. ],
  350. ),
  351. ),
  352. Row(
  353. children: [
  354. selectionCount(model.selectedPeers.length),
  355. selectAll(model),
  356. closeSelection(),
  357. ],
  358. )
  359. ],
  360. );
  361. }
  362. Widget deleteSelection() {
  363. final model = Provider.of<PeerTabModel>(context);
  364. if (model.currentTab == PeerTabIndex.group.index) {
  365. return Offstage();
  366. }
  367. return _hoverAction(
  368. context: context,
  369. toolTip: translate('Delete'),
  370. onTap: () {
  371. onSubmit() async {
  372. final peers = model.selectedPeers;
  373. switch (model.currentTab) {
  374. case 0:
  375. for (var p in peers) {
  376. await bind.mainRemovePeer(id: p.id);
  377. }
  378. bind.mainLoadRecentPeers();
  379. break;
  380. case 1:
  381. final favs = (await bind.mainGetFav()).toList();
  382. peers.map((p) {
  383. favs.remove(p.id);
  384. }).toList();
  385. await bind.mainStoreFav(favs: favs);
  386. bind.mainLoadFavPeers();
  387. break;
  388. case 2:
  389. for (var p in peers) {
  390. await bind.mainRemoveDiscovered(id: p.id);
  391. }
  392. bind.mainLoadLanPeers();
  393. break;
  394. case 3:
  395. await gFFI.abModel.deletePeers(peers.map((p) => p.id).toList());
  396. break;
  397. default:
  398. break;
  399. }
  400. gFFI.peerTabModel.setMultiSelectionMode(false);
  401. if (model.currentTab != 3) showToast(translate('Successful'));
  402. }
  403. deleteConfirmDialog(onSubmit, translate('Delete'));
  404. },
  405. child: Icon(Icons.delete, color: Colors.red));
  406. }
  407. Widget addSelectionToFav() {
  408. final model = Provider.of<PeerTabModel>(context);
  409. return Offstage(
  410. offstage:
  411. model.currentTab != PeerTabIndex.recent.index, // show based on recent
  412. child: _hoverAction(
  413. context: context,
  414. toolTip: translate('Add to Favorites'),
  415. onTap: () async {
  416. final peers = model.selectedPeers;
  417. final favs = (await bind.mainGetFav()).toList();
  418. for (var p in peers) {
  419. if (!favs.contains(p.id)) {
  420. favs.add(p.id);
  421. }
  422. }
  423. await bind.mainStoreFav(favs: favs);
  424. model.setMultiSelectionMode(false);
  425. showToast(translate('Successful'));
  426. },
  427. child: Icon(PeerTabModel.icons[PeerTabIndex.fav.index]),
  428. ).marginOnly(left: !(isDesktop || isWebDesktop) ? 11 : 6),
  429. );
  430. }
  431. Widget addSelectionToAb() {
  432. final model = Provider.of<PeerTabModel>(context);
  433. final addressbooks = gFFI.abModel.addressBooksCanWrite();
  434. if (model.currentTab == PeerTabIndex.ab.index) {
  435. addressbooks.remove(gFFI.abModel.currentName.value);
  436. }
  437. return Offstage(
  438. offstage: !gFFI.userModel.isLogin || addressbooks.isEmpty,
  439. child: _hoverAction(
  440. context: context,
  441. toolTip: translate('Add to address book'),
  442. onTap: () {
  443. final peers = model.selectedPeers.map((e) => Peer.copy(e)).toList();
  444. addPeersToAbDialog(peers);
  445. model.setMultiSelectionMode(false);
  446. },
  447. child: Icon(PeerTabModel.icons[PeerTabIndex.ab.index]),
  448. ).marginOnly(left: !(isDesktop || isWebDesktop) ? 11 : 6),
  449. );
  450. }
  451. Widget editSelectionTags() {
  452. final model = Provider.of<PeerTabModel>(context);
  453. return Offstage(
  454. offstage: !gFFI.userModel.isLogin ||
  455. model.currentTab != PeerTabIndex.ab.index ||
  456. gFFI.abModel.currentAbTags.isEmpty,
  457. child: _hoverAction(
  458. context: context,
  459. toolTip: translate('Edit Tag'),
  460. onTap: () {
  461. editAbTagDialog(List.empty(), (selectedTags) async {
  462. final peers = model.selectedPeers;
  463. await gFFI.abModel.changeTagForPeers(
  464. peers.map((p) => p.id).toList(), selectedTags);
  465. model.setMultiSelectionMode(false);
  466. showToast(translate('Successful'));
  467. });
  468. },
  469. child: Icon(Icons.tag))
  470. .marginOnly(left: !(isDesktop || isWebDesktop) ? 11 : 6),
  471. );
  472. }
  473. Widget selectionCount(int count) {
  474. return Align(
  475. alignment: Alignment.center,
  476. child: Text('$count ${translate('Selected')}'),
  477. );
  478. }
  479. Widget selectAll(PeerTabModel model) {
  480. return Offstage(
  481. offstage:
  482. model.selectedPeers.length >= model.currentTabCachedPeers.length,
  483. child: _hoverAction(
  484. context: context,
  485. toolTip: translate('Select All'),
  486. onTap: () {
  487. model.selectAll();
  488. },
  489. child: Icon(Icons.select_all),
  490. ).marginOnly(left: 6),
  491. );
  492. }
  493. Widget closeSelection() {
  494. final model = Provider.of<PeerTabModel>(context);
  495. return _hoverAction(
  496. context: context,
  497. toolTip: translate('Close'),
  498. onTap: () {
  499. model.setMultiSelectionMode(false);
  500. },
  501. child: Icon(Icons.clear))
  502. .marginOnly(left: 6);
  503. }
  504. Widget _toggleTags() {
  505. return _hoverAction(
  506. context: context,
  507. toolTip: translate('Toggle Tags'),
  508. hoverableWhenfalse: hideAbTagsPanel,
  509. child: Icon(
  510. Icons.tag_rounded,
  511. size: 18,
  512. ),
  513. onTap: () async {
  514. await bind.mainSetLocalOption(
  515. key: kOptionHideAbTagsPanel,
  516. value: hideAbTagsPanel.value ? defaultOptionNo : "Y");
  517. hideAbTagsPanel.value = !hideAbTagsPanel.value;
  518. });
  519. }
  520. List<Widget> _landscapeRightActions(BuildContext context) {
  521. final model = Provider.of<PeerTabModel>(context);
  522. return [
  523. const PeerSearchBar().marginOnly(right: 13),
  524. _createRefresh(
  525. index: PeerTabIndex.ab, loading: gFFI.abModel.currentAbLoading),
  526. _createRefresh(
  527. index: PeerTabIndex.group, loading: gFFI.groupModel.groupLoading),
  528. Offstage(
  529. offstage: model.currentTabCachedPeers.isEmpty,
  530. child: _createMultiSelection(),
  531. ),
  532. _createPeerViewTypeSwitch(context),
  533. Offstage(
  534. offstage: model.currentTab == PeerTabIndex.recent.index,
  535. child: PeerSortDropdown(),
  536. ),
  537. Offstage(
  538. offstage: model.currentTab != PeerTabIndex.ab.index,
  539. child: _toggleTags(),
  540. ),
  541. ];
  542. }
  543. List<Widget> _portraitRightActions(BuildContext context) {
  544. final model = Provider.of<PeerTabModel>(context);
  545. final screenWidth = MediaQuery.of(context).size.width;
  546. final leftIconSize = Theme.of(context).iconTheme.size ?? 24;
  547. final leftActionsSize =
  548. (leftIconSize + (4 + 4) * 2) * model.visibleEnabledOrderedIndexs.length;
  549. final availableWidth = screenWidth - 10 * 2 - leftActionsSize - 2 * 2;
  550. final searchWidth = 120;
  551. final otherActionWidth = 18 + 10;
  552. dropDown(List<Widget> menus) {
  553. final padding = 6.0;
  554. final textColor = Theme.of(context).textTheme.titleLarge?.color;
  555. return PullDownButton(
  556. buttonBuilder:
  557. (BuildContext context, Future<void> Function() showMenu) {
  558. return _hoverAction(
  559. context: context,
  560. toolTip: translate('More'),
  561. child: SvgPicture.asset(
  562. "assets/chevron_up_chevron_down.svg",
  563. width: 18,
  564. height: 18,
  565. colorFilter: svgColor(textColor),
  566. ),
  567. onTap: showMenu,
  568. );
  569. },
  570. routeTheme: PullDownMenuRouteTheme(
  571. width: menus.length * (otherActionWidth + padding * 2) * 1.0),
  572. itemBuilder: (context) => [
  573. PullDownMenuEntryImpl(
  574. child: Row(
  575. mainAxisSize: MainAxisSize.min,
  576. children: menus
  577. .map((e) =>
  578. Material(child: e.paddingSymmetric(horizontal: padding)))
  579. .toList(),
  580. ),
  581. )
  582. ],
  583. );
  584. }
  585. // Always show search, refresh
  586. List<Widget> actions = [
  587. const PeerSearchBar(),
  588. if (model.currentTab == PeerTabIndex.ab.index)
  589. _createRefresh(
  590. index: PeerTabIndex.ab, loading: gFFI.abModel.currentAbLoading),
  591. if (model.currentTab == PeerTabIndex.group.index)
  592. _createRefresh(
  593. index: PeerTabIndex.group, loading: gFFI.groupModel.groupLoading),
  594. ];
  595. final List<Widget> dynamicActions = [
  596. if (model.currentTabCachedPeers.isNotEmpty) _createMultiSelection(),
  597. if (model.currentTab != PeerTabIndex.recent.index) PeerSortDropdown(),
  598. if (model.currentTab == PeerTabIndex.ab.index) _toggleTags()
  599. ];
  600. final rightWidth = availableWidth -
  601. searchWidth -
  602. (actions.length == 2 ? otherActionWidth : 0);
  603. final availablePositions = rightWidth ~/ otherActionWidth;
  604. if (availablePositions < dynamicActions.length &&
  605. dynamicActions.length > 1) {
  606. if (availablePositions < 2) {
  607. actions.addAll([
  608. dropDown(dynamicActions),
  609. ]);
  610. } else {
  611. actions.addAll([
  612. ...dynamicActions.sublist(0, availablePositions - 1),
  613. dropDown(dynamicActions.sublist(availablePositions - 1)),
  614. ]);
  615. }
  616. } else {
  617. actions.addAll(dynamicActions);
  618. }
  619. return actions;
  620. }
  621. }
  622. class PeerSearchBar extends StatefulWidget {
  623. const PeerSearchBar({Key? key}) : super(key: key);
  624. @override
  625. State<StatefulWidget> createState() => _PeerSearchBarState();
  626. }
  627. class _PeerSearchBarState extends State<PeerSearchBar> {
  628. var drawer = false;
  629. @override
  630. Widget build(BuildContext context) {
  631. return drawer
  632. ? _buildSearchBar()
  633. : _hoverAction(
  634. context: context,
  635. toolTip: translate('Search'),
  636. padding: const EdgeInsets.only(right: 2),
  637. onTap: () {
  638. setState(() {
  639. drawer = true;
  640. });
  641. },
  642. child: Icon(
  643. Icons.search_rounded,
  644. color: Theme.of(context).hintColor,
  645. ));
  646. }
  647. Widget _buildSearchBar() {
  648. RxBool focused = false.obs;
  649. FocusNode focusNode = FocusNode();
  650. focusNode.addListener(() {
  651. focused.value = focusNode.hasFocus;
  652. peerSearchTextController.selection = TextSelection(
  653. baseOffset: 0,
  654. extentOffset: peerSearchTextController.value.text.length);
  655. });
  656. return Obx(() => Container(
  657. width: stateGlobal.isPortrait.isTrue ? 120 : 140,
  658. decoration: BoxDecoration(
  659. color: Theme.of(context).colorScheme.background,
  660. borderRadius: BorderRadius.circular(6),
  661. ),
  662. child: Row(
  663. children: [
  664. Expanded(
  665. child: Row(
  666. children: [
  667. Icon(
  668. Icons.search_rounded,
  669. color: Theme.of(context).hintColor,
  670. ).marginSymmetric(horizontal: 4),
  671. Expanded(
  672. child: TextField(
  673. autofocus: true,
  674. controller: peerSearchTextController,
  675. onChanged: (searchText) {
  676. peerSearchText.value = searchText;
  677. },
  678. focusNode: focusNode,
  679. textAlign: TextAlign.start,
  680. maxLines: 1,
  681. cursorColor: Theme.of(context)
  682. .textTheme
  683. .titleLarge
  684. ?.color
  685. ?.withOpacity(0.5),
  686. cursorHeight: 18,
  687. cursorWidth: 1,
  688. style: const TextStyle(fontSize: 14),
  689. decoration: InputDecoration(
  690. contentPadding:
  691. const EdgeInsets.symmetric(vertical: 6),
  692. hintText:
  693. focused.value ? null : translate("Search ID"),
  694. hintStyle: TextStyle(
  695. fontSize: 14, color: Theme.of(context).hintColor),
  696. border: InputBorder.none,
  697. isDense: true,
  698. ),
  699. ).workaroundFreezeLinuxMint(),
  700. ),
  701. // Icon(Icons.close),
  702. IconButton(
  703. alignment: Alignment.centerRight,
  704. padding: const EdgeInsets.only(right: 2),
  705. onPressed: () {
  706. setState(() {
  707. peerSearchTextController.clear();
  708. peerSearchText.value = "";
  709. drawer = false;
  710. });
  711. },
  712. icon: Tooltip(
  713. message: translate('Close'),
  714. child: Icon(
  715. Icons.close,
  716. color: Theme.of(context).hintColor,
  717. )),
  718. ),
  719. ],
  720. ),
  721. )
  722. ],
  723. ),
  724. ));
  725. }
  726. }
  727. class PeerViewDropdown extends StatefulWidget {
  728. const PeerViewDropdown({super.key});
  729. @override
  730. State<PeerViewDropdown> createState() => _PeerViewDropdownState();
  731. }
  732. class _PeerViewDropdownState extends State<PeerViewDropdown> {
  733. @override
  734. Widget build(BuildContext context) {
  735. final List<PeerUiType> types = [
  736. PeerUiType.grid,
  737. PeerUiType.tile,
  738. PeerUiType.list
  739. ];
  740. final style = TextStyle(
  741. color: Theme.of(context).textTheme.titleLarge?.color,
  742. fontSize: MenuConfig.fontSize,
  743. fontWeight: FontWeight.normal);
  744. List<PopupMenuEntry> items = List.empty(growable: true);
  745. items.add(PopupMenuItem(
  746. height: 36,
  747. enabled: false,
  748. child: Text(translate("Change view"), style: style)));
  749. for (var e in PeerUiType.values) {
  750. items.add(PopupMenuItem(
  751. height: 36,
  752. child: Obx(() => Center(
  753. child: SizedBox(
  754. height: 36,
  755. child: getRadio<PeerUiType>(
  756. Tooltip(
  757. message: translate(types.indexOf(e) == 0
  758. ? 'Big tiles'
  759. : types.indexOf(e) == 1
  760. ? 'Small tiles'
  761. : 'List'),
  762. child: Icon(
  763. e == PeerUiType.grid
  764. ? Icons.grid_view_rounded
  765. : e == PeerUiType.list
  766. ? Icons.view_list_rounded
  767. : Icons.view_agenda_rounded,
  768. size: 18,
  769. )),
  770. e,
  771. peerCardUiType.value,
  772. dense: true,
  773. isOptionFixed(kOptionPeerCardUiType)
  774. ? null
  775. : (PeerUiType? v) async {
  776. if (v != null) {
  777. peerCardUiType.value = v;
  778. setState(() {});
  779. await bind.setLocalFlutterOption(
  780. k: kOptionPeerCardUiType,
  781. v: peerCardUiType.value.index.toString(),
  782. );
  783. if (Navigator.canPop(context)) {
  784. Navigator.pop(context);
  785. }
  786. }
  787. }),
  788. ),
  789. ))));
  790. }
  791. var menuPos = RelativeRect.fromLTRB(0, 0, 0, 0);
  792. return _hoverAction(
  793. context: context,
  794. toolTip: translate('Change view'),
  795. child: Icon(
  796. peerCardUiType.value == PeerUiType.grid
  797. ? Icons.grid_view_rounded
  798. : peerCardUiType.value == PeerUiType.list
  799. ? Icons.view_list_rounded
  800. : Icons.view_agenda_rounded,
  801. size: 18,
  802. ),
  803. onTapDown: (details) {
  804. final x = details.globalPosition.dx;
  805. final y = details.globalPosition.dy;
  806. menuPos = RelativeRect.fromLTRB(x, y, x, y);
  807. },
  808. onTap: () => showMenu(
  809. context: context,
  810. position: menuPos,
  811. items: items,
  812. elevation: 8,
  813. ));
  814. }
  815. }
  816. class PeerSortDropdown extends StatefulWidget {
  817. const PeerSortDropdown({super.key});
  818. @override
  819. State<PeerSortDropdown> createState() => _PeerSortDropdownState();
  820. }
  821. class _PeerSortDropdownState extends State<PeerSortDropdown> {
  822. _PeerSortDropdownState() {
  823. if (!PeerSortType.values.contains(peerSort.value)) {
  824. _loadLocalOptions();
  825. }
  826. }
  827. void _loadLocalOptions() {
  828. peerSort.value = PeerSortType.remoteId;
  829. bind.setLocalFlutterOption(
  830. k: kOptionPeerSorting,
  831. v: peerSort.value,
  832. );
  833. }
  834. @override
  835. Widget build(BuildContext context) {
  836. final style = TextStyle(
  837. color: Theme.of(context).textTheme.titleLarge?.color,
  838. fontSize: MenuConfig.fontSize,
  839. fontWeight: FontWeight.normal);
  840. List<PopupMenuEntry> items = List.empty(growable: true);
  841. items.add(PopupMenuItem(
  842. height: 36,
  843. enabled: false,
  844. child: Text(translate("Sort by"), style: style)));
  845. for (var e in PeerSortType.values) {
  846. items.add(PopupMenuItem(
  847. height: 36,
  848. child: Obx(() => Center(
  849. child: SizedBox(
  850. height: 36,
  851. child: getRadio(
  852. Text(translate(e), style: style), e, peerSort.value,
  853. dense: true, (String? v) async {
  854. if (v != null) {
  855. peerSort.value = v;
  856. await bind.setLocalFlutterOption(
  857. k: kOptionPeerSorting,
  858. v: peerSort.value,
  859. );
  860. }
  861. }),
  862. ),
  863. ))));
  864. }
  865. var menuPos = RelativeRect.fromLTRB(0, 0, 0, 0);
  866. return _hoverAction(
  867. context: context,
  868. toolTip: translate('Sort by'),
  869. child: Icon(
  870. Icons.sort_rounded,
  871. size: 18,
  872. ),
  873. onTapDown: (details) {
  874. final x = details.globalPosition.dx;
  875. final y = details.globalPosition.dy;
  876. menuPos = RelativeRect.fromLTRB(x, y, x, y);
  877. },
  878. onTap: () => showMenu(
  879. context: context,
  880. position: menuPos,
  881. items: items,
  882. elevation: 8,
  883. ),
  884. );
  885. }
  886. }
  887. class RefreshWidget extends StatefulWidget {
  888. final VoidCallback onPressed;
  889. final Widget child;
  890. final RxBool? spinning;
  891. const RefreshWidget(
  892. {super.key, required this.onPressed, required this.child, this.spinning});
  893. @override
  894. State<RefreshWidget> createState() => RefreshWidgetState();
  895. }
  896. class RefreshWidgetState extends State<RefreshWidget> {
  897. double turns = 0.0;
  898. bool hover = false;
  899. @override
  900. void initState() {
  901. super.initState();
  902. widget.spinning?.listen((v) {
  903. if (v && mounted) {
  904. setState(() {
  905. turns += 1;
  906. });
  907. }
  908. });
  909. }
  910. @override
  911. Widget build(BuildContext context) {
  912. final deco = BoxDecoration(
  913. color: Theme.of(context).colorScheme.background,
  914. borderRadius: BorderRadius.circular(6),
  915. );
  916. return AnimatedRotation(
  917. turns: turns,
  918. duration: const Duration(milliseconds: 200),
  919. onEnd: () {
  920. if (widget.spinning?.value == true && mounted) {
  921. setState(() => turns += 1.0);
  922. }
  923. },
  924. child: Container(
  925. padding: EdgeInsets.all(4.0),
  926. margin: EdgeInsets.symmetric(horizontal: 1),
  927. decoration: hover ? deco : null,
  928. child: InkWell(
  929. onTap: () {
  930. if (mounted) setState(() => turns += 1.0);
  931. widget.onPressed();
  932. },
  933. onHover: (value) {
  934. if (mounted) {
  935. setState(() {
  936. hover = value;
  937. });
  938. }
  939. },
  940. child: widget.child),
  941. ));
  942. }
  943. }
  944. Widget _hoverAction(
  945. {required BuildContext context,
  946. required Widget child,
  947. required Function() onTap,
  948. required String toolTip,
  949. GestureTapDownCallback? onTapDown,
  950. RxBool? hoverableWhenfalse,
  951. EdgeInsetsGeometry padding = const EdgeInsets.all(4.0)}) {
  952. final hover = false.obs;
  953. final deco = BoxDecoration(
  954. color: Theme.of(context).colorScheme.background,
  955. borderRadius: BorderRadius.circular(6),
  956. );
  957. return Tooltip(
  958. message: toolTip,
  959. child: Obx(
  960. () => Container(
  961. margin: EdgeInsets.symmetric(horizontal: 1),
  962. decoration:
  963. (hover.value || hoverableWhenfalse?.value == false) ? deco : null,
  964. child: InkWell(
  965. onHover: (value) => hover.value = value,
  966. onTap: onTap,
  967. onTapDown: onTapDown,
  968. child: Container(padding: padding, child: child))),
  969. ),
  970. );
  971. }
  972. class PullDownMenuEntryImpl extends StatelessWidget
  973. implements PullDownMenuEntry {
  974. final Widget child;
  975. const PullDownMenuEntryImpl({super.key, required this.child});
  976. @override
  977. Widget build(BuildContext context) {
  978. return child;
  979. }
  980. }