diff --git a/cw_core/lib/node.dart b/cw_core/lib/node.dart index 7550fc626..b7f2703b8 100644 --- a/cw_core/lib/node.dart +++ b/cw_core/lib/node.dart @@ -132,6 +132,26 @@ class Node { }; } + Node.fromUri(Uri uri, WalletType type) + : id = 0, + uriRaw = + "${uri.host}${uri.hasPort ? ':${uri.port}' : (uri.queryParameters['port']?.isNotEmpty == true ? ':${uri.queryParameters['port']}' : '')}", + path = uri.path, + login = uri.userInfo.contains(':') + ? uri.userInfo.split(':')[0] + : uri.queryParameters['username'], + password = uri.userInfo.contains(':') + ? uri.userInfo.split(':')[1] + : uri.queryParameters['password'], + useSSL = uri.queryParameters['protocol'] == 'https', + trusted = uri.queryParameters['trusted'] == 'true', + isPow = false, + isEnabledForAutoSwitching = false, + isOfficial = false, + isBuiltin = false, + isDefault = false, + typeRaw = serializeToInt(type); + Future delete() async { return await db!.delete(tableName, where: '${selfIdColumn} = ?', whereArgs: [id]); } @@ -378,14 +398,14 @@ class Node { final body = {'jsonrpc': '2.0', 'id': '0', 'method': methodName}; try { - final client = ProxyWrapper().getHttpIOClient(); final jsonBody = json.encode(body); - final response = await client.post( - rpcUri, + final response = await ProxyWrapper().post( + clearnetUri: rpcUri, headers: {'Content-Type': 'application/json'}, body: jsonBody, + allowMitmMoneroBypassSSLCheck: true ); // Check if we received a 401 Unauthorized response if (response.statusCode == 401) { diff --git a/cw_core/lib/node_list.dart b/cw_core/lib/node_list.dart index e46f0eee4..2c5007bea 100644 --- a/cw_core/lib/node_list.dart +++ b/cw_core/lib/node_list.dart @@ -1,5 +1,4 @@ import 'package:flutter/services.dart'; -import 'package:hive/hive.dart'; import "package:yaml/yaml.dart"; import 'package:cw_core/node.dart'; import 'package:cw_core/wallet_type.dart'; @@ -78,6 +77,7 @@ Future> loadDefaultNodes(WalletType type) async { map["isBuiltin"] = 1; map["isOfficial"] = ((map["isOfficial"] as bool? ?? false) ? 1 : 0); map["isDefault"] = ((map["isDefault"] as bool? ?? false) ? 1 : 0); + final node = Node.fromMap(Map.from(map)); node.type = type; nodes.add(node); @@ -94,7 +94,11 @@ Future> loadDefaultNanoPowNodes() async { for (final raw in loadedPowNodes) { if (raw is Map) { - final node = Node.fromMap(Map.from(raw)); + final map = Map.from(raw); + map["isBuiltin"] = 1; + map["isOfficial"] = ((map["isOfficial"] as bool? ?? false) ? 1 : 0); + map["isDefault"] = ((map["isDefault"] as bool? ?? false) ? 1 : 0); + final node = Node.fromMap(Map.from(map)); node.isPow = true; node.type = WalletType.nano; nodes.add(node); diff --git a/lib/di.dart b/lib/di.dart index aa42887d7..991e2f7ee 100644 --- a/lib/di.dart +++ b/lib/di.dart @@ -1118,9 +1118,9 @@ Future setup({ getIt.registerFactory(() => AddressListPage(getIt.get())); - getIt.registerLazySingleton(() { + getIt.registerFactoryParam((isPow, _) { final appStore = getIt.get(); - return NodeListViewModel(appStore); + return NodeListViewModel(appStore, isPow); }); getIt.registerFactoryParam( @@ -1131,11 +1131,6 @@ Future setup({ ), ); - getIt.registerLazySingleton(() { - final appStore = getIt.get(); - return PowNodeListViewModel(appStore); - }); - getIt.registerFactory(() => ConnectionSyncViewModel(getIt.get(), getIt.get().wallet!)); getIt.registerFactory(() => ConnectionSyncPage(getIt.get())); @@ -1178,8 +1173,6 @@ Future setup({ final Node? editingNode = args['editingNode'] as Node?; return NodeCreateOrEditViewModel( isPow, - getIt.get(), - getIt.get(), type, getIt.get(), editingNode: editingNode @@ -1700,10 +1693,7 @@ Future setup({ ); getIt.registerFactoryParam((bool isPow, _) { - if (isPow) { - return ManageNodesPage(isPow, powNodeListViewModel: getIt.get(), dashboardViewModel: getIt.get()); - } - return ManageNodesPage(isPow, nodeListViewModel: getIt.get(), dashboardViewModel: getIt.get()); + return ManageNodesPage(isPow, nodeListViewModel: getIt.get(param1: isPow), dashboardViewModel: getIt.get()); }); getIt.registerFactory( diff --git a/lib/new-ui/widgets/coins_page/action_row/coin_action_row.dart b/lib/new-ui/widgets/coins_page/action_row/coin_action_row.dart index 10a411c2f..b700f4aab 100644 --- a/lib/new-ui/widgets/coins_page/action_row/coin_action_row.dart +++ b/lib/new-ui/widgets/coins_page/action_row/coin_action_row.dart @@ -17,10 +17,10 @@ import 'package:cake_wallet/view_model/send/send_view_model.dart'; import 'package:cake_wallet/view_model/wallet_address_list/wallet_address_list_view_model.dart'; import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/lnurl.dart'; +import 'package:cw_core/node.dart'; import 'package:cw_core/unspent_coin_type.dart'; import 'package:cw_core/wallet_type.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_svg/svg.dart'; import 'package:modal_bottom_sheet/modal_bottom_sheet.dart'; import '../../../pages/receive_page.dart'; @@ -198,7 +198,12 @@ class CoinActionRow extends StatelessWidget { Navigator.of(context) .pushNamed(Routes.walletConnectConnectionsListing, arguments: Uri.parse(code)); return; - } else { + } else if(["http", "https", "tcp"].contains(Uri.tryParse(code)?.scheme)) { + Navigator.of(context).pushNamed(Routes.newNode, + arguments: {"editingNode": Node.fromUri(Uri.parse(code), walletType)}); + return; + + }else { final uri = Uri.tryParse(code); if (uri == null) return; req = PaymentRequest.fromUri(uri); diff --git a/lib/new-ui/widgets/modal_header.dart b/lib/new-ui/widgets/modal_header.dart index 46c292cea..6bef2bba5 100644 --- a/lib/new-ui/widgets/modal_header.dart +++ b/lib/new-ui/widgets/modal_header.dart @@ -4,11 +4,12 @@ import 'package:flutter_svg/svg.dart'; class ModalHeader extends StatelessWidget { const ModalHeader( - {super.key, required this.iconPath, required this.message, required this.title}); + {super.key, required this.iconPath, required this.message, required this.title, this.bottomWidget}); final String iconPath; final String title; final String message; + final Widget? bottomWidget; @override Widget build(BuildContext context) { @@ -41,7 +42,11 @@ class ModalHeader extends StatelessWidget { fontWeight: FontWeight.w400, color: Theme.of(context).colorScheme.onSurfaceVariant), textAlign: TextAlign.center, - )) + )), + if(bottomWidget != null) ...[ + Padding(padding: EdgeInsets.symmetric(horizontal: 18), child: Container(height:1, color: Theme.of(context).colorScheme.surfaceContainerHigh),), + bottomWidget! + ] ], ), ), diff --git a/lib/src/screens/nodes/node_create_or_edit_page.dart b/lib/src/screens/nodes/node_create_or_edit_page.dart index 92bccb27a..0e45c51f4 100644 --- a/lib/src/screens/nodes/node_create_or_edit_page.dart +++ b/lib/src/screens/nodes/node_create_or_edit_page.dart @@ -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 { }, ); + + 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 { 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: [ - Flexible( - child: Container( - padding: const EdgeInsets.only(right: 8.0), - child: LoadingPrimaryButton( - onPressed: () async { - final confirmed = await showPopUp( - 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( + 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(); + } + + }) + ]}) + + ] ), ), ), diff --git a/lib/src/screens/nodes/node_share_page.dart b/lib/src/screens/nodes/node_share_page.dart new file mode 100644 index 000000000..1319d8426 --- /dev/null +++ b/lib/src/screens/nodes/node_share_page.dart @@ -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)), + ) + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/screens/nodes/widgets/node_list_row.dart b/lib/src/screens/nodes/widgets/node_list_row.dart index 2e43ad3ff..5be027481 100644 --- a/lib/src/screens/nodes/widgets/node_list_row.dart +++ b/lib/src/screens/nodes/widgets/node_list_row.dart @@ -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 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: [ - 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: [ - 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; + } } diff --git a/lib/src/screens/settings/manage_nodes_page.dart b/lib/src/screens/settings/manage_nodes_page.dart index 02c2e4e6f..9f69c6587 100644 --- a/lib/src/screens/settings/manage_nodes_page.dart +++ b/lib/src/screens/settings/manage_nodes_page.dart @@ -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 createState() => _ManageNodesPageState(); +} + +class _ManageNodesPageState extends State { + @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( 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 _presentReconnectAlert(BuildContext context) async { - await showPopUp( - 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()); - }, - ); - } } diff --git a/lib/view_model/node_list/node_create_or_edit_view_model.dart b/lib/view_model/node_list/node_create_or_edit_view_model.dart index 774136ac4..bef64ef50 100644 --- a/lib/view_model/node_list/node_create_or_edit_view_model.dart +++ b/lib/view_model/node_list/node_create_or_edit_view_model.dart @@ -3,10 +3,7 @@ import 'package:cake_wallet/entities/new_ui_entities/list_item/list_item.dart'; import 'package:cake_wallet/entities/qr_scanner.dart'; import 'package:cake_wallet/store/settings_store.dart'; import 'package:cake_wallet/utils/permission_handler.dart'; -import 'package:collection/collection.dart'; import 'package:cw_core/node.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/utils/proxy_wrapper.dart'; import 'package:cw_core/wallet_type.dart'; import 'package:flutter/cupertino.dart'; @@ -20,7 +17,7 @@ class NodeCreateOrEditViewModel = NodeCreateOrEditViewModelBase abstract class NodeCreateOrEditViewModelBase with Store { NodeCreateOrEditViewModelBase( - this.isPow, this.nodeListViewModel,this.powNodeListViewModel, this.walletType, this._settingsStore, {this.editingNode}) + this.isPow, this.walletType, this._settingsStore, {this.editingNode}) : state = InitialExecutionState(), connectionState = InitialExecutionState(), label = editingNode?.label ?? '', @@ -116,8 +113,6 @@ abstract class NodeCreateOrEditViewModelBase with Store { @observable bool isPow; - final NodeListViewModel? nodeListViewModel; - final PowNodeListViewModel? powNodeListViewModel; @computed bool get isReady => @@ -257,12 +252,6 @@ abstract class NodeCreateOrEditViewModelBase with Store { @action Future delete({required Node editingNode}) async { await editingNode.delete(); - if(nodeListViewModel != null) { - nodeListViewModel!.bindNodes(); - } - if(powNodeListViewModel != null) { - powNodeListViewModel!.bindNodes(); - } } @action @@ -287,12 +276,6 @@ abstract class NodeCreateOrEditViewModelBase with Store { } state = ExecutedSuccessfullyState(); - if(nodeListViewModel != null) { - nodeListViewModel!.bindNodes(); - } - if(powNodeListViewModel != null) { - powNodeListViewModel!.bindNodes(); - } } catch (e) { state = FailureState(e.toString()); diff --git a/lib/view_model/node_list/node_list_view_model.dart b/lib/view_model/node_list/node_list_view_model.dart index 55207f0df..cc91577af 100644 --- a/lib/view_model/node_list/node_list_view_model.dart +++ b/lib/view_model/node_list/node_list_view_model.dart @@ -1,12 +1,14 @@ +import 'dart:math'; + import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/store/app_store.dart'; import 'package:cw_core/node_list.dart'; +import 'package:cw_core/utils/print_verbose.dart'; import 'package:cw_core/utils/proxy_wrapper.dart'; import 'package:mobx/mobx.dart'; import 'package:cw_core/wallet_base.dart'; import 'package:cake_wallet/store/settings_store.dart'; import 'package:cw_core/node.dart'; -import 'package:cake_wallet/entities/node_list.dart'; import 'package:cake_wallet/entities/default_settings_migration.dart'; import 'package:cw_core/wallet_type.dart'; import 'package:cake_wallet/evm/evm.dart'; @@ -14,11 +16,34 @@ import 'package:cake_wallet/reactions/wallet_connect.dart'; part 'node_list_view_model.g.dart'; + +class NodeSpeed { + final String iconPath; + + // fraction of best node ping needed to be classified as this speed. + final double treshold; + + const NodeSpeed._(this.iconPath, this.treshold); + + static const disconnected = NodeSpeed._( + "assets/new-ui/node_speed_badges/disconnected.svg", 0); + static const slow = NodeSpeed._( + "assets/new-ui/node_speed_badges/slow.svg", 0); + static const medium = NodeSpeed._( + "assets/new-ui/node_speed_badges/medium.svg", 0.5); + static const fast = NodeSpeed._( + "assets/new-ui/node_speed_badges/fast.svg", 0.8); + + static const all = [fast, medium, slow, disconnected]; +} + + class NodeListViewModel = NodeListViewModelBase with _$NodeListViewModel; abstract class NodeListViewModelBase with Store { - NodeListViewModelBase(this._appStore) + NodeListViewModelBase(this._appStore, this.isPow) : nodes = ObservableList(), + _nodeSpeeds = ObservableMap(), settingsStore = _appStore.settingsStore { bindNodes(); @@ -37,6 +62,7 @@ abstract class NodeListViewModelBase with Store { bindNodes(); }); } + @computed Node get currentNode { @@ -52,7 +78,7 @@ abstract class NodeListViewModelBase with Store { return settingsStore.getCurrentNode(walletType, chainId: chainId); } - final node = settingsStore.nodes[walletType]; + final node = isPow ? settingsStore.powNodes[walletType] : settingsStore.nodes[walletType]; if (node == null) { throw Exception('No node for wallet type: $walletType'); } @@ -73,6 +99,59 @@ abstract class NodeListViewModelBase with Store { final ObservableList nodes; final SettingsStore settingsStore; final AppStore _appStore; + final bool isPow; + final ObservableMap _nodeSpeeds; + + + @computed + List get nonCurrentNodes => nodes.where((item)=>item != currentNode).toList(); + + @observable + bool isTestingNodeSpeed = false; + + Future speedTestNodes() async { + try { + isTestingNodeSpeed = true; + _nodeSpeeds.clear(); + final nodes = this.nodes.toList(); + await Future.wait(nodes.map((node) async { + final sw = Stopwatch()..start(); + final res = await node + .requestNode() + .timeout(const Duration(seconds: 10), onTimeout: () => false); + sw.stop(); + if (res) { + _nodeSpeeds[node.uriRaw] = sw.elapsedMilliseconds; + } + })); + } finally { + isTestingNodeSpeed = false; + } + } + + NodeSpeed? nodeSpeedFor(Node node) { + final bestSpeed = _bestNodeSpeed; + final currentSpeed = _nodeSpeeds[node.uriRaw]; + if (bestSpeed == null) { + return null; + } + if (currentSpeed == null) { + return NodeSpeed.disconnected; + } + for (final speed in NodeSpeed.all) { + if (currentSpeed < (bestSpeed / speed.treshold)) return speed; + } + + return null; + } + + int? get _bestNodeSpeed { + try { + printV(_nodeSpeeds); + return _nodeSpeeds.values.reduce(min); + } catch (e) {} + return null; + } Future reset() async { await resetToDefault(); @@ -125,7 +204,12 @@ abstract class NodeListViewModelBase with Store { } // For non-EVM wallets, use the wallet type directly - settingsStore.nodes[walletType] = node; + if(isPow) { + settingsStore.powNodes[walletType] = node; + } else { + settingsStore.nodes[walletType] = node; + } + } @action @@ -150,7 +234,7 @@ abstract class NodeListViewModelBase with Store { } // For non-EVM wallets, use the wallet type directly - nodes.addAll(await Node.getAllForWalletType(walletType)); + nodes.addAll(isPow ? await Node.getAllForWalletTypePow(walletType) : await Node.getAllForWalletType(walletType)); } } diff --git a/lib/view_model/node_list/pow_node_list_view_model.dart b/lib/view_model/node_list/pow_node_list_view_model.dart index b7e185e2b..e69de29bb 100644 --- a/lib/view_model/node_list/pow_node_list_view_model.dart +++ b/lib/view_model/node_list/pow_node_list_view_model.dart @@ -1,83 +0,0 @@ -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/store/app_store.dart'; -import 'package:cake_wallet/utils/mobx.dart'; -import 'package:cw_core/node_list.dart'; -import 'package:cw_core/utils/proxy_wrapper.dart'; -import 'package:hive/hive.dart'; -import 'package:mobx/mobx.dart'; -import 'package:cw_core/wallet_base.dart'; -import 'package:cake_wallet/store/settings_store.dart'; -import 'package:cw_core/node.dart'; -import 'package:cake_wallet/entities/node_list.dart'; -import 'package:cake_wallet/entities/default_settings_migration.dart'; -import 'package:cw_core/wallet_type.dart'; - -part 'pow_node_list_view_model.g.dart'; - -class PowNodeListViewModel = PowNodeListViewModelBase with _$PowNodeListViewModel; - -abstract class PowNodeListViewModelBase with Store { - PowNodeListViewModelBase(this._appStore) - : nodes = ObservableList(), - settingsStore = _appStore.settingsStore { - bindNodes(); - - reaction((_) => _appStore.wallet, (WalletBase? _wallet) { - bindNodes(); - }); - } - - @computed - Node get currentNode { - final node = settingsStore.powNodes[_appStore.wallet!.type]; - - if (node == null) { - throw Exception('No node for wallet type: ${_appStore.wallet!.type}'); - } - - return node; - } - - String getAlertContent(String uri) => - S.current.change_current_node(uri) + - '${uri.endsWith('.onion') || uri.contains('.onion:') ? '\n' + (CakeTor.instance!.enabled ? '' : S.current.orbot_running_alert) : ''}'; - - @computed - bool get enableAutomaticNodeSwitching => settingsStore.enableAutomaticNodeSwitching; - - @action - void setEnableAutomaticNodeSwitching(bool value) => - settingsStore.enableAutomaticNodeSwitching = value; - - final ObservableList nodes; - final SettingsStore settingsStore; - final AppStore _appStore; - - Future reset() async { - await resetPowToDefault(); - - Node node; - - switch (_appStore.wallet!.type) { - case WalletType.nano: - node = (await getNanoDefaultPowNode())!; - break; - default: - throw Exception('Unexpected wallet type: ${_appStore.wallet!.type} for pow node list'); - } - - await setAsCurrent(node); - } - - @action - Future delete(Node node) async => node.delete(); - - Future setAsCurrent(Node node) async => - settingsStore.powNodes[_appStore.wallet!.type] = node; - - @action - Future bindNodes() async { - nodes.clear(); - nodes.addAll(await Node.getAllPow()); - } -} diff --git a/pubspec_base.yaml b/pubspec_base.yaml index 416c6dea5..a04b093d6 100644 --- a/pubspec_base.yaml +++ b/pubspec_base.yaml @@ -290,6 +290,7 @@ flutter: - assets/new-ui/card_icons/outline_icons/ - assets/new-ui/card_icons/symbol_icons/ - assets/new-ui/crypto_full_icons/ + - assets/new-ui/node_speed_badges/ fonts: - family: Lato diff --git a/res/pictures/default_node.svg b/res/pictures/default_node.svg new file mode 100644 index 000000000..43d730b90 --- /dev/null +++ b/res/pictures/default_node.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/pictures/node_speed_badges/disconnected.svg b/res/pictures/node_speed_badges/disconnected.svg new file mode 100644 index 000000000..33b7d28ba --- /dev/null +++ b/res/pictures/node_speed_badges/disconnected.svg @@ -0,0 +1,4 @@ + + + + diff --git a/res/pictures/node_speed_badges/fast.svg b/res/pictures/node_speed_badges/fast.svg new file mode 100644 index 000000000..4205def6f --- /dev/null +++ b/res/pictures/node_speed_badges/fast.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/pictures/node_speed_badges/medium.svg b/res/pictures/node_speed_badges/medium.svg new file mode 100644 index 000000000..398280251 --- /dev/null +++ b/res/pictures/node_speed_badges/medium.svg @@ -0,0 +1,4 @@ + + + + diff --git a/res/pictures/node_speed_badges/slow.svg b/res/pictures/node_speed_badges/slow.svg new file mode 100644 index 000000000..fc4d0b200 --- /dev/null +++ b/res/pictures/node_speed_badges/slow.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/res/pictures/official_node.svg b/res/pictures/official_node.svg new file mode 100644 index 000000000..75a09a0b9 --- /dev/null +++ b/res/pictures/official_node.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/values/strings_en.arb b/res/values/strings_en.arb index 70468bc30..f8828872e 100644 --- a/res/values/strings_en.arb +++ b/res/values/strings_en.arb @@ -300,6 +300,7 @@ "default_sell_provider": "Default Sell Provider", "delete": "Delete", "delete_account": "Delete Account", + "delete_this_node": "Delete this node", "delete_wallet": "Delete wallet", "delete_wallet_confirm_message": "Are you sure that you want to delete ${wallet_name} wallet?", "deleteConnectionConfirmationPrompt": "Are you sure that you want to delete the connection to", @@ -681,8 +682,10 @@ "node_new": "New Node", "node_port": "Node port", "node_reset_settings_title": "Reset settings", + "node_share_desc": "Scan this QR code from the Scan screen to import this node.", "node_test": "Test", "nodes": "Nodes", + "nodes_desc": "Manage existing nodes or add a custom one.", "nodes_list_reset_to_default_message": "Are you sure that you want to reset settings to default?", "none_of_selected_providers_can_exchange": "None of the selected providers can make this swap", "noNFTYet": "No NFTs yet", @@ -1022,6 +1025,7 @@ "share_address": "Share address", "shared_seed_wallet_groups": "Shared Seed Wallet Groups", "shared_seed_wallets": "Shared Seed Wallets", + "share_this_node": "Share this node", "show": "Show", "show_address_book_popup": "Show Address Book popup", "show_balance": "Long Press to Show Balance",