Files
rustdesk/flutter/lib/common/widgets/peer_tab_page.dart
T
Cody SwartzandClaude Opus 5.5 ed6a1acce8 feat(address-book): add a toolbar button to open the web console (#16680)
The web console is only reachable from the "More on web console" item
in the menu beside the Tags heading, which is easy to miss and hidden
when the tags panel is turned off.

Add a button to the Address Book toolbar that opens the same URL and
reuses the same tooltip text. It shows under the same conditions as the
menu item: logged in, not in legacy address book mode, and write access
to the current book.

Signed-off-by: Cody Swartz <ctsstc@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 10:41:57 +08:00

1068 lines
34 KiB
Dart

import 'dart:ui' as ui;
import 'package:bot_toast/bot_toast.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common/widgets/address_book.dart';
import 'package:flutter_hbb/common/widgets/dialog.dart';
import 'package:flutter_hbb/common/widgets/my_group.dart';
import 'package:flutter_hbb/common/widgets/peers_view.dart';
import 'package:flutter_hbb/common/widgets/peer_card.dart';
import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/desktop/widgets/popup_menu.dart';
import 'package:flutter_hbb/desktop/widgets/material_mod_popup_menu.dart'
as mod_menu;
import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
import 'package:flutter_hbb/models/ab_model.dart';
import 'package:flutter_hbb/models/peer_model.dart';
import 'package:flutter_hbb/models/peer_tab_model.dart';
import 'package:flutter_hbb/models/state_model.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:get/get.dart';
import 'package:provider/provider.dart';
import 'package:pull_down_button/pull_down_button.dart';
import 'package:url_launcher/url_launcher_string.dart';
import '../../common.dart';
import '../../models/platform_model.dart';
class PeerTabPage extends StatefulWidget {
const PeerTabPage({Key? key}) : super(key: key);
@override
State<PeerTabPage> createState() => _PeerTabPageState();
}
class _TabEntry {
final Widget widget;
final Function({dynamic hint})? load;
_TabEntry(this.widget, [this.load]);
}
EdgeInsets? _menuPadding() {
return (isDesktop || isWebDesktop) ? kDesktopMenuPadding : null;
}
class _PeerTabPageState extends State<PeerTabPage>
with SingleTickerProviderStateMixin {
final List<_TabEntry> entries = [
_TabEntry(RecentPeersView(
menuPadding: _menuPadding(),
)),
_TabEntry(FavoritePeersView(
menuPadding: _menuPadding(),
)),
_TabEntry(DiscoveredPeersView(
menuPadding: _menuPadding(),
)),
_TabEntry(
AddressBook(
menuPadding: _menuPadding(),
),
({dynamic hint}) => gFFI.abModel.pullAb(
force: hint == null ? ForcePullAb.listAndCurrent : null,
quiet: false)),
_TabEntry(
MyGroup(
menuPadding: _menuPadding(),
),
({dynamic hint}) => gFFI.groupModel.pull(force: hint == null),
),
];
RelativeRect? mobileTabContextMenuPos;
final isOptVisiableFixed = isOptionFixed(kOptionPeerTabVisible);
_PeerTabPageState() {
_loadLocalOptions();
}
void _loadLocalOptions() {
final uiType = bind.getLocalFlutterOption(k: kOptionPeerCardUiType);
if (uiType != '') {
peerCardUiType.value = int.parse(uiType) == 0
? PeerUiType.grid
: int.parse(uiType) == 1
? PeerUiType.tile
: PeerUiType.list;
}
hideAbTagsPanel.value =
bind.mainGetLocalOption(key: kOptionHideAbTagsPanel) == 'Y';
}
Future<void> handleTabSelection(int tabIndex) async {
if (tabIndex < entries.length) {
if (tabIndex != gFFI.peerTabModel.currentTab) {
gFFI.peerTabModel.setCurrentTabCachedPeers([]);
}
gFFI.peerTabModel.setCurrentTab(tabIndex);
entries[tabIndex].load?.call(hint: false);
}
}
@override
Widget build(BuildContext context) {
final model = Provider.of<PeerTabModel>(context);
Widget selectionWrap(Widget widget) {
return model.multiSelectionMode ? createMultiSelectionBar(model) : widget;
}
return Column(
textBaseline: TextBaseline.ideographic,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Obx(() => SizedBox(
height: 32,
child: Container(
padding: stateGlobal.isPortrait.isTrue
? EdgeInsets.symmetric(horizontal: 2)
: null,
child: selectionWrap(Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: visibleContextMenuListener(
_createSwitchBar(context))),
if (stateGlobal.isPortrait.isTrue)
..._portraitRightActions(context)
else
..._landscapeRightActions(context)
],
)),
),
).paddingOnly(right: stateGlobal.isPortrait.isTrue ? 0 : 12)),
_createPeersView(),
],
);
}
Widget _createSwitchBar(BuildContext context) {
final model = Provider.of<PeerTabModel>(context);
var counter = -1;
return ReorderableListView(
buildDefaultDragHandles: false,
onReorder: model.reorder,
scrollDirection: Axis.horizontal,
physics: NeverScrollableScrollPhysics(),
children: model.visibleEnabledOrderedIndexs.map((t) {
final selected = model.currentTab == t;
final color = selected
? MyTheme.tabbar(context).selectedTextColor
: MyTheme.tabbar(context).unSelectedTextColor
?..withOpacity(0.5);
final hover = false.obs;
final deco = BoxDecoration(
color: Theme.of(context).colorScheme.background,
borderRadius: BorderRadius.circular(6));
final decoBorder = BoxDecoration(
border: Border(
bottom: BorderSide(width: 2, color: color!),
));
counter += 1;
return ReorderableDragStartListener(
key: ValueKey(t),
index: counter,
child: Obx(() => Tooltip(
preferBelow: false,
message: model.tabTooltip(t),
onTriggered: isMobile ? mobileShowTabVisibilityMenu : null,
child: InkWell(
child: Container(
decoration: (hover.value
? (selected ? decoBorder : deco)
: (selected ? decoBorder : null)),
child: Icon(model.tabIcon(t), color: color)
.paddingSymmetric(horizontal: 4),
).paddingSymmetric(horizontal: 4),
onTap: isOptionFixed(kOptionPeerTabIndex)
? null
: () async {
await handleTabSelection(t);
await bind.setLocalFlutterOption(
k: kOptionPeerTabIndex, v: t.toString());
},
onHover: (value) => hover.value = value,
),
)));
}).toList());
}
Widget _createPeersView() {
final model = Provider.of<PeerTabModel>(context);
Widget child;
if (model.visibleEnabledOrderedIndexs.isEmpty) {
child = visibleContextMenuListener(Row(
children: [Expanded(child: InkWell())],
));
} else {
if (model.visibleEnabledOrderedIndexs.contains(model.currentTab)) {
child = entries[model.currentTab].widget;
} else {
debugPrint("should not happen! currentTab not in visibleIndexs");
Future.delayed(Duration.zero, () {
model.setCurrentTab(model.visibleEnabledOrderedIndexs[0]);
});
child = entries[0].widget;
}
}
return Expanded(
child: child.marginSymmetric(
vertical: (isDesktop || isWebDesktop) ? 12.0 : 6.0));
}
Widget _createRefresh(
{required PeerTabIndex index, required RxBool loading}) {
final model = Provider.of<PeerTabModel>(context);
final textColor = Theme.of(context).textTheme.titleLarge?.color;
return Offstage(
offstage: model.currentTab != index.index,
child: Tooltip(
message: translate('Refresh'),
child: RefreshWidget(
onPressed: () {
if (gFFI.peerTabModel.currentTab < entries.length) {
entries[gFFI.peerTabModel.currentTab].load?.call();
}
},
spinning: loading,
child: RotatedBox(
quarterTurns: 2,
child: Icon(
Icons.refresh,
size: 18,
color: textColor,
))),
),
);
}
Widget _createPeerViewTypeSwitch(BuildContext context) {
return PeerViewDropdown();
}
Widget _createMultiSelection() {
final textColor = Theme.of(context).textTheme.titleLarge?.color;
final model = Provider.of<PeerTabModel>(context);
return _hoverAction(
toolTip: translate('Select'),
context: context,
onTap: () {
model.setMultiSelectionMode(true);
if (isMobile && Navigator.canPop(context)) {
Navigator.pop(context);
}
},
child: SvgPicture.asset(
"assets/checkbox-outline.svg",
width: 18,
height: 18,
colorFilter: svgColor(textColor),
),
);
}
void mobileShowTabVisibilityMenu() {
final model = gFFI.peerTabModel;
final items = List<PopupMenuItem>.empty(growable: true);
for (int i = 0; i < PeerTabModel.maxTabCount; i++) {
if (!model.isEnabled[i]) continue;
items.add(PopupMenuItem(
height: kMinInteractiveDimension * 0.8,
onTap: isOptVisiableFixed
? null
: () => model.setTabVisible(i, !model.isVisibleEnabled[i]),
enabled: !isOptVisiableFixed,
child: Row(
children: [
Checkbox(
value: model.isVisibleEnabled[i],
onChanged: isOptVisiableFixed
? null
: (_) {
model.setTabVisible(i, !model.isVisibleEnabled[i]);
if (Navigator.canPop(context)) {
Navigator.pop(context);
}
}),
Expanded(child: Text(model.tabTooltip(i))),
],
),
));
}
if (mobileTabContextMenuPos != null) {
showMenu(
context: context, position: mobileTabContextMenuPos!, items: items);
}
}
Widget visibleContextMenuListener(Widget child) {
if (!(isDesktop || isWebDesktop)) {
return GestureDetector(
onLongPressDown: (e) {
final x = e.globalPosition.dx;
final y = e.globalPosition.dy;
mobileTabContextMenuPos = RelativeRect.fromLTRB(x, y, x, y);
},
onLongPressUp: () {
mobileShowTabVisibilityMenu();
},
child: child,
);
} else {
return Listener(
onPointerDown: (e) {
if (e.kind != ui.PointerDeviceKind.mouse) {
return;
}
if (e.buttons == 2) {
showRightMenu(
(CancelFunc cancelFunc) {
return visibleContextMenu(cancelFunc);
},
target: e.position,
);
}
},
child: child);
}
}
Widget visibleContextMenu(CancelFunc cancelFunc) {
final model = Provider.of<PeerTabModel>(context);
final menu = List<MenuEntrySwitchSync>.empty(growable: true);
for (int i = 0; i < model.orders.length; i++) {
int tabIndex = model.orders[i];
if (tabIndex < 0 || tabIndex >= PeerTabModel.maxTabCount) continue;
if (!model.isEnabled[tabIndex]) continue;
menu.add(MenuEntrySwitchSync(
switchType: SwitchType.scheckbox,
text: model.tabTooltip(tabIndex),
currentValue: model.isVisibleEnabled[tabIndex],
setter: (show) async {
model.setTabVisible(tabIndex, show);
// Do not hide the current menu (checkbox)
// cancelFunc();
},
enabled: (!isOptVisiableFixed).obs));
}
return mod_menu.PopupMenu(
items: menu
.map((entry) => entry.build(
context,
const MenuConfig(
commonColor: MyTheme.accent,
height: 20.0,
dividerHeight: 12.0,
)))
.expand((i) => i)
.toList());
}
Widget createMultiSelectionBar(PeerTabModel model) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Offstage(
offstage: model.selectedPeers.isEmpty,
child: Row(
children: [
deleteSelection(),
addSelectionToFav(),
addSelectionToAb(),
editSelectionTags(),
],
),
),
Row(
children: [
selectionCount(model.selectedPeers.length),
selectAll(model),
closeSelection(),
],
)
],
);
}
Widget deleteSelection() {
final model = Provider.of<PeerTabModel>(context);
if (model.currentTab == PeerTabIndex.group.index) {
return Offstage();
}
return _hoverAction(
context: context,
toolTip: translate('Delete'),
onTap: () {
onSubmit() async {
final peers = model.selectedPeers;
switch (model.currentTab) {
case 0:
for (var p in peers) {
await bind.mainRemovePeer(id: p.id);
}
bind.mainLoadRecentPeers();
break;
case 1:
final favs = (await bind.mainGetFav()).toList();
peers.map((p) {
favs.remove(p.id);
}).toList();
await bind.mainStoreFav(favs: favs);
bind.mainLoadFavPeers();
break;
case 2:
for (var p in peers) {
await bind.mainRemoveDiscovered(id: p.id);
}
bind.mainLoadLanPeers();
break;
case 3:
await gFFI.abModel.deletePeers(peers.map((p) => p.id).toList());
break;
default:
break;
}
gFFI.peerTabModel.setMultiSelectionMode(false);
if (model.currentTab != 3) showToast(translate('Successful'));
}
deleteConfirmDialog(onSubmit, translate('Delete'));
},
child: Icon(Icons.delete, color: Colors.red));
}
Widget addSelectionToFav() {
final model = Provider.of<PeerTabModel>(context);
return Offstage(
offstage:
model.currentTab != PeerTabIndex.recent.index, // show based on recent
child: _hoverAction(
context: context,
toolTip: translate('Add to Favorites'),
onTap: () async {
final peers = model.selectedPeers;
final favs = (await bind.mainGetFav()).toList();
for (var p in peers) {
if (!favs.contains(p.id)) {
favs.add(p.id);
}
}
await bind.mainStoreFav(favs: favs);
model.setMultiSelectionMode(false);
showToast(translate('Successful'));
},
child: Icon(PeerTabModel.icons[PeerTabIndex.fav.index]),
).marginOnly(left: !(isDesktop || isWebDesktop) ? 11 : 6),
);
}
Widget addSelectionToAb() {
final model = Provider.of<PeerTabModel>(context);
final addressbooks = gFFI.abModel.addressBooksCanWrite();
if (model.currentTab == PeerTabIndex.ab.index) {
addressbooks.remove(gFFI.abModel.currentName.value);
}
return Offstage(
offstage: !gFFI.userModel.isLogin || addressbooks.isEmpty,
child: _hoverAction(
context: context,
toolTip: translate('Add to address book'),
onTap: () {
final peers = model.selectedPeers.map((e) => Peer.copy(e)).toList();
addPeersToAbDialog(peers);
model.setMultiSelectionMode(false);
},
child: Icon(PeerTabModel.icons[PeerTabIndex.ab.index]),
).marginOnly(left: !(isDesktop || isWebDesktop) ? 11 : 6),
);
}
Widget editSelectionTags() {
final model = Provider.of<PeerTabModel>(context);
return Offstage(
offstage: !gFFI.userModel.isLogin ||
model.currentTab != PeerTabIndex.ab.index ||
gFFI.abModel.currentAbTags.isEmpty,
child: _hoverAction(
context: context,
toolTip: translate('Edit Tag'),
onTap: () {
editAbTagDialog(List.empty(), (selectedTags) async {
final peers = model.selectedPeers;
await gFFI.abModel.changeTagForPeers(
peers.map((p) => p.id).toList(), selectedTags);
model.setMultiSelectionMode(false);
showToast(translate('Successful'));
});
},
child: Icon(Icons.tag))
.marginOnly(left: !(isDesktop || isWebDesktop) ? 11 : 6),
);
}
Widget selectionCount(int count) {
return Align(
alignment: Alignment.center,
child: Text('$count ${translate('Selected')}'),
);
}
Widget selectAll(PeerTabModel model) {
return Offstage(
offstage:
model.selectedPeers.length >= model.currentTabCachedPeers.length,
child: _hoverAction(
context: context,
toolTip: translate('Select All'),
onTap: () {
model.selectAll();
},
child: Icon(Icons.select_all),
).marginOnly(left: 6),
);
}
Widget closeSelection() {
final model = Provider.of<PeerTabModel>(context);
return _hoverAction(
context: context,
toolTip: translate('Close'),
onTap: () {
model.setMultiSelectionMode(false);
},
child: Icon(Icons.clear))
.marginOnly(left: 6);
}
Widget _toggleTags() {
return _hoverAction(
context: context,
toolTip: translate('Toggle Tags'),
hoverableWhenfalse: hideAbTagsPanel,
child: Icon(
Icons.tag_rounded,
size: 18,
),
onTap: () async {
await bind.mainSetLocalOption(
key: kOptionHideAbTagsPanel,
value: hideAbTagsPanel.value ? defaultOptionNo : "Y");
hideAbTagsPanel.value = !hideAbTagsPanel.value;
});
}
Widget _openWebConsole() {
return _hoverAction(
context: context,
toolTip: translate('ab_web_console_tip'),
child: Icon(
Icons.open_in_new,
size: 18,
),
onTap: () async {
final url = await bind.mainGetApiServer();
if (await canLaunchUrlString(url)) {
launchUrlString(url);
}
});
}
bool _showWebConsole(PeerTabModel model) =>
model.currentTab == PeerTabIndex.ab.index &&
gFFI.userModel.isLogin &&
!gFFI.abModel.legacyMode.value &&
gFFI.abModel.current.canWrite();
List<Widget> _landscapeRightActions(BuildContext context) {
final model = Provider.of<PeerTabModel>(context);
return [
const PeerSearchBar().marginOnly(right: 13),
_createRefresh(
index: PeerTabIndex.ab, loading: gFFI.abModel.currentAbLoading),
_createRefresh(
index: PeerTabIndex.group, loading: gFFI.groupModel.groupLoading),
Offstage(
offstage: model.currentTabCachedPeers.isEmpty,
child: _createMultiSelection(),
),
_createPeerViewTypeSwitch(context),
Offstage(
offstage: model.currentTab == PeerTabIndex.recent.index,
child: PeerSortDropdown(),
),
Offstage(
offstage: !_showWebConsole(model),
child: _openWebConsole(),
),
Offstage(
offstage: model.currentTab != PeerTabIndex.ab.index,
child: _toggleTags(),
),
];
}
List<Widget> _portraitRightActions(BuildContext context) {
final model = Provider.of<PeerTabModel>(context);
final screenWidth = MediaQuery.of(context).size.width;
final leftIconSize = Theme.of(context).iconTheme.size ?? 24;
final leftActionsSize =
(leftIconSize + (4 + 4) * 2) * model.visibleEnabledOrderedIndexs.length;
final availableWidth = screenWidth - 10 * 2 - leftActionsSize - 2 * 2;
final searchWidth = 120;
final otherActionWidth = 18 + 10;
dropDown(List<Widget> menus) {
final padding = 6.0;
final textColor = Theme.of(context).textTheme.titleLarge?.color;
return PullDownButton(
buttonBuilder:
(BuildContext context, Future<void> Function() showMenu) {
return _hoverAction(
context: context,
toolTip: translate('More'),
child: SvgPicture.asset(
"assets/chevron_up_chevron_down.svg",
width: 18,
height: 18,
colorFilter: svgColor(textColor),
),
onTap: showMenu,
);
},
routeTheme: PullDownMenuRouteTheme(
width: menus.length * (otherActionWidth + padding * 2) * 1.0),
itemBuilder: (context) => [
PullDownMenuEntryImpl(
child: Row(
mainAxisSize: MainAxisSize.min,
children: menus
.map((e) =>
Material(child: e.paddingSymmetric(horizontal: padding)))
.toList(),
),
)
],
);
}
// Always show search, refresh
List<Widget> actions = [
const PeerSearchBar(),
if (model.currentTab == PeerTabIndex.ab.index)
_createRefresh(
index: PeerTabIndex.ab, loading: gFFI.abModel.currentAbLoading),
if (model.currentTab == PeerTabIndex.group.index)
_createRefresh(
index: PeerTabIndex.group, loading: gFFI.groupModel.groupLoading),
];
final List<Widget> dynamicActions = [
if (model.currentTabCachedPeers.isNotEmpty) _createMultiSelection(),
if (model.currentTab != PeerTabIndex.recent.index) PeerSortDropdown(),
if (_showWebConsole(model)) _openWebConsole(),
if (model.currentTab == PeerTabIndex.ab.index) _toggleTags()
];
final rightWidth = availableWidth -
searchWidth -
(actions.length == 2 ? otherActionWidth : 0);
final availablePositions = rightWidth ~/ otherActionWidth;
if (availablePositions < dynamicActions.length &&
dynamicActions.length > 1) {
if (availablePositions < 2) {
actions.addAll([
dropDown(dynamicActions),
]);
} else {
actions.addAll([
...dynamicActions.sublist(0, availablePositions - 1),
dropDown(dynamicActions.sublist(availablePositions - 1)),
]);
}
} else {
actions.addAll(dynamicActions);
}
return actions;
}
}
class PeerSearchBar extends StatefulWidget {
const PeerSearchBar({Key? key}) : super(key: key);
@override
State<StatefulWidget> createState() => _PeerSearchBarState();
}
class _PeerSearchBarState extends State<PeerSearchBar> {
var drawer = false;
@override
Widget build(BuildContext context) {
return drawer
? _buildSearchBar()
: _hoverAction(
context: context,
toolTip: translate('Search'),
padding: const EdgeInsets.only(right: 2),
onTap: () {
setState(() {
drawer = true;
});
},
child: Icon(
Icons.search_rounded,
color: Theme.of(context).hintColor,
));
}
Widget _buildSearchBar() {
RxBool focused = false.obs;
FocusNode focusNode = FocusNode();
focusNode.addListener(() {
focused.value = focusNode.hasFocus;
peerSearchTextController.selection = TextSelection(
baseOffset: 0,
extentOffset: peerSearchTextController.value.text.length);
});
return Obx(() => Container(
width: stateGlobal.isPortrait.isTrue ? 120 : 140,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.background,
borderRadius: BorderRadius.circular(6),
),
child: Row(
children: [
Expanded(
child: Row(
children: [
Icon(
Icons.search_rounded,
color: Theme.of(context).hintColor,
).marginSymmetric(horizontal: 4),
Expanded(
child: TextField(
autofocus: true,
controller: peerSearchTextController,
onChanged: (searchText) {
peerSearchText.value = searchText;
},
focusNode: focusNode,
textAlign: TextAlign.start,
maxLines: 1,
cursorColor: Theme.of(context)
.textTheme
.titleLarge
?.color
?.withOpacity(0.5),
cursorHeight: 18,
cursorWidth: 1,
style: const TextStyle(fontSize: 14),
decoration: InputDecoration(
contentPadding:
const EdgeInsets.symmetric(vertical: 6),
hintText:
focused.value ? null : translate("Search ID"),
hintStyle: TextStyle(
fontSize: 14, color: Theme.of(context).hintColor),
border: InputBorder.none,
isDense: true,
),
).workaroundFreezeLinuxMint(),
),
// Icon(Icons.close),
IconButton(
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 2),
onPressed: () {
setState(() {
peerSearchTextController.clear();
peerSearchText.value = "";
drawer = false;
});
},
icon: Tooltip(
message: translate('Close'),
child: Icon(
Icons.close,
color: Theme.of(context).hintColor,
)),
),
],
),
)
],
),
));
}
}
class PeerViewDropdown extends StatefulWidget {
const PeerViewDropdown({super.key});
@override
State<PeerViewDropdown> createState() => _PeerViewDropdownState();
}
class _PeerViewDropdownState extends State<PeerViewDropdown> {
@override
Widget build(BuildContext context) {
final List<PeerUiType> types = [
PeerUiType.grid,
PeerUiType.tile,
PeerUiType.list
];
final style = TextStyle(
color: Theme.of(context).textTheme.titleLarge?.color,
fontSize: MenuConfig.fontSize,
fontWeight: FontWeight.normal);
List<PopupMenuEntry> items = List.empty(growable: true);
items.add(PopupMenuItem(
height: 36,
enabled: false,
child: Text(translate("Change view"), style: style)));
for (var e in PeerUiType.values) {
items.add(PopupMenuItem(
height: 36,
child: Obx(() => Center(
child: SizedBox(
height: 36,
child: getRadio<PeerUiType>(
Tooltip(
message: translate(types.indexOf(e) == 0
? 'Big tiles'
: types.indexOf(e) == 1
? 'Small tiles'
: 'List'),
child: Icon(
e == PeerUiType.grid
? Icons.grid_view_rounded
: e == PeerUiType.list
? Icons.view_list_rounded
: Icons.view_agenda_rounded,
size: 18,
)),
e,
peerCardUiType.value,
dense: true,
isOptionFixed(kOptionPeerCardUiType)
? null
: (PeerUiType? v) async {
if (v != null) {
peerCardUiType.value = v;
setState(() {});
await bind.setLocalFlutterOption(
k: kOptionPeerCardUiType,
v: peerCardUiType.value.index.toString(),
);
if (Navigator.canPop(context)) {
Navigator.pop(context);
}
}
}),
),
))));
}
var menuPos = RelativeRect.fromLTRB(0, 0, 0, 0);
return _hoverAction(
context: context,
toolTip: translate('Change view'),
child: Icon(
peerCardUiType.value == PeerUiType.grid
? Icons.grid_view_rounded
: peerCardUiType.value == PeerUiType.list
? Icons.view_list_rounded
: Icons.view_agenda_rounded,
size: 18,
),
onTapDown: (details) {
final x = details.globalPosition.dx;
final y = details.globalPosition.dy;
menuPos = RelativeRect.fromLTRB(x, y, x, y);
},
onTap: () => showMenu(
context: context,
position: menuPos,
items: items,
elevation: 8,
));
}
}
class PeerSortDropdown extends StatefulWidget {
const PeerSortDropdown({super.key});
@override
State<PeerSortDropdown> createState() => _PeerSortDropdownState();
}
class _PeerSortDropdownState extends State<PeerSortDropdown> {
_PeerSortDropdownState() {
if (!PeerSortType.values.contains(peerSort.value)) {
_loadLocalOptions();
}
}
void _loadLocalOptions() {
peerSort.value = PeerSortType.remoteId;
bind.setLocalFlutterOption(
k: kOptionPeerSorting,
v: peerSort.value,
);
}
@override
Widget build(BuildContext context) {
final style = TextStyle(
color: Theme.of(context).textTheme.titleLarge?.color,
fontSize: MenuConfig.fontSize,
fontWeight: FontWeight.normal);
List<PopupMenuEntry> items = List.empty(growable: true);
items.add(PopupMenuItem(
height: 36,
enabled: false,
child: Text(translate("Sort by"), style: style)));
for (var e in PeerSortType.values) {
items.add(PopupMenuItem(
height: 36,
child: Obx(() => Center(
child: SizedBox(
height: 36,
child: getRadio(
Text(translate(e), style: style), e, peerSort.value,
dense: true, (String? v) async {
if (v != null) {
peerSort.value = v;
await bind.setLocalFlutterOption(
k: kOptionPeerSorting,
v: peerSort.value,
);
}
}),
),
))));
}
var menuPos = RelativeRect.fromLTRB(0, 0, 0, 0);
return _hoverAction(
context: context,
toolTip: translate('Sort by'),
child: Icon(
Icons.sort_rounded,
size: 18,
),
onTapDown: (details) {
final x = details.globalPosition.dx;
final y = details.globalPosition.dy;
menuPos = RelativeRect.fromLTRB(x, y, x, y);
},
onTap: () => showMenu(
context: context,
position: menuPos,
items: items,
elevation: 8,
),
);
}
}
class RefreshWidget extends StatefulWidget {
final VoidCallback onPressed;
final Widget child;
final RxBool? spinning;
const RefreshWidget(
{super.key, required this.onPressed, required this.child, this.spinning});
@override
State<RefreshWidget> createState() => RefreshWidgetState();
}
class RefreshWidgetState extends State<RefreshWidget> {
double turns = 0.0;
bool hover = false;
@override
void initState() {
super.initState();
widget.spinning?.listen((v) {
if (v && mounted) {
setState(() {
turns += 1;
});
}
});
}
@override
Widget build(BuildContext context) {
final deco = BoxDecoration(
color: Theme.of(context).colorScheme.background,
borderRadius: BorderRadius.circular(6),
);
return AnimatedRotation(
turns: turns,
duration: const Duration(milliseconds: 200),
onEnd: () {
if (widget.spinning?.value == true && mounted) {
setState(() => turns += 1.0);
}
},
child: Container(
padding: EdgeInsets.all(4.0),
margin: EdgeInsets.symmetric(horizontal: 1),
decoration: hover ? deco : null,
child: InkWell(
onTap: () {
if (mounted) setState(() => turns += 1.0);
widget.onPressed();
},
onHover: (value) {
if (mounted) {
setState(() {
hover = value;
});
}
},
child: widget.child),
));
}
}
Widget _hoverAction(
{required BuildContext context,
required Widget child,
required Function() onTap,
required String toolTip,
GestureTapDownCallback? onTapDown,
RxBool? hoverableWhenfalse,
EdgeInsetsGeometry padding = const EdgeInsets.all(4.0)}) {
final hover = false.obs;
final deco = BoxDecoration(
color: Theme.of(context).colorScheme.background,
borderRadius: BorderRadius.circular(6),
);
return Tooltip(
message: toolTip,
child: Obx(
() => Container(
margin: EdgeInsets.symmetric(horizontal: 1),
decoration:
(hover.value || hoverableWhenfalse?.value == false) ? deco : null,
child: InkWell(
onHover: (value) => hover.value = value,
onTap: onTap,
onTapDown: onTapDown,
child: Container(padding: padding, child: child))),
),
);
}
class PullDownMenuEntryImpl extends StatelessWidget
implements PullDownMenuEntry {
final Widget child;
const PullDownMenuEntryImpl({super.key, required this.child});
@override
Widget build(BuildContext context) {
return child;
}
}