peer_tab_page.dart 34 KB

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