new-node-list-ui-on-dev (#3259)

* node list new ui

# Conflicts:
#	cw_core/lib/node_list.dart

* increase touch target sizes

* fix scroll behavior

* fix concurrent modification

* Update lib/src/screens/settings/manage_nodes_page.dart [skip ci]

* Update lib/src/screens/nodes/node_create_or_edit_page.dart [skip ci]

* run speed test in parallel not sequential

---------

Co-authored-by: Omar Hatem <omarh.ismail1@gmail.com>
This commit is contained in:
malik1004x
2026-06-01 21:32:12 +02:00
committed by GitHub
co-authored by Omar Hatem
parent cfe332a878
commit 6c1ee16513
20 changed files with 472 additions and 404 deletions
@@ -1,20 +1,20 @@
import 'package:cake_wallet/core/execution_state.dart';
import 'package:cake_wallet/entities/new_ui_entities/list_item/list_item_regular_row.dart';
import 'package:cake_wallet/generated/i18n.dart';
import 'package:cake_wallet/new-ui/widgets/keyboard_hide_overlay.dart';
import 'package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
import 'package:cake_wallet/src/screens/nodes/node_share_page.dart';
import 'package:cake_wallet/src/screens/nodes/widgets/node_form.dart';
import 'package:cake_wallet/src/widgets/alert_with_one_action.dart';
import 'package:cake_wallet/src/widgets/alert_with_two_actions.dart';
import 'package:cake_wallet/src/widgets/cake_image_widget.dart';
import 'package:cake_wallet/src/widgets/primary_button.dart';
import 'package:cake_wallet/src/widgets/scrollable_with_bottom_section.dart';
import 'package:cake_wallet/src/widgets/new_list_row/new_list_section.dart';
import 'package:cake_wallet/utils/show_pop_up.dart';
import 'package:cake_wallet/view_model/node_list/node_create_or_edit_view_model.dart';
import 'package:cw_core/currency_for_wallet_type.dart';
import 'package:cw_core/node.dart';
import 'package:cw_core/wallet_type.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:flutter_svg/svg.dart';
import 'package:mobx/mobx.dart';
class NodeCreateOrEditPage extends StatefulWidget {
@@ -76,6 +76,14 @@ class _NodeCreateOrEditPageState extends State<NodeCreateOrEditPage> {
},
);
reaction((_)=>widget.nodeCreateOrEditViewModel.state, (state) async {
if(state is ExecutedSuccessfullyState) {
await Future.delayed(Duration(milliseconds: 100));
if (mounted) Navigator.of(context).pop(widget.nodeCreateOrEditViewModel.editingNode);
}
});
}
@override
@@ -90,86 +98,62 @@ class _NodeCreateOrEditPageState extends State<NodeCreateOrEditPage> {
title: widget.editingNode != null ? S.current.edit_node : S.current.node_new,
leadingIcon: Icon(Icons.arrow_back_ios_new),
onLeadingPressed: Navigator.of(context).pop,
trailingIcon: CakeImageWidget(imageUrl:
"assets/new-ui/scan.svg",width:24,height:24,
colorFilter: ColorFilter.mode(Theme.of(context).colorScheme.primary, BlendMode.srcIn),
),
onTrailingPressed: () => widget.nodeCreateOrEditViewModel.scanQRCodeForNewNode(context),
trailingIcon: Icon(Icons.check),
onTrailingPressed: () async {
if (_nodeFormKey.currentState != null && !_nodeFormKey.currentState!.validate()) {
return;
}
await widget.nodeCreateOrEditViewModel
.save(saveAsCurrent: widget.isSelected ?? false);
},
),
Expanded(
child: KeyboardHideOverlay(
child: Container(
padding: const EdgeInsets.only(left: 18, right: 18),
child: ScrollableWithBottomSection(
contentPadding: const EdgeInsets.only(bottom: 24.0, top: 8),
content: NodeForm(
child: Column(
spacing: 18,
children: [NodeForm(
key: _nodeFormKey,
nodeViewModel: widget.nodeCreateOrEditViewModel,
),
bottomSectionPadding: const EdgeInsets.only(bottom: 24),
bottomSection: Observer(
builder: (_) => Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Flexible(
child: Container(
padding: const EdgeInsets.only(right: 8.0),
child: LoadingPrimaryButton(
onPressed: () async {
final confirmed = await showPopUp<bool>(
context: context,
builder: (context) => AlertWithTwoActions(
alertTitle: S.of(context).remove_node,
alertContent: S.of(context).remove_node_message,
rightButtonText: S.of(context).remove,
leftButtonText: S.of(context).cancel,
actionRightButton: () => Navigator.pop(context, true),
actionLeftButton: () => Navigator.pop(context, false),
),
) ??
false;
NewListSections(sections: {"":[
if(widget.editingNode != null)
if (confirmed) {
await widget.nodeCreateOrEditViewModel.delete(editingNode: widget.editingNode!);
Navigator.of(context).pop();
}
},
text: S.of(context).delete,
isDisabled: widget.editingNode == null ||
!widget.nodeCreateOrEditViewModel.isReady ||
widget.editingNode!.isBuiltin ||
(widget.isSelected ?? false),
color: Theme.of(context).colorScheme.errorContainer,
textColor: Theme.of(context).colorScheme.onErrorContainer,
),
),
),
Flexible(
child: Container(
padding: const EdgeInsets.only(left: 8.0),
child: PrimaryButton(
onPressed: () async {
if (_nodeFormKey.currentState != null && !_nodeFormKey.currentState!.validate()) {
return;
}
ListItemRegularRow(keyValue: "share", label: S.of(context).share_this_node, foregroundColor: Theme.of(context).colorScheme.primary, showArrow: false, onTap: (){
await widget.nodeCreateOrEditViewModel.save(
saveAsCurrent: widget.isSelected ?? false);
if (context.mounted) {
Navigator.of(context).pop();
}
},
text: S.of(context).save,
color: Theme.of(context).colorScheme.primary,
textColor: Theme.of(context).colorScheme.onPrimary,
isDisabled: (!widget.nodeCreateOrEditViewModel.isReady) ||
(widget.nodeCreateOrEditViewModel.connectionState is IsExecutingState),
),
),
),
],
),
),
Navigator.of(context).push(CupertinoPageRoute(builder: (context)=>NodeSharePage(uri: widget.editingNode!.uri, currency: walletTypeToCryptoCurrency(widget.type!))));
}),
if(!(widget.editingNode == null ||
!widget.nodeCreateOrEditViewModel.isReady ||
widget.editingNode!.isBuiltin ||
(widget.isSelected ?? false)))
ListItemRegularRow(keyValue: "delete", label: S.of(context).delete_this_node, foregroundColor: Theme.of(context).colorScheme.errorContainer, showArrow: false, onTap: ()async{
final confirmed = await showPopUp<bool>(
context: context,
builder: (context) => AlertWithTwoActions(
alertTitle: S.of(context).remove_node,
alertContent: S.of(context).remove_node_message,
rightButtonText: S.of(context).remove,
leftButtonText: S.of(context).cancel,
actionRightButton: () => Navigator.pop(context, true),
actionLeftButton: () => Navigator.pop(context, false),
),
) ??
false;
if (confirmed) {
await widget.nodeCreateOrEditViewModel.delete(editingNode: widget.editingNode!);
Navigator.of(context).pop();
}
})
]})
]
),
),
),
@@ -0,0 +1,82 @@
import 'package:cake_wallet/generated/i18n.dart';
import 'package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
import 'package:cake_wallet/src/screens/receive/widgets/qr_image.dart';
import 'package:cake_wallet/src/widgets/cake_image_widget.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:flutter/material.dart';
class NodeSharePage extends StatelessWidget {
const NodeSharePage({super.key, required this.uri, required this.currency});
final Uri uri;
final CryptoCurrency currency;
@override
Widget build(BuildContext context) {
final qrSize = MediaQuery.of(context).size.width * 0.786;
final padding = 16.0;
return Container(
color: Theme.of(context).colorScheme.surface,
child: SafeArea(
child: Column(
mainAxisSize: MainAxisSize.max,
children: [
ModalTopBar(
title: S.of(context).share,
leadingIcon: Icon(Icons.arrow_back_ios_new),
onLeadingPressed: Navigator.of(context).pop,
),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
spacing: 40,
children: [
Stack(alignment: Alignment.center, children: [
SizedBox(
width: qrSize,
height: qrSize,
child: ClipRRect(
borderRadius: BorderRadius.circular(18),
child: QrImage(
size: qrSize - padding * 2,
embeddedImagePath: "",
data: uri.toString())),
),
SizedBox(
width: 60,
height: 60,
child: Stack(
children: [
CakeImageWidget(
imageUrl: "assets/new-ui/settings_row_icons/nodes.svg",
width: 54,
height: 54,
),
Align(
alignment: Alignment.bottomRight,
child: CakeImageWidget(
imageUrl: currency.iconPath,
width: 24,
height: 24,
))
],
),
),
]),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 36.0),
child: Text(S.of(context).node_share_desc,
textAlign: TextAlign.center,
style: TextStyle(
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurfaceVariant)),
)
],
),
),
],
),
),
);
}
}
@@ -1,115 +1,55 @@
import 'package:cake_wallet/routes.dart';
import 'package:cake_wallet/src/screens/nodes/widgets/node_indicator.dart';
import 'package:cake_wallet/src/widgets/standard_list.dart';
import 'package:cake_wallet/src/widgets/cake_image_widget.dart';
import 'package:cake_wallet/view_model/node_list/node_list_view_model.dart';
import 'package:cw_core/node.dart';
import 'package:flutter/material.dart';
class NodeListRow extends StandardListRow {
class NodeListRow extends StatelessWidget {
NodeListRow(
{required String title,
String? subtitle,
required this.node,
required void Function(BuildContext context) onTap,
required bool isSelected,
required this.isPow})
: super(title: title, onTap: onTap, isSelected: isSelected, subtitle: subtitle);
{required this.node,
required this.onTap,
required this.onEditComplete,
required this.isSelected,
required this.isPow, this.speed});
final Node node;
final bool isPow;
final VoidCallback onTap;
final Future<void> Function(Object?) onEditComplete;
final bool isSelected;
final NodeSpeed? speed;
@override
Widget build(BuildContext context) {
final leading = buildLeading(context);
final trailing = buildTrailing(context);
return Container(
padding: EdgeInsets.only(left: 18, right: 18, top: 2, bottom: 2),
margin: EdgeInsets.only(top: 2, bottom: 2),
child: FilledButton(
onPressed: () => onTap?.call(context),
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(20)),
),
),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
leading,
buildCenter(context, hasLeftOffset: true),
trailing,
],
),
),
),
);
}
@override
Widget buildLeading(BuildContext context) {
return FutureBuilder(
future: node.requestNode(),
builder: (context, snapshot) {
switch (snapshot.connectionState) {
case ConnectionState.done:
return NodeIndicator(isLive: snapshot.data ?? false);
default:
return NodeIndicator(isLive: false);
}
});
}
@override
Widget buildTrailing(BuildContext context) {
final hasLabel = node.label != null && node.label!.isNotEmpty;
final uriText = "${node.uri.host}:${node.uri.port}";
return GestureDetector(
onTap: () => Navigator.of(context).pushNamed(
isPow ? Routes.newPowNode : Routes.newNode,
arguments: {'editingNode': node, 'isSelected': isSelected},
),
child: Container(
padding: EdgeInsets.all(10),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Theme.of(context).colorScheme.surface,
),
child: Icon(
Icons.edit,
size: 14,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
);
}
}
class NodeHeaderListRow extends StandardListRow {
NodeHeaderListRow({required String title, required void Function(BuildContext context) onTap})
: super(title: title, onTap: onTap, isSelected: false);
@override
Widget build(BuildContext context) {
final leading = buildLeading(context);
final trailing = buildTrailing(context);
return Container(
height: 56,
padding: EdgeInsets.only(left: 12, right: 12, top: 2, bottom: 2),
child: TextButton(
onPressed: () => onTap?.call(context),
style: ButtonStyle(
backgroundColor: WidgetStateProperty.all(Theme.of(context).colorScheme.surface),
shape: WidgetStateProperty.all(
RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(10)),
),
),
),
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 10.0, horizontal: 12),
child: Row(
spacing: 12,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
if (leading != null) leading,
buildCenter(context, hasLeftOffset: leading != null),
leading,
Expanded(
child: Column(crossAxisAlignment:CrossAxisAlignment.start,spacing:4,children: [
Row(
spacing: 4,
mainAxisAlignment: MainAxisAlignment.start,
children: [
Text(hasLabel ? node.label! : uriText, style: TextStyle(fontSize: 12),),
if(badgePath != null)
CakeImageWidget(imageUrl: badgePath, colorFilter: ColorFilter.mode(Theme.of(context).colorScheme.primary,BlendMode.srcIn),)
],
),
if(hasLabel) Text(uriText, style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.onSurfaceVariant),),
],),
),
trailing,
],
),
@@ -117,12 +57,44 @@ class NodeHeaderListRow extends StandardListRow {
);
}
@override
Widget buildLeading(BuildContext context) => isSelected
? Padding(
padding: const EdgeInsets.all(4.0),
child: Icon(
Icons.check,
size: 16,
color: Theme.of(context).colorScheme.primary,
),
)
: SizedBox(width: 24, height: 24);
Widget buildTrailing(BuildContext context) {
return SizedBox(
width: 20,
child: Icon(Icons.add,
color: Theme.of(context).colorScheme.onSurfaceVariant, size: 24.0),
return Row(
children: [
if (speed != null) CakeImageWidget(imageUrl: speed!.iconPath, width: 24, height: 24),
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () async {
final res = await Navigator.of(context).pushNamed(isPow ? Routes.newPowNode : Routes.newNode,
arguments: {'editingNode': node, 'isSelected': isSelected});
await onEditComplete(res);
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: CakeImageWidget(
imageUrl: "assets/new-ui/3dots_vertical.svg",
colorFilter:
ColorFilter.mode(Theme.of(context).colorScheme.onSurfaceVariant, BlendMode.srcIn),
),
),
)
],
);
}
String? get badgePath {
if(node.isOfficial) return "assets/new-ui/official_node.svg";
if(node.isBuiltin) return "assets/new-ui/default_node.svg";
return null;
}
}
+95 -102
View File
@@ -1,42 +1,42 @@
import 'package:cake_wallet/generated/i18n.dart';
import 'package:cake_wallet/new-ui/widgets/modal_header.dart';
import 'package:cake_wallet/new-ui/widgets/modal_page_wrapper.dart';
import 'package:cake_wallet/new-ui/widgets/modern_button.dart';
import 'package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
import 'package:cake_wallet/routes.dart';
import 'package:cake_wallet/src/screens/base_page.dart';
import 'package:cake_wallet/src/screens/nodes/widgets/node_list_row.dart';
import 'package:cake_wallet/src/screens/settings/widgets/settings_switcher_cell.dart';
import 'package:cake_wallet/src/widgets/alert_with_two_actions.dart';
import 'package:cake_wallet/src/widgets/standard_list.dart';
import 'package:cake_wallet/utils/feature_flag.dart';
import 'package:cake_wallet/utils/show_pop_up.dart';
import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart';
import 'package:cake_wallet/view_model/node_list/node_list_view_model.dart';
import 'package:cake_wallet/view_model/node_list/pow_node_list_view_model.dart';
import 'package:cw_core/node.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:modal_bottom_sheet/modal_bottom_sheet.dart';
class ManageNodesPage extends BasePage {
ManageNodesPage(this.isPow, {this.nodeListViewModel, this.powNodeListViewModel, this.dashboardViewModel})
: assert((isPow && powNodeListViewModel != null) || (!isPow && nodeListViewModel != null)),
assert(powNodeListViewModel == null || nodeListViewModel == null);
class ManageNodesPage extends StatefulWidget {
ManageNodesPage(this.isPow, {required this.nodeListViewModel, this.dashboardViewModel});
final DashboardViewModel? dashboardViewModel;
final NodeListViewModel? nodeListViewModel;
final PowNodeListViewModel? powNodeListViewModel;
final NodeListViewModel nodeListViewModel;
final bool isPow;
@override
bool get hideAppBar => true;
State<ManageNodesPage> createState() => _ManageNodesPageState();
}
class _ManageNodesPageState extends State<ManageNodesPage> {
@override
void initState() {
super.initState();
Future.delayed(Duration(milliseconds: 300))
.then((_) => widget.nodeListViewModel.speedTestNodes());
}
@override
String get title => S.current.manage_nodes;
@override
Widget body(BuildContext context) {
Widget build(BuildContext context) {
return ModalPageWrapper(
horizontalPadding: 0,
topBar: ModalTopBar(
title: S.of(context).manage_nodes,
leadingIcon: Icon(Icons.arrow_back_ios_new),
@@ -44,87 +44,96 @@ class ManageNodesPage extends BasePage {
trailingWidget: Row(
spacing: 8,
children: [
if (dashboardViewModel?.hasRescan ?? true)
ModernButton(
Observer(
builder: (_) => ModernButton(
size: 36,
icon: Icon(Icons.history),
onPressed: () => Navigator.of(context).pushNamed(Routes.rescan)),
icon: widget.nodeListViewModel.isTestingNodeSpeed
? CupertinoActivityIndicator()
: Icon(Icons.refresh),
onPressed: () => widget.nodeListViewModel.speedTestNodes()),
),
ModernButton(
size: 36,
icon: Icon(Icons.add),
onPressed: () => Navigator.of(context).pushNamed(isPow ? Routes.newPowNode : Routes.newNode))
onPressed: ()async {
final res = await Navigator.of(context)
.pushNamed(widget.isPow ? Routes.newPowNode : Routes.newNode);
if(res != null && res is Node) {
widget.nodeListViewModel.nodes.add(res);
}
})
],
),
),
header: ModalHeader(
iconPath: "assets/new-ui/settings_row_icons/nodes.svg",
message: S.of(context).nodes_desc,
title: S.of(context).nodes,
bottomWidget: Observer(
builder: (_) => NodeListRow(
node: widget.nodeListViewModel.currentNode,
speed: widget.nodeListViewModel.nodeSpeedFor(widget.nodeListViewModel.currentNode),
onEditComplete: (res)async{
if(res != null && res is Node) {
widget.nodeListViewModel.nodes.removeWhere((item)=>item.id == res.id);
widget.nodeListViewModel.nodes.add(res);
}
},
onTap: () {},
isSelected: true,
isPow: widget.isPow)),
),
content: Column(
children: [
if (FeatureFlag.isAutomaticNodeSwitchingEnabled)
Observer(
builder: (_) => SettingsSwitcherCell(
key: ValueKey('manage_nodes_page_enable_auto_node_switching_button_key'),
title: S.current.enable_auto_node_switching,
value: isPow
? powNodeListViewModel!.enableAutomaticNodeSwitching
: nodeListViewModel!.enableAutomaticNodeSwitching,
onValueChange: (BuildContext context, bool value) {
if (isPow) {
powNodeListViewModel!.setEnableAutomaticNodeSwitching(value);
} else {
nodeListViewModel!.setEnableAutomaticNodeSwitching(value);
}
},
),
),
if (FeatureFlag.isAutomaticNodeSwitchingEnabled) SizedBox(height: 8),
Observer(
builder: (BuildContext context) {
int itemsCount =
nodeListViewModel?.nodes.length ?? powNodeListViewModel!.nodes.length;
return SectionStandardList(
scrollController: ModalScrollController.of(context),
sectionCount: 1,
shrinkWrap: true,
physics: NeverScrollableScrollPhysics(),
dividerPadding: EdgeInsets.symmetric(horizontal: 24),
itemCounter: (int sectionIndex) => itemsCount,
itemBuilder: (_, index) {
return Observer(
builder: (context) {
final node =
nodeListViewModel?.nodes[index] ?? powNodeListViewModel!.nodes[index];
late bool isSelected;
if (isPow) {
isSelected = node.id == powNodeListViewModel!.currentNode.id;
} else {
isSelected = node.id == nodeListViewModel!.currentNode.id;
}
final nodeListRow = NodeListRow(
title: node.uriRaw,
subtitle: node.label ?? '',
node: node,
isSelected: isSelected,
isPow: isPow,
onTap: (_) async {
if (isSelected) {
return;
}
Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(18)),
child: Observer(
builder: (BuildContext context) {
int itemsCount = widget.nodeListViewModel.nonCurrentNodes.length;
return ListView.separated(
controller: ModalScrollController.of(context),
shrinkWrap: true,
physics: BouncingScrollPhysics(),
itemCount: itemsCount,
separatorBuilder: (context, index) => Padding(
padding: EdgeInsets.symmetric(horizontal: 12),
child: Container(
height: 1,
color: Theme.of(context).colorScheme.surfaceContainerHigh,
),
),
itemBuilder: (_, index) {
return Observer(
builder: (context) {
final node = widget.nodeListViewModel.nonCurrentNodes[index];
final nodeListRow = NodeListRow(
node: node,
isSelected: false,
isPow: widget.isPow,
speed: widget.nodeListViewModel.nodeSpeedFor(node),
onEditComplete: (res) async {
if(res != null && res is Node) {
widget.nodeListViewModel.nodes.removeWhere((item)=>item.id == res.id);
widget.nodeListViewModel.nodes.add(res);
}
},
onTap: () async {
await showPopUp<void>(
context: context,
builder: (BuildContext context) {
return AlertWithTwoActions(
alertTitle: S.of(context).change_current_node_title,
alertContent: nodeListViewModel?.getAlertContent(node.uriRaw) ??
powNodeListViewModel!.getAlertContent(node.uriRaw),
alertContent:
widget.nodeListViewModel.getAlertContent(node.uriRaw),
leftButtonText: S.of(context).cancel,
rightButtonText: S.of(context).change,
actionLeftButton: () => Navigator.of(context).pop(),
actionRightButton: () async {
if (isPow) {
await powNodeListViewModel!.setAsCurrent(node);
} else {
await nodeListViewModel!.setAsCurrent(node);
}
await widget.nodeListViewModel.setAsCurrent(node);
Navigator.of(context).pop();
},
);
@@ -132,32 +141,16 @@ class ManageNodesPage extends BasePage {
);
},
);
return nodeListRow;
},
);
},
);
},
return nodeListRow;
},
);
},
);
},
),
),
],
),
);
}
Future<void> _presentReconnectAlert(BuildContext context) async {
await showPopUp<void>(
context: context,
builder: (BuildContext context) {
return AlertWithTwoActions(
alertTitle: S.of(context).reconnection,
alertContent: S.of(context).reconnect_alert_text,
rightButtonText: S.of(context).ok,
leftButtonText: S.of(context).cancel,
actionRightButton: () async {
Navigator.of(context).pop();
await dashboardViewModel!.reconnect();
},
actionLeftButton: () => Navigator.of(context).pop());
},
);
}
}